From c1d8045d86a9ef6b35b10c4106e67719247706e4 Mon Sep 17 00:00:00 2001 From: Jakob Borg Date: Thu, 16 Jul 2026 09:19:13 +0800 Subject: [PATCH] fix(ignore, fs): allow loading ignore patterns behind symlink (fixes #10785) (#10786) The recent change to disallow following symlinks by default conflicts with the expected behavior of .stignore. This adds a new flag which may be passed to OpenFile to skip default symlink-forbidding open flag. --------- Signed-off-by: Jakob Borg --- lib/fs/basicfs.go | 12 +++++++++++- lib/fs/basicfs_unix.go | 2 +- lib/fs/basicfs_windows.go | 2 +- lib/fs/filesystem.go | 11 +++++++++++ lib/ignore/ignore.go | 4 ++-- lib/ignore/ignore_test.go | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 60 insertions(+), 5 deletions(-) diff --git a/lib/fs/basicfs.go b/lib/fs/basicfs.go index 9121fec20..de2c56fd1 100644 --- a/lib/fs/basicfs.go +++ b/lib/fs/basicfs.go @@ -240,16 +240,24 @@ func (f *BasicFilesystem) DirNames(name string) ([]string, error) { return names, nil } +// Open opens the file for reading, while not following symlinks func (f *BasicFilesystem) Open(name string) (File, error) { return f.OpenFile(name, os.O_RDONLY, 0) } +// OpenFile is the generalised file open call, using the flags to determine +// the open semantics. We always add O_NOFOLLOW, disallowing opening files +// by following symlinks, unless OptFollow is set. func (f *BasicFilesystem) OpenFile(name string, flags int, mode FileMode) (File, error) { rootedName, err := f.rooted(name) if err != nil { return nil, err } - flags |= alwaysOpenFlags // enforce extra bits in flags + if flags&OptFollow != 0 { + flags &^= OptFollow // unset the synthetic OptFollow bit + } else { + flags |= optNoFollow + } fd, err := os.OpenFile(rootedName, flags, os.FileMode(mode)) if err != nil { return nil, err @@ -257,6 +265,8 @@ func (f *BasicFilesystem) OpenFile(name string, flags int, mode FileMode) (File, return basicFile{fd, name}, err } +// Create opens a file for writing, creating it as required. The Create will +// fail if the destination is a symlink. func (f *BasicFilesystem) Create(name string) (File, error) { return f.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o666) } diff --git a/lib/fs/basicfs_unix.go b/lib/fs/basicfs_unix.go index f9527f1bd..97e24bd4d 100644 --- a/lib/fs/basicfs_unix.go +++ b/lib/fs/basicfs_unix.go @@ -17,7 +17,7 @@ import ( "syscall" ) -const alwaysOpenFlags = syscall.O_NOFOLLOW // never open symlinks as the final path component +const optNoFollow = syscall.O_NOFOLLOW func (f *BasicFilesystem) CreateSymlink(target, name string) error { name, err := f.rooted(name) diff --git a/lib/fs/basicfs_windows.go b/lib/fs/basicfs_windows.go index cde316701..5230431e7 100644 --- a/lib/fs/basicfs_windows.go +++ b/lib/fs/basicfs_windows.go @@ -19,7 +19,7 @@ import ( "golang.org/x/sys/windows" ) -const alwaysOpenFlags = 0 // no extra flags +const optNoFollow = 0 // not defined for Windows var errNotSupported = errors.New("symlinks not supported") diff --git a/lib/fs/filesystem.go b/lib/fs/filesystem.go index 31bbb9770..b001dcefc 100644 --- a/lib/fs/filesystem.go +++ b/lib/fs/filesystem.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "io/fs" + "math/bits" "os" "path/filepath" "strings" @@ -173,8 +174,18 @@ const ( OptSync = os.O_SYNC OptTruncate = os.O_TRUNC OptWriteOnly = os.O_WRONLY + OptFollow = 1 << (bits.UintSize - 2) ) +func init() { + // Ensure OptFollow doesn't clash with any other flag. + flags := OptAppend | OptCreate | OptExclusive | OptReadOnly | OptReadWrite | OptSync | OptTruncate | OptWriteOnly + flags &= OptFollow + if flags != 0 { + panic("bug: flag clash") + } +} + // SkipDir is used as a return value from WalkFuncs to indicate that // the directory named in the call is to be skipped. It is not returned // as an error by any function. diff --git a/lib/ignore/ignore.go b/lib/ignore/ignore.go index 2acf7c2e2..1c8978c78 100644 --- a/lib/ignore/ignore.go +++ b/lib/ignore/ignore.go @@ -357,8 +357,8 @@ func hashPatterns(patterns []Pattern) string { return hex.EncodeToString(h.Sum(nil)) } -func loadIgnoreFile(fs fs.Filesystem, file string) (fs.File, fs.FileInfo, error) { - fd, err := fs.Open(file) +func loadIgnoreFile(ffs fs.Filesystem, file string) (fs.File, fs.FileInfo, error) { + fd, err := ffs.OpenFile(file, fs.OptReadOnly|fs.OptFollow, 0o666) if err != nil { return fd, nil, err } diff --git a/lib/ignore/ignore_test.go b/lib/ignore/ignore_test.go index 0232a8b6a..09c2b06e9 100644 --- a/lib/ignore/ignore_test.go +++ b/lib/ignore/ignore_test.go @@ -1731,3 +1731,37 @@ func testEscape(t *testing.T, tests []escapeTest, noErrors bool) { } } } + +// TestIgnoreThroughSymlink verifies that an ignore file can be loaded when +// it is itself a symlink pointing at the real file. This exercises the +// SkipDefaultOpenFlags path, as our normal Open enforces O_NOFOLLOW. We use +// a real filesystem here because the fake one does not implement the +// O_NOFOLLOW semantics that make this distinction meaningful. +func TestIgnoreThroughSymlink(t *testing.T) { + if build.IsWindows { + t.Skip("symlinks not supported on Windows") + } + + testFS := fs.NewFilesystem(fs.FilesystemTypeBasic, t.TempDir()) + + // The real ignore file lives under a different name, and .stignore is a + // symlink pointing at it. + if err := fs.WriteFile(testFS, "real-ignores", []byte("bfile\n"), 0o666); err != nil { + t.Fatal(err) + } + if err := testFS.CreateSymlink("real-ignores", ".stignore"); err != nil { + t.Fatal(err) + } + + pats := New(testFS, WithCache(true)) + if err := pats.Load(".stignore"); err != nil { + t.Fatal(err) + } + + if !pats.Match("bfile").IsIgnored() { + t.Error("bfile should be ignored") + } + if pats.Match("afile").IsIgnored() { + t.Error("afile should not be ignored") + } +}