Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion file_handling.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,19 @@ func (f *File) Read() (string, error) {
// step after its creation fails (including the rename); on success the remove
// is a no-op because the file has already been renamed away.
func (f *File) Write(content string) error {
mode, err := f.Mode()
info, err := f.Info()
if err != nil {
return err
}
mode := info.Mode()

tempName := filepath.Join(f.Dir(), RandomString(20))
if err := os.WriteFile(tempName, []byte(content), mode); err != nil {
return fmt.Errorf("create tempfile in %v: %w", f.Dir(), err)
}
if err := chownTempFromInfo(tempName, info); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore mode after changing temp-file ownership

For setuid/setgid files, Unix kernels clear those special permission bits on chown, so the temp file is created with info.Mode() and then loses those bits here before it is renamed over the original. Rewriting a matching setuid/setgid executable therefore silently strips its mode bits; apply ownership before restoring the original mode, or chmod again after the chown succeeds.

Useful? React with 👍 / 👎.

return fmt.Errorf("preserve ownership on temp file %v: %w", tempName, err)
Comment on lines +106 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Install cleanup before chowning the temp file

When chownTempFromInfo fails after the temp file has been created, this returns before the defer os.Remove(tempName) is registered, leaving the random temp file in the target directory. This can happen for a non-root user rewriting a group-writable file owned by another uid/gid, where the write succeeds but os.Chown returns EPERM; move the cleanup defer immediately after os.WriteFile succeeds so all post-create failures are cleaned up.

Useful? React with 👍 / 👎.

}
// Make sure the temp file is removed if the rename below fails. On
// success, the rename has already moved the file to f.Path so this is
// a no-op (we deliberately ignore the not-exist error).
Expand Down
53 changes: 53 additions & 0 deletions file_handling_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package main

import (
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
)

Expand Down Expand Up @@ -77,3 +80,53 @@ func TestNewFile(t *testing.T) {
})
}
}

func TestWritePreservesOwnership(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("ownership preservation not implemented on Windows")
}

dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}

before, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
beforeStat, ok := before.Sys().(*syscall.Stat_t)
if !ok {
t.Skip("syscall.Stat_t not available")
}

f, err := NewFile(path)
if err != nil {
t.Fatal(err)
}
if err := f.Write("new"); err != nil {
t.Fatal(err)
}

after, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
afterStat, ok := after.Sys().(*syscall.Stat_t)
if !ok {
t.Fatal("expected syscall.Stat_t after rewrite")
}
if afterStat.Uid != beforeStat.Uid || afterStat.Gid != beforeStat.Gid {
t.Fatalf("ownership changed: uid %d->%d gid %d->%d",
beforeStat.Uid, afterStat.Uid, beforeStat.Gid, afterStat.Gid)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "new" {
t.Fatalf("content = %q; want %q", got, "new")
}
}

16 changes: 16 additions & 0 deletions ownership_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !windows

package main

import (
"os"
"syscall"
)

func chownTempFromInfo(tempPath string, info os.FileInfo) error {
sys, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
return os.Chown(tempPath, int(sys.Uid), int(sys.Gid))
}
9 changes: 9 additions & 0 deletions ownership_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build windows

package main

import "os"

func chownTempFromInfo(tempPath string, info os.FileInfo) error {
return nil
}