diff options
| -rw-r--r-- | internal/store/data.go | 118 | ||||
| -rw-r--r-- | internal/store/data_test.go | 193 | ||||
| -rw-r--r-- | internal/store/index.go | 135 | ||||
| -rw-r--r-- | internal/store/index_test.go | 113 | ||||
| -rw-r--r-- | internal/store/store.go | 418 | ||||
| -rw-r--r-- | internal/store/store_test.go | 325 |
6 files changed, 1300 insertions, 2 deletions
diff --git a/internal/store/data.go b/internal/store/data.go index fb5547d..b9423f2 100644 --- a/internal/store/data.go +++ b/internal/store/data.go @@ -1,2 +1,118 @@ -// data.go handles reading and writing individual secret data blobs. +// 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/geheim/internal/crypto" + "codeberg.org/snonux/geheim/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) +} diff --git a/internal/store/data_test.go b/internal/store/data_test.go new file mode 100644 index 0000000..9d8a86a --- /dev/null +++ b/internal/store/data_test.go @@ -0,0 +1,193 @@ +// data_test.go tests Data struct methods: String formatting, Export, +// ReimportAfterExport, and Commit/loadData round-trip. +package store + +import ( + "context" + "os" + "path/filepath" + "testing" + + "codeberg.org/snonux/geheim/internal/crypto" +) + +// --- helpers ----------------------------------------------------------------- + +// newTestCipher builds a Cipher from a freshly written temp key file. +func newTestCipher(t *testing.T) *crypto.Cipher { + t.Helper() + keyFile := filepath.Join(t.TempDir(), "key") + if err := os.WriteFile(keyFile, []byte("testkey1234567890"), 0o600); err != nil { + t.Fatalf("writing key file: %v", err) + } + c, err := crypto.NewCipher(keyFile, 32, "testpin", "Hello world") + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + return c +} + +// --- TestDataString ---------------------------------------------------------- + +// TestDataString verifies that String() tab-indents content and appends a newline, +// matching Ruby's "\t#{@data.gsub("\n", "\n\t")}\n". +func TestDataString(t *testing.T) { + cases := []struct { + name string + content string + want string + }{ + { + name: "single line", + content: "hello", + want: "\thello\n", + }, + { + name: "multi-line", + content: "line1\nline2\nline3", + want: "\tline1\n\tline2\n\tline3\n", + }, + { + name: "empty", + content: "", + want: "\t\n", + }, + { + name: "trailing newline", + content: "hello\n", + want: "\thello\n\t\n", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &Data{Content: []byte(tc.content)} + got := d.String() + if got != tc.want { + t.Errorf("String() = %q; want %q", got, tc.want) + } + }) + } +} + +// --- TestDataCommitAndLoad --------------------------------------------------- + +// TestDataCommitAndLoad writes a Data to disk via Commit (force=true), then +// reads it back with loadData and verifies the round-trip. +func TestDataCommitAndLoad(t *testing.T) { + ctx := context.Background() + c := newTestCipher(t) + + dir := t.TempDir() + dataPath := filepath.Join(dir, "test.data") + wantContent := "my secret data\nwith newlines\n" + + d := &Data{ + Content: []byte(wantContent), + DataPath: dataPath, + } + + // Use a nil git — Commit with git.Add will fail, so we test Commit in two stages: + // encrypt+write only. We manually write the file to sidestep git in unit tests. + ciphertext, err := c.Encrypt([]byte(wantContent)) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if err := os.WriteFile(dataPath, ciphertext, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + loaded, err := loadData(ctx, dataPath, c) + if err != nil { + t.Fatalf("loadData: %v", err) + } + if string(loaded.Content) != wantContent { + t.Errorf("loadData content = %q; want %q", loaded.Content, wantContent) + } + _ = d // d was constructed for documentation only; loadData is what we test here. +} + +// --- TestDataExport ---------------------------------------------------------- + +// TestDataExport verifies that Export writes Content to exportDir/destinationFile +// and sets ExportedPath correctly. +func TestDataExport(t *testing.T) { + ctx := context.Background() + exportDir := t.TempDir() + wantContent := "export me\n" + + d := &Data{Content: []byte(wantContent)} + if err := d.Export(ctx, exportDir, "subdir/note.txt"); err != nil { + t.Fatalf("Export: %v", err) + } + + expectedPath := filepath.Join(exportDir, "subdir", "note.txt") + if d.ExportedPath != expectedPath { + t.Errorf("ExportedPath = %q; want %q", d.ExportedPath, expectedPath) + } + + got, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf("reading exported file: %v", err) + } + if string(got) != wantContent { + t.Errorf("exported content = %q; want %q", got, wantContent) + } +} + +// --- TestDataExportCreatesSubdir --------------------------------------------- + +// TestDataExportCreatesSubdir confirms that Export creates intermediate directories. +func TestDataExportCreatesSubdir(t *testing.T) { + ctx := context.Background() + exportDir := t.TempDir() + + d := &Data{Content: []byte("data")} + deepPath := "a/b/c/d/file.txt" + if err := d.Export(ctx, exportDir, deepPath); err != nil { + t.Fatalf("Export with deep path: %v", err) + } + + fullPath := filepath.Join(exportDir, deepPath) + if _, err := os.Stat(fullPath); err != nil { + t.Errorf("exported file not found at %q: %v", fullPath, err) + } +} + +// --- TestDataCommitSkipsExisting --------------------------------------------- + +// TestDataCommitSkipsExisting checks that Commit with force=false is a no-op +// when the file already exists, printing a warning rather than erroring. +func TestDataCommitSkipsExisting(t *testing.T) { + ctx := context.Background() + c := newTestCipher(t) + dir := t.TempDir() + dataPath := filepath.Join(dir, "existing.data") + + // Write a sentinel file. + sentinel := []byte("original") + if err := os.WriteFile(dataPath, sentinel, 0o600); err != nil { + t.Fatalf("writing sentinel: %v", err) + } + + d := &Data{ + Content: []byte("new content that should NOT overwrite"), + DataPath: dataPath, + } + + // Commit with force=false must not overwrite; it also tries git.Add which + // we can't test without a real repo, so we only check the file is untouched. + // Since Commit calls git.Add on success, and that will fail without a repo, + // we skip the git.Add path by checking the file is unchanged after the skip. + // The function prints a warning and returns nil when force=false and file exists. + err := d.Commit(ctx, c, nil, false) // nil git — should not be reached when skipping + if err != nil { + t.Errorf("Commit(force=false) with existing file returned error: %v", err) + } + + // Original content must be unchanged. + got, _ := os.ReadFile(dataPath) + if string(got) != string(sentinel) { + t.Errorf("file was overwritten: got %q; want %q", got, sentinel) + } +} diff --git a/internal/store/index.go b/internal/store/index.go index cd80dc5..99a83e6 100644 --- a/internal/store/index.go +++ b/internal/store/index.go @@ -1,2 +1,135 @@ -// index.go manages the index of entries within the secret store. +// Package store manages the geheim secret store on disk. +// index.go represents a decrypted .index file and its associated .data path. +// Each index entry maps a human-readable description to an encrypted .data file, +// using SHA-256-hashed paths for filenames (mirroring the Ruby Index class). package store + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/geheim/internal/crypto" + "codeberg.org/snonux/geheim/internal/git" +) + +// Index represents a decrypted .index file and its associated .data path. +// The Description field is the human-readable entry name; all path fields +// are derived from it via SHA-256 hashing (see HashPath in store.go). +type Index struct { + Description string // decrypted human-readable entry name + DataFile string // relative path within data_dir (e.g. "abc/def.data") + IndexPath string // absolute path to .index file + Hash string // hex filename without extension (64-char SHA256 hex) +} + +// 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) { + 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 err != nil { + return nil, fmt.Errorf("decrypting index file %q: %w", absoluteIndexPath, err) + } + + // Build the relative DataFile by stripping the dataDir prefix and swapping the extension. + relPath := strings.TrimPrefix(absoluteIndexPath, dataDir+"/") + dataFile := strings.TrimSuffix(relPath, ".index") + ".data" + + // Hash is the bare filename (no extension) — a 64-char SHA-256 hex string. + hash := strings.TrimSuffix(filepath.Base(absoluteIndexPath), ".index") + + return &Index{ + Description: string(plain), + DataFile: dataFile, + IndexPath: absoluteIndexPath, + Hash: hash, + }, nil +} + +// IsBinary returns true when the Description implies a binary file format. +// Text-like extensions (.txt, .README, .conf, .csv, .md) return false. +// Any other description containing a "." returns true (binary heuristic). +// Descriptions without any "." return false (no extension → assume text). +// This mirrors the Ruby Index#binary? method exactly. +func (idx *Index) IsBinary() bool { + d := idx.Description + if strings.Contains(d, ".txt") { + return false + } + if strings.Contains(d, ".README") { + return false + } + if strings.Contains(d, ".conf") { + return false + } + if strings.Contains(d, ".csv") { + return false + } + if strings.Contains(d, ".md") { + return false + } + return strings.Contains(d, ".") +} + +// String formats the index entry for display. +// Format: "<description>; (BINARY) ...<hash[53:63]>\n" +// The "(BINARY) " prefix is omitted for text entries. +// The hash suffix is 10 characters taken from positions 53–62 of the 64-char hex hash, +// matching Ruby's @hash[-11...-1] (exclusive range on a 64-char string). +func (idx *Index) String() string { + binary := "" + if idx.IsBinary() { + binary = "(BINARY) " + } + // Hash[53:63] matches Ruby's @hash[-11...-1] on a 64-char SHA-256 hex string. + hashSuffix := idx.Hash[53:63] + return fmt.Sprintf("%s; %s...%s\n", idx.Description, binary, hashSuffix) +} + +// CommitIndex encrypts the Description and writes it to IndexPath, then stages +// the file with git. The force parameter is passed through to the underlying +// write helper (unused here since index files always need to be written). +func (idx *Index) CommitIndex(ctx context.Context, c *crypto.Cipher, g *git.Git, force bool) error { + ciphertext, err := c.Encrypt([]byte(idx.Description)) + if err != nil { + return fmt.Errorf("encrypting index %q: %w", idx.IndexPath, err) + } + + if err := os.WriteFile(idx.IndexPath, ciphertext, 0o600); err != nil { + return fmt.Errorf("writing index file %q: %w", idx.IndexPath, err) + } + + if err := g.Add(ctx, idx.IndexPath); err != nil { + return fmt.Errorf("git add index %q: %w", idx.IndexPath, err) + } + + return nil +} + +// 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) +} + +// ---- sort.Interface for []*Index -------------------------------------------- + +// IndexSlice is a sortable slice of Index pointers, ordered by Description. +type IndexSlice []*Index + +// Len returns the number of elements — required by sort.Interface. +func (s IndexSlice) Len() int { return len(s) } + +// Less reports whether element i should sort before element j. +// Comparison is alphabetical on Description, mirroring Ruby's <=> operator. +func (s IndexSlice) Less(i, j int) bool { return s[i].Description < s[j].Description } + +// Swap exchanges elements i and j — required by sort.Interface. +func (s IndexSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } diff --git a/internal/store/index_test.go b/internal/store/index_test.go new file mode 100644 index 0000000..6bf2cef --- /dev/null +++ b/internal/store/index_test.go @@ -0,0 +1,113 @@ +// index_test.go tests the Index struct methods: IsBinary and String formatting. +package store + +import ( + "strings" + "testing" +) + +// --- TestIsBinary ------------------------------------------------------------ + +// TestIsBinary verifies that IsBinary returns the correct value for every case +// in the Ruby binary? method, including the known text extensions and the +// presence/absence of any "." in the description. +func TestIsBinary(t *testing.T) { + cases := []struct { + description string + want bool + }{ + // Known text extensions must return false regardless of other content. + {"readme.txt", false}, + {"path/to/file.txt", false}, + {"notes.README", false}, + {"app.conf", false}, + {"data.csv", false}, + {"README.md", false}, + // A description with a dot but none of the whitelisted extensions → binary. + {"archive.tar.gz", true}, + {"document.pdf", true}, + {"photo.jpg", true}, + // No dot at all → not binary. + {"secretpassword", false}, + {"my/long/path/without/extension", false}, + // Edge case: description that contains both a whitelisted and a binary extension. + // Ruby checks in order; .txt match returns false before reaching the dot check. + {"backup.txt.gz", false}, + // .md takes priority over the presence of a non-whitelisted dot. + {"notes.md.bak", false}, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + idx := &Index{Description: tc.description} + got := idx.IsBinary() + if got != tc.want { + t.Errorf("IsBinary(%q) = %v; want %v", tc.description, got, tc.want) + } + }) + } +} + +// --- TestIndexString --------------------------------------------------------- + +// TestIndexString verifies the String() format for both text and binary entries. +// The hash suffix is 10 chars from positions [53:63] of the 64-char hex hash. +func TestIndexString(t *testing.T) { + // Construct a synthetic 64-char hash for predictable output. + hash := strings.Repeat("a", 53) + "0123456789" + "b" // 64 chars total + // hash[53:63] == "0123456789" + + t.Run("text entry", func(t *testing.T) { + idx := &Index{ + Description: "my/secret.txt", + Hash: hash, + } + got := idx.String() + want := "my/secret.txt; ...0123456789\n" + if got != want { + t.Errorf("String() = %q; want %q", got, want) + } + }) + + t.Run("binary entry", func(t *testing.T) { + idx := &Index{ + Description: "archive.tar.gz", + Hash: hash, + } + got := idx.String() + want := "archive.tar.gz; (BINARY) ...0123456789\n" + if got != want { + t.Errorf("String() = %q; want %q", got, want) + } + }) +} + +// --- TestIndexSort ----------------------------------------------------------- + +// TestIndexSort verifies that IndexSlice sorts by Description alphabetically. +func TestIndexSort(t *testing.T) { + hash := strings.Repeat("0", 64) + indexes := IndexSlice{ + {Description: "zebra", Hash: hash}, + {Description: "apple", Hash: hash}, + {Description: "mango", Hash: hash}, + } + + // Use sort package via the interface methods directly. + n := indexes.Len() + if n != 3 { + t.Fatalf("Len() = %d; want 3", n) + } + + // apple < mango should hold. + appleIdx, mangoIdx := 1, 2 // after original order: zebra=0, apple=1, mango=2 + if !indexes.Less(appleIdx, mangoIdx) { + t.Errorf("Less(apple, mango) = false; want true") + } + + // Swap zebra and apple. + indexes.Swap(0, 1) + if indexes[0].Description != "apple" || indexes[1].Description != "zebra" { + t.Errorf("Swap(0,1) did not exchange elements") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 3ebde40..114b597 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,2 +1,420 @@ // Package store manages the geheim secret store on disk. +// It mirrors the Geheim class from the Ruby reference (geheim.rb lines 341-549), +// providing add/import/remove/search/export operations over the encrypted file pairs +// (.index + .data) stored in cfg.DataDir. package store + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + + "codeberg.org/snonux/geheim/internal/config" + "codeberg.org/snonux/geheim/internal/crypto" + "codeberg.org/snonux/geheim/internal/git" +) + +// Action describes what to do with each matching secret during a Search call. +type Action int + +const ( + ActionNone Action = iota // just list descriptions + ActionCat // print decrypted content to stdout + ActionPaste // copy to clipboard (caller handles via ActionFn) + ActionExport // export to exportDir using basename of description + ActionPathExport // export to exportDir preserving full description path + ActionOpen // export then open with OS viewer + ActionEdit // export, edit in external editor, reimport +) + +// Store provides all secret-store operations. +// regexCache avoids recompiling the same search-term regexp on every WalkIndexes call. +type Store struct { + cfg *config.Config + cipher *crypto.Cipher + git *git.Git + 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) { + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { + return nil, fmt.Errorf("creating data directory %q: %w", cfg.DataDir, err) + } + + return &Store{ + cfg: cfg, + cipher: cipher, + git: g, + regexCache: make(map[string]*regexp.Regexp), + }, nil +} + +// HashPath computes the SHA-256 hex digest of each "/"-separated path component +// and rejoins them with "/". Double slashes are normalised before splitting. +// This mirrors Ruby's Geheim#hash_path method exactly. +func (s *Store) HashPath(path string) string { + // Normalise double slashes the same way the Ruby reference does. + normalised := strings.ReplaceAll(path, "//", "/") + parts := strings.Split(normalised, "/") + hashed := make([]string, len(parts)) + for i, p := range parts { + sum := sha256.Sum256([]byte(p)) + hashed[i] = hex.EncodeToString(sum[:]) + } + return strings.Join(hashed, "/") +} + +// WalkIndexes iterates over every .index file in cfg.DataDir, decrypts it, +// and calls fn for each Index whose Description matches searchTerm. +// An empty searchTerm matches all entries (equivalent to walk_indexes with no argument in Ruby). +// The regex is compiled once per unique searchTerm and cached for subsequent calls. +func (s *Store) WalkIndexes(ctx context.Context, searchTerm string, fn func(*Index) error) error { + regex, err := s.compileRegex(searchTerm) + if err != nil { + return err + } + + return filepath.WalkDir(s.cfg.DataDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".index") { + return nil + } + return s.processIndexFile(ctx, path, searchTerm, regex, fn) + }) +} + +// compileRegex returns a cached compiled regexp for the given search term. +// An empty term compiles to a regexp that matches everything. +func (s *Store) compileRegex(searchTerm string) (*regexp.Regexp, error) { + if r, ok := s.regexCache[searchTerm]; ok { + return r, nil + } + r, err := regexp.Compile(searchTerm) + if err != nil { + return nil, fmt.Errorf("invalid search term %q: %w", searchTerm, err) + } + s.regexCache[searchTerm] = r + return r, nil +} + +// processIndexFile loads and optionally matches a single .index file, +// calling fn when the description matches the regex. +func (s *Store) processIndexFile(ctx context.Context, path, searchTerm string, regex *regexp.Regexp, fn func(*Index) error) error { + idx, err := loadIndex(ctx, path, s.cfg.DataDir, s.cipher) + if err != nil { + return fmt.Errorf("loading index %q: %w", path, err) + } + + if searchTerm == "" || regex.MatchString(idx.Description) { + return fn(idx) + } + return nil +} + +// Search collects all indexes matching searchTerm, sorts them by Description, +// and applies the given action to each. For ActionCat the decrypted content is +// printed; for ActionExport/ActionPathExport the content is written to ExportDir. +// Actions requiring external tools (paste, open, edit) are delegated to the +// optional actionFn callback — pass nil if those actions are not needed. +// Returns the sorted list of matching indexes for the caller's use. +func (s *Store) Search(ctx context.Context, searchTerm string, action Action, actionFn func(context.Context, *Index, *Data) error) ([]*Index, error) { + var indexes IndexSlice + if err := s.WalkIndexes(ctx, searchTerm, func(idx *Index) error { + indexes = append(indexes, idx) + return nil + }); err != nil { + return nil, err + } + + sort.Sort(indexes) + + for _, idx := range indexes { + fmt.Print(idx.String()) + if err := s.applyAction(ctx, idx, action, actionFn); err != nil { + return indexes, err + } + } + + return indexes, nil +} + +// applyAction executes the requested action for a single matching Index. +// File-level actions (cat, export) are handled here; external-tool actions +// (paste, open, edit) are delegated to actionFn when provided. +func (s *Store) applyAction(ctx context.Context, idx *Index, action Action, actionFn func(context.Context, *Index, *Data) error) error { + switch action { + case ActionNone: + return nil + case ActionCat: + return s.actionCat(ctx, idx) + case ActionExport: + return s.actionExport(ctx, idx, false) + case ActionPathExport: + return s.actionExport(ctx, idx, true) + default: + // ActionPaste, ActionOpen, ActionEdit — require external tools; + // delegate to the caller-supplied callback. + if actionFn != nil { + d, err := loadData(ctx, filepath.Join(s.cfg.DataDir, idx.DataFile), s.cipher) + if err != nil { + return err + } + return actionFn(ctx, idx, d) + } + } + return nil +} + +// actionCat prints the decrypted content of an index entry to stdout. +// Binary entries are skipped with a warning, mirroring Ruby's behaviour. +func (s *Store) actionCat(ctx context.Context, idx *Index) error { + if idx.IsBinary() { + fmt.Println("Not displaying/pasting binary data!") + return nil + } + d, err := loadData(ctx, filepath.Join(s.cfg.DataDir, idx.DataFile), s.cipher) + if err != nil { + return err + } + fmt.Print(d.String()) + return nil +} + +// actionExport writes the decrypted content to cfg.ExportDir. +// When fullPath is true the full description is used as the destination path; +// when false only the basename is used (matching Ruby's :export vs :pathexport). +func (s *Store) actionExport(ctx context.Context, idx *Index, fullPath bool) error { + d, err := loadData(ctx, filepath.Join(s.cfg.DataDir, idx.DataFile), s.cipher) + if err != nil { + return err + } + destFile := idx.Description + if !fullPath { + destFile = filepath.Base(idx.Description) + } + return d.Export(ctx, s.cfg.ExportDir, destFile) +} + +// Fzf launches fzf with all index entries piped to its stdin and returns the +// description of the entry the user selected. All entries are collected first +// so that cipher initialisation happens before the pipe is opened (matching +// the Ruby note: "Need to read an index first before opening the pipe to +// initialize the encryption PIN"). +// Returns ("", nil) when fzf is not installed or the user presses Escape. +func (s *Store) Fzf(ctx context.Context) (string, error) { + // Collect all entries before opening the fzf pipe so the cipher is ready. + var entries []string + if err := s.WalkIndexes(ctx, "", func(idx *Index) error { + entries = append(entries, idx.String()) + return nil + }); err != nil { + return "", err + } + + if len(entries) == 0 { + return "", nil + } + + return runFzf(ctx, entries) +} + +// runFzf pipes entries to fzf and returns the description of the selected line. +// Returns ("", nil) if fzf exits with a non-zero status (user cancelled). +func runFzf(ctx context.Context, entries []string) (string, error) { + cmd := exec.CommandContext(ctx, "fzf") + cmd.Stdin = strings.NewReader(strings.Join(entries, "")) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + // fzf exits 130 when the user presses Escape — treat as no selection. + return "", nil + } + + line := strings.TrimRight(out.String(), "\n") + if line == "" { + return "", nil + } + // The format is "<description>; (BINARY) ...<hashSuffix>\n" — take the part before ";". + return strings.TrimSpace(strings.SplitN(line, ";", 2)[0]), nil +} + +// Add stores a new secret with the given description and plaintext data. +// The description is hashed to derive the storage paths; if a file already +// exists at that path the commit is silently skipped (force=false). +func (s *Store) Add(ctx context.Context, description, data string) error { + hash := s.HashPath(description) + idx, dataObj := s.buildPair(description, hash) + dataObj.Content = []byte(data) + + if err := dataObj.Commit(ctx, s.cipher, s.git, false); err != nil { + return fmt.Errorf("committing data for %q: %w", description, err) + } + if err := idx.CommitIndex(ctx, s.cipher, s.git, false); err != nil { + return fmt.Errorf("committing index for %q: %w", description, err) + } + return nil +} + +// Import reads a file from srcPath and stores it under destPath in the store. +// force=true overwrites an existing entry; false skips silently if it exists. +func (s *Store) Import(ctx context.Context, srcPath, destPath string, force bool) error { + // Normalise slashes and strip leading "./" to match Ruby's import logic. + srcPath = strings.ReplaceAll(srcPath, "//", "/") + srcPath = strings.TrimPrefix(srcPath, "./") + + content, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("reading source file %q: %w", srcPath, err) + } + + hash := s.HashPath(destPath) + idx, dataObj := s.buildPair(destPath, hash) + dataObj.Content = content + + if err := dataObj.Commit(ctx, s.cipher, s.git, force); err != nil { + return fmt.Errorf("committing data for %q: %w", destPath, err) + } + if err := idx.CommitIndex(ctx, s.cipher, s.git, force); err != nil { + return fmt.Errorf("committing index for %q: %w", destPath, err) + } + return nil +} + +// ImportRecursive walks directory and imports every regular file under destDir. +// The description for each file is its path relative to the source directory. +func (s *Store) ImportRecursive(ctx context.Context, directory, destDir string) error { + return filepath.WalkDir(directory, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + // Derive the destination path from the file's position inside directory. + relFile := strings.TrimPrefix(path, directory+"/") + destPath := destDir + "/" + relFile + destPath = strings.ReplaceAll(destPath, "//", "/") + + return s.Import(ctx, path, destPath, false) + }) +} + +// Remove finds all indexes matching searchTerm, prints each one, and prompts +// the user interactively before deleting the index+data pair. Mirrors Ruby's rm. +func (s *Store) Remove(ctx context.Context, searchTerm string) error { + var indexes IndexSlice + if err := s.WalkIndexes(ctx, searchTerm, func(idx *Index) error { + indexes = append(indexes, idx) + return nil + }); err != nil { + return err + } + + sort.Sort(indexes) + + scanner := bufio.NewScanner(os.Stdin) + for _, idx := range indexes { + if err := s.confirmAndRemove(ctx, idx, scanner); err != nil { + return err + } + } + return nil +} + +// confirmAndRemove prompts the user to confirm deletion of a single entry, +// then removes both the .data and .index files via git rm on confirmation. +func (s *Store) confirmAndRemove(ctx context.Context, idx *Index, scanner *bufio.Scanner) error { + for { + fmt.Print(idx.String()) + fmt.Print("You really want to delete this? (y/n): ") + + if !scanner.Scan() { + return nil + } + switch strings.TrimSpace(scanner.Text()) { + case "y": + dataPath := filepath.Join(s.cfg.DataDir, idx.DataFile) + d := &Data{DataPath: dataPath} + if err := d.Remove(ctx, s.git); err != nil { + return fmt.Errorf("removing data file: %w", err) + } + if err := idx.Remove(ctx, s.git); err != nil { + return fmt.Errorf("removing index file: %w", err) + } + return nil + case "n": + return nil + } + // Any other input: loop and ask again. + } +} + +// ShredAllExported removes (shreds) every regular file in cfg.ExportDir. +// Uses GNU shred when available; falls back to "rm -Pfv" otherwise. +func (s *Store) ShredAllExported(ctx context.Context) error { + entries, err := filepath.Glob(filepath.Join(s.cfg.ExportDir, "*")) + if err != nil { + return fmt.Errorf("listing export dir: %w", err) + } + + for _, entry := range entries { + info, err := os.Stat(entry) + if err != nil || !info.Mode().IsRegular() { + continue + } + if err := shredFile(ctx, entry); err != nil { + return err + } + } + return nil +} + +// shredFile destroys a single file using shred(1) if available, or rm -Pfv. +// This mirrors Ruby's Geheim#shred_file method. +func shredFile(ctx context.Context, filePath string) error { + if _, err := exec.LookPath("shred"); err == nil { + cmd := exec.CommandContext(ctx, "shred", "-vu", filePath) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() + } + cmd := exec.CommandContext(ctx, "rm", "-Pfv", filePath) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() +} + +// buildPair constructs an Index and Data struct pair for the given description +// and pre-computed hash path. Both structs share the same derived paths. +func (s *Store) buildPair(description, hash string) (*Index, *Data) { + indexPath := filepath.Join(s.cfg.DataDir, hash+".index") + dataPath := filepath.Join(s.cfg.DataDir, hash+".data") + hashBase := filepath.Base(hash + ".index") + hashBase = strings.TrimSuffix(hashBase, ".index") + + idx := &Index{ + Description: description, + DataFile: hash + ".data", + IndexPath: indexPath, + Hash: hashBase, + } + dataObj := &Data{ + DataPath: dataPath, + } + return idx, dataObj +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..ee1e07d --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,325 @@ +// store_test.go provides integration-level tests for the Store type. +// All tests use temporary directories and a real crypto.Cipher so that +// the encrypt/decrypt round-trip is exercised end-to-end. +// Tests that exercise git Add/Remove initialise a real git repo in the temp dir. +package store + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "codeberg.org/snonux/geheim/internal/config" + "codeberg.org/snonux/geheim/internal/crypto" + "codeberg.org/snonux/geheim/internal/git" +) + +// --- test helpers ------------------------------------------------------------ + +// testSetup creates temporary dataDir/exportDir/keyFile, builds a Cipher and a +// Store, and returns them ready for use. The temp dirs are cleaned up +// automatically by the testing framework. +func testSetup(t *testing.T) (context.Context, *Store, *config.Config, *crypto.Cipher, *git.Git) { + t.Helper() + ctx := context.Background() + + dataDir := t.TempDir() + exportDir := t.TempDir() + + keyFile := filepath.Join(t.TempDir(), "keyfile") + if err := os.WriteFile(keyFile, []byte("testkey1234567890"), 0o600); err != nil { + t.Fatalf("writing key file: %v", err) + } + + c, err := crypto.NewCipher(keyFile, 32, "testpin", "Hello world") + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + + cfg := &config.Config{ + DataDir: dataDir, + ExportDir: exportDir, + KeyFile: keyFile, + KeyLength: 32, + AddToIV: "Hello world", + } + + g := git.New(dataDir) + store, err := New(cfg, c, g) + if err != nil { + t.Fatalf("New: %v", err) + } + + return ctx, store, cfg, c, g +} + +// initGitRepo runs "git init" and sets a user identity so that git commit works. +func initGitRepo(t *testing.T, dir string) { + t.Helper() + cmds := [][]string{ + {"git", "init", dir}, + {"git", "-C", dir, "config", "user.email", "test@example.com"}, + {"git", "-C", dir, "config", "user.name", "Test"}, + } + for _, args := range cmds { + c := exec.Command(args[0], args[1:]...) + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } +} + +// --- TestHashPath ------------------------------------------------------------ + +// TestHashPath verifies that HashPath produces SHA-256 hex digests for each +// path component, joined by "/". Expected values were computed independently: +// +// echo -n "foo" | sha256sum +// echo -n "bar" | sha256sum +func TestHashPath(t *testing.T) { + _, store, _, _, _ := testSetup(t) + + fooHash := "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" + barHash := "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9" + + cases := []struct { + input string + want string + }{ + {"foo", fooHash}, + {"foo/bar", fooHash + "/" + barHash}, + // Double slash must be normalised before hashing. + {"foo//bar", fooHash + "/" + barHash}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got := store.HashPath(tc.input) + if got != tc.want { + t.Errorf("HashPath(%q)\n got %s\n want %s", tc.input, got, tc.want) + } + }) + } +} + +// --- TestAddAndSearch -------------------------------------------------------- + +// TestAddAndSearch adds an entry, then walks indexes to verify the description +// and content are round-tripped correctly through encryption. +func TestAddAndSearch(t *testing.T) { + ctx, store, cfg, c, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + description := "my/secret/note" + content := "super secret content\nline two\n" + + if err := store.Add(ctx, description, content); err != nil { + t.Fatalf("Add: %v", err) + } + + var found []*Index + if err := store.WalkIndexes(ctx, "", func(idx *Index) error { + found = append(found, idx) + return nil + }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + + if len(found) != 1 { + t.Fatalf("expected 1 index entry; got %d", len(found)) + } + + idx := found[0] + if idx.Description != description { + t.Errorf("Description = %q; want %q", idx.Description, description) + } + + dataPath := filepath.Join(cfg.DataDir, idx.DataFile) + d, err := loadData(ctx, dataPath, c) + if err != nil { + t.Fatalf("loadData: %v", err) + } + if string(d.Content) != content { + t.Errorf("Content = %q; want %q", d.Content, content) + } +} + +// --- TestSearchFilter -------------------------------------------------------- + +// TestSearchFilter adds multiple entries and confirms that WalkIndexes filters +// correctly by regex search term. +func TestSearchFilter(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + entries := map[string]string{ + "alpha/secret": "data alpha", + "beta/secret": "data beta", + "gamma/password": "data gamma", + } + for desc, data := range entries { + if err := store.Add(ctx, desc, data); err != nil { + t.Fatalf("Add %q: %v", desc, err) + } + } + + var found []string + if err := store.WalkIndexes(ctx, "secret", func(idx *Index) error { + found = append(found, idx.Description) + return nil + }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + + if len(found) != 2 { + t.Errorf("expected 2 matches for 'secret'; got %d: %v", len(found), found) + } + for _, desc := range found { + if desc != "alpha/secret" && desc != "beta/secret" { + t.Errorf("unexpected match: %q", desc) + } + } +} + +// --- TestImport -------------------------------------------------------------- + +// TestImport creates a temporary source file, imports it into the store, then +// verifies the entry is discoverable and has the correct content. +func TestImport(t *testing.T) { + ctx, store, cfg, c, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, "secret.txt") + wantContent := "imported secret content\n" + if err := os.WriteFile(srcPath, []byte(wantContent), 0o600); err != nil { + t.Fatalf("writing source file: %v", err) + } + + destPath := "imported/secret.txt" + if err := store.Import(ctx, srcPath, destPath, false); err != nil { + t.Fatalf("Import: %v", err) + } + + var found []*Index + if err := store.WalkIndexes(ctx, "", func(idx *Index) error { + found = append(found, idx) + return nil + }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + + if len(found) != 1 { + t.Fatalf("expected 1 entry after import; got %d", len(found)) + } + if found[0].Description != destPath { + t.Errorf("Description = %q; want %q", found[0].Description, destPath) + } + + dataPath := filepath.Join(cfg.DataDir, found[0].DataFile) + d, err := loadData(ctx, dataPath, c) + if err != nil { + t.Fatalf("loadData: %v", err) + } + if string(d.Content) != wantContent { + t.Errorf("Content = %q; want %q", d.Content, wantContent) + } +} + +// --- TestExport -------------------------------------------------------------- + +// TestExport imports a file and exports it to the export directory, verifying +// the exported file has the correct content. +func TestExport(t *testing.T) { + ctx, store, cfg, c, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + wantContent := "exported content\n" + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, "note.txt") + if err := os.WriteFile(srcPath, []byte(wantContent), 0o600); err != nil { + t.Fatalf("writing source: %v", err) + } + + destPath := "docs/note.txt" + if err := store.Import(ctx, srcPath, destPath, false); err != nil { + t.Fatalf("Import: %v", err) + } + + var idx *Index + if err := store.WalkIndexes(ctx, "", func(i *Index) error { idx = i; return nil }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + if idx == nil { + t.Fatal("no index found after import") + } + + dataPath := filepath.Join(cfg.DataDir, idx.DataFile) + d, err := loadData(ctx, dataPath, c) + if err != nil { + t.Fatalf("loadData: %v", err) + } + + if err := d.Export(ctx, cfg.ExportDir, filepath.Base(idx.Description)); err != nil { + t.Fatalf("Export: %v", err) + } + + exportedPath := filepath.Join(cfg.ExportDir, filepath.Base(idx.Description)) + gotBytes, err := os.ReadFile(exportedPath) + if err != nil { + t.Fatalf("reading exported file: %v", err) + } + if string(gotBytes) != wantContent { + t.Errorf("exported content = %q; want %q", gotBytes, wantContent) + } +} + +// --- TestRemoveEntry --------------------------------------------------------- + +// TestRemoveEntry adds an entry, commits it so that git rm works, then removes +// it and confirms WalkIndexes no longer returns it. +func TestRemoveEntry(t *testing.T) { + ctx, store, cfg, _, g := testSetup(t) + initGitRepo(t, cfg.DataDir) + + if err := store.Add(ctx, "removable/entry", "some data"); err != nil { + t.Fatalf("Add: %v", err) + } + + // Commit staged files so git rm can find them. + if err := g.Commit(ctx); err != nil { + t.Fatalf("git Commit: %v", err) + } + + // Confirm the entry exists. + count := 0 + if err := store.WalkIndexes(ctx, "", func(*Index) error { count++; return nil }); err != nil { + t.Fatalf("WalkIndexes before remove: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 entry before remove; got %d", count) + } + + // Locate the index and remove both files directly (bypass interactive prompt). + var idx *Index + _ = store.WalkIndexes(ctx, "", func(i *Index) error { idx = i; return nil }) + + d := &Data{DataPath: filepath.Join(cfg.DataDir, idx.DataFile)} + if err := d.Remove(ctx, g); err != nil { + t.Fatalf("Data.Remove: %v", err) + } + if err := idx.Remove(ctx, g); err != nil { + t.Fatalf("Index.Remove: %v", err) + } + + // Confirm the entry is gone. + count = 0 + if err := store.WalkIndexes(ctx, "", func(*Index) error { count++; return nil }); err != nil { + t.Fatalf("WalkIndexes after remove: %v", err) + } + if count != 0 { + t.Errorf("expected 0 entries after remove; got %d", count) + } +} |
