package vaultik_test import ( "bytes" "context" "fmt" "os" "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) // openFailFs is the real filesystem, except that opening path fails // with err. Phase 1 of a backup only lstats a file, so it still counts // path; phase 2 is the first to open it. type openFailFs struct { afero.OsFs path string err error } //nolint:ireturn // afero.Fs.Open is defined to return the interface. func (f *openFailFs) Open(name string) (afero.File, error) { if name == f.path { return nil, &os.PathError{Op: "open", Path: name, Err: f.err} } return f.OsFs.Open(name) } // A file that phase 2 cannot open is reported as failed, not as // unchanged, and neither the summary's data total nor the snapshots row // counts it. See https://git.eeqj.de/sneak/vaultik/issues/280. func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() tests := []struct { name string openErr error skipErrors bool }{ // What a normal user gets opening a file with mode 000. {name: "unopenable under skip-errors", openErr: os.ErrPermission, skipErrors: true}, // What opening a file removed after phase 1 gives. {name: "removed between the phases", openErr: os.ErrNotExist, skipErrors: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() // The scan walks the source path with symlinks resolved, so // failedPath must be spelled the same way to match. tempDir, err := filepath.EvalSymlinks(t.TempDir()) require.NoError(t, err) srcDir := filepath.Join(tempDir, "src") failedPath := filepath.Join(srcDir, "failed.txt") storedContent := []byte("this file is backed up") storedSize := int64(len(storedContent)) fs := &openFailFs{path: failedPath, err: tt.openErr} require.NoError(t, fs.MkdirAll(srcDir, 0o755)) require.NoError(t, afero.WriteFile(fs, filepath.Join(srcDir, "stored.txt"), storedContent, 0o644)) require.NoError(t, afero.WriteFile(fs, failedPath, []byte("this file cannot be opened"), 0o644)) cfg := faultTestConfig() cfg.IndexPath = filepath.Join(tempDir, "index.sqlite") cfg.Snapshots = map[string]config.SnapshotConfig{ "src": {Paths: []string{srcDir}}, } store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) require.NoError(t, err) db, err := database.New(ctx, cfg.IndexPath) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) repos := database.NewRepositories(db) out := &bytes.Buffer{} v := newBackupVaultik(ctx, cfg, store, repos, db, fs) v.UI = ui.NewWithColor(out, false) require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ SkipErrors: tt.skipErrors, Snapshots: []string{"src"}, })) summary := out.String() assert.Contains(t, summary, "Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.") assert.Contains(t, summary, fmt.Sprintf("Data: %s total (%s backed up).", v.UI.Size(storedSize), v.UI.Size(storedSize))) snap, err := repos.Snapshots.GetByID(ctx, localSnapshotID(ctx, t, repos, "src")) require.NoError(t, err) require.NotNil(t, snap) assert.Equal(t, int64(1), snap.FileCount) assert.Equal(t, storedSize, snap.TotalSize) }) } }