From 71a42fbe80c8d9d3a56a64d353ceeaa59295452b Mon Sep 17 00:00:00 2001 From: mptyl Date: Wed, 26 Aug 2026 04:25:55 +0200 Subject: [PATCH] fix restore preserve Unix file ownership --- .../tht/internal/backup/restore_file_unix.go | 34 +++++++-- .../internal/backup/restore_file_unix_test.go | 69 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tools/tht/internal/backup/restore_file_unix_test.go diff --git a/tools/tht/internal/backup/restore_file_unix.go b/tools/tht/internal/backup/restore_file_unix.go index 310117d8..e79358c5 100644 --- a/tools/tht/internal/backup/restore_file_unix.go +++ b/tools/tht/internal/backup/restore_file_unix.go @@ -19,6 +19,8 @@ type restoreTargetIdentity struct { exists bool device uint64 inode uint64 + uid uint32 + gid uint32 } func replaceRestoreFile(target string, contents []byte, mode os.FileMode) error { @@ -47,7 +49,11 @@ func replaceRestoreFile(target string, contents []byte, mode os.FileMode) error if err != nil { return safeio.ErrUnsafeFile } - temporary, err := writeRestoreTemporaryAt(directory, contents, mode.Perm()) + uid, gid, err := restoreTargetOwnerAt(directory, identity) + if err != nil { + return safeio.ErrUnsafeFile + } + temporary, err := writeRestoreTemporaryAt(directory, contents, mode.Perm(), uid, gid) if err != nil { return safeio.ErrUnsafeFile } @@ -75,10 +81,27 @@ func inspectRestoreTargetAt(directory int, name string) (restoreTargetIdentity, if err != nil || status.Mode&unix.S_IFMT != unix.S_IFREG || status.Nlink != 1 { return restoreTargetIdentity{}, safeio.ErrUnsafeFile } - return restoreTargetIdentity{exists: true, device: uint64(status.Dev), inode: status.Ino}, nil + return restoreTargetIdentity{ + exists: true, + device: uint64(status.Dev), + inode: status.Ino, + uid: status.Uid, + gid: status.Gid, + }, nil } -func writeRestoreTemporaryAt(directory int, contents []byte, mode os.FileMode) (string, error) { +func restoreTargetOwnerAt(directory int, identity restoreTargetIdentity) (uint32, uint32, error) { + if identity.exists { + return identity.uid, identity.gid, nil + } + var status unix.Stat_t + if err := unix.Fstat(directory, &status); err != nil || status.Mode&unix.S_IFMT != unix.S_IFDIR { + return 0, 0, safeio.ErrUnsafeFile + } + return status.Uid, status.Gid, nil +} + +func writeRestoreTemporaryAt(directory int, contents []byte, mode os.FileMode, uid, gid uint32) (string, error) { for attempt := 0; attempt < 16; attempt++ { random := make([]byte, 8) if _, err := rand.Read(random); err != nil { @@ -97,7 +120,10 @@ func writeRestoreTemporaryAt(directory int, contents []byte, mode os.FileMode) ( unix.Close(descriptor) return "", safeio.ErrUnsafeFile } - if err := file.Chmod(mode); err == nil { + if err := file.Chown(int(uid), int(gid)); err == nil { + err = file.Chmod(mode) + } + if err == nil { var written int written, err = file.Write(contents) if err == nil && written != len(contents) { diff --git a/tools/tht/internal/backup/restore_file_unix_test.go b/tools/tht/internal/backup/restore_file_unix_test.go new file mode 100644 index 00000000..cd8c153f --- /dev/null +++ b/tools/tht/internal/backup/restore_file_unix_test.go @@ -0,0 +1,69 @@ +//go:build !windows + +package backup + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +const restoreRuntimeUID = 10001 + +func TestReplaceRestoreFilePreservesExistingOwner(t *testing.T) { + requireRootForRestoreOwnershipTest(t) + + target := filepath.Join(t.TempDir(), "trust.json") + if err := os.WriteFile(target, []byte("before"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chown(target, restoreRuntimeUID, restoreRuntimeUID); err != nil { + t.Fatal(err) + } + + if err := replaceRestoreFile(target, []byte("after"), 0o600); err != nil { + t.Fatal(err) + } + assertRestoreOwner(t, target, restoreRuntimeUID, restoreRuntimeUID) +} + +func TestReplaceRestoreFileUsesParentOwnerForNewTarget(t *testing.T) { + requireRootForRestoreOwnershipTest(t) + + parent := filepath.Join(t.TempDir(), "agent") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chown(parent, restoreRuntimeUID, restoreRuntimeUID); err != nil { + t.Fatal(err) + } + target := filepath.Join(parent, "trust.json") + + if err := replaceRestoreFile(target, []byte("restored"), 0o600); err != nil { + t.Fatal(err) + } + assertRestoreOwner(t, target, restoreRuntimeUID, restoreRuntimeUID) +} + +func requireRootForRestoreOwnershipTest(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("numeric ownership assertions require root") + } +} + +func assertRestoreOwner(t *testing.T, path string, uid, gid uint32) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + status, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatal("restored file has no Unix stat metadata") + } + if status.Uid != uid || status.Gid != gid { + t.Fatalf("restored owner = %d:%d, want %d:%d", status.Uid, status.Gid, uid, gid) + } +}