From 4c3ea7ae152b6692ae53474ac3e8e427133e748b Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Oct 2026 08:39:18 +0000 Subject: [PATCH] Never skip a local index error for a directory or symlink (closes #284) Under --skip-errors, an error recording a directory or symlink in the local index was skipped like an unreadable file. The snapshot completed without the entry, and a restore did not recreate it. processFileWithErrorHandling now records a directory or symlink itself and returns any error, which stops the backup with or without the flag. The skip sees only a regular file's errors. The test injects the error with a SQLite trigger that refuses the directory's files row. Model: opus-5-5 --- TODO.md | 9 +++ internal/snapshot/scanner.go | 27 ++++--- internal/vaultik/snapshot_skip_errors_test.go | 75 +++++++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 internal/vaultik/snapshot_skip_errors_test.go diff --git a/TODO.md b/TODO.md index 2c8fffe..930a1ae 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,15 @@ the tag exists and is exercised; what is left is merging `next` to # Completed Steps +- 2026-10-08: Made a local index error while recording a directory or + symlink stop a backup under `--skip-errors` + ([issue #284](https://git.eeqj.de/sneak/vaultik/issues/284)). Phase 2 + only records such an entry in the local index, since it has no data + to open or read, but an error doing so was skipped like an unreadable + file. The snapshot then completed without the entry, and a restore + did not recreate it. The error now stops the backup, as it does + without the flag. + - 2026-10-08: Counted a file that a backup could not store as failed ([issue #280](https://git.eeqj.de/sneak/vaultik/issues/280)). A file that phase 1 counted and phase 2 could not open, because it was diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 70641c0..165c7a2 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -1348,11 +1348,25 @@ func (s *Scanner) processPhase( return s.finalizeProcessPhase(ctx, result) } -// processFileWithErrorHandling wraps processFileStreaming with error recovery for -// deleted files and skip-errors mode. Returns (skipped, error). +// processFileWithErrorHandling records a directory or symlink, or wraps +// processFileStreaming for a regular file with error recovery for deleted +// files and skip-errors mode. Returns (skipped, error). func (s *Scanner) processFileWithErrorHandling( ctx context.Context, fileToProcess *FileToProcess, result *ScanResult, ) (bool, error) { + // A directory or symlink has no data to open or read; it is only + // recorded in the local index. An error recording it stops the run + // even under --skip-errors, or the snapshot would complete without it. + mode := os.FileMode(fileToProcess.File.Mode) + if mode&os.ModeSymlink != 0 || mode.IsDir() { + err := s.recordNonRegularFile(ctx, fileToProcess) + if err != nil { + return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err) + } + + return false, nil + } + err := s.processFileStreaming(ctx, fileToProcess, result) if err != nil { // A packer/database/encryption/upload failure means the chunk's data @@ -1770,16 +1784,11 @@ func (e *packerError) Error() string { return e.err.Error() } func (e *packerError) Unwrap() error { return e.err } -// processFileStreaming processes a file by streaming chunks directly to the packer +// processFileStreaming processes a regular file by streaming chunks directly +// to the packer func (s *Scanner) processFileStreaming( ctx context.Context, fileToProcess *FileToProcess, result *ScanResult, ) error { - // Symlinks and directories have no data to chunk — just record them in the DB. - mode := os.FileMode(fileToProcess.File.Mode) - if mode&os.ModeSymlink != 0 || mode.IsDir() { - return s.recordNonRegularFile(ctx, fileToProcess) - } - file, err := s.fs.Open(fileToProcess.Path) if err != nil { return fmt.Errorf("opening file: %w", wrapPermissionError(fileToProcess.Path, err)) diff --git a/internal/vaultik/snapshot_skip_errors_test.go b/internal/vaultik/snapshot_skip_errors_test.go new file mode 100644 index 0000000..0f88515 --- /dev/null +++ b/internal/vaultik/snapshot_skip_errors_test.go @@ -0,0 +1,75 @@ +package vaultik_test + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/config" + "sneak.berlin/go/vaultik/internal/database" + "sneak.berlin/go/vaultik/internal/log" + "sneak.berlin/go/vaultik/internal/storage" + "sneak.berlin/go/vaultik/internal/vaultik" +) + +// --skip-errors skips only a file that cannot be opened or read. A local +// index error while a directory is recorded stops the backup, and the +// snapshot is not recorded as complete. See +// https://git.eeqj.de/sneak/vaultik/issues/284. +func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + + // The scan walks the source path with symlinks resolved, so dirPath + // must be spelled the same way to match the row the scan inserts. + tempDir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + + srcDir := filepath.Join(tempDir, "src") + dirPath := filepath.Join(srcDir, "dir") + + fs := afero.NewOsFs() + require.NoError(t, fs.MkdirAll(dirPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, + filepath.Join(dirPath, "file.txt"), []byte("file content"), 0o644)) + + cfg := faultTestConfig() + cfg.IndexPath = filepath.Join(tempDir, "index.sqlite") + cfg.Snapshots = map[string]config.SnapshotConfig{ + "tree": {Paths: []string{srcDir}}, + } + + store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + require.NoError(t, err) + + db, err := database.New(ctx, cfg.IndexPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + // The local index refuses the directory's files row. + _, err = db.Conn().ExecContext(ctx, fmt.Sprintf(` + CREATE TRIGGER refuse_directory BEFORE INSERT ON files + WHEN NEW.path = '%s' + BEGIN SELECT RAISE(ABORT, 'simulated index error'); END`, dirPath)) + require.NoError(t, err) + + repos := database.NewRepositories(db) + v := newBackupVaultik(ctx, cfg, store, repos, db, fs) + + err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ + SkipErrors: true, + Snapshots: []string{"tree"}, + }) + require.ErrorContains(t, err, "simulated index error") + + snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit) + require.NoError(t, err) + require.Len(t, snapshots, 1) + assert.Nil(t, snapshots[0].CompletedAt) +}