1 Commits
Author SHA1 Message Date
clawbot 4227ef8ba0 Do not prune when a backup misses an earlier mount point (closes #395)
check / check (push) Canceled after 0s
snapshot create --prune purged the older snapshots holding a volume's
files when the volume, mounted below a configured path, was not
mounted: its empty mount point read as deleted files.

The scan now records in the local index each directory at or below a
configured path that is on a different device from its parent
directory. Under --prune, when an earlier snapshot of that name
recorded such a directory and this run does not find it on a different
device, the run skips the prune, keeps every older snapshot, names the
directory and exits non-zero, as for an empty configured path. For a
volume retired, or a directory no longer backed up, on purpose, the
older snapshots are removed with snapshot purge.

Model: opus-5-5
2026-10-15 03:50:08 +00:00
8 changed files with 95 additions and 275 deletions
+9 -10
View File
@@ -337,16 +337,15 @@ value where a list belongs or a word where a number belongs), is refused.
path that is empty on purpose out of the config, or remove the older path that is empty on purpose out of the config, or remove the older
snapshots by hand with `snapshot purge`. When an earlier snapshot of the snapshots by hand with `snapshot purge`. When an earlier snapshot of the
same name in the local index found a volume mounted at or below a same name in the local index found a volume mounted at or below a
configured path (a directory on a different filesystem from its parent, configured path (a directory on a different filesystem from its parent)
with something in it) and this backup finds none mounted there, the prune and this backup finds none mounted there, the prune does not run either:
does not run either: every older snapshot is kept, and the command names every older snapshot is kept, and the command names the directory and
the directory and exits non-zero, on every run until the volume is mounted exits non-zero, on every run until the volume is mounted again. The same
again. The same happens when the volume is empty, as an automounted volume happens when that directory is no longer backed up, because it is excluded
that could not be mounted looks, or when that directory is no longer or its configured path was removed. If the volume was retired or the
backed up, because it is excluded or its configured path was removed. If directory is no longer backed up on purpose, remove the older snapshots by
any of this is on purpose, as for a retired volume, remove the older hand with `snapshot purge`. A file removed during the backup does not stop
snapshots by hand with `snapshot purge`. A file removed during the backup the prune.
does not stop the prune.
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than * `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`). this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
Without `--prune`, or with a value that is not a valid duration, the Without `--prune`, or with a value that is not a valid duration, the
+1 -1
View File
@@ -30,7 +30,7 @@ owner's; a `v*` tag runs the release build in
([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a ([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a
prune could delete every snapshot still holding the volume's files. prune could delete every snapshot still holding the volume's files.
Each snapshot now records in the local index the directories it found Each snapshot now records in the local index the directories it found
on a different filesystem from their parent, with something in them. on a different filesystem from their parent.
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup - 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that found nothing in a configured path that found nothing in a configured path
+2 -2
View File
@@ -154,7 +154,7 @@ Maps snapshots to the blobs they reference.
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation. **Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
### 10. `snapshot_mount_points` ### 10. `snapshot_mount_points`
The directories at or below a configured path, the configured path itself included, that a snapshot's backup found on a different filesystem from their parent directory (a different device number), as a mount point is, and found an entry in. An empty one, as an automount point whose volume could not be mounted, is left out. The directories at or below a configured path that a snapshot's backup found on a different filesystem from their parent directory (a different device number), as a mount point is, the configured path itself included.
**Columns:** **Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id) - `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
@@ -183,7 +183,7 @@ Tracks blob upload metrics.
- `INSERT OR REPLACE INTO files` - Update file metadata - `INSERT OR REPLACE INTO files` - Update file metadata
- `SELECT * FROM files WHERE path = ?` - Check if file has changed - `SELECT * FROM files WHERE path = ?` - Check if file has changed
- `INSERT INTO snapshot_files` - Add file to current snapshot - `INSERT INTO snapshot_files` - Add file to current snapshot
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent that holds an entry - `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent
2. **Chunking** (for changed files) 2. **Chunking** (for changed files)
- `INSERT OR IGNORE INTO chunks` - Store new chunks - `INSERT OR IGNORE INTO chunks` - Store new chunks
+1 -1
View File
@@ -125,7 +125,7 @@ CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id)
-- Snapshot mount points table: the directories at or below a configured -- Snapshot mount points table: the directories at or below a configured
-- path that the snapshot's backup found on a different filesystem from -- path that the snapshot's backup found on a different filesystem from
-- their parent directory, as a mount point is, and found an entry in -- their parent directory, as a mount point is
CREATE TABLE IF NOT EXISTS snapshot_mount_points ( CREATE TABLE IF NOT EXISTS snapshot_mount_points (
snapshot_id TEXT NOT NULL, snapshot_id TEXT NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
+3 -132
View File
@@ -5,7 +5,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -16,9 +15,9 @@ import (
) )
// A scan records with the snapshot each directory on a different device // 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, // from its parent directory, as a mount point is, the scanned path
// the scanned path included. A filesystem that gives no device numbers, // included. A filesystem that gives no device numbers, as the in-memory
// as the in-memory one, records none. See https://git.eeqj.de/sneak/vaultik/issues/395. // one, records none. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsMountPoints(t *testing.T) { func TestScanRecordsMountPoints(t *testing.T) {
t.Parallel() t.Parallel()
@@ -96,131 +95,3 @@ func TestScanRecordsMountPoints(t *testing.T) {
}) })
} }
} }
// 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)
}
})
}
}
+60 -82
View File
@@ -310,7 +310,7 @@ func (s *Scanner) Scan(
} }
} }
err = s.recordDirectories(ctx, scanResult) err = s.recordDirectories(ctx, scanResult.Directories)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -888,10 +888,6 @@ type ScanPhaseResult struct {
FilesToProcess []*FileToProcess FilesToProcess []*FileToProcess
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
Directories []*FileToProcess // Every directory found, to record Directories []*FileToProcess // Every directory found, to record
// MountPoints are the directories found on a different device from
// their parent directory, the scanned path included, that the walk
// found an entry in.
MountPoints []string
} }
// scanPhase performs the initial directory scan to identify files to process // scanPhase performs the initial directory scan to identify files to process
@@ -911,7 +907,7 @@ func (s *Scanner) scanPhase(
// estimate leaves them out. // estimate leaves them out.
estimatedTotal := countNonDirectories(knownFiles) estimatedTotal := countNonDirectories(knownFiles)
collector := &scanCollector{mountPoints: make(map[string]bool)} collector := &scanCollector{}
// Set up periodic status output // Set up periodic status output
startTime := time.Now() startTime := time.Now()
@@ -954,8 +950,6 @@ func (s *Scanner) scanPhase(
// The scanned directory itself is not an entry found in it. // The scanned directory itself is not an entry found in it.
if filePath != path || !info.IsDir() { if filePath != path || !info.IsDir() {
result.EntriesFound++ result.EntriesFound++
collector.foundEntryIn(filepath.Dir(filePath))
} }
// Handle symlinks and directories // Handle symlinks and directories
@@ -992,7 +986,6 @@ func (s *Scanner) scanPhase(
FilesToProcess: collector.filesToProcess, FilesToProcess: collector.filesToProcess,
UnchangedFileIDs: collector.unchangedFileIDs, UnchangedFileIDs: collector.unchangedFileIDs,
Directories: collector.directories, Directories: collector.directories,
MountPoints: collector.mountPointsWithEntries(),
}, nil }, nil
} }
@@ -1012,10 +1005,8 @@ func countNonDirectories(files map[string]*database.File) int64 {
// recordSpecialEntry records symlinks and directories (which have no // recordSpecialEntry records symlinks and directories (which have no
// data to chunk) and reports whether it handled the entry. A symlink is // data to chunk) and reports whether it handled the entry. A symlink is
// counted and checked for changes as a regular file is; a directory is // counted and checked for changes as a regular file is; a directory is
// set aside to be recorded, also as a mount point when it is on a // set aside to be recorded and is not counted. For a symlink whose
// different device from its parent directory, and is not counted. For a // target cannot be read it returns handleWalkError's result.
// symlink whose target cannot be read it returns handleWalkError's
// result.
func (s *Scanner) recordSpecialEntry( func (s *Scanner) recordSpecialEntry(
filePath string, info os.FileInfo, filePath string, info os.FileInfo,
existingFiles map[string]struct{}, existingFiles map[string]struct{},
@@ -1044,10 +1035,6 @@ func (s *Scanner) recordSpecialEntry(
existingFiles[filePath] = struct{}{} existingFiles[filePath] = struct{}{}
collector.addDirectory(filePath, info, file) collector.addDirectory(filePath, info, file)
if s.onOtherDeviceThanParent(filePath, info) {
collector.addMountPoint(filePath)
}
return true, nil return true, nil
} }
@@ -1125,46 +1112,6 @@ type scanCollector struct {
unchangedFileIDs []types.FileID // Just IDs - no new records needed unchangedFileIDs []types.FileID // Just IDs - no new records needed
directories []*FileToProcess directories []*FileToProcess
filesScanned int64 filesScanned int64
// mountPoints maps each directory found on a different device from
// its parent directory to whether the walk found an entry in it.
mountPoints map[string]bool
}
// addMountPoint sets aside a directory found on a different device from
// its parent directory, to be recorded if the walk finds an entry in it.
func (c *scanCollector) addMountPoint(path string) {
c.mu.Lock()
defer c.mu.Unlock()
c.mountPoints[path] = false
}
// foundEntryIn notes that the walk found an entry in directory dir.
func (c *scanCollector) foundEntryIn(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.mountPoints[dir]; ok {
c.mountPoints[dir] = true
}
}
// mountPointsWithEntries returns the mount points the walk found an
// entry in. An empty one, as an automount point whose volume could not
// be mounted, does not show a volume mounted there.
func (c *scanCollector) mountPointsWithEntries() []string {
c.mu.Lock()
defer c.mu.Unlock()
var mountPoints []string
for dir, foundEntry := range c.mountPoints {
if foundEntry {
mountPoints = append(mountPoints, dir)
}
}
return mountPoints
} }
// addDirectory sets a directory aside to be recorded. It is not counted // addDirectory sets a directory aside to be recorded. It is not counted
@@ -1360,23 +1307,29 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
// mount points (see recordMountPoints). A directory has no data to back // mount points (see recordMountPoints). A directory has no data to back
// up, so it is not among the files counted and backed up. // up, so it is not among the files counted and backed up.
func (s *Scanner) recordDirectories( func (s *Scanner) recordDirectories(
ctx context.Context, scanned *ScanPhaseResult, ctx context.Context, directories []*FileToProcess,
) error { ) error {
for _, dir := range scanned.Directories { for _, dir := range directories {
err := s.recordNonRegularFile(ctx, dir) err := s.recordNonRegularFile(ctx, dir)
if err != nil { if err != nil {
return fmt.Errorf("recording directory %s: %w", dir.Path, err) return fmt.Errorf("recording directory %s: %w", dir.Path, err)
} }
} }
return s.recordMountPoints(ctx, scanned.MountPoints) return s.recordMountPoints(ctx, directories)
} }
// recordMountPoints records with the snapshot the mount points the walk // recordMountPoints records with the snapshot each directory the walk
// found (see ScanPhaseResult.MountPoints). // found on a different device from its parent directory, as a mount
// point is, the scanned path included.
func (s *Scanner) recordMountPoints( func (s *Scanner) recordMountPoints(
ctx context.Context, mountPoints []string, ctx context.Context, directories []*FileToProcess,
) error { ) error {
mountPoints, err := s.findMountPoints(directories)
if err != nil {
return err
}
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error { return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
for _, dir := range mountPoints { for _, dir := range mountPoints {
log.Info("Found a mount point", "path", dir) log.Info("Found a mount point", "path", dir)
@@ -1391,32 +1344,57 @@ func (s *Scanner) recordMountPoints(
}) })
} }
// onOtherDeviceThanParent reports whether the directory at path, which // findMountPoints returns the directories, among those the walk found,
// info describes, is on a different device from its parent directory, // that are on a different device from their parent directory. The
// looked up now. The walk calls it on reaching the directory, by when it // scanned path is compared with the directory that holds it, which the
// has listed the parent (the scanned path aside). Listing a directory // walk did not reach.
// mounts a volume mounted there on first access (a systemd automount), func (s *Scanner) findMountPoints(
// so the walk's own entry for the parent, looked up before the listing, directories []*FileToProcess,
// can show the automounter's device instead. A directory whose device ) ([]string, error) {
// number, or whose parent's, cannot be read counts as on its parent's walked := make(map[string]os.FileInfo, len(directories))
// device: a mount point missed can only stop a prune, never let one run. for _, dir := range directories {
func (s *Scanner) onOtherDeviceThanParent(path string, info os.FileInfo) bool { walked[dir.Path] = dir.FileInfo
dirDevice, ok := s.deviceNumber(info) }
var mountPoints []string
for _, dir := range directories {
parentPath := filepath.Dir(dir.Path)
parent, ok := walked[parentPath]
if !ok {
var err error
parent, err = s.fs.Stat(parentPath)
if err != nil {
return nil, fmt.Errorf(
"looking up the parent of %s: %w", dir.Path, err)
}
}
if s.onOtherDevice(dir.FileInfo, parent) {
mountPoints = append(mountPoints, dir.Path)
}
}
return mountPoints, nil
}
// onOtherDevice reports whether dir is on a different device from
// parent. A file whose device number cannot be read counts as on the
// same device as its parent.
func (s *Scanner) onOtherDevice(dir, parent os.FileInfo) bool {
dirDevice, ok := s.deviceNumber(dir)
if !ok { if !ok {
return false return false
} }
parent, err := s.fs.Stat(filepath.Dir(path)) parentDevice, ok := s.deviceNumber(parent)
if err != nil { if !ok {
log.Warn("Could not look up the parent of a directory "+
"to check for a mount point", "path", path, "error", err)
return false return false
} }
parentDevice, ok := s.deviceNumber(parent) return dirDevice != parentDevice
return ok && dirDevice != parentDevice
} }
// deviceNumberFromStat returns the number of the device that holds the // deviceNumberFromStat returns the number of the device that holds the
+10 -9
View File
@@ -56,11 +56,11 @@ var ErrSourcePathEmpty = errors.New("source path(s) were empty")
// ErrVolumeNotMounted is returned when --prune skipped the post-backup // ErrVolumeNotMounted is returned when --prune skipped the post-backup
// prune because a directory that an earlier snapshot of the same name // prune because a directory that an earlier snapshot of the same name
// found as a mount point was not found as one in this run: the volume // found as a mount point was not found as one in this run: the volume
// mounted there is not mounted or is empty, or the directory is no // mounted there is not mounted, or the directory is no longer backed up.
// longer backed up. The snapshots are complete and kept, so the caller // The snapshots are complete and kept, so the caller can report it apart
// can report it apart from a failed backup. A run that also skipped // from a failed backup. A run that also skipped unreadable files or
// unreadable files or found a configured path empty returns // found a configured path empty returns ErrFilesUnreadable or
// ErrFilesUnreadable or ErrSourcePathEmpty instead. // ErrSourcePathEmpty instead.
var ErrVolumeNotMounted = errors.New( var ErrVolumeNotMounted = errors.New(
"mount point(s) an earlier snapshot recorded were not found") "mount point(s) an earlier snapshot recorded were not found")
@@ -237,10 +237,11 @@ func (v *Vaultik) skipPostBackupPrune(
if len(unmountedPaths) > 0 { if len(unmountedPaths) > 0 {
v.UI.Warningf("Skipped the post-backup prune because this backup "+ v.UI.Warningf("Skipped the post-backup prune because this backup "+
"did not find a mount point at %s, where an earlier snapshot "+ "did not find a mount point at %s, where an earlier snapshot "+
"found one: the volume is not mounted or is empty, or the "+ "found one: either the volume is not mounted or the directory "+
"directory is no longer backed up. Older snapshots are kept. "+ "is no longer backed up. Older snapshots are kept. Mount the "+
"Mount the volume again, or, if that is on purpose, remove the "+ "volume again, or, if the volume was retired or the directory "+
"older snapshots by hand with 'vaultik snapshot purge'.", "is no longer backed up on purpose, remove the older snapshots "+
"by hand with 'vaultik snapshot purge'.",
strings.Join(unmountedPaths, ", ")) strings.Join(unmountedPaths, ", "))
} }
+9 -38
View File
@@ -265,40 +265,11 @@ func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
// mounted below the configured path is not mounted does not prune: run // mounted below the configured path is not mounted does not prune: run
// after run, the snapshot that holds the volume's files is kept with // after run, the snapshot that holds the volume's files is kept with
// everything it stored, and the run fails. Once the volume is mounted // everything it stored, and the run fails. Once the volume is mounted
// again, the prune runs. The volume's directory is left either as a // again, the prune runs. See https://git.eeqj.de/sneak/vaultik/issues/395.
// plain directory or as an automount point whose volume cannot be
// mounted, which stays empty on the automounter's own device. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) { func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
// unmountedDevice is the device number of the volume's directory
// while the volume is not mounted. Its parent directory is on device
// 1, the mounted volume on device 2.
tests := []struct {
name string
unmountedDevice uint64
}{
{name: "plain directory", unmountedDevice: 1},
{name: "automount point that cannot be mounted", unmountedDevice: 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
backUpWithVolumeNotMounted(t, tt.unmountedDevice)
})
}
}
// backUpWithVolumeNotMounted runs the backups of
// TestBackupWithVolumeNotMountedDoesNotPrune, with the volume's directory
// on device unmountedDevice while the volume is not mounted.
func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
t.Helper()
// volume is the directory, below the configured path, where the // volume is the directory, below the configured path, where the
// volume is mounted. // volume is mounted.
const ( const (
@@ -318,14 +289,14 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
Prune: true, Snapshots: []string{snapshotName}, Prune: true, Snapshots: []string{snapshotName},
} }
// volumeDevice is the device of the volume's directory, 2 while the // While the volume is mounted, its directory is on a device of its
// volume is mounted. Everything else is on device 1. // own.
volumeDevice := uint64(2) mounted := true
newScanner := env.v.ScannerFactory newScanner := env.v.ScannerFactory
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner { env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) { params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
if info.Name() == volume { if mounted && info.Name() == volume {
return volumeDevice, true return 2, true
} }
return 1, true return 1, true
@@ -341,12 +312,12 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
require.NoError(t, err) require.NoError(t, err)
// A volume that is not mounted leaves its mount point behind as an // A volume that is not mounted leaves its mount point behind as an
// empty directory. // empty directory on its parent's device.
volumeDir := filepath.Join(env.src, volume) volumeDir := filepath.Join(env.src, volume)
require.NoError(t, os.RemoveAll(volumeDir)) require.NoError(t, os.RemoveAll(volumeDir))
require.NoError(t, os.Mkdir(volumeDir, 0o750)) require.NoError(t, os.Mkdir(volumeDir, 0o750))
volumeDevice = unmountedDevice mounted = false
for range 2 { for range 2 {
env.out.Reset() env.out.Reset()
@@ -375,7 +346,7 @@ func backUpWithVolumeNotMounted(t *testing.T, unmountedDevice uint64) {
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600)) filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
volumeDevice = 2 mounted = true
require.NoError(t, env.v.CreateSnapshot(prune)) require.NoError(t, env.v.CreateSnapshot(prune))