Exit non-zero when snapshot create --skip-errors skips anything it cannot read (closes #310)
check / check (push) Canceled after 0s

A backup under --skip-errors printed its skip errors, then "Finished
successfully.", and exited 0, so a cron wrapper could not tell it from
a complete backup.

Each skipped entry, including a directory the walk could not list, is
now counted as examined and failed. If --skip-errors skipped anything
it could not read or list, the run still keeps its snapshots and
prunes as before, then fails with "Backup completed, but N file(s)
could not be read". A file removed during the run, even before the
walk reaches it, counts once as failed and leaves the exit status
alone; without the flag it no longer stops the backup. Excluded
entries are never counted.

Model: opus-5-5
This commit was merged in pull request #381.
This commit is contained in:
2026-10-14 07:13:48 +02:00
parent 16b03dfda5
commit 7a950ea2aa
8 changed files with 292 additions and 33 deletions
@@ -3,6 +3,7 @@ package vaultik_test
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
@@ -73,3 +74,144 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
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.")
}