summaryrefslogtreecommitdiff
path: root/internal/store/store_crud.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store/store_crud.go')
-rw-r--r--internal/store/store_crud.go160
1 files changed, 160 insertions, 0 deletions
diff --git a/internal/store/store_crud.go b/internal/store/store_crud.go
new file mode 100644
index 0000000..b7844c3
--- /dev/null
+++ b/internal/store/store_crud.go
@@ -0,0 +1,160 @@
+// store_crud.go implements create/read/update/delete operations for the secret store.
+// Each operation (Add, Import, ImportRecursive, Remove) maps cleanly to its Ruby
+// counterpart in geheim.rb and delegates encryption/git staging to Data and Index.
+package store
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// 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, 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, 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.
+// Note: the Ruby import_recursive flattens subdirectories to basename in the
+// hash/storage path while preserving the full relative path only in the
+// description. Go preserves the full subpath in both description and hash path.
+// The compatibility verification task (355) will surface any impact on live data.
+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.
+// Pass os.Stdin as the reader for interactive use; a strings.Reader in tests.
+func (s *Store) Remove(ctx context.Context, searchTerm string, input io.Reader) 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(input)
+ 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.
+// Any input other than "y" or "n" causes the prompt to repeat.
+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":
+ return s.removeEntry(ctx, idx)
+ case "n":
+ return nil
+ }
+ // Any other input: loop and ask again.
+ }
+}
+
+// removeEntry deletes the .data and .index files for a confirmed removal,
+// delegating the actual git rm to Data.Remove and Index.Remove.
+func (s *Store) removeEntry(ctx context.Context, idx *Index) error {
+ 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
+}
+
+// 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")
+ // filepath.Base of the hash gives the final path component (the filename stem).
+ hashBase := filepath.Base(hash)
+
+ idx := &Index{
+ Description: description,
+ DataFile: hash + ".data",
+ IndexPath: indexPath,
+ Hash: hashBase,
+ }
+ dataObj := &Data{
+ DataPath: dataPath,
+ encryptor: s.cipher,
+ committer: s.git,
+ }
+ return idx, dataObj
+}