package snapshot_test import ( "context" "os" "path/filepath" "slices" "strings" "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 holds an entry, 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 in which opening mountPoint to // list it mounts a volume there, if canMount, as a systemd automount // does on first access. Stat records the device an entry is on at that // moment, as a real stat does: the automounter's, 3, 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 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 { 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 = 3 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. Only the mount point is recorded, by the backup that mounts // the volume and by a later one. An automount point whose volume cannot // be mounted stays on the automounter's device and lists nothing; it is // not recorded, so it does not count as the volume found mounted. 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 canMount bool want []string }{ {name: "volume that is mounted", canMount: true, want: []string{mountPoint}}, {name: "volume that cannot be mounted", 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, canMount: tt.canMount, } require.NoError(t, fs.MkdirAll(mountPoint, 0o755)) require.NoError(t, afero.WriteFile( fs, scannedPath+"/notes.txt", []byte("notes"), 0o644)) if tt.canMount { require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755)) require.NoError(t, afero.WriteFile( fs, mountPoint+"/photos/a.jpg", []byte("photo"), 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) } }) } }