From 7ad7ea97884d8ee9058439b46feb77c4e296a3f5 Mon Sep 17 00:00:00 2001 From: konsta95 Date: Fri, 23 Oct 2026 19:40:31 +0000 Subject: [PATCH] fix(real-path): follow the kernel's trailing-slash rule after a link macOS 14 and 15 drop a trailing slash after a followed symlink (xnu clears NAMEI_TRAILINGSLASH), so link-to-file/ resolves to the file, while Linux and macOS 26 answer ENOTDIR. wayTo always applied the Linux rule, so the realPathOf property failed intermittently on macOS 14/15 and could time out while fast-check shrank the failure. A stat probe of / now decides. When it succeeds the trailing slash is dropped; when it fails the walk keeps its own error and partial result, so ELOOP, EILSEQ and wayTo's partial return stay as they were. The property now puts its error in the report, and a run still going at 90 s counts as a failure, before the 120 s timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: a68131fc-e1c6-45c2-9cf6-4d99438fbf1d --- src/utils/real-path.ts | 10 +++++ test/utils/real-path.test.ts | 79 +++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/utils/real-path.ts b/src/utils/real-path.ts index 8ffbceebd..acd1a5d49 100644 --- a/src/utils/real-path.ts +++ b/src/utils/real-path.ts @@ -104,6 +104,16 @@ export function wayTo(folded: string): { ) } const target = textOf(fs.readlinkSync, next) + // Kernels differ on a trailing slash after a followed link. A successful + // probe settles that requirement; the target still needs its links walked. + if (rest.length > 0 && rest.every(name => name === '')) { + try { + fs.statSync(`${next}/`) + rest.length = 0 + } catch { + // An unresolved probe must not override the walk's error or partial path. + } + } if (target.startsWith('/')) real = '' rest.unshift(...target.split('/')) } diff --git a/test/utils/real-path.test.ts b/test/utils/real-path.test.ts index ac9580d49..371ba4ce7 100644 --- a/test/utils/real-path.test.ts +++ b/test/utils/real-path.test.ts @@ -108,6 +108,15 @@ describe.if(!isWindows)('realPathOf', () => { ['a\\b/up-from-a-file', 'file/..'], ['a\\b/in-a-file', 'file/.'], ['a\\b/file-as-a-directory', 'file/'], + ['a\\b/file-link', 'file'], + ['a\\b/absolute-file-link', at('a\\b/file')], + ['a\\b/directory-link', 'in'], + ['a\\b/slash-after-file-link', 'file-link/'], + ['a\\b/slashes-after-file-link', 'file-link///'], + ['a\\b/slash-after-absolute-file-link', 'absolute-file-link/'], + ['a\\b/slash-after-directory-link', 'directory-link/'], + ['a\\b/through-file', 'file/sub'], + ['a\\b/slash-after-through-file', 'through-file/'], ['a\\b/up-from-shut', '../shut\\/..'], ['a\\b/1', 'file'], ['plain/out', '../a/b'], @@ -117,6 +126,7 @@ describe.if(!isWindows)('realPathOf', () => { fs.symlinkSync(target!, at(link!)) } for (let i = 2; i <= 41; i++) fs.symlinkSync(`${i - 1}`, at(`a\\b/${i}`)) + fs.symlinkSync('40/', at('a\\b/slash-after-40-links')) fs.chmodSync(at('shut\\'), 0o600) }) @@ -192,6 +202,68 @@ describe.if(!isWindows)('realPathOf', () => { ]) }, 30_000) + it("keeps the kernel's trailing-slash rule after following a link", () => { + const paths = [ + 'a\\b/slash-after-file-link', + 'a\\b/slashes-after-file-link', + 'a\\b/slash-after-absolute-file-link', + 'a\\b/slash-after-directory-link', + ].map(at) + const answers = inNode(paths) + paths.forEach((p, i) => { + const real = answer(() => realPathOf(p)) + const there = answers[i]![0]! + const kernel = fileAt(p) + console.log(`${p}: kernel ${kernel}; here ${real}; Node ${there}`) + expect([ + p, + real.startsWith('/') ? fileAt(real) : real, + there.startsWith('/') ? fileAt(there) : there, + ]).toEqual([p, kernel, kernel]) + }) + }, 30_000) + + it("uses the kernel's successful trailing-slash probe", () => { + const stat = spyOn(fs, 'statSync').mockReturnValue( + fs.statSync(at('a\\b/file')), + ) + try { + expect(realPathOf(at('a\\b/slash-after-file-link'))).toBe(at('a\\b/file')) + } finally { + stat.mockRestore() + } + }) + + it('preserves ELOOP when a trailing-slash probe has not counted earlier links', () => { + const p = at('a\\b/slash-after-40-links') + expect(fileAt(p)).toBe('ELOOP') + expect(answer(() => realPathOf(p))).toBe('ELOOP') + }) + + it('returns the partial way through a file behind a trailing slash', () => { + const { real, rest, notThere } = wayTo(at('a\\b/slash-after-through-file')) + expect({ + real, + rest, + code: (notThere as NodeJS.ErrnoException).code, + }).toEqual({ + real: at('a\\b/file'), + rest: ['sub', ''], + code: 'ENOTDIR', + }) + }) + + it.if(isLinux)('preserves EILSEQ behind a trailing slash', () => { + const bytes = Buffer.from([0xff]) + fs.writeFileSync(Buffer.concat([Buffer.from(at('a\\b/')), bytes]), '') + fs.symlinkSync(bytes, at('a\\b/non-text-target')) + fs.symlinkSync('non-text-target', at('a\\b/to-non-text-target')) + fs.symlinkSync('to-non-text-target/', at('a\\b/slash-before-non-text')) + expect(answer(() => realPathOf(at('a\\b/slash-before-non-text')))).toBe( + 'EILSEQ', + ) + }) + // macOS lets no such name be made. it.if(isLinux)( 'refuses a way through a name that is no text, and takes U+FFFD in a name for itself, in Node too', @@ -487,7 +559,12 @@ describe.if(!isWindows)('property: realPathOf', () => { } }, ), - { numRuns: 60 }, + { + numRuns: 60, + includeErrorInReport: true, + interruptAfterTimeLimit: 90_000, + markInterruptAsFailure: true, + }, ) }, 120_000) })