From 4a167e153aebb2294f5e21ca559f8748c36d0a2e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 09:46:16 +0200 Subject: [PATCH] Record the real uid and gid of backed-up files (closes #216) 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 --- TODO.md | 8 ++++ internal/snapshot/scanner.go | 31 ++++--------- internal/snapshot/scanner_test.go | 77 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index d8432df..c4059a0 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,14 @@ the tag exists and is exercised; what is left is merging `next` to # Completed Steps +- 2026-10-06: Made a backup record the real uid and gid of files, + directories and symlinks + ([issue #216](https://git.eeqj.de/sneak/vaultik/issues/216)). The + scanner asked the stat result for `Uid()` and `Gid()` methods, which + `*syscall.Stat_t` does not have, so every entry was stored as `0:0` + and a restore as root gave every file to root. It now reads the + `Uid` and `Gid` fields of `*syscall.Stat_t`. + - 2026-10-06: Made a `file://` destination whose directory is missing count as one that cannot be listed ([issue #220](https://git.eeqj.de/sneak/vaultik/issues/220)). The file diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 71f8fd8..850b1b8 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" "sync" + "syscall" "time" "github.com/dustin/go-humanize" @@ -1142,12 +1143,9 @@ func (s *Scanner) buildSymlinkEntry(path string, info os.FileInfo) *database.Fil } var uid, gid uint32 - if stat, ok := info.Sys().(interface { - Uid() uint32 - Gid() uint32 - }); ok { - uid = stat.Uid() - gid = stat.Gid() + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + uid = stat.Uid + gid = stat.Gid } return &database.File{ @@ -1166,12 +1164,9 @@ func (s *Scanner) buildSymlinkEntry(path string, info os.FileInfo) *database.Fil // buildDirectoryEntry creates a File record for a directory. func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.File { var uid, gid uint32 - if stat, ok := info.Sys().(interface { - Uid() uint32 - Gid() uint32 - }); ok { - uid = stat.Uid() - gid = stat.Gid() + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + uid = stat.Uid + gid = stat.Gid } return &database.File{ @@ -1204,16 +1199,10 @@ func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) func (s *Scanner) checkFileInMemory( path string, info os.FileInfo, knownFiles map[string]*database.File, ) (*database.File, bool) { - // Get file stats - stat, ok := info.Sys().(interface { - Uid() uint32 - Gid() uint32 - }) - var uid, gid uint32 - if ok { - uid = stat.Uid() - gid = stat.Gid() + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + uid = stat.Uid + gid = stat.Gid } // Check against in-memory map first to get existing ID if available diff --git a/internal/snapshot/scanner_test.go b/internal/snapshot/scanner_test.go index dfac28e..a28b58c 100644 --- a/internal/snapshot/scanner_test.go +++ b/internal/snapshot/scanner_test.go @@ -307,3 +307,80 @@ func TestScannerLargeFile(t *testing.T) { } } } + +// 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()) + } + } +}