diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-18 08:43:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-18 08:43:10 +0300 |
| commit | 526e3bd1bea7e2ef67c6984b91cdb9b3ac3be4e5 (patch) | |
| tree | 5774a8d492f2442403d2b02ed55fd814e646f585 | |
| parent | 7bca02bddcc88002e8edf72d2f20f526cf9d2668 (diff) | |
refactor: split store.go into focused files by responsibility (task o6)
Split 562-line store.go into four files:
- store.go (264 lines): Store type, WalkIndexes, Search, action dispatch
- store_crud.go (160 lines): Add, Import, Remove, buildPair
- store_picker.go (160 lines): Fzf, PickerResult, key parsing
- store_shred.go (54 lines): ShredFile, ShredAllExported
No API or behaviour changes. All functions under 30 lines, all files under 300 lines.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/store/store.go | 316 | ||||
| -rw-r--r-- | internal/store/store_crud.go | 160 | ||||
| -rw-r--r-- | internal/store/store_picker.go | 160 | ||||
| -rw-r--r-- | internal/store/store_shred.go | 54 |
4 files changed, 383 insertions, 307 deletions
diff --git a/internal/store/store.go b/internal/store/store.go index 9d661c5..72eeee0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2,24 +2,29 @@ // 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. +// +// The package is split into focused files: +// - store.go — Store type, constructor, walk/search (core business logic) +// - store_crud.go — add/import/remove/buildPair (mutation operations) +// - store_picker.go — fzf/picker types, Fzf/FzfInteractive, key parsing +// - store_shred.go — ShredFile, ShredAllExported (secure deletion) +// - data.go — Data struct: encrypt/decrypt/export/reimport +// - index.go — Index struct: load, commit, remove, sort +// - dependencies.go — Encryptor and Committer interfaces package store import ( - "bufio" "context" "crypto/sha256" "encoding/hex" "fmt" - "io" "os" - "os/exec" "path/filepath" "regexp" "sort" "strings" "codeberg.org/snonux/foostore/internal/config" - "codeberg.org/snonux/foostore/internal/picker" ) // Action describes what to do with each matching secret during a Search call. @@ -35,24 +40,6 @@ const ( ActionEdit // export, edit in external editor, reimport ) -// PickerAction describes the action requested from the interactive fzf picker. -type PickerAction string - -const ( - PickerSelect PickerAction = "select" - PickerCat PickerAction = "cat" - PickerPaste PickerAction = "paste" - PickerOpen PickerAction = "open" - PickerEdit PickerAction = "edit" -) - -// PickerResult is the selected description plus the desired action from fzf. -// Description is empty when the picker was cancelled. -type PickerResult struct { - Description string - Action PickerAction -} - // Store provides all secret-store operations. // regexCache avoids recompiling the same search-term regexp on every WalkIndexes call. type Store struct { @@ -275,288 +262,3 @@ func (s *Store) actionExport(ctx context.Context, idx *Index, fullPath bool) err } return d.Export(ctx, s.cfg.ExportDir, destFile) } - -// Fzf launches fzf and returns only the selected description for compatibility -// with callers that do not care about picker action keys. -func (s *Store) Fzf(ctx context.Context) (string, error) { - result, err := s.FzfInteractive(ctx) - if err != nil { - return "", err - } - return result.Description, nil -} - -// FzfInteractive launches fzf with helper bars, preview metadata, and action -// key bindings, then returns both the selected description and action. -func (s *Store) FzfInteractive(ctx context.Context) (PickerResult, error) { - var indexes IndexSlice - if err := s.WalkIndexes(ctx, "", func(idx *Index) error { - indexes = append(indexes, idx) - return nil - }); err != nil { - return PickerResult{}, err - } - if len(indexes) == 0 { - return PickerResult{}, nil - } - - sort.Sort(indexes) - entries := make([]picker.Entry, 0, len(indexes)) - for i, idx := range indexes { - kind := "TEXT" - if idx.IsBinary() { - kind = "BINARY" - } - hashSuffix := "" - if len(idx.Hash) >= 63 { - hashSuffix = idx.Hash[53:63] - } - entries = append(entries, picker.Entry{ - RowID: i + 1, - Description: idx.Description, - Kind: kind, - HashSuffix: hashSuffix, - }) - } - - return runFzfInteractive(ctx, entries) -} - -func runFzfInteractive(ctx context.Context, entries []picker.Entry) (PickerResult, error) { - selection, err := picker.Run(ctx, entries) - if err != nil { - return PickerResult{}, err - } - - action, ok := parsePickerAction(selection.Key) - if !ok { - return PickerResult{}, nil - } - if selection.Description == "" { - return PickerResult{}, nil - } - - return PickerResult{ - Description: selection.Description, - Action: action, - }, nil -} - -func buildFzfArgs(entryCount int) []string { - return picker.BuildArgs( - entryCount, - os.Getenv("FOOSTORE_TUI_THEME"), - os.Getenv("FOOSTORE_FZF_OPTS"), - ) -} - -func pickerColorTheme(theme string) string { - return picker.ColorTheme(theme) -} - -func parsePickerResult(output string, idToDescription map[string]string) PickerResult { - selection := picker.ParseSelection(output, idToDescription) - action, ok := parsePickerAction(selection.Key) - if !ok || selection.Description == "" { - return PickerResult{} - } - return PickerResult{ - Description: selection.Description, - Action: action, - } -} - -func parsePickerAction(keyLine string) (PickerAction, bool) { - switch strings.TrimSpace(keyLine) { - case "", "enter": - return PickerSelect, true - case "ctrl-t", "alt-t": - return PickerCat, true - case "ctrl-y", "alt-y": - return PickerPaste, true - case "ctrl-o", "alt-o": - return PickerOpen, true - case "ctrl-e", "alt-e": - return PickerEdit, true - default: - return "", false - } -} - -// 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. -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. -// Mirrors Ruby's shred_all_exported: iterates all files and returns the last -// non-nil error so that as many files as possible are shredded even on failure. -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) - } - - var lastErr error - for _, entry := range entries { - info, err := os.Stat(entry) - if err != nil || !info.Mode().IsRegular() { - continue - } - if err := ShredFile(ctx, entry); err != nil { - // Record the error but keep shredding — security demands best-effort - // destruction of all exported secrets even if one fails. - lastErr = err - } - } - return lastErr -} - -// 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") - // 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 -} 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 +} diff --git a/internal/store/store_picker.go b/internal/store/store_picker.go new file mode 100644 index 0000000..1e8b6b7 --- /dev/null +++ b/internal/store/store_picker.go @@ -0,0 +1,160 @@ +// store_picker.go handles fzf/picker integration for the interactive secret browser. +// It wraps the internal/picker package to provide fuzzy-selection with action key +// bindings (cat, paste, open, edit) and maps the raw fzf output back to typed +// PickerResult values consumed by the CLI layer. +package store + +import ( + "context" + "os" + "sort" + "strings" + + "codeberg.org/snonux/foostore/internal/picker" +) + +// PickerAction describes the action requested from the interactive fzf picker. +type PickerAction string + +const ( + PickerSelect PickerAction = "select" + PickerCat PickerAction = "cat" + PickerPaste PickerAction = "paste" + PickerOpen PickerAction = "open" + PickerEdit PickerAction = "edit" +) + +// PickerResult is the selected description plus the desired action from fzf. +// Description is empty when the picker was cancelled. +type PickerResult struct { + Description string + Action PickerAction +} + +// Fzf launches fzf and returns only the selected description for compatibility +// with callers that do not care about picker action keys. +func (s *Store) Fzf(ctx context.Context) (string, error) { + result, err := s.FzfInteractive(ctx) + if err != nil { + return "", err + } + return result.Description, nil +} + +// FzfInteractive launches fzf with helper bars, preview metadata, and action +// key bindings, then returns both the selected description and action. +func (s *Store) FzfInteractive(ctx context.Context) (PickerResult, error) { + var indexes IndexSlice + if err := s.WalkIndexes(ctx, "", func(idx *Index) error { + indexes = append(indexes, idx) + return nil + }); err != nil { + return PickerResult{}, err + } + if len(indexes) == 0 { + return PickerResult{}, nil + } + + sort.Sort(indexes) + entries := buildPickerEntries(indexes) + + return runFzfInteractive(ctx, entries) +} + +// buildPickerEntries converts a sorted IndexSlice into picker.Entry values, +// annotating each entry with its kind (TEXT/BINARY) and a short hash suffix +// for visual disambiguation in the fzf list. +func buildPickerEntries(indexes IndexSlice) []picker.Entry { + entries := make([]picker.Entry, 0, len(indexes)) + for i, idx := range indexes { + kind := "TEXT" + if idx.IsBinary() { + kind = "BINARY" + } + hashSuffix := "" + if len(idx.Hash) >= 63 { + hashSuffix = idx.Hash[53:63] + } + entries = append(entries, picker.Entry{ + RowID: i + 1, + Description: idx.Description, + Kind: kind, + HashSuffix: hashSuffix, + }) + } + return entries +} + +// runFzfInteractive delegates to picker.Run, then maps the raw fzf selection +// (key line + description) to a typed PickerResult. An empty or cancelled +// selection returns a zero PickerResult without error. +func runFzfInteractive(ctx context.Context, entries []picker.Entry) (PickerResult, error) { + selection, err := picker.Run(ctx, entries) + if err != nil { + return PickerResult{}, err + } + + action, ok := parsePickerAction(selection.Key) + if !ok { + return PickerResult{}, nil + } + if selection.Description == "" { + return PickerResult{}, nil + } + + return PickerResult{ + Description: selection.Description, + Action: action, + }, nil +} + +// buildFzfArgs returns the fzf argument slice for the given entry count, +// reading FOOSTORE_TUI_THEME and FOOSTORE_FZF_OPTS from the environment. +// Delegates to picker.BuildArgs so that the fzf argument format stays in one place. +func buildFzfArgs(entryCount int) []string { + return picker.BuildArgs( + entryCount, + os.Getenv("FOOSTORE_TUI_THEME"), + os.Getenv("FOOSTORE_FZF_OPTS"), + ) +} + +// pickerColorTheme returns the fzf --color string for the named TUI theme. +// Delegates to picker.ColorTheme; unknown names fall back to the bold preset. +func pickerColorTheme(theme string) string { + return picker.ColorTheme(theme) +} + +// parsePickerResult decodes a raw fzf --expect output string into a PickerResult. +// idToDescription maps row-ID strings back to their human-readable descriptions. +// Returns a zero PickerResult when the key is unknown or the selection is empty. +func parsePickerResult(output string, idToDescription map[string]string) PickerResult { + selection := picker.ParseSelection(output, idToDescription) + action, ok := parsePickerAction(selection.Key) + if !ok || selection.Description == "" { + return PickerResult{} + } + return PickerResult{ + Description: selection.Description, + Action: action, + } +} + +// parsePickerAction maps an fzf key line (e.g. "ctrl-t", "enter") to a typed +// PickerAction. Returns (action, true) on a recognised key, ("", false) otherwise. +func parsePickerAction(keyLine string) (PickerAction, bool) { + switch strings.TrimSpace(keyLine) { + case "", "enter": + return PickerSelect, true + case "ctrl-t", "alt-t": + return PickerCat, true + case "ctrl-y", "alt-y": + return PickerPaste, true + case "ctrl-o", "alt-o": + return PickerOpen, true + case "ctrl-e", "alt-e": + return PickerEdit, true + default: + return "", false + } +} diff --git a/internal/store/store_shred.go b/internal/store/store_shred.go new file mode 100644 index 0000000..7d28f70 --- /dev/null +++ b/internal/store/store_shred.go @@ -0,0 +1,54 @@ +// store_shred.go provides secure file deletion for the secret store. +// ShredFile uses GNU shred(1) when available and falls back to "rm -Pfv". +// ShredAllExported iterates cfg.ExportDir and shreds every regular file, +// mirroring Ruby's Geheim#shred_all_exported best-effort deletion strategy. +package store + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// ShredAllExported removes (shreds) every regular file in cfg.ExportDir. +// Uses GNU shred when available; falls back to "rm -Pfv" otherwise. +// Mirrors Ruby's shred_all_exported: iterates all files and returns the last +// non-nil error so that as many files as possible are shredded even on failure. +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) + } + + var lastErr error + for _, entry := range entries { + info, err := os.Stat(entry) + if err != nil || !info.Mode().IsRegular() { + continue + } + if err := ShredFile(ctx, entry); err != nil { + // Record the error but keep shredding — security demands best-effort + // destruction of all exported secrets even if one fails. + lastErr = err + } + } + return lastErr +} + +// 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() +} |
