Restore files at 0600 and make the blob hash check unskippable (closes #163)
Regular files are now created with O_EXCL at mode 0600 and given their stored mode only after the content is written and closed, so a file whose stored mode is restrictive is never briefly readable by other local users mid-restore. A file whose write or close fails is removed rather than left partial, and a chmod failure is a user-visible warning instead of a debug line. hashVerifyReader.Close now errors when closed before EOF, so a short read or early close can never obtain a blob whose hash was not verified; downloadBlobToCache drops the cache entry on any such failure. verifyFile (--verify) now rejects a restored file with bytes past its last chunk. Tests cover each behaviour under umask 022. Model: opus-4-8
This commit was merged in pull request #182.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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 := filepath.Join(tempDir, "remote")
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user