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
119
120
121
122
123
124
125
126
127
128
129
130
131
|
// 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"
)
// 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()
encryptor Encryptor
committer Committer
}
// 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, encryptor Encryptor, committer Committer) (*Data, error) {
ciphertext, err := os.ReadFile(absoluteDataPath)
if err != nil {
return nil, fmt.Errorf("reading data file %q: %w", absoluteDataPath, err)
}
if encryptor == nil {
return nil, fmt.Errorf("decrypting data file %q: missing encryptor", absoluteDataPath)
}
plain, err := encryptor.Decrypt(ciphertext)
if err != nil {
return nil, fmt.Errorf("decrypting data file %q: %w", absoluteDataPath, err)
}
return &Data{
Content: plain,
DataPath: absoluteDataPath,
encryptor: encryptor,
committer: committer,
}, 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) 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, 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, 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)
}
if d.encryptor == nil {
return fmt.Errorf("encrypting data for %q: missing encryptor", d.DataPath)
}
ciphertext, err := d.encryptor.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 d.committer == nil {
return fmt.Errorf("git add data %q: missing committer", d.DataPath)
}
if err := d.committer.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, committer Committer) error {
if committer == nil {
return fmt.Errorf("git remove data %q: missing committer", d.DataPath)
}
return committer.Remove(ctx, d.DataPath)
}
|