Skip to content

setuid/setgid/sticky bits are silently dropped when replaying chmod and rmdir entries #14

Description

@codeAnqiang-ma

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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions