package vaultik //nolint:testpackage // drives restore through unexported session import ( "bytes" "context" "errors" "io" "os" "path/filepath" "strings" "sync" "syscall" "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/snapshot" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/ui" ) // errSpyWrite is the injected write failure used to exercise the // partial-file cleanup path. var errSpyWrite = errors.New("injected write failure") // modeSpyFs wraps a real filesystem so restore tests can observe and // perturb the single output file whose path contains watch. It records // the on-disk permission bits seen at the moment content is first // written (the window during which another local user could read it), // and can inject a write failure or append trailing bytes on close. type modeSpyFs struct { afero.Fs watch string mu sync.Mutex writeModes []os.FileMode failWrite bool trailing int } //nolint:ireturn // afero.Fs.OpenFile is defined to return the interface func (m *modeSpyFs) OpenFile( name string, flag int, perm os.FileMode, ) (afero.File, error) { f, err := m.Fs.OpenFile(name, flag, perm) if err != nil || !strings.Contains(name, m.watch) { return f, err } return &modeSpyFile{File: f, fs: m, path: name}, nil } type modeSpyFile struct { afero.File fs *modeSpyFs path string written bool } func (f *modeSpyFile) Write(p []byte) (int, error) { if !f.written { f.written = true info, err := f.fs.Stat(f.path) if err == nil { f.fs.mu.Lock() f.fs.writeModes = append(f.fs.writeModes, info.Mode().Perm()) f.fs.mu.Unlock() } } if f.fs.failWrite { return 0, errSpyWrite } return f.File.Write(p) } func (f *modeSpyFile) Close() error { if f.fs.trailing > 0 { _, _ = f.File.Write(bytes.Repeat([]byte{'x'}, f.fs.trailing)) } return f.File.Close() } // backupOneFile writes a single source file with the given mode and // backs it up into a fresh file storer, returning everything a restore // needs. The index database is closed before returning so the restore // half runs from the exported metadata and remote bytes only. func backupOneFile( ctx context.Context, t *testing.T, fs afero.Fs, tempDir, name string, content []byte, mode os.FileMode, ) (*config.Config, *storage.FileStorer, string, string) { t.Helper() dataDir := filepath.Join(tempDir, "src") require.NoError(t, fs.MkdirAll(dataDir, 0o755)) srcPath := filepath.Join(dataDir, name) require.NoError(t, afero.WriteFile(fs, srcPath, content, mode)) require.NoError(t, fs.Chmod(srcPath, mode)) storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") storer, err := storage.NewFileStorer(storeDir) require.NoError(t, err) cfg := &config.Config{ AgeRecipients: []string{ "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg", }, AgeSecretKey: "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKU" + "T68TXSFPK7APHXA2QS2NJA5", CompressionLevel: 3, Hostname: "test-host", BlobSizeLimit: config.Size(5 * 1024 * 1024), } db, err := database.New(ctx, dbPath) require.NoError(t, err) repos := database.NewRepositories(db) sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{ Repos: repos, Storage: storer, Config: cfg, }) sm.SetFilesystem(fs) scanner := snapshot.NewScanner(snapshot.ScannerConfig{ FS: fs, Storage: storer, ChunkSize: 4 * 1024 * 1024, MaxBlobSize: 5 * 1024 * 1024, CompressionLevel: cfg.CompressionLevel, AgeRecipients: cfg.AgeRecipients, Repositories: repos, }) snapshotID, err := sm.CreateSnapshotWithName( ctx, cfg.Hostname, "perms", "test-version", "test-git") require.NoError(t, err) _, err = scanner.Scan(ctx, dataDir, snapshotID) require.NoError(t, err) require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID)) require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID)) require.NoError(t, db.Close()) return cfg, storer, snapshotID, srcPath } // restoredPathFor returns where backupOneFile's source lands under a // restore target: restore recreates each file at its original absolute // path beneath TargetDir. func restoredPathFor(restoreDir, srcPath string) string { return filepath.Join(restoreDir, srcPath) } // withUmask022 forces the process umask to 022 for the duration of a // test, so the difference between a 0600 create and a default create is // observable. Restored serially (no t.Parallel) so it does not race // other tests. func withUmask022(t *testing.T) { t.Helper() old := syscall.Umask(0o022) t.Cleanup(func() { syscall.Umask(old) }) } // TestRestoreCreatesFileNeverWiderThanStoredMode checks that a file with // a restrictive stored mode (0600) is never observable with a wider mode // while its content is being written, and ends at its stored mode. // //nolint:paralleltest // sets the process umask; must run serially func TestRestoreCreatesFileNeverWiderThanStoredMode(t *testing.T) { log.Initialize(log.Config{}) withUmask022(t) fs := afero.NewOsFs() tempDir := t.TempDir() ctx := context.Background() content := randomBytes(t, 4096) cfg, storer, snapshotID, srcPath := backupOneFile( ctx, t, fs, tempDir, "secret.bin", content, 0o600) restoreDir := filepath.Join(tempDir, "restored") spy := &modeSpyFs{Fs: fs, watch: "secret.bin"} v := newRestoreVaultik(ctx, cfg, storer, spy) require.NoError(t, v.Restore(&RestoreOptions{ SnapshotID: snapshotID, TargetDir: restoreDir, })) spy.mu.Lock() observed := append([]os.FileMode(nil), spy.writeModes...) spy.mu.Unlock() require.NotEmpty(t, observed, "spy never saw the output file being written") for _, m := range observed { assert.Equalf(t, os.FileMode(0o600), m, "file was observable at mode %o during write; must be 0600", m) } // The stored mode is applied after the content is written. info, err := fs.Stat(restoredPathFor(restoreDir, srcPath)) require.NoError(t, err) assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) got, err := afero.ReadFile(fs, restoredPathFor(restoreDir, srcPath)) require.NoError(t, err) require.True(t, bytes.Equal(got, content)) } // TestRestoreRemovesPartialFileOnWriteFailure checks that a file whose // content write fails is not left behind. // //nolint:paralleltest // sets the process umask; must run serially func TestRestoreRemovesPartialFileOnWriteFailure(t *testing.T) { log.Initialize(log.Config{}) withUmask022(t) fs := afero.NewOsFs() tempDir := t.TempDir() ctx := context.Background() cfg, storer, snapshotID, srcPath := backupOneFile( ctx, t, fs, tempDir, "doomed.bin", randomBytes(t, 4096), 0o600) restoreDir := filepath.Join(tempDir, "restored") spy := &modeSpyFs{Fs: fs, watch: "doomed.bin", failWrite: true} v := newRestoreVaultik(ctx, cfg, storer, spy) err := v.Restore(&RestoreOptions{ SnapshotID: snapshotID, TargetDir: restoreDir, }) require.Error(t, err, "restore should fail when the write fails") exists, err := afero.Exists(fs, restoredPathFor(restoreDir, srcPath)) require.NoError(t, err) assert.False(t, exists, "partial file must be removed after a failed write") } // TestVerifyRejectsTrailingBytes checks that --verify fails a restored // file that has bytes past its last chunk. // //nolint:paralleltest // sets the process umask; must run serially func TestVerifyRejectsTrailingBytes(t *testing.T) { log.Initialize(log.Config{}) withUmask022(t) fs := afero.NewOsFs() tempDir := t.TempDir() ctx := context.Background() cfg, storer, snapshotID, _ := backupOneFile( ctx, t, fs, tempDir, "padded.bin", randomBytes(t, 4096), 0o600) restoreDir := filepath.Join(tempDir, "restored") // Append one byte to the file as it is written, so its content still // matches the stored chunks but it is one byte too long. spy := &modeSpyFs{Fs: fs, watch: "padded.bin", trailing: 1} v := newRestoreVaultik(ctx, cfg, storer, spy) err := v.Restore(&RestoreOptions{ SnapshotID: snapshotID, TargetDir: restoreDir, Verify: true, }) require.Error(t, err, "verify should fail on a file with trailing bytes") assert.ErrorIs(t, err, errFilesFailedVerify) } // newRestoreVaultik builds a Vaultik wired for a restore-only test. func newRestoreVaultik( ctx context.Context, cfg *config.Config, storer storage.Storer, fs afero.Fs, ) *Vaultik { v := &Vaultik{ Config: cfg, Storage: storer, Fs: fs, Stdout: io.Discard, Stderr: io.Discard, UI: ui.NewWithColor(io.Discard, false), } v.SetContext(ctx) return v }