detached f9fc798

fix(executor): chown the file created by createExcl through its open handle, not by name after the close (GO-003)

commit
f9fc79880a98d918ca56266355e3b08808d75c01
author
brice · · 2026-10-12 20:10 UTC
parent
63269d4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +32 -6
M internal/executor/filecmd.go +9 -6
@@ -230,7 +230,10 @@ func readText(root *os.Root, name string) (text string, ok bool, err error) {
230230 // createExcl creates name in root with the content, failing if it exists (an
231231 // existing entry, a symbolic link included, is removed first, never followed),
232232 // and gives it to owner when set. The creation uses O_EXCL|O_NOFOLLOW, so
233-// something planted between the removal and the creation makes it fail.
233+// something planted between the removal and the creation makes it fail. The
234+// owner is set on the open file, not by name: the user of the steps owns the
235+// directory and could swap the name for something else between the close and a
236+// chown by name.
234237 func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, owner *[2]int) error {
235238 if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {
236239 return err
@@ -243,13 +246,13 @@ func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, ow
243246 _ = fh.Close()
244247 return err
245248 }
246- if err := fh.Close(); err != nil {
247- return err
248- }
249249 if owner != nil {
250- return root.Lchown(name, owner[0], owner[1])
250+ if err := fh.Chown(owner[0], owner[1]); err != nil {
251+ _ = fh.Close()
252+ return err
253+ }
251254 }
252- return nil
255+ return fh.Close()
253256 }
254257
255258 // processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).
@@ -230,7 +230,10 @@ func readText(root *os.Root, name string) (text string, ok bool, err error) {
230 // createExcl creates name in root with the content, failing if it exists (an230 // createExcl creates name in root with the content, failing if it exists (an
231 // existing entry, a symbolic link included, is removed first, never followed),231 // existing entry, a symbolic link included, is removed first, never followed),
232 // and gives it to owner when set. The creation uses O_EXCL|O_NOFOLLOW, so232 // and gives it to owner when set. The creation uses O_EXCL|O_NOFOLLOW, so
233-// something planted between the removal and the creation makes it fail.233+// something planted between the removal and the creation makes it fail. The
234+// owner is set on the open file, not by name: the user of the steps owns the
235+// directory and could swap the name for something else between the close and a
236+// chown by name.
234 func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, owner *[2]int) error {237 func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, owner *[2]int) error {
235 if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {238 if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {
236 return err239 return err
@@ -243,13 +246,13 @@ func createExcl(root *os.Root, name string, content []byte, perm os.FileMode, ow
243 _ = fh.Close()246 _ = fh.Close()
244 return err247 return err
245 }248 }
246- if err := fh.Close(); err != nil {
247- return err
248- }
249 if owner != nil {249 if owner != nil {
250- return root.Lchown(name, owner[0], owner[1])250+ if err := fh.Chown(owner[0], owner[1]); err != nil {
251+ _ = fh.Close()
252+ return err
253+ }
251 }254 }
252- return nil255+ return fh.Close()
253 }256 }
254 257
255 // processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).258 // processAddPath is AddPathFileCommand.ProcessCommand (FileCommandManager.cs:134).
M internal/executor/filecmd_test.go +23 -0
@@ -353,3 +353,26 @@ func TestCreateExclReplacesAndNeverFollows(t *testing.T) {
353353 t.Fatal("still a link")
354354 }
355355 }
356+
357+// The owner is applied to the open file (GO-003): chown by name after the close
358+// would follow a name that the user of the steps could have swapped. Own uid and
359+// gid are accepted by an unprivileged process, so the path runs here.
360+func TestCreateExclChownsTheOpenFile(t *testing.T) {
361+ dir := t.TempDir()
362+ root, err := os.OpenRoot(dir)
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ defer func() { _ = root.Close() }()
367+ owner := &[2]int{os.Getuid(), os.Getgid()}
368+ if err := createExcl(root, "f", []byte("x"), 0o644, owner); err != nil {
369+ t.Fatal(err)
370+ }
371+ // A uid that this process may not give files to makes the creation fail, and
372+ // the half-made file is not reported as success.
373+ if os.Getuid() != 0 {
374+ if err := createExcl(root, "g", []byte("x"), 0o644, &[2]int{0, 0}); err == nil {
375+ t.Fatal("chown to root by an unprivileged process succeeded")
376+ }
377+ }
378+}
@@ -353,3 +353,26 @@ func TestCreateExclReplacesAndNeverFollows(t *testing.T) {
353 t.Fatal("still a link")353 t.Fatal("still a link")
354 }354 }
355 }355 }
356+
357+// The owner is applied to the open file (GO-003): chown by name after the close
358+// would follow a name that the user of the steps could have swapped. Own uid and
359+// gid are accepted by an unprivileged process, so the path runs here.
360+func TestCreateExclChownsTheOpenFile(t *testing.T) {
361+ dir := t.TempDir()
362+ root, err := os.OpenRoot(dir)
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ defer func() { _ = root.Close() }()
367+ owner := &[2]int{os.Getuid(), os.Getgid()}
368+ if err := createExcl(root, "f", []byte("x"), 0o644, owner); err != nil {
369+ t.Fatal(err)
370+ }
371+ // A uid that this process may not give files to makes the creation fail, and
372+ // the half-made file is not reported as success.
373+ if os.Getuid() != 0 {
374+ if err := createExcl(root, "g", []byte("x"), 0o644, &[2]int{0, 0}); err == nil {
375+ t.Fatal("chown to root by an unprivileged process succeeded")
376+ }
377+ }
378+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close