-
Notifications
You must be signed in to change notification settings - Fork 185
fix(tools): use atomic temp-and-replace writes for write_file and edit_file #941
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f794fc3
ddd71dd
cffebdc
c77e4bd
f978e88
679ec0b
fef6928
64e262d
5b68cd9
580e0ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| //go:build darwin | ||
|
|
||
| package fsutil | ||
|
|
||
| import ( | ||
| "syscall" | ||
| "unsafe" | ||
|
|
||
| "golang.org/x/sys/unix" | ||
| ) | ||
|
|
||
| var getattrlistZero byte | ||
|
|
||
| var libc_getattrlist_trampoline_addr uintptr | ||
|
|
||
| //go:linkname syscall_syscall6 syscall.syscall6 | ||
| func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) | ||
|
|
||
| //go:cgo_import_dynamic libc_getattrlist getattrlist "/usr/lib/libSystem.B.dylib" | ||
|
|
||
| func getattrlist(path string, attrList *unix.Attrlist, attrBuf []byte, options uint32) error { | ||
| p, err := unix.BytePtrFromString(path) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| bufPtr := unsafe.Pointer(&getattrlistZero) | ||
| if len(attrBuf) > 0 { | ||
| bufPtr = unsafe.Pointer(&attrBuf[0]) | ||
| } | ||
| _, _, e := syscall_syscall6(libc_getattrlist_trampoline_addr, | ||
| uintptr(unsafe.Pointer(p)), | ||
| uintptr(unsafe.Pointer(attrList)), | ||
| uintptr(bufPtr), | ||
| uintptr(len(attrBuf)), | ||
| uintptr(options), | ||
| 0) | ||
| if e != 0 { | ||
| return e | ||
| } | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| //go:build darwin | ||
|
|
||
| #include "textflag.h" | ||
|
|
||
| TEXT libc_getattrlist_trampoline<>(SB),NOSPLIT,$0-0 | ||
| JMP libc_getattrlist(SB) | ||
| GLOBL ·libc_getattrlist_trampoline_addr(SB), RODATA, $8 | ||
| DATA ·libc_getattrlist_trampoline_addr(SB)/8, $libc_getattrlist_trampoline<>(SB) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,14 +2,170 @@ | |
| package fsutil | ||
|
|
||
| import ( | ||
| "crypto/rand" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "runtime" | ||
| "syscall" | ||
| "time" | ||
| ) | ||
|
|
||
| // ErrNonRegularDestination is returned when WriteFileAtomic would replace a | ||
| // FIFO, device, socket, directory, or other special file with a regular file. | ||
| var ErrNonRegularDestination = errors.New("fsutil: destination exists and is not a regular file") | ||
|
|
||
| // stagingModeObserver, when non-nil, receives the mode of the freshly created | ||
| // staging file before any metadata is copied onto it. Tests use it to assert | ||
| // that a replacement is staged no broader than its destination. | ||
| var stagingModeObserver func(os.FileMode) | ||
|
|
||
| // stagingProtectionObserver, when non-nil, receives the path of the staging | ||
| // file after protectStaging has copied the destination's authorization metadata | ||
| // onto it, still before any replacement bytes are written. Tests use it to | ||
| // assert that a replacement is staged no broader than its destination on | ||
| // platforms (Windows) where the mode bits do not carry that answer. | ||
| var stagingProtectionObserver func(stagingPath string) | ||
|
|
||
| // WriteFileAtomic writes data to a temporary file in the same directory as filename, | ||
| // flushes and syncs it to disk, and replaces filename atomically via ReplaceWithRetry. | ||
| // For new files, it honors the process umask by creating the temporary file with | ||
| // os.OpenFile(..., perm). For existing regular files, it requires write access to | ||
| // the current destination and copies that destination's authorization metadata onto | ||
| // the replacement (mode bits including setuid/setgid/sticky, owner, and xattrs such | ||
| // as POSIX ACLs and capabilities). If that metadata cannot be preserved, the call | ||
| // fails and leaves the destination unchanged. Non-regular, non-symlink destinations | ||
| // are refused before staging so a FIFO, device, or socket is not replaced. | ||
| // | ||
| // Platform differences on symlink destinations: On Unix (Linux/macOS), replacing a | ||
| // symlink destination replaces the symlink itself with the new regular file. On Windows, | ||
| // ReplaceFileW refuses symlink destinations outright and returns an error. | ||
| // Hard links to destination files are broken by design (temp-and-rename publishes a new inode). | ||
| func WriteFileAtomic(filename string, data []byte, perm os.FileMode) error { | ||
| dir := filepath.Dir(filename) | ||
| if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| var existingMode *os.FileMode | ||
| info, err := os.Lstat(filename) | ||
| switch { | ||
| case err == nil: | ||
| mode := info.Mode() | ||
| switch { | ||
| case mode.IsRegular(): | ||
| if err := ensureWritable(filename); err != nil { | ||
| return err | ||
| } | ||
| existingMode = &mode | ||
| case mode&os.ModeSymlink != 0: | ||
| // Documented platform-specific replacement of the symlink itself. | ||
| default: | ||
| return fmt.Errorf("%w: %s", ErrNonRegularDestination, filename) | ||
| } | ||
| case !os.IsNotExist(err): | ||
| return err | ||
| } | ||
|
|
||
| stagePerm := perm | ||
| if existingMode != nil { | ||
| stagePerm = *existingMode | ||
| } | ||
| tmpFile, err := createTempFile(dir, stagePerm) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if stagingModeObserver != nil { | ||
| if info, statErr := tmpFile.Stat(); statErr == nil { | ||
| stagingModeObserver(info.Mode()) | ||
| } | ||
| } | ||
| tmpName := tmpFile.Name() | ||
| closed := false | ||
| defer func() { | ||
| if !closed { | ||
| _ = tmpFile.Close() | ||
| } | ||
| _ = os.Remove(tmpName) | ||
| }() | ||
|
|
||
| if existingMode != nil { | ||
| if err := tmpFile.Chmod(*existingMode); err != nil { | ||
| return err | ||
| } | ||
| if err := preserveOwner(tmpFile, info); err != nil { | ||
| return err | ||
| } | ||
| if err := preserveXattrs(tmpFile, filename); err != nil { | ||
| return err | ||
|
Comment on lines
+100
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
target="$tmpdir/target"
: > "$target"
chmod 4755 "$target"
printf 'updated' > "$target"
mode="$(stat -c '%a' "$target")"
printf 'mode after write: %s\n' "$mode"
test "$mode" != 4755Repository: Gitlawb/zero Length of output: 193 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository convention files ---'
find /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- file outline ---'
ast-grep outline internal/fsutil/rename.go
printf '%s\n' '--- relevant source ---'
cat -n internal/fsutil/rename.go | sed -n '1,150p'
printf '%s\n' '--- applicable repository conventions ---'
for f in /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/*/*.md; do
case "$f" in
*/learnings/*|*/architecture/*) ;;
*) printf '\n### %s\n' "$f"; cat "$f" ;;
esac
doneRepository: Gitlawb/zero Length of output: 8793 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- metadata helper definitions and call sites ---'
rg -n -C 8 'func preserve(Owner|Xattrs)|preserveOwner|preserveXattrs|WriteFileAtomic' internal/fsutil --glob '*.go'
printf '%s\n' '--- fsutil file list ---'
git ls-files internal/fsutilRepository: Gitlawb/zero Length of output: 24317 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Unix owner preservation ---'
cat -n internal/fsutil/rename_owner_unix.go
printf '%s\n' '--- Unix xattr preservation ---'
cat -n internal/fsutil/rename_xattr_unix.go
printf '%s\n' '--- metadata-related tests ---'
cat -n internal/fsutil/rename_acl_linux_test.go | sed -n '1,130p'
cat -n internal/fsutil/rename_owner_unix_test.go | sed -n '1,90p'Repository: Gitlawb/zero Length of output: 6929 🌐 Web query:
💡 Result: In the Linux kernel, file_remove_privs is a VFS-level function responsible for clearing security-related file attributes—specifically setuid, setgid, and file capabilities—whenever a file is modified via operations such as write(2) or truncation [1][2][3]. The mechanism functions as follows: 1. Purpose: The primary goal of this function is to prevent unprivileged processes from modifying binaries that possess elevated privileges [1][3]. If a process without sufficient capabilities (specifically CAP_FSETID) modifies a file, the kernel ensures these sensitive permission bits are stripped to maintain system security [4][1]. 2. Integration with write(2): When a user calls write(2) on a file, the kernel eventually invokes file_modified (or similar paths in recent kernel versions) before the data is actually written [1][5][6]. This function calls file_remove_privs [1][2]. 3. Logic: - File Privileges Check: The function first determines if the file requires privilege removal using logic (often split into file_needs_remove_privs in newer kernels) [3]. This check examines the inode for the presence of setuid/setgid bits and file capabilities [1][2][3]. - Privilege Validation: If the file is a regular file and possesses these bits, the kernel checks if the calling process has the CAP_FSETID capability [4]. If the process lacks this capability, the setuid/setgid bits are cleared [4]. - Execution: If removal is required, remove_privs is called to perform the actual update to the file's metadata via notify_change [1][5]. This process ensures that if a user writes to a setuid root binary, the setuid bit is automatically removed, effectively neutralizing the potential for an unprivileged user to gain elevated execution rights through file modification [4][1]. Citations:
Restore metadata after writing the staged content.
🤖 Prompt for AI Agents |
||
| } | ||
| if err := preserveNativeACL(tmpFile, filename); err != nil { | ||
| return err | ||
| } | ||
| if err := protectStaging(tmpFile, filename); err != nil { | ||
| return err | ||
| } | ||
| if stagingProtectionObserver != nil { | ||
| stagingProtectionObserver(tmpName) | ||
| } | ||
| } | ||
| if _, err := tmpFile.Write(data); err != nil { | ||
| return err | ||
| } | ||
| if err := tmpFile.Sync(); err != nil { | ||
| return err | ||
| } | ||
| closed = true | ||
| if err := tmpFile.Close(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| replaceErr := ReplaceWithRetry(tmpName, filename, nil) | ||
| if replaceErr == nil || isCommittedReplacement(replaceErr) { | ||
| syncDir(dir) | ||
| } | ||
| return replaceErr | ||
| } | ||
|
|
||
| func ensureWritable(path string) error { | ||
| f, err := os.OpenFile(path, os.O_WRONLY, 0) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| return f.Close() | ||
| } | ||
|
|
||
| func createTempFile(dir string, perm os.FileMode) (*os.File, error) { | ||
| for i := 0; i < 10000; i++ { | ||
| var b [8]byte | ||
| if _, err := rand.Read(b[:]); err != nil { | ||
| return nil, err | ||
| } | ||
| name := filepath.Join(dir, fmt.Sprintf(".zero-tmp-%x", b)) | ||
| f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm) | ||
| if errors.Is(err, os.ErrExist) { | ||
| continue | ||
| } | ||
| return f, err | ||
| } | ||
| return nil, errors.New("fsutil: failed to create temporary file after repeated attempts") | ||
| } | ||
|
|
||
| func isCommittedReplacement(err error) bool { | ||
| var committed *CommittedReplacementCleanupError | ||
| return errors.As(err, &committed) | ||
| } | ||
|
|
||
| func syncDir(dir string) { | ||
| d, err := os.Open(dir) | ||
| if err != nil { | ||
| return | ||
| } | ||
| defer d.Close() | ||
| _ = d.Sync() | ||
| } | ||
|
|
||
| // CommittedReplacementCleanupError reports that a replacement was committed, | ||
| // but the old destination retained at BackupPath could not be removed. Callers | ||
| // must treat the replacement itself as successful and surface the cleanup | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| //go:build darwin | ||
|
|
||
| package fsutil | ||
|
|
||
| import ( | ||
| "encoding/binary" | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "golang.org/x/sys/unix" | ||
| ) | ||
|
|
||
| // preserveNativeACL copies the Darwin kauth/FILESEC ACL using getattrlist and | ||
| // setattrlist ATTR_CMN_EXTENDED_SECURITY. Listxattr/Getxattr/Fsetxattr cannot | ||
| // see com.apple.system.Security (protected namespace); that path is not used. | ||
| func preserveNativeACL(f *os.File, srcPath string) error { | ||
| acl, err := readNativeACL(srcPath) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if acl == nil { | ||
| return nil | ||
| } | ||
| if err := applyNativeACL(f.Name(), acl); err != nil { | ||
| return fmt.Errorf("fsutil: preserving native ACL on replacement for %s: %w", srcPath, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func extendedSecurityAttrlist() unix.Attrlist { | ||
| return unix.Attrlist{ | ||
| Bitmapcount: unix.ATTR_BIT_MAP_COUNT, | ||
| Commonattr: unix.ATTR_CMN_EXTENDED_SECURITY, | ||
| } | ||
| } | ||
|
|
||
| func readNativeACL(path string) ([]byte, error) { | ||
| al := extendedSecurityAttrlist() | ||
| buf := make([]byte, 4096) | ||
| if err := getattrlist(path, &al, buf, 0); err != nil { | ||
| if isXattrNotFound(err) || isXattrUnsupported(err) { | ||
| return nil, nil | ||
| } | ||
| return nil, fmt.Errorf("fsutil: reading native ACL of %s: %w", path, err) | ||
| } | ||
| if len(buf) < 12 { | ||
| return nil, nil | ||
| } | ||
| total := binary.LittleEndian.Uint32(buf[:4]) | ||
| if total < 12 { | ||
| return nil, nil | ||
| } | ||
| off := int32(binary.LittleEndian.Uint32(buf[4:8])) | ||
| length := binary.LittleEndian.Uint32(buf[8:12]) | ||
| if length == 0 { | ||
| return nil, nil | ||
| } | ||
| start := 4 + int(off) | ||
| end := start + int(length) | ||
| if start < 12 || end > int(total) || end > len(buf) { | ||
| return nil, fmt.Errorf("fsutil: truncated native ACL on %s", path) | ||
| } | ||
| out := make([]byte, length) | ||
| copy(out, buf[start:end]) | ||
| return out, nil | ||
| } | ||
|
|
||
| func applyNativeACL(path string, blob []byte) error { | ||
| al := extendedSecurityAttrlist() | ||
| buf := make([]byte, 8+len(blob)) | ||
| binary.LittleEndian.PutUint32(buf[0:4], 8) | ||
| binary.LittleEndian.PutUint32(buf[4:8], uint32(len(blob))) | ||
| copy(buf[8:], blob) | ||
| if err := unix.Setattrlist(path, &al, buf, 0); err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| //go:build darwin | ||
|
|
||
| package fsutil | ||
|
|
||
| import ( | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestWriteFileAtomicPreservesOrRefusesNativeACL pins the fail-closed contract | ||
| // for a Darwin native (kauth/FILESEC) ACL: replacing the destination either | ||
| // carries the ACL over to the new inode, or the replacement is refused and the | ||
| // original file is left byte-for-byte intact. What must never happen is a | ||
| // successful replacement that silently drops the ACL. | ||
| func TestWriteFileAtomicPreservesOrRefusesNativeACL(t *testing.T) { | ||
| if _, err := exec.LookPath("chmod"); err != nil { | ||
| t.Skip("chmod not on PATH") | ||
| } | ||
| if _, err := exec.LookPath("ls"); err != nil { | ||
| t.Skip("ls not on PATH") | ||
| } | ||
|
|
||
| dir := t.TempDir() | ||
| target := filepath.Join(dir, "restricted.txt") | ||
| original := "old" | ||
| if err := os.WriteFile(target, []byte(original), 0o644); err != nil { | ||
| t.Fatalf("WriteFile: %v", err) | ||
| } | ||
|
|
||
| set := exec.Command("chmod", "+a", "user:nobody deny read", target) | ||
| if out, err := set.CombinedOutput(); err != nil { | ||
| t.Skipf("chmod +a failed (ACLs unavailable on this filesystem): %v\n%s", err, out) | ||
| } | ||
|
|
||
| acl, err := readNativeACL(target) | ||
| if err != nil { | ||
| t.Skipf("cannot read the native ACL that chmod +a just set: %v", err) | ||
| } | ||
| if len(acl) == 0 { | ||
| t.Skip("chmod +a reported success but no native ACL is present") | ||
| } | ||
| before, err := exec.Command("ls", "-le", target).CombinedOutput() | ||
| if err != nil { | ||
| t.Fatalf("ls -le before: %v\n%s", err, before) | ||
| } | ||
| if !strings.Contains(string(before), "deny") { | ||
| t.Skipf("named deny entry did not stick; ls -le:\n%s", before) | ||
| } | ||
|
|
||
| replaceErr := WriteFileAtomic(target, []byte("new"), 0o600) | ||
| if replaceErr != nil { | ||
| // Refusal path: the error is acceptable only if the destination was | ||
| // not mutated on the way out. | ||
| got, rerr := os.ReadFile(target) | ||
| if rerr != nil { | ||
| t.Fatalf("WriteFileAtomic failed (%v) and the original is unreadable: %v", replaceErr, rerr) | ||
| } | ||
| if string(got) != original { | ||
| t.Fatalf("WriteFileAtomic failed (%v) but mutated the destination to %q", replaceErr, got) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // Preservation path: the replacement succeeded, so the deny entry must | ||
| // still be attached to the new inode. | ||
| after, err := readNativeACL(target) | ||
| if err != nil { | ||
| t.Fatalf("WriteFileAtomic succeeded but the native ACL became unreadable: %v", err) | ||
| } | ||
| if len(after) == 0 { | ||
| t.Fatalf("WriteFileAtomic replaced the destination and lost the native ACL") | ||
| } | ||
| afterListing, err := exec.Command("ls", "-le", target).CombinedOutput() | ||
| if err != nil { | ||
| t.Fatalf("ls -le after: %v\n%s", err, afterListing) | ||
| } | ||
| if !strings.Contains(string(afterListing), "deny") { | ||
| t.Fatalf("native ACL deny entry was lost after replacement\nbefore:\n%s\nafter:\n%s", before, afterListing) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Reject link destinations or implement their prior semantics.
Line 26 accepts a symbolic link as a non-regular destination. Line 66 then replaces that link with the temporary file. A
write_fileoredit_fileoperation can succeed, leave the symlink referent unchanged, and remove the symlink.A hard-linked regular file passes the current regular-file check. Replacement detaches only
filename, so other hard-link aliases retain stale content.Define a fail-closed policy before replacement. Reject symbolic links and multiply-linked regular files, or implement explicit supported semantics for them. Add regression tests for the selected failure behavior.
As per coding guidelines, “Every behavior or security-boundary change needs a regression test, including the failure path.”
Also applies to: 66-70
🤖 Prompt for AI Agents
Source: Coding guidelines