package snapshot_test import ( "context" "os" "path/filepath" "slices" "strings" "syscall" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/snapshot" ) // A scan records with the snapshot each directory on a different device // from its parent directory that the walk found an entry in, as a mount // point is, the scanned path included. A filesystem that gives no device // numbers, as the in-memory one, records none. See // https://git.eeqj.de/sneak/vaultik/issues/395. func TestScanRecordsMountPoints(t *testing.T) { t.Parallel() const ( scannedPath = "/data/source" snapshotID = "mount-points" ) tests := []struct { name string // onVolume names the directories on the volume's device; every // other directory is on another one. nil leaves the device // numbers to the in-memory filesystem, which has none. onVolume []string want []string }{ { name: "volume below the scanned path", onVolume: []string{"nas"}, want: []string{scannedPath + "/nas"}, }, { name: "volume at the scanned path", onVolume: []string{"source", "nas"}, want: []string{scannedPath}, }, {name: "no device numbers", want: nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() fs := afero.NewMemMapFs() for _, path := range []string{ scannedPath + "/notes.txt", scannedPath + "/nas/a.txt", } { require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644)) } db, err := database.NewTestDB() require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) repos := database.NewRepositories(db) createTestSnapshotRecord(ctx, t, repos, snapshotID) cfg := snapshot.ScannerConfig{ FS: fs, ChunkSize: int64(1024 * 16), Repositories: repos, MaxBlobSize: int64(1024 * 1024), CompressionLevel: 3, AgeRecipients: []string{testAgePublicKey}, } if tt.onVolume != nil { cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) { if slices.Contains(tt.onVolume, info.Name()) { return 2, true } return 1, true } } _, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID) require.NoError(t, err) got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID) require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } // automountFs is an in-memory filesystem with a volume at mountPoint // that is mounted on first access: opening mountPoint to list it mounts // the volume or, if it cannot be mounted, fails with "no such file or // directory", as with the autofs daemon. Stat records the device an // entry is on at that moment, as a real stat does: unmountedDevice for // mountPoint until the volume is mounted; the volume's, 2, for // mountPoint once it is and for everything in it; 1 for everything else. type automountFs struct { afero.Fs mountPoint string unmountedDevice uint64 canMount bool mounted bool } //nolint:ireturn // afero.Fs.Open is defined to return the interface. func (fs *automountFs) Open(name string) (afero.File, error) { if name == fs.mountPoint && !fs.canMount { return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT} } if name == fs.mountPoint { fs.mounted = true } return fs.Fs.Open(name) } func (fs *automountFs) Stat(name string) (os.FileInfo, error) { info, err := fs.Fs.Stat(name) if err != nil { return nil, err } device := uint64(1) switch { case name == fs.mountPoint && !fs.mounted: device = fs.unmountedDevice case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"): device = 2 } return statWithDevice{FileInfo: info, device: device}, nil } // statWithDevice is an entry automountFs.Stat looked up, with the device // it was on then. type statWithDevice struct { os.FileInfo device uint64 } // deviceAtLookup returns the device automountFs.Stat recorded for info. func deviceAtLookup(info os.FileInfo) (uint64, bool) { stat, ok := info.(statWithDevice) return stat.device, ok } // A volume mounted on first access is mounted when the walk lists its // mount point. Until then the mount point is on the automounter's own // device (a systemd automount) or on its parent's (an autofs map that // shows its mount points before mounting them). Either way only the // mount point is recorded, by the backup that mounts the volume and by a // later one that finds it mounted. Listing an automount point whose // volume cannot be mounted fails; it is not recorded, because the walk // finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395. func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) { t.Parallel() const scannedPath = "/data/source" mountPoint := scannedPath + "/nas" tests := []struct { name string unmountedDevice uint64 canMount bool want []string }{ { name: "on the automounter's device until listed", unmountedDevice: 3, canMount: true, want: []string{mountPoint}, }, { name: "on its parent's device until listed", unmountedDevice: 1, canMount: true, want: []string{mountPoint}, }, { name: "volume that cannot be mounted", unmountedDevice: 1, canMount: false, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() fs := &automountFs{ Fs: afero.NewMemMapFs(), mountPoint: mountPoint, unmountedDevice: tt.unmountedDevice, canMount: tt.canMount, } require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755)) for _, path := range []string{ scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg", } { require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644)) } db, err := database.NewTestDB() require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) repos := database.NewRepositories(db) for _, snapshotID := range []string{"first", "later"} { createTestSnapshotRecord(ctx, t, repos, snapshotID) scanner := snapshot.NewScanner(snapshot.ScannerConfig{ FS: fs, ChunkSize: int64(1024 * 16), Repositories: repos, MaxBlobSize: int64(1024 * 1024), CompressionLevel: 3, AgeRecipients: []string{testAgePublicKey}, DeviceNumber: deviceAtLookup, }) _, err = scanner.Scan(ctx, scannedPath, snapshotID) require.NoError(t, err) got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID) require.NoError(t, err) assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID) } }) } }