check / check (pull_request) Successful in 5m51s
The scanner read uid and gid by asserting the stat result to an interface with Uid() and Gid() methods. *syscall.Stat_t has Uid and Gid fields, not methods, so the assertion never matched and every file, directory and symlink was stored as 0:0; a restore as root then gave everything to root. The scanner now reads the fields of *syscall.Stat_t. The first backup after this change re-reads every file not owned by root, because its stored uid and gid no longer match the disk. When the tests run as root, as in the Docker build, the new test compares 0 with 0 and cannot catch the defect; a non-root run does. Model: opus-5-5
387 lines
10 KiB
Go
387 lines
10 KiB
Go
package snapshot_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// Shared test fixture values for the snapshot_test package.
|
|
const (
|
|
// testHost is the hostname recorded on test snapshot rows.
|
|
testHost = "test-host"
|
|
|
|
// testVersion is the vaultik version recorded on test snapshot rows.
|
|
testVersion = "test"
|
|
|
|
// testAgePublicKey is the fixed age public key used for test encryption.
|
|
testAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
|
)
|
|
|
|
// TestMain initializes the shared logger once, before any tests run, so
|
|
// parallel tests never race on the logger's global state.
|
|
func TestMain(m *testing.M) {
|
|
log.Initialize(log.Config{})
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// createTestSnapshotRecord inserts an empty snapshot row used as the
|
|
// association target for scan tests.
|
|
func createTestSnapshotRecord(
|
|
ctx context.Context, t *testing.T, repos *database.Repositories, snapshotID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
snapshot := &database.Snapshot{
|
|
ID: types.SnapshotID(snapshotID),
|
|
Hostname: testHost,
|
|
VaultikVersion: testVersion,
|
|
StartedAt: time.Now(),
|
|
CompletedAt: nil,
|
|
FileCount: 0,
|
|
ChunkCount: 0,
|
|
BlobCount: 0,
|
|
TotalSize: 0,
|
|
BlobSize: 0,
|
|
CompressionRatio: 1.0,
|
|
}
|
|
|
|
return repos.Snapshots.Create(ctx, tx, snapshot)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
// verifySimpleScanDatabase checks the database contents produced by
|
|
// TestScannerSimpleDirectory's scan.
|
|
func verifySimpleScanDatabase(
|
|
ctx context.Context, t *testing.T, repos *database.Repositories,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Verify files in database - includes regular files and directories
|
|
files, err := repos.Files.ListByPrefix(ctx, "/source")
|
|
if err != nil {
|
|
t.Fatalf("failed to list files: %v", err)
|
|
}
|
|
|
|
// 6 regular files + 3 directories (/source, /source/subdir, /source/subdir2)
|
|
if len(files) != 9 {
|
|
t.Errorf("expected 9 entries in database (6 files + 3 dirs), got %d", len(files))
|
|
}
|
|
|
|
// Verify specific file
|
|
file1, err := repos.Files.GetByPath(ctx, "/source/file1.txt")
|
|
if err != nil {
|
|
t.Fatalf("failed to get file1.txt: %v", err)
|
|
}
|
|
|
|
if file1.Size != 13 {
|
|
t.Errorf("expected file1.txt size 13, got %d", file1.Size)
|
|
}
|
|
|
|
if file1.Mode != 0644 {
|
|
t.Errorf("expected file1.txt mode 0644, got %o", file1.Mode)
|
|
}
|
|
|
|
// Verify chunks were created
|
|
chunks, err := repos.FileChunks.GetByFile(ctx, "/source/file1.txt")
|
|
if err != nil {
|
|
t.Fatalf("failed to get chunks for file1.txt: %v", err)
|
|
}
|
|
|
|
if len(chunks) != 1 { // Small file should be one chunk
|
|
t.Errorf("expected 1 chunk for file1.txt, got %d", len(chunks))
|
|
}
|
|
|
|
// Verify deduplication - file3.txt and file4.txt have different content
|
|
// but we should still have the correct number of unique chunks
|
|
allChunks, err := repos.Chunks.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to list all chunks: %v", err)
|
|
}
|
|
|
|
// We should have at most 6 chunks (one per unique file content)
|
|
// Empty file might not create a chunk
|
|
if len(allChunks) > 6 {
|
|
t.Errorf("expected at most 6 chunks, got %d", len(allChunks))
|
|
}
|
|
}
|
|
|
|
func TestScannerSimpleDirectory(t *testing.T) {
|
|
// Initialize logger for tests
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
// Create in-memory filesystem
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test directory structure
|
|
testFiles := map[string]string{
|
|
"/source/file1.txt": "Hello, world!", // 13 bytes
|
|
"/source/file2.txt": "This is another file", // 20 bytes
|
|
"/source/subdir/file3.txt": "File in subdirectory", // 20 bytes
|
|
"/source/subdir/file4.txt": "Another file in subdirectory", // 28 bytes
|
|
"/source/empty.txt": "", // 0 bytes
|
|
"/source/subdir2/file5.txt": "Yet another file", // 16 bytes
|
|
}
|
|
|
|
// Create files with specific times
|
|
testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
for path, content := range testFiles {
|
|
dir := filepath.Dir(path)
|
|
|
|
err := fs.MkdirAll(dir, 0755)
|
|
if err != nil {
|
|
t.Fatalf("failed to create directory %s: %v", dir, err)
|
|
}
|
|
|
|
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
|
if err != nil {
|
|
t.Fatalf("failed to write file %s: %v", path, err)
|
|
}
|
|
// Set times
|
|
err = fs.Chtimes(path, testTime, testTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to set times for %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// Create test database
|
|
db, err := database.NewTestDB()
|
|
if err != nil {
|
|
t.Fatalf("failed to create test database: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := db.Close()
|
|
if err != nil {
|
|
t.Errorf("failed to close database: %v", err)
|
|
}
|
|
}()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
// Create scanner
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: fs,
|
|
ChunkSize: int64(1024 * 16), // 16KB chunks for testing
|
|
Repositories: repos,
|
|
MaxBlobSize: int64(1024 * 1024), // 1MB blobs
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
})
|
|
|
|
// Create a snapshot record for testing
|
|
ctx := context.Background()
|
|
snapshotID := "test-snapshot-001"
|
|
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
// Scan the directory
|
|
result, err := scanner.Scan(ctx, "/source", snapshotID)
|
|
if err != nil {
|
|
t.Fatalf("scan failed: %v", err)
|
|
}
|
|
|
|
// Verify results - we only scan regular files, not directories
|
|
if result.FilesScanned != 6 {
|
|
t.Errorf("expected 6 files scanned, got %d", result.FilesScanned)
|
|
}
|
|
|
|
// Total bytes should be the sum of all file contents
|
|
if result.BytesScanned < 97 { // At minimum we have 97 bytes of file content
|
|
t.Errorf("expected at least 97 bytes scanned, got %d", result.BytesScanned)
|
|
}
|
|
|
|
verifySimpleScanDatabase(ctx, t, repos)
|
|
}
|
|
|
|
func TestScannerLargeFile(t *testing.T) {
|
|
// Initialize logger for tests
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
// Create in-memory filesystem
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create a large file that will require multiple chunks
|
|
// Use random content to ensure good chunk boundaries
|
|
largeContent := make([]byte, 1024*1024) // 1MB
|
|
// Fill with pseudo-random data to ensure chunk boundaries
|
|
for i := range largeContent {
|
|
// Simple pseudo-random generator for deterministic tests
|
|
//nolint:gosec // G115: intentional byte truncation of test data
|
|
largeContent[i] = byte((i * 7919) ^ (i >> 3))
|
|
}
|
|
|
|
err := fs.MkdirAll("/source", 0755)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = afero.WriteFile(fs, "/source/large.bin", largeContent, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create test database
|
|
db, err := database.NewTestDB()
|
|
if err != nil {
|
|
t.Fatalf("failed to create test database: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := db.Close()
|
|
if err != nil {
|
|
t.Errorf("failed to close database: %v", err)
|
|
}
|
|
}()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
// Create scanner with 64KB average chunk size
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: fs,
|
|
ChunkSize: int64(1024 * 64), // 64KB average chunks
|
|
Repositories: repos,
|
|
MaxBlobSize: int64(1024 * 1024),
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
})
|
|
|
|
// Create a snapshot record for testing
|
|
ctx := context.Background()
|
|
snapshotID := "test-snapshot-001"
|
|
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
// Scan the directory
|
|
result, err := scanner.Scan(ctx, "/source", snapshotID)
|
|
if err != nil {
|
|
t.Fatalf("scan failed: %v", err)
|
|
}
|
|
|
|
// We scan only regular files, not directories
|
|
if result.FilesScanned != 1 {
|
|
t.Errorf("expected 1 file scanned, got %d", result.FilesScanned)
|
|
}
|
|
|
|
// The file size should be at least 1MB
|
|
if result.BytesScanned < 1024*1024 {
|
|
t.Errorf("expected at least %d bytes scanned, got %d",
|
|
1024*1024, result.BytesScanned)
|
|
}
|
|
|
|
// Verify chunks
|
|
chunks, err := repos.FileChunks.GetByFile(ctx, "/source/large.bin")
|
|
if err != nil {
|
|
t.Fatalf("failed to get chunks: %v", err)
|
|
}
|
|
|
|
// With content-defined chunking, the number of chunks depends on content
|
|
// For a 1MB file, we should get at least 1 chunk
|
|
if len(chunks) < 1 {
|
|
t.Errorf("expected at least 1 chunk, got %d", len(chunks))
|
|
}
|
|
|
|
// Log the actual number of chunks for debugging
|
|
t.Logf("1MB file produced %d chunks with 64KB average chunk size", len(chunks))
|
|
|
|
// Verify chunk sequence
|
|
for i, fc := range chunks {
|
|
if fc.Idx != i {
|
|
t.Errorf("chunk %d has incorrect sequence %d", i, fc.Idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScannerRecordsOwnership backs up real files on disk and checks that
|
|
// the uid and gid of a file, a directory and a symlink are recorded.
|
|
// When the tests run as root both sides are 0, so only a run as another
|
|
// user can catch ownership recorded as 0.
|
|
func TestScannerRecordsOwnership(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sourceDir := t.TempDir()
|
|
filePath := filepath.Join(sourceDir, "file.txt")
|
|
dirPath := filepath.Join(sourceDir, "subdir")
|
|
linkPath := filepath.Join(sourceDir, "link")
|
|
|
|
err := os.WriteFile(filePath, []byte("owned"), 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Mkdir(dirPath, 0o700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Symlink("file.txt", linkPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
db, err := database.NewTestDB()
|
|
if err != nil {
|
|
t.Fatalf("failed to create test database: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := db.Close()
|
|
if err != nil {
|
|
t.Errorf("failed to close database: %v", err)
|
|
}
|
|
}()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: afero.NewOsFs(),
|
|
ChunkSize: int64(1024 * 16),
|
|
Repositories: repos,
|
|
MaxBlobSize: int64(1024 * 1024),
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
})
|
|
|
|
ctx := context.Background()
|
|
snapshotID := "test-snapshot-ownership"
|
|
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
_, err = scanner.Scan(ctx, sourceDir, snapshotID)
|
|
if err != nil {
|
|
t.Fatalf("scan failed: %v", err)
|
|
}
|
|
|
|
for _, path := range []string{filePath, dirPath, linkPath} {
|
|
file, err := repos.Files.GetByPath(ctx, path)
|
|
if err != nil {
|
|
t.Fatalf("failed to get %s: %v", path, err)
|
|
}
|
|
|
|
if file == nil {
|
|
t.Fatalf("%s was not recorded", path)
|
|
}
|
|
|
|
if int(file.UID) != os.Getuid() || int(file.GID) != os.Getgid() {
|
|
t.Errorf("%s recorded as uid %d gid %d, want uid %d gid %d",
|
|
path, file.UID, file.GID, os.Getuid(), os.Getgid())
|
|
}
|
|
}
|
|
}
|