Stop a backup on a symlink whose target cannot be read (closes #269)
check / check (push) Waiting to run
check / check (push) Waiting to run
When readlink failed, the scanner logged at debug level and left the symlink out of the snapshot, and the run reported success even without --skip-errors. The error now goes through the same handling as any other entry the walk cannot read: the run aborts, or with --skip-errors the symlink is skipped with the usual "Failed to access" error line. A symlink removed between the walk's lstat and the readlink also aborts the run, as an entry that vanishes during the walk already does. Model: opus-5-5
This commit is contained in:
@@ -3,6 +3,7 @@ package snapshot_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
||||
@@ -80,6 +82,30 @@ func (f *readFailFs) Open(name string) (afero.File, error) {
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// linkRemovedAfterLstatFs is the real filesystem, except that the symlink at
|
||||
// target is removed right after the walk lstats it, as happens when a link is
|
||||
// deleted during a backup. The scanner's readlink of it then fails.
|
||||
type linkRemovedAfterLstatFs struct {
|
||||
afero.OsFs
|
||||
|
||||
t *testing.T
|
||||
target string
|
||||
}
|
||||
|
||||
func (f *linkRemovedAfterLstatFs) LstatIfPossible(
|
||||
name string,
|
||||
) (os.FileInfo, bool, error) {
|
||||
info, lstatCalled, err := f.OsFs.LstatIfPossible(name)
|
||||
if err == nil && name == f.target {
|
||||
rmErr := os.Remove(name)
|
||||
if rmErr != nil {
|
||||
f.t.Errorf("removing %s: %v", name, rmErr)
|
||||
}
|
||||
}
|
||||
|
||||
return info, lstatCalled, err
|
||||
}
|
||||
|
||||
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
||||
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
t.Helper()
|
||||
@@ -102,10 +128,11 @@ func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runSkipErrorScan scans /source on fs with the given skip-errors setting and
|
||||
// returns the repositories (for inspection) and the scan error.
|
||||
// runSkipErrorScan scans source on fs with the given skip-errors setting,
|
||||
// printing user-facing messages to uiw (nil discards them), and returns the
|
||||
// repositories (for inspection) and the scan error.
|
||||
func runSkipErrorScan(
|
||||
t *testing.T, fs afero.Fs, skipErrors bool,
|
||||
t *testing.T, fs afero.Fs, source string, skipErrors bool, uiw *ui.Writer,
|
||||
) (*database.Repositories, error) {
|
||||
t.Helper()
|
||||
|
||||
@@ -130,6 +157,7 @@ func runSkipErrorScan(
|
||||
MaxBlobSize: int64(1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
UI: uiw,
|
||||
SkipErrors: skipErrors,
|
||||
})
|
||||
|
||||
@@ -137,7 +165,7 @@ func runSkipErrorScan(
|
||||
snapshotID := "test-snapshot-skip-errors"
|
||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||
|
||||
_, err = scanner.Scan(ctx, "/source", snapshotID)
|
||||
_, err = scanner.Scan(ctx, source, snapshotID)
|
||||
|
||||
return repos, err
|
||||
}
|
||||
@@ -157,7 +185,7 @@ func TestScannerPackingFailureAbortsUnderSkipErrors(t *testing.T) {
|
||||
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
||||
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, true)
|
||||
repos, err := runSkipErrorScan(t, fs, "/source", true, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
||||
}
|
||||
@@ -184,7 +212,7 @@ func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
_, err := runSkipErrorScan(t, fs, false)
|
||||
_, err := runSkipErrorScan(t, fs, "/source", false, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected scan to fail on the read error, got nil")
|
||||
}
|
||||
@@ -200,7 +228,7 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, true)
|
||||
repos, err := runSkipErrorScan(t, fs, "/source", true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||
}
|
||||
@@ -214,3 +242,63 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
||||
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
// writeSymlinkSource creates a source directory on disk holding one symlink
|
||||
// and returns the directory and the symlink's path.
|
||||
func writeSymlinkSource(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
sourceDir := t.TempDir()
|
||||
linkPath := filepath.Join(sourceDir, "link")
|
||||
|
||||
err := os.Symlink("target.txt", linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("creating symlink: %v", err)
|
||||
}
|
||||
|
||||
return sourceDir, linkPath
|
||||
}
|
||||
|
||||
// TestScannerUnreadableSymlinkAbortsWithoutSkipErrors checks that a symlink
|
||||
// whose target cannot be read aborts the run when --skip-errors is not set.
|
||||
func TestScannerUnreadableSymlinkAbortsWithoutSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDir, linkPath := writeSymlinkSource(t)
|
||||
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||
|
||||
_, err := runSkipErrorScan(t, fs, sourceDir, false, nil)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected scan to fail on the removed symlink, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScannerUnreadableSymlinkSkippedWithSkipErrors checks that a symlink
|
||||
// whose target cannot be read is skipped with an error line, and the run
|
||||
// completes, when --skip-errors is set.
|
||||
func TestScannerUnreadableSymlinkSkippedWithSkipErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDir, linkPath := writeSymlinkSource(t)
|
||||
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||
uiw := ui.NewWithColor(io.Discard, false)
|
||||
|
||||
repos, err := runSkipErrorScan(t, fs, sourceDir, true, uiw)
|
||||
if err != nil {
|
||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||
}
|
||||
|
||||
if uiw.ErrorCount() != 1 {
|
||||
t.Fatalf("expected one error line for the symlink, got %d",
|
||||
uiw.ErrorCount())
|
||||
}
|
||||
|
||||
file, err := repos.Files.GetByPath(context.Background(), linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("getting %s: %v", linkPath, err)
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
t.Fatalf("expected %s not to be recorded", linkPath)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user