Skip to content

Commit 041dc8f

Browse files
committed
Harden atomicWriteGitattributes writability check against TOCTOU symlink swap
Add fstatFn injectable and compare lstat vs fstat via os.SameFile after opening the existing target file. If the path is swapped to a symlink between lstatFile and os.OpenFile, the opened fd's inode will differ from the lstat result, and the function returns an error rather than silently writing through the link. Add TestAtomicWriteGitattributes_FstatFails and TestAtomicWriteGitattributes_LstatFdMismatch tests covering the new paths. https://claude.ai/code/session_01UFDupDRxvbPgETqngjCta7
1 parent 207e8b1 commit 041dc8f

2 files changed

Lines changed: 53 additions & 5 deletions

File tree

internal/githooks/githooks.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -798,26 +798,36 @@ func writeGitattributesFile(path, content string) error {
798798
return nil
799799
}
800800

801-
// createTempFn, syncTempFn, closeTempFn, and chmodFn are variables so tests
802-
// can inject failures into atomicWriteGitattributes without OS-level tricks.
801+
// createTempFn, syncTempFn, closeTempFn, chmodFn, and fstatFn are variables
802+
// so tests can inject failures into atomicWriteGitattributes without OS tricks.
803803
var createTempFn = os.CreateTemp
804804
var syncTempFn = (*os.File).Sync
805805
var closeTempFn = (*os.File).Close
806806
var chmodFn = os.Chmod
807+
var fstatFn = (*os.File).Stat
807808

808809
// atomicWriteGitattributes writes data to a temp file in the same directory
809810
// as path, sets its permissions, then renames it over path. The rename
810811
// replaces the directory entry atomically, so it cannot follow a symlink
811812
// that might have been introduced between an earlier lstat check and the write.
812813
func atomicWriteGitattributes(path string, data []byte, mode os.FileMode) error {
813-
// Verify an existing target is writable. os.Rename can replace read-only
814-
// files when the directory is writable, so we check explicitly.
815-
if _, err := lstatFile(path); err == nil {
814+
// Verify an existing target is writable and has not been swapped to a
815+
// symlink. os.Rename can replace read-only files when the directory is
816+
// writable, so we check writability explicitly. We then compare lstat and
817+
// fstat to detect a TOCTOU swap between the lstat and the open.
818+
if lstatInfo, err := lstatFile(path); err == nil {
816819
f, err := os.OpenFile(path, os.O_WRONLY, 0)
817820
if err != nil {
818821
return err
819822
}
823+
fdInfo, statErr := fstatFn(f)
820824
_ = f.Close()
825+
if statErr != nil {
826+
return statErr
827+
}
828+
if !os.SameFile(lstatInfo, fdInfo) {
829+
return fmt.Errorf("%s: file changed since lstat", path)
830+
}
821831
} else if !os.IsNotExist(err) {
822832
return err
823833
}

internal/githooks/githooks_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,44 @@ func TestAtomicWriteGitattributes_LstatNonENOENTError_ReturnsError(t *testing.T)
12241224
assert.Contains(t, err.Error(), "mock lstat failure")
12251225
}
12261226

1227+
func TestAtomicWriteGitattributes_FstatFails_ReturnsError(t *testing.T) {
1228+
dir := t.TempDir()
1229+
path := filepath.Join(dir, ".gitattributes")
1230+
require.NoError(t, os.WriteFile(path, []byte("existing"), 0o644))
1231+
1232+
orig := fstatFn
1233+
t.Cleanup(func() { fstatFn = orig })
1234+
fstatFn = func(*os.File) (os.FileInfo, error) {
1235+
return nil, fmt.Errorf("mock fstat failure")
1236+
}
1237+
1238+
err := atomicWriteGitattributes(path, []byte("content"), 0o644)
1239+
require.Error(t, err)
1240+
assert.Contains(t, err.Error(), "mock fstat failure")
1241+
}
1242+
1243+
func TestAtomicWriteGitattributes_LstatFdMismatch_ReturnsError(t *testing.T) {
1244+
dir := t.TempDir()
1245+
path := filepath.Join(dir, ".gitattributes")
1246+
other := filepath.Join(dir, "other")
1247+
require.NoError(t, os.WriteFile(path, []byte("existing"), 0o644))
1248+
require.NoError(t, os.WriteFile(other, []byte("other"), 0o644))
1249+
1250+
otherInfo, err := os.Lstat(other)
1251+
require.NoError(t, err)
1252+
1253+
// Inject lstatFile to return info for 'other' (different inode than path).
1254+
orig := lstatFile
1255+
t.Cleanup(func() { lstatFile = orig })
1256+
lstatFile = func(string) (os.FileInfo, error) {
1257+
return otherInfo, nil
1258+
}
1259+
1260+
err = atomicWriteGitattributes(path, []byte("content"), 0o644)
1261+
require.Error(t, err)
1262+
assert.Contains(t, err.Error(), "changed since lstat")
1263+
}
1264+
12271265
func TestWriteGitattributes_LstatNonENOENTError_ReturnsError(t *testing.T) {
12281266
orig := lstatFile
12291267
t.Cleanup(func() { lstatFile = orig })

0 commit comments

Comments
 (0)