diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-02 10:52:39 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-02 10:52:39 +0200 |
| commit | 1bd6d282d2352870e68654afca3fa4a4ea7195ea (patch) | |
| tree | 8159f9a453a4b4a61dd5fd45ac442623b6906ea5 | |
| parent | 858b51ff554b823c9cf943723db48224ef130d99 (diff) | |
store: decouple crypto/git via interfaces (task 402)
| -rw-r--r-- | internal/store/data.go | 33 | ||||
| -rw-r--r-- | internal/store/data_test.go | 36 | ||||
| -rw-r--r-- | internal/store/dependencies.go | 15 | ||||
| -rw-r--r-- | internal/store/index.go | 29 | ||||
| -rw-r--r-- | internal/store/index_test.go | 17 | ||||
| -rw-r--r-- | internal/store/store.go | 8 |
6 files changed, 111 insertions, 27 deletions
diff --git a/internal/store/data.go b/internal/store/data.go index fb33bf0..25e64b1 100644 --- a/internal/store/data.go +++ b/internal/store/data.go @@ -8,9 +8,6 @@ import ( "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. @@ -24,13 +21,16 @@ type Data struct { // 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) { +func loadData(ctx context.Context, absoluteDataPath string, encryptor Encryptor) (*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 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) } @@ -69,21 +69,21 @@ func (d *Data) Export(ctx context.Context, exportDir, destinationFile string) er // 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 { +func (d *Data) ReimportAfterExport(ctx context.Context, encryptor Encryptor, committer Committer) 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) + return d.Commit(ctx, encryptor, committer, 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 { +func (d *Data) Commit(ctx context.Context, encryptor Encryptor, committer Committer, force bool) error { if !force { if _, err := os.Stat(d.DataPath); err == nil { // File already exists; skip without error to preserve existing data. @@ -96,7 +96,10 @@ func (d *Data) Commit(ctx context.Context, c *crypto.Cipher, g *git.Git, force b return fmt.Errorf("creating data directory for %q: %w", d.DataPath, err) } - ciphertext, err := c.Encrypt(d.Content) + if encryptor == nil { + return fmt.Errorf("encrypting data for %q: missing encryptor", d.DataPath) + } + ciphertext, err := encryptor.Encrypt(d.Content) if err != nil { return fmt.Errorf("encrypting data for %q: %w", d.DataPath, err) } @@ -105,7 +108,10 @@ func (d *Data) Commit(ctx context.Context, c *crypto.Cipher, g *git.Git, force b return fmt.Errorf("writing data file %q: %w", d.DataPath, err) } - if err := g.Add(ctx, d.DataPath); err != nil { + if committer == nil { + return fmt.Errorf("git add data %q: missing committer", d.DataPath) + } + if err := committer.Add(ctx, d.DataPath); err != nil { return fmt.Errorf("git add data %q: %w", d.DataPath, err) } @@ -113,6 +119,9 @@ func (d *Data) Commit(ctx context.Context, c *crypto.Cipher, g *git.Git, force b } // 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) +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) } diff --git a/internal/store/data_test.go b/internal/store/data_test.go index 42721af..7bffd71 100644 --- a/internal/store/data_test.go +++ b/internal/store/data_test.go @@ -6,6 +6,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "codeberg.org/snonux/foostore/internal/crypto" @@ -240,3 +241,38 @@ func TestDataCommitSkipsExisting(t *testing.T) { t.Errorf("file was overwritten: got %q; want %q", got, sentinel) } } + +func TestDataCommitMissingEncryptor(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + d := &Data{ + Content: []byte("content"), + DataPath: filepath.Join(dir, "entry.data"), + } + + err := d.Commit(ctx, nil, nil, true) + if err == nil { + t.Fatal("Commit with nil encryptor: expected error, got nil") + } + if !strings.Contains(err.Error(), "missing encryptor") { + t.Fatalf("Commit error = %q; want missing encryptor", err.Error()) + } +} + +func TestDataCommitMissingCommitter(t *testing.T) { + ctx := context.Background() + c := newTestCipher(t) + dir := t.TempDir() + d := &Data{ + Content: []byte("content"), + DataPath: filepath.Join(dir, "entry.data"), + } + + err := d.Commit(ctx, c, nil, true) + if err == nil { + t.Fatal("Commit with nil committer: expected error, got nil") + } + if !strings.Contains(err.Error(), "missing committer") { + t.Fatalf("Commit error = %q; want missing committer", err.Error()) + } +} diff --git a/internal/store/dependencies.go b/internal/store/dependencies.go new file mode 100644 index 0000000..c8935ce --- /dev/null +++ b/internal/store/dependencies.go @@ -0,0 +1,15 @@ +package store + +import "context" + +// Encryptor is the minimal crypto dependency needed by store operations. +type Encryptor interface { + Encrypt([]byte) ([]byte, error) + Decrypt([]byte) ([]byte, error) +} + +// Committer is the minimal git dependency needed by store operations. +type Committer interface { + Add(context.Context, string) error + Remove(context.Context, string) error +} diff --git a/internal/store/index.go b/internal/store/index.go index 923082f..896c5da 100644 --- a/internal/store/index.go +++ b/internal/store/index.go @@ -10,9 +10,6 @@ import ( "os" "path/filepath" "strings" - - "codeberg.org/snonux/foostore/internal/crypto" - "codeberg.org/snonux/foostore/internal/git" ) // Index represents a decrypted .index file and its associated .data path. @@ -28,13 +25,16 @@ type Index struct { // loadIndex decrypts an .index file and builds an Index struct. // absoluteIndexPath is the full path to the .index file on disk; // dataDir is the root of the secret store (used to compute the relative DataFile). -func loadIndex(ctx context.Context, absoluteIndexPath, dataDir string, c *crypto.Cipher) (*Index, error) { +func loadIndex(ctx context.Context, absoluteIndexPath, dataDir string, encryptor Encryptor) (*Index, error) { ciphertext, err := os.ReadFile(absoluteIndexPath) if err != nil { return nil, fmt.Errorf("reading index file %q: %w", absoluteIndexPath, err) } - plain, err := c.Decrypt(ciphertext) + if encryptor == nil { + return nil, fmt.Errorf("decrypting index file %q: missing encryptor", absoluteIndexPath) + } + plain, err := encryptor.Decrypt(ciphertext) if err != nil { return nil, fmt.Errorf("decrypting index file %q: %w", absoluteIndexPath, err) } @@ -98,7 +98,7 @@ func (idx *Index) String() string { // the file with git. When force is false and IndexPath already exists the write // is silently skipped, matching the Ruby CommitFile#commit_content behaviour and // keeping the .index in sync with a skipped .data Commit. -func (idx *Index) CommitIndex(ctx context.Context, c *crypto.Cipher, g *git.Git, force bool) error { +func (idx *Index) CommitIndex(ctx context.Context, encryptor Encryptor, committer Committer, force bool) error { if !force { if _, err := os.Stat(idx.IndexPath); err == nil { // File already exists; skip without error to keep the index/data pair consistent @@ -108,7 +108,10 @@ func (idx *Index) CommitIndex(ctx context.Context, c *crypto.Cipher, g *git.Git, } } - ciphertext, err := c.Encrypt([]byte(idx.Description)) + if encryptor == nil { + return fmt.Errorf("encrypting index %q: missing encryptor", idx.IndexPath) + } + ciphertext, err := encryptor.Encrypt([]byte(idx.Description)) if err != nil { return fmt.Errorf("encrypting index %q: %w", idx.IndexPath, err) } @@ -117,7 +120,10 @@ func (idx *Index) CommitIndex(ctx context.Context, c *crypto.Cipher, g *git.Git, return fmt.Errorf("writing index file %q: %w", idx.IndexPath, err) } - if err := g.Add(ctx, idx.IndexPath); err != nil { + if committer == nil { + return fmt.Errorf("git add index %q: missing committer", idx.IndexPath) + } + if err := committer.Add(ctx, idx.IndexPath); err != nil { return fmt.Errorf("git add index %q: %w", idx.IndexPath, err) } @@ -125,8 +131,11 @@ func (idx *Index) CommitIndex(ctx context.Context, c *crypto.Cipher, g *git.Git, } // Remove stages the .index file for deletion via git rm. -func (idx *Index) Remove(ctx context.Context, g *git.Git) error { - return g.Remove(ctx, idx.IndexPath) +func (idx *Index) Remove(ctx context.Context, committer Committer) error { + if committer == nil { + return fmt.Errorf("git remove index %q: missing committer", idx.IndexPath) + } + return committer.Remove(ctx, idx.IndexPath) } // ---- sort.Interface for []*Index -------------------------------------------- diff --git a/internal/store/index_test.go b/internal/store/index_test.go index 57aea8c..5624b3e 100644 --- a/internal/store/index_test.go +++ b/internal/store/index_test.go @@ -136,6 +136,23 @@ func TestLoadIndexCorrupted(t *testing.T) { } } +func TestLoadIndexMissingEncryptor(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + indexPath := filepath.Join(dir, "entry.index") + if err := os.WriteFile(indexPath, []byte("ciphertext"), 0o600); err != nil { + t.Fatalf("writing index file: %v", err) + } + + _, err := loadIndex(ctx, indexPath, dir, nil) + if err == nil { + t.Fatal("loadIndex with nil encryptor: expected error, got nil") + } + if !strings.Contains(err.Error(), "missing encryptor") { + t.Fatalf("loadIndex error = %q; want missing encryptor", err.Error()) + } +} + // --- TestIndexSort ----------------------------------------------------------- // TestIndexSort verifies that IndexSlice sorts by Description alphabetically diff --git a/internal/store/store.go b/internal/store/store.go index 44b22c3..9acb52d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -21,8 +21,6 @@ import ( "strings" "codeberg.org/snonux/foostore/internal/config" - "codeberg.org/snonux/foostore/internal/crypto" - "codeberg.org/snonux/foostore/internal/git" ) // Action describes what to do with each matching secret during a Search call. @@ -60,13 +58,13 @@ type PickerResult struct { // regexCache avoids recompiling the same search-term regexp on every WalkIndexes call. type Store struct { cfg *config.Config - cipher *crypto.Cipher - git *git.Git + cipher Encryptor + git Committer regexCache map[string]*regexp.Regexp } // New creates a Store, ensuring cfg.DataDir exists on disk. -func New(cfg *config.Config, cipher *crypto.Cipher, g *git.Git) (*Store, error) { +func New(cfg *config.Config, cipher Encryptor, g Committer) (*Store, error) { if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { return nil, fmt.Errorf("creating data directory %q: %w", cfg.DataDir, err) } |
