1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
|
// data.go handles reading, writing, encrypting, and exporting individual
// secret data blobs. It mirrors the Ruby GeheimData class (geheim.rb lines 237-284).
package store
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"codeberg.org/snonux/foostore/internal/crypto"
"codeberg.org/snonux/foostore/internal/git"
)
// Data holds a decrypted secret blob and the paths used to persist it.
// DataPath is the absolute path to the on-disk .data file.
// ExportedPath is populated by Export() and consumed by ReimportAfterExport().
type Data struct {
Content []byte
DataPath string // absolute path to .data file
ExportedPath string // set by Export(), used by ReimportAfterExport()
}
// loadData decrypts a .data file and returns a Data struct with Content populated.
// absoluteDataPath must be the full filesystem path to the encrypted .data file.
func loadData(ctx context.Context, absoluteDataPath string, c *crypto.Cipher) (*Data, error) {
ciphertext, err := os.ReadFile(absoluteDataPath)
if err != nil {
return nil, fmt.Errorf("reading data file %q: %w", absoluteDataPath, err)
}
plain, err := c.Decrypt(ciphertext)
if err != nil {
return nil, fmt.Errorf("decrypting data file %q: %w", absoluteDataPath, err)
}
return &Data{
Content: plain,
DataPath: absoluteDataPath,
}, nil
}
// String returns the content formatted for display with tab-indented lines.
// Mirrors Ruby: "\t#{@data.gsub("\n", "\n\t")}\n"
func (d *Data) String() string {
indented := strings.ReplaceAll(string(d.Content), "\n", "\n\t")
return "\t" + indented + "\n"
}
// Export writes the decrypted Content to exportDir/destinationFile, creating
// any intermediate directories as needed. ExportedPath is set to the resulting
// absolute path so that ReimportAfterExport() can locate the file later.
func (d *Data) Export(ctx context.Context, exportDir, destinationFile string) error {
destination := filepath.Join(exportDir, destinationFile)
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
return fmt.Errorf("creating export directory for %q: %w", destination, err)
}
if err := os.WriteFile(destination, d.Content, 0o600); err != nil {
return fmt.Errorf("exporting to %q: %w", destination, err)
}
d.ExportedPath = destination
return nil
}
// ReimportAfterExport reads the (possibly edited) file from ExportedPath back
// into Content and then commits it. This is used by the edit workflow: export →
// user edits in external editor → reimport.
func (d *Data) ReimportAfterExport(ctx context.Context, c *crypto.Cipher, g *git.Git) error {
content, err := os.ReadFile(d.ExportedPath)
if err != nil {
return fmt.Errorf("reading exported file %q: %w", d.ExportedPath, err)
}
d.Content = content
return d.Commit(ctx, c, g, true)
}
// Commit encrypts Content and writes it to DataPath, then stages the file with git.
// If force is false and the file already exists, the commit is silently skipped
// (matching the Ruby CommitFile#commit_content behaviour that avoids overwrites
// without explicit force).
func (d *Data) Commit(ctx context.Context, c *crypto.Cipher, g *git.Git, force bool) error {
if !force {
if _, err := os.Stat(d.DataPath); err == nil {
// File already exists; skip without error to preserve existing data.
fmt.Printf("Warning: %s already exists, skipping (use force to overwrite)\n", d.DataPath)
return nil
}
}
if err := os.MkdirAll(filepath.Dir(d.DataPath), 0o700); err != nil {
return fmt.Errorf("creating data directory for %q: %w", d.DataPath, err)
}
ciphertext, err := c.Encrypt(d.Content)
if err != nil {
return fmt.Errorf("encrypting data for %q: %w", d.DataPath, err)
}
if err := os.WriteFile(d.DataPath, ciphertext, 0o600); err != nil {
return fmt.Errorf("writing data file %q: %w", d.DataPath, err)
}
if err := g.Add(ctx, d.DataPath); err != nil {
return fmt.Errorf("git add data %q: %w", d.DataPath, err)
}
return nil
}
// Remove stages the .data file for deletion via git rm.
func (d *Data) Remove(ctx context.Context, g *git.Git) error {
return g.Remove(ctx, d.DataPath)
}
|