package vaultik_test import ( "context" "fmt" "os" "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(t.TempDir()) 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) } // A backup under --skip-errors that skips a file it cannot open, or a // directory it cannot list, keeps the snapshot, counts what it skipped // as failed and returns an error instead of finishing successfully. See // https://git.eeqj.de/sneak/vaultik/issues/310. func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() tests := []struct { name string skipped string // relative to the source directory }{ {name: "file it cannot open", skipped: "skipdir/file.txt"}, {name: "directory it cannot list", skipped: "skipdir"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() env := newDeletedCountEnv(t, map[string][]byte{ "stored.txt": []byte("stored"), "skipdir/file.txt": []byte("in skipdir"), }) env.setSnapshot("work", "") // A mode does not lock out root, who runs the tests, so // opening the skipped path is made to fail instead. env.v.Fs = &openFailFs{ path: filepath.Join(env.src, tt.skipped), err: os.ErrPermission, } err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ SkipErrors: true, Snapshots: []string{"work"}, }) require.ErrorIs(t, err, vaultik.ErrFilesUnreadable) assert.Contains(t, env.out.String(), "Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.") assert.NotContains(t, env.out.String(), "Finished successfully.") snapshots, err := env.v.Repositories.Snapshots.ListRecent( context.Background(), listRecentTestLimit) require.NoError(t, err) require.Len(t, snapshots, 1) assert.NotNil(t, snapshots[0].CompletedAt, "the snapshot is kept") }) } } // removeOnLookupFs is the real filesystem, except that path is removed // just before it is looked up. The walk looks an entry up after listing // its directory, so path is listed but gone when the walk reaches it. type removeOnLookupFs struct { afero.OsFs path string } func (f *removeOnLookupFs) LstatIfPossible( name string, ) (os.FileInfo, bool, error) { if name == f.path { _ = os.Remove(name) } return f.OsFs.LstatIfPossible(name) } // A file removed before the walk reaches it is counted once, as failed, // like a file removed before its data is read, and the backup finishes // successfully, with or without --skip-errors. See // https://git.eeqj.de/sneak/vaultik/issues/310. func TestBackupCountsFileRemovedBeforeWalkReachesItAsFailed(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() tests := []struct { name string skipErrors bool }{ {name: "under skip-errors", skipErrors: true}, {name: "without skip-errors", skipErrors: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() env := newDeletedCountEnv(t, map[string][]byte{ "stays.txt": []byte("stays"), "removed.txt": []byte("removed"), }) env.setSnapshot("walk", "") // The previous snapshot has the file, so a removal the walk // also counted as deleted would show. env.backUp(t, "walk") env.v.Fs = &removeOnLookupFs{ path: filepath.Join(env.src, "removed.txt"), } env.out.Reset() require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ SkipErrors: tt.skipErrors, Snapshots: []string{"walk"}, })) assert.Contains(t, env.out.String(), "Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.") assert.Contains(t, env.out.String(), "Finished successfully.") }) } } // A file an exclude pattern matches is not counted when it is removed // before the walk reaches it. See // https://git.eeqj.de/sneak/vaultik/issues/310. func TestBackupIgnoresExcludedFileRemovedBeforeWalkReachesIt(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() env := newDeletedCountEnv(t, map[string][]byte{ "stays.txt": []byte("stays"), "removed.tmp": []byte("removed"), }) env.setSnapshot("walk", "", "*.tmp") env.v.Fs = &removeOnLookupFs{ path: filepath.Join(env.src, "removed.tmp"), } require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ SkipErrors: true, Snapshots: []string{"walk"}, })) assert.Contains(t, env.out.String(), filesLine(1, 1, 0)) assert.Contains(t, env.out.String(), "Finished successfully.") } // A backup under --skip-errors --prune that skipped a file it could not // read does not prune: the previous snapshot, the only one that holds // the file, is kept, and the output says the prune was skipped. See // https://git.eeqj.de/sneak/vaultik/issues/382. func TestSkipErrorsBackupDoesNotPruneWhenItSkips(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() env := newDeletedCountEnv(t, map[string][]byte{ "other.txt": []byte("other"), "skipped.txt": []byte("skipped"), }) env.setSnapshot("photos", "") env.backUp(t, "photos") // An unchanged file is not read again, so the file is changed before // it becomes unreadable. skipped := filepath.Join(env.src, "skipped.txt") require.NoError(t, os.WriteFile(skipped, []byte("changed"), 0o600)) env.v.Fs = &openFailFs{path: skipped, err: os.ErrPermission} env.out.Reset() err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Prune: true, SkipErrors: true, Snapshots: []string{"photos"}, }) require.ErrorIs(t, err, vaultik.ErrFilesUnreadable) assert.Contains(t, env.out.String(), "Skipped the post-backup prune because 1 file(s) could not be read") assert.NotContains(t, env.out.String(), "Running post-backup prune.") snapshots, err := env.v.Repositories.Snapshots.ListRecent( context.Background(), listRecentTestLimit) require.NoError(t, err) assert.Len(t, snapshots, 2, "the previous snapshot is kept") } // A file removed during a backup under --skip-errors --prune is not a // skipped file, so the backup prunes as usual. See // https://git.eeqj.de/sneak/vaultik/issues/382. func TestSkipErrorsBackupPrunesWhenAFileIsRemovedDuringIt(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() env := newDeletedCountEnv(t, map[string][]byte{ "other.txt": []byte("other"), "removed.txt": []byte("removed"), }) env.setSnapshot("photos", "") env.backUp(t, "photos") env.v.Fs = &removeOnLookupFs{path: filepath.Join(env.src, "removed.txt")} require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Prune: true, SkipErrors: true, Snapshots: []string{"photos"}, })) snapshots, err := env.v.Repositories.Snapshots.ListRecent( context.Background(), listRecentTestLimit) require.NoError(t, err) assert.Len(t, snapshots, 1, "the previous snapshot is pruned") }