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()) + } + } +}