package vaultik_test import ( "context" "os" "path/filepath" "syscall" "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/log" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/vaultik" ) // TestSnapshotCreateOptions_PruneFlag verifies the Prune field exists on // SnapshotCreateOptions and can be set. func TestSnapshotCreateOptions_PruneFlag(t *testing.T) { t.Parallel() opts := &vaultik.SnapshotCreateOptions{ Prune: true, } if !opts.Prune { t.Error("Expected Prune to be true") } opts2 := &vaultik.SnapshotCreateOptions{ Prune: false, } if opts2.Prune { t.Error("Expected Prune to be false") } } // A backup under --prune that finds nothing in a configured path, as in // the mount point of a volume that is not mounted, does not prune: run // after run, the snapshot that holds the files is kept with everything // it stored, and the run fails. That holds whether the empty path is the // snapshot's only path or one of two. See // https://git.eeqj.de/sneak/vaultik/issues/388. func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() // emptied is the path, relative to the source directory, that is // emptied after the first backup. const ( snapshotName = "media" emptied = "music" ) tests := []struct { name string paths []string // relative to the source directory }{ {name: "only path", paths: []string{emptied}}, {name: "one of two paths", paths: []string{emptied, "docs"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() env := newDeletedCountEnv(t, map[string][]byte{ emptied + "/a.mp3": []byte("song a"), emptied + "/b.mp3": []byte("song b"), "docs/notes.txt": []byte("notes"), }) paths := make([]string, 0, len(tt.paths)) for _, rel := range tt.paths { paths = append(paths, filepath.Join(env.src, rel)) } env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{ Paths: paths, } env.backUp(t, snapshotName) firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName) stored, err := env.v.Storage.List(ctx, "") require.NoError(t, err) // A volume that is not mounted leaves its mount point behind // as an empty directory. emptiedDir := filepath.Join(env.src, emptied) require.NoError(t, os.RemoveAll(emptiedDir)) require.NoError(t, os.Mkdir(emptiedDir, 0o750)) for range 2 { env.out.Reset() err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Prune: true, Snapshots: []string{snapshotName}, }) require.ErrorIs(t, err, vaultik.ErrSourcePathEmpty) assert.Contains(t, env.out.String(), "Skipped the post-backup prune because nothing was found in ") assert.Contains(t, env.out.String(), emptied+" of snapshot "+snapshotName+";") assert.NotContains(t, env.out.String(), "Running post-backup prune.") } snapshots, err := env.v.Repositories.Snapshots.ListRecent( ctx, listRecentTestLimit) require.NoError(t, err) require.Len(t, snapshots, 3) assert.Equal(t, firstID, snapshots[2].ID.String(), "the snapshot that holds the files is kept") for _, key := range stored { _, err := env.v.Storage.Stat(ctx, key) require.NoError(t, err, "%s is kept", key) } }) } } // A backup under --prune of a configured path that is a symlink whose // target is gone, as one into a volume that is not mounted, fails before // anything is pruned: the snapshot that holds the files is kept with // everything it stored. See https://git.eeqj.de/sneak/vaultik/issues/388. func TestBackupThroughSymlinkWhoseTargetIsGoneDoesNotPrune(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() const snapshotName = "photos" ctx := context.Background() env := newDeletedCountEnv(t, map[string][]byte{ "volume/a.jpg": []byte("photo a"), "volume/b.jpg": []byte("photo b"), }) target := filepath.Join(env.src, "volume") link := filepath.Join(env.src, "photos") require.NoError(t, os.Symlink(target, link)) env.setSnapshot(snapshotName, "photos") env.backUp(t, snapshotName) firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName) stored, err := env.v.Storage.List(ctx, "") require.NoError(t, err) // The volume is not mounted, so the symlink points at nothing. require.NoError(t, os.RemoveAll(target)) env.out.Reset() err = env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Prune: true, Snapshots: []string{snapshotName}, }) require.ErrorIs(t, err, os.ErrNotExist) assert.Contains(t, err.Error(), link) assert.NotContains(t, env.out.String(), "Running post-backup prune.") snapshots, err := env.v.Repositories.Snapshots.ListRecent( ctx, listRecentTestLimit) require.NoError(t, err) require.Len(t, snapshots, 1) assert.Equal(t, firstID, snapshots[0].ID.String(), "the snapshot that holds the files is kept") for _, key := range stored { _, err := env.v.Storage.Stat(ctx, key) require.NoError(t, err, "%s is kept", key) } } // A backup under --skip-errors --prune that both skipped a file it could // not read and found nothing in a configured path names the empty path // too when it skips the prune. See // https://git.eeqj.de/sneak/vaultik/issues/388. func TestBackupWithEmptySourcePathAndUnreadableFileNamesBoth(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() const snapshotName = "media" env := newDeletedCountEnv(t, map[string][]byte{ "docs/notes.txt": []byte("notes"), }) require.NoError(t, os.Mkdir(filepath.Join(env.src, "music"), 0o750)) env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{ Paths: []string{ filepath.Join(env.src, "docs"), filepath.Join(env.src, "music"), }, } // A mode does not lock out root, who runs the tests, so opening the // file is made to fail instead. env.v.Fs = &openFailFs{ path: filepath.Join(env.src, "docs", "notes.txt"), err: os.ErrPermission, } err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Prune: true, SkipErrors: true, Snapshots: []string{snapshotName}, }) 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.Contains(t, env.out.String(), "Skipped the post-backup prune because nothing was found in ") assert.Contains(t, env.out.String(), "music of snapshot "+snapshotName+";") assert.NotContains(t, env.out.String(), "Running post-backup prune.") } // A backup under --prune that finds something in each configured path // prunes the previous snapshot, also when a path is a single file. See // https://git.eeqj.de/sneak/vaultik/issues/388. func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() const ( snapshotName = "album" file = "pictures/one.jpg" // relative to the source directory ) tests := []struct { name string path string // relative to the source directory }{ {name: "directory", path: filepath.Dir(file)}, {name: "single file", path: file}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() env := newDeletedCountEnv(t, map[string][]byte{ file: []byte("picture"), }) env.setSnapshot(snapshotName, tt.path) env.backUp(t, snapshotName) firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName) require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ Prune: true, Snapshots: []string{snapshotName}, })) snapshots, err := env.v.Repositories.Snapshots.ListRecent( ctx, listRecentTestLimit) require.NoError(t, err) require.Len(t, snapshots, 1, "the previous snapshot is pruned") assert.NotEqual(t, firstID, snapshots[0].ID.String()) }) } } // automountFs is the real filesystem with a volume at volumeDir that is // mounted on first access, as by an autofs map that shows its mount // points before mounting them: listing volumeDir mounts the volume or, // while cannotMount, fails with "no such file or directory". type automountFs struct { afero.OsFs volumeDir string cannotMount bool mounted bool } //nolint:ireturn // afero.Fs.Open is defined to return the interface. func (f *automountFs) Open(name string) (afero.File, error) { if name == f.volumeDir && f.cannotMount { return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT} } if name == f.volumeDir { f.mounted = true } return f.OsFs.Open(name) } // A backup under --prune in which a volume that an earlier snapshot found // mounted below the configured path is missing does not prune: run after // run, the snapshot that holds the volume's files is kept with everything // it stored, and the run fails. Once the volume is back, the prune runs. // The volume is mounted on first access and every backup starts with it // not mounted, so its directory is on its parent's device until the walk // lists it. While the volume is missing, its directory is left empty, or // listing it fails with "no such file or directory", as when an autofs // volume cannot be mounted, which the walk counts as a directory removed // during the backup. See https://git.eeqj.de/sneak/vaultik/issues/395. func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() tests := []struct { name string listingFails bool }{ {name: "directory left empty", listingFails: false}, {name: "listing fails", listingFails: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() backUpWithVolumeNotMounted(t, tt.listingFails) }) } } // backUpWithVolumeNotMounted runs the backups of // TestBackupWithVolumeNotMountedDoesNotPrune. While the volume is // missing, listing its directory fails if listingFails; otherwise the // directory is empty. func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) { t.Helper() // volume is the directory, below the configured path, where the // volume is mounted. const ( snapshotName = "data" volume = "nas" ) ctx := context.Background() env := newDeletedCountEnv(t, map[string][]byte{ volume + "/a.txt": []byte("file a"), "notes.txt": []byte("notes"), }) env.setSnapshot(snapshotName, "") prune := &vaultik.SnapshotCreateOptions{ Prune: true, Snapshots: []string{snapshotName}, } // The volume's directory is on the volume's device, 2, while the // volume is mounted. Everything else is on device 1. volumeDir := filepath.Join(env.src, volume) fs := &automountFs{volumeDir: volumeDir} newScanner := env.v.ScannerFactory env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner { fs.mounted = false params.Fs = fs params.DeviceNumber = func(info os.FileInfo) (uint64, bool) { if info.Name() == volume && fs.mounted { return 2, true } return 1, true } return newScanner(params) } env.backUp(t, snapshotName) firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName) stored, err := env.v.Storage.List(ctx, "") require.NoError(t, err) // The volume goes missing. fs.cannotMount = listingFails if !listingFails { require.NoError(t, os.RemoveAll(volumeDir)) require.NoError(t, os.Mkdir(volumeDir, 0o750)) } for range 2 { env.out.Reset() err := env.v.CreateSnapshot(prune) require.ErrorIs(t, err, vaultik.ErrVolumeNotMounted) assert.Contains(t, env.out.String(), "did not find a mount point at "+volumeDir+" of snapshot "+snapshotName) assert.Contains(t, env.out.String(), "'vaultik snapshot purge'") assert.NotContains(t, env.out.String(), "Running post-backup prune.") } snapshots, err := env.v.Repositories.Snapshots.ListRecent( ctx, listRecentTestLimit) require.NoError(t, err) require.Len(t, snapshots, 3) assert.Equal(t, firstID, snapshots[2].ID.String(), "the snapshot that holds the volume's files is kept") for _, key := range stored { _, err := env.v.Storage.Stat(ctx, key) require.NoError(t, err, "%s is kept", key) } // The volume is back. fs.cannotMount = false require.NoError(t, os.WriteFile( filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600)) require.NoError(t, env.v.CreateSnapshot(prune)) snapshots, err = env.v.Repositories.Snapshots.ListRecent( ctx, listRecentTestLimit) require.NoError(t, err) require.Len(t, snapshots, 1, "the older snapshots are pruned") }