Summary
When undoing a chmod, or recreating a directory removed by rmdir, the setuid / setgid / sticky bits are silently dropped. The preview even prints the correct 4-digit mode and the run reports success, but what lands on disk has only the permission bits:
mode /tmp/x/tool (4755 -> 755)
restored 2 change(s)
…and afterwards /tmp/x/tool is 0755. A sticky directory recorded as 1777 comes back as 0777.
The journal is fine — the shim records the full 4-digit octal (undo_shim.c:816, undo_shim.c:1134). The loss happens on the replay side.
Root cause
internal/restore/restore.go converts the recorded libc mode to a Go os.FileMode with a direct cast, in three places:
// restore.go:328-329 (recreating a removed directory)
if err = os.MkdirAll(field(0), os.FileMode(mode)); err == nil {
err = os.Chmod(field(0), os.FileMode(mode))
}
// restore.go:362 (undoing a chmod)
err = os.Chmod(field(0), os.FileMode(mode))
The shim writes st_mode & 07777, i.e. POSIX layout where setuid/setgid/sticky are bits 11/10/9. Go does not use that layout: os.FileMode keeps the permission bits in the low 9 bits but encodes ModeSetuid, ModeSetgid and ModeSticky as bits 23, 22 and 20. So the POSIX special bits land on bits Go doesn't interpret, and os.Chmod — which passes through Perm() plus those three named flags — simply never sees them.
Demonstrated with plain Go:
libc := 0o4755
cast := os.FileMode(libc)
fmt.Println(cast) // -rwxr-xr-x (no setuid marker)
fmt.Printf("0o%o\n", cast.Perm()) // 0o755
fmt.Println(cast&os.ModeSetuid != 0) // false
correct := os.FileMode(0o755) | os.ModeSetuid
fmt.Println(correct) // urwxr-xr-x
fmt.Println(correct&os.ModeSetuid != 0) // true
Output:
-rwxr-xr-x
0o755
false
urwxr-xr-x
true
Reproduction
Two independent ways:
-
End-to-end with the real binary. Build ./cmd/undo, hand-write a session in the shim's journal format recording a mod entry with mode 4755, run undo apply. The preview prints (4755 -> 755) and the command reports restored, but stat shows 0755 — the setuid bit is gone. Same for a 1777 sticky directory recreated from an rmdir entry.
-
Unit level. Feeding 0o4755 through the same os.FileMode(mode) conversion and asserting mode&os.ModeSetuid != 0 fails, as shown above.
The existing test suite never catches this because every mode in the fixtures is 3-digit — the e2e cases use 640, 644, 600. Nothing exercises a 4-digit mode.
Suggested fix
Translate POSIX bits into Go's encoding instead of casting. Something like:
func modeFromOctal(m uint32) 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
}
…used at all three sites above. Note that MkdirAll's mode is also subject to the process umask, so the follow-up os.Chmod is what actually has to carry the special bits — which is already the shape of the code at restore.go:328-329.
Worth considering as well: if the mode cannot be fully restored, saying so would be better than reporting success. Right now the summary line claims the change was restored while the on-disk result differs from what the preview promised.
Environment
macOS 15 / arm64, Go 1.26.5, commit 513536e. The setuid/sticky semantics and Go's FileMode encoding are platform-independent; I verified the conversion behaviour directly with Go, and reproduced the end-to-end symptom on macOS. I did not run it on Linux.
Found with AI assistance. I reproduced the mode-bit loss locally, verified each cited line, and reviewed all conclusions before filing.
Summary
When undoing a
chmod, or recreating a directory removed byrmdir, the setuid / setgid / sticky bits are silently dropped. The preview even prints the correct 4-digit mode and the run reports success, but what lands on disk has only the permission bits:…and afterwards
/tmp/x/toolis0755. A sticky directory recorded as1777comes back as0777.The journal is fine — the shim records the full 4-digit octal (
undo_shim.c:816,undo_shim.c:1134). The loss happens on the replay side.Root cause
internal/restore/restore.goconverts the recorded libc mode to a Goos.FileModewith a direct cast, in three places:The shim writes
st_mode & 07777, i.e. POSIX layout where setuid/setgid/sticky are bits 11/10/9. Go does not use that layout:os.FileModekeeps the permission bits in the low 9 bits but encodesModeSetuid,ModeSetgidandModeStickyas bits 23, 22 and 20. So the POSIX special bits land on bits Go doesn't interpret, andos.Chmod— which passes throughPerm()plus those three named flags — simply never sees them.Demonstrated with plain Go:
Output:
Reproduction
Two independent ways:
End-to-end with the real binary. Build
./cmd/undo, hand-write a session in the shim's journal format recording amodentry with mode4755, runundo apply. The preview prints(4755 -> 755)and the command reportsrestored, butstatshows0755— the setuid bit is gone. Same for a1777sticky directory recreated from anrmdirentry.Unit level. Feeding
0o4755through the sameos.FileMode(mode)conversion and assertingmode&os.ModeSetuid != 0fails, as shown above.The existing test suite never catches this because every mode in the fixtures is 3-digit — the e2e cases use
640,644,600. Nothing exercises a 4-digit mode.Suggested fix
Translate POSIX bits into Go's encoding instead of casting. Something like:
…used at all three sites above. Note that
MkdirAll's mode is also subject to the process umask, so the follow-upos.Chmodis what actually has to carry the special bits — which is already the shape of the code atrestore.go:328-329.Worth considering as well: if the mode cannot be fully restored, saying so would be better than reporting success. Right now the summary line claims the change was restored while the on-disk result differs from what the preview promised.
Environment
macOS 15 / arm64, Go 1.26.5, commit
513536e. The setuid/sticky semantics and Go'sFileModeencoding are platform-independent; I verified the conversion behaviour directly with Go, and reproduced the end-to-end symptom on macOS. I did not run it on Linux.Found with AI assistance. I reproduced the mode-bit loss locally, verified each cited line, and reviewed all conclusions before filing.