From 6934a9c990ca52b6366d72338e4bb63a730fb45a Mon Sep 17 00:00:00 2001 From: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:32:50 +0800 Subject: [PATCH] fix(restore): keep setuid, setgid and sticky bits when replaying modes The shim journals st_mode & 07777, so a chmod entry or a removed directory can carry setuid, setgid or sticky. Replay cast that value straight to os.FileMode, which keeps the permission bits where POSIX has them but encodes those three as flags of its own, and os.Chmod only honours the flags. A 4755 binary came back 0755 and a 1777 directory came back 0777, while the preview printed the recorded mode and the summary counted the entry as restored. Translate the journal value into Go's encoding at the three sites that apply a recorded mode. Co-authored-by: Cursor --- internal/restore/restore.go | 24 +++++++++-- internal/restore/restore_test.go | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/internal/restore/restore.go b/internal/restore/restore.go index eb175d6..e0bfd5f 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -103,6 +103,24 @@ func slot(s *session.Session, i int) string { return filepath.Join(s.Dir, "data", fmt.Sprintf("undo-%d", i)) } +// modeFromOctal turns a mode as the shim journals it (st_mode & 07777) +// into an os.FileMode. Go keeps the permission bits where POSIX has +// them but encodes setuid, setgid and sticky as flags of its own, and +// os.Chmod only looks at those, so casting drops all three. +func modeFromOctal(m uint64) os.FileMode { + fm := os.FileMode(m & 0o777) + if m&0o4000 != 0 { + fm |= os.ModeSetuid + } + if m&0o2000 != 0 { + fm |= os.ModeSetgid + } + if m&0o1000 != 0 { + fm |= os.ModeSticky + } + return fm +} + // Run replays the journal of s in the given direction. func Run(s *session.Session, dir Direction, opts Options) (*Result, error) { res := &Result{} @@ -325,8 +343,8 @@ func Run(s *session.Session, dir Direction, opts Options) (*Result, error) { if perr != nil { mode = 0o755 } - if err = os.MkdirAll(field(0), os.FileMode(mode)); err == nil { - err = os.Chmod(field(0), os.FileMode(mode)) + if err = os.MkdirAll(field(0), modeFromOctal(mode)); err == nil { + err = os.Chmod(field(0), modeFromOctal(mode)) } } else { if !exists(field(0)) { @@ -359,7 +377,7 @@ func Run(s *session.Session, dir Direction, opts Options) (*Result, error) { if !act() { continue } - err = os.Chmod(field(0), os.FileMode(mode)) + err = os.Chmod(field(0), modeFromOctal(mode)) case journal.OpLost: if dir == Undo { diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go index 1d6fa22..4983505 100644 --- a/internal/restore/restore_test.go +++ b/internal/restore/restore_test.go @@ -168,6 +168,76 @@ func TestRmdirUndoRecreatesDirectory(t *testing.T) { } } +func TestModeFromOctalKeepsHighBits(t *testing.T) { + cases := []struct { + journal uint64 + want os.FileMode + }{ + {0o644, 0o644}, + {0o4755, 0o755 | os.ModeSetuid}, + {0o2775, 0o775 | os.ModeSetgid}, + {0o1777, 0o777 | os.ModeSticky}, + {0o7000, os.ModeSetuid | os.ModeSetgid | os.ModeSticky}, + } + for _, c := range cases { + got := modeFromOctal(c.journal) + if got != c.want { + t.Errorf("modeFromOctal(%04o) = %v, want %v", c.journal, got, c.want) + } + if got.Perm() != os.FileMode(c.journal&0o777) { + t.Errorf("modeFromOctal(%04o).Perm() = %04o", c.journal, got.Perm()) + } + } +} + +func TestRmdirUndoRestoresStickyDirectory(t *testing.T) { + work := t.TempDir() + gone := filepath.Join(work, "shared") + s := newSession(t, []journal.Entry{ + {Op: journal.OpRmdir, Fields: []string{gone, "1777"}}, + }) + if _, err := Run(s, Undo, Options{}); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(gone) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&os.ModeSticky == 0 { + t.Errorf("recreated directory lost the sticky bit: %v", fi.Mode()) + } + if fi.Mode().Perm() != 0o777 { + t.Errorf("recreated directory perm = %04o, want 0777", fi.Mode().Perm()) + } +} + +func TestChmodUndoRestoresSetuid(t *testing.T) { + work := t.TempDir() + bin := filepath.Join(work, "helper") + write(t, bin, "#!/bin/sh\n") + // the command ran `chmod 755` over a setuid binary, so the journal + // holds 4755 as the mode to go back to + if err := os.Chmod(bin, 0o755); err != nil { + t.Fatal(err) + } + s := newSession(t, []journal.Entry{ + {Op: journal.OpChmod, Fields: []string{bin, "4755", "755"}}, + }) + if _, err := Run(s, Undo, Options{}); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(bin) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&os.ModeSetuid == 0 { + t.Errorf("restored file lost the setuid bit: %v", fi.Mode()) + } + if fi.Mode().Perm() != 0o755 { + t.Errorf("restored file perm = %04o, want 0755", fi.Mode().Perm()) + } +} + func TestDryRunTouchesNothing(t *testing.T) { work := t.TempDir() victim := filepath.Join(work, "gone.txt")