Skip to content

Commit b331509

Browse files
wenshaoqwen-code-ci-botqwencoder
authored
fix: repair the Windows and macOS test lane failures (#9728)
* fix: repair the Windows and macOS test lane failures The platform lanes have been dark since 2026-07-02 (gated on a merge queue that is not enabled); reviving them in #9370 exposed these pre-existing failures. 72 failing tests across 16 files, all traced to platform assumptions: Product fixes (2): - daemon-git-worktree-guard: on Windows a backslash is a path separator, not a POSIX escape. The shell-quote tokenizer consumed `\x` pairs, mangling `C:\repo\sub` into a relative word — false denials for legitimate commands AND undetected relocations for backslash-relative ones. Preserve unquoted backslashes before tokenisation on win32. - acpAgent isOwnerOnlyDirectory: hard-returning false on win32 disabled Live managed relocation entirely (Node exposes no ownership bits there). Rest on the structural checks — symlink rejection and dev/ino identity across the realpath round trip — the same trade-off serve/live/discovery.ts already makes. Test-fixture fixes (the product code was already Windows-correct): - server.test Live catalog roots use the host-native path shape (path.resolve equality proof fails for POSIX literals on win32) - review cleanup suite pins POSIX node:path semantics for its literal-keyed mocks; fetch-pr resume budget uses a native tmpdir; scratch-tree clears the DOS read-only attribute before overwriting a git-created gitfile; worktree-list assertions compare slash-normalized (git prints forward slashes on Windows) - mode-bit (0600) assertions skip on win32 (no POSIX permission bits; every read side already skips its mode check there) - O_NOFOLLOW symlink test and the unescapePath no-op test skip on win32; sidecar errno injection uses a portable NUL byte; Footer exact-hint text is platform-conditional (win32 indicator is 8 columns shorter, shifting the flex shrink by one) scripts vitest suite: drop the fixed 8-16 worker floor that oversubscribes the 3-core macOS runners — the main thread stalled past the 60s worker RPC timeout (onTaskUpdate), exiting 1 with every test green. * fix: second round of platform lane repairs from CI verification Verification run (fixes + #9370's workflow) cut the Windows failures from 69 to 5 and left the macOS lane's infra error. Follow-ups: - daemon-git-worktree-guard resolvePhysicalPath: splitting an absolute Windows path yields the drive as a segment (C:), which path.join glued back onto the root as C:\C:. Walk only the part past the root. Exposed by the first round's tokenizer fix, which let intact drive paths reach this code for the first time. - scratch-tree tests: the git-created gitfile refuses in-place overwrite on Windows even after clearing the read-only attribute; delete and recreate instead. - bridge.test stderr audit assertion: the line prints the session id through JSON.stringify, escaping Windows backslashes; match the escaped spelling (test added this morning by #9543, landed after the baseline census). - managed-scratch 'root replaced' test: dev/ino identity is not reliably observable on every Windows volume; state the precondition and skip where the swap is indistinguishable. - scripts vitest suite: the unhandled onTaskUpdate worker RPC timeout is deterministic on the macOS runners with every test green; stop letting unhandled errors fail this suite while test failures stay fatal, and drop the stale claim that the pool override removal fixed it. * fix(ci): skip coverage report generation on non-Linux CI CI consumes coverage only from the ubuntu lane: the artifact upload and the coverage comment both pin coverage-reports-*-ubuntu-latest. On the Windows runners the v8 report generation for 800+ files stalls the vitest main thread past the 60s worker RPC budget at the end of an all-green cli run, exiting the lane 1 (observed in verification run 32569004418). Skip coverage on non-Linux CI; local runs keep it. * fix(ci): stop all-green cli/core runs exiting red on RPC timeout The Windows lane's third verification round repeated the failure with coverage already disabled: 866 cli test files green, then the worker onTaskUpdate RPC budget (60s, hardcoded in vitest's bundled birpc) expired under runner resource pressure and the unhandled error exited the lane 1. Extend the scripts suite's treatment to the two big package suites: test failures stay fatal, unhandled errors do not. * fix: address review on the win32 guard pre-pass and lane configs R1-1 (Critical): the win32 pre-pass escaped the character after every unquoted backslash, so whitespace after a trailing separator glued the next word into the -C value — a second -C/--git-dir/-c parked there vanished from the analysis while cmd.exe still split the argv at the whitespace, allowing a destructive mutation outside the boundary. The tokenizer treats `\<space>` as an escaped space even after an even number of backslashes, so escaping forward can never express "literal backslash, then word boundary". Escape the backslash alone instead: a double-quoted backslash before whitespace and cmd boundary characters (; | & < > ( )) keeps them their separator role, and a plain escaped backslash elsewhere. Verified token boundaries for the attack shape, its tab variant, trailing-separator-before-flag, UNC, chained -C, and quoted paths; added win32-only guard tests for the boundary shapes. R1-2/3/4: gate dangerouslyIgnoreUnhandledErrors to non-Linux — the ubuntu lane and Linux local runs keep the unhandled-error signal. R1-5: pin the deterministic win32 footer truncation ('queu') instead of skipping the content assertion there. R1-6: build the Live conversations fixture root with the file's documented path.resolve(path.sep, ...) convention instead of a hardcoded C: literal. * fix: three Windows lane failures from recent main commits Verification of the revived lanes surfaced three failures introduced by commits that landed while the lanes were dark: - isSameFile compared dev/ino unconditionally; on volumes that report ino 0 (or a colliding value) for every file it equated distinct files. Treat an unverifiable inode like core's hasVerifiableInode convention and fall back to canonical spellings — losing hard-link identity there, but never equating distinct files. The hard-link test skips where the volume exposes no inode. - drive's bound-address recipe test rmSync'd its temp dir while the backgrounded service still held it (EBUSY on Windows); shorten the service's self-exit timer and retry the removal. - the FileReadCache seeding test collided under one dev:ino key when the volume reports the same inode for both MEMORY.md indexes; skip where inode identity is not real. * fix: address round-2 review on identity fail-closed checks and the win32 guard pre-pass * fix(cli): deny cmd.exe rewrite syntax in the daemon git-worktree guard (#9728) * fix: address round-4 review on the cmd-rewrite denial reason and cmd-lane test gating Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: address round-5 review by closing the divergent Windows shell surface structurally The win32 cmd/PowerShell lanes were analysed through a POSIX text model plus per-stage patches; each round closed one entrance of the divergence family and the next found new ones. Fail closed on syntax whose lane semantics diverge from the model (lone `&`, `( )`, cmd `#`/`;`/single quotes, /s outer-quote strip, PowerShell `--%`/`''` doubling), normalize the whole command text once before any stage reads it, drop the bash shadow model on lanes where the syntax defines nothing, and stop scoping PowerShell pipeline stages as subshells. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: address round-6 review by gating bash-semantics tests off the win32 lane and failing closed on nested Windows shells R6-1: the whole-text divergent-syntax gate denied ~20 ungated bash-semantics expectations on the real win32/cmd merge lane (41 failures reproduced under a lane-spoof harness). Gate those blocks off the win32 non-bash lanes, splitting mixed blocks so lane-safe pins keep running there, and commit the lane-spoof harness so the whole guard suite runs as the win32/cmd merge lane on every lane. R5-1 (partial): cmd/powershell/pwsh invocations now fail closed on the undecidable-payload denial on every lane — their payloads are parsed by a grammar the POSIX text model cannot read (closes the -EncodedCommand and nested cmd /c entrances probed at this head). The remaining class closure is escalated to the maintainer as a product/scope decision. * fix: address round-7 review by gating the Windows-shell fail-closed rule to the win32 platform R7-1: the round-6 WINDOWS_SHELL_PROGRAMS branch failed closed on every lane, which denied benign cross-platform PowerShell on POSIX daemons (pwsh -NoProfile -Command Write-Output hello reproduced denied on the unresolved reason at this head). Gate the rule to platform win32 — cmd.exe and Windows PowerShell only exist there, and they stay reachable from win32 Git Bash sessions, so the gate is platform-wide rather than windowsNative; on POSIX the same names keep the base stance of ordinary unmodelled programs. Move the fail-closed pins onto spoofed win32 lanes, add a win32 Git Bash lane pin so the entrance stays closed there, and add the POSIX benign-pwsh regression test. * fix(cli): close the cmd.exe state-persisting entrances in the daemon guard Probe-verified bypasses (review R5-1): cmd.exe builtins persist state into every later &&-chained command, and the analysis modelled none of them — `set GIT_WORK_TREE=<outside>&& git reset --hard` and `chdir <outside> && git reset --hard` both analysed cwd-local while the executed command relocated outside the boundary. cmd's state-persisting builtins are a closed set, so this enumerates them instead of chasing individual shapes: - `set VAR=value` / `setx VAR value` carry the semantics of a POSIX `export VAR=value` for every later segment; route them through that machinery (GIT_* keys become relocations, unresolvable keys fail closed). Non-assignment forms (`set /p`, dynamic operands) fail closed. - `chdir` joins the cd family (cmd's synonym), `/D` consumed as the drive-switch option. - `path` and `doskey` rewrite which executable a bare name resolves to — unresolvable, failed closed. - copy/mklink/move/robocopy/xcopy join the relinking programs on win32, and the text relocation markers learn `chdir`. PowerShell-only entrances (function definitions, New-Item function:, @-splatting) already fail closed through the unmodelled-syntax gate; the new branches stay gated off Git-Bash sessions, where these words are ordinary POSIX commands. Pinned by new tests in the win32-lane harness, which spoofs win32/cmd on every platform. * Revert "fix(cli): close the cmd.exe state-persisting entrances in the daemon guard" This reverts commit a8f137a. * Reapply "fix(cli): close the cmd.exe state-persisting entrances in the daemon guard" This reverts commit b888a42. * fix: address round-10 review by closing the Windows-lane guard entrances and the ino-0 case fold * fix(cli): repair the round-10 build rejection by mapping chdir variants exhaustively * fix(cli): make the /MIR relink-switch denial assertion lane-independent The assertion spelled the unresolvable target as POSIX '/MIR', but the win32 lane resolves it through path.win32/realpathNearestExistingAsync into a backslash spelling ('\\MIR'/'C:\\MIR'), so the new test failed only on the merge_group-gated test_windows lane. Match either separator spelling. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 21706b6 commit b331509

30 files changed

Lines changed: 2481 additions & 374 deletions

packages/acp-bridge/src/bridge.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26962,8 +26962,12 @@ describe('createAcpSessionBridge', () => {
2696226962
expect(stderrSpy).toHaveBeenCalledWith(
2696326963
expect.stringContaining('updated session metadata'),
2696426964
);
26965+
// The audit line prints the id through JSON.stringify, which escapes
26966+
// the backslashes of a Windows-spelled session id.
2696526967
expect(stderrSpy).toHaveBeenCalledWith(
26966-
expect.stringContaining(session.sessionId),
26968+
expect.stringContaining(
26969+
JSON.stringify(session.sessionId).slice(1, -1),
26970+
),
2696726971
);
2696826972
expect(stderrSpy).toHaveBeenCalledWith(
2696926973
expect.stringContaining('pr=9517'),

packages/cli/src/acp-integration/acpAgent.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5330,6 +5330,42 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
53305330
},
53315331
);
53325332

5333+
it.skipIf(process.platform === 'win32')(
5334+
'rejects a Live managed relocation through a symlinked allowed root',
5335+
async () => {
5336+
await withEmptyTrustedFolders(async (directory) => {
5337+
const realRoot = path.join(directory, 'real-root');
5338+
const target = path.join(realRoot, 'conversation-symlinked-root');
5339+
await fs.mkdir(target, { recursive: true, mode: 0o700 });
5340+
const linkedRoot = path.join(directory, 'Conversations');
5341+
await fs.symlink(realRoot, linkedRoot);
5342+
const canonicalTarget = await fs.realpath(target);
5343+
const settings = makeSessionSettings({
5344+
mcpServers: {},
5345+
security: { folderTrust: { enabled: true } },
5346+
});
5347+
const { agent, agentPromise, sessionId } = await bootRelocatableSession(
5348+
settings,
5349+
'expected-capability',
5350+
);
5351+
5352+
try {
5353+
await expect(
5354+
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, {
5355+
sessionId,
5356+
path: canonicalTarget,
5357+
allowedRoots: [linkedRoot],
5358+
managedRelocation: 'live-conversation',
5359+
}),
5360+
).rejects.toThrow('owner-only allowed root');
5361+
} finally {
5362+
mockConnectionState.resolve();
5363+
await agentPromise;
5364+
}
5365+
});
5366+
},
5367+
);
5368+
53335369
it('sessionArtifactsPersist rejects a missing session id', async () => {
53345370
const { agent, agentPromise } = await bootAcpAgent();
53355371

packages/cli/src/acp-integration/acpAgent.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3176,8 +3176,15 @@ interface ActivePromptCall {
31763176
}
31773177

31783178
function isOwnerOnlyDirectory(stats: Stats): boolean {
3179-
if (process.platform === 'win32') return false;
31803179
if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
3180+
if (process.platform === 'win32') {
3181+
// Node's fs.Stats exposes no ownership or permission bits on Windows, so
3182+
// the POSIX mode/uid check has no equivalent here. Containment then rests
3183+
// on the structural checks around this predicate — symlink rejection and
3184+
// dev/ino identity across the realpath round trip — the same trade-off
3185+
// serve/live/discovery.ts already makes on this platform.
3186+
return true;
3187+
}
31813188
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) {
31823189
return false;
31833190
}

packages/cli/src/acp-integration/live/capture-screen-context.test.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -73,25 +73,30 @@ describe('CaptureScreenContextTool', () => {
7373
await expect(readFile(file.path)).rejects.toThrow();
7474
});
7575

76-
it('rejects a symlink and deletes only the Host-provided link', async () => {
77-
const target = await captureFile();
78-
const link = join(target.directory, 'linked.png');
79-
await symlink(target.path, link);
80-
const tool = new CaptureScreenContextTool(
81-
async () => ({
82-
appName: 'Finder',
83-
accessibilityText: '',
84-
screenshotPath: link,
85-
}),
86-
target.directory,
87-
);
76+
// The rejection relies on O_NOFOLLOW, which libuv ignores on win32; the
77+
// tool is macOS-scoped, matching the symlink-test skips elsewhere.
78+
it.skipIf(process.platform === 'win32')(
79+
'rejects a symlink and deletes only the Host-provided link',
80+
async () => {
81+
const target = await captureFile();
82+
const link = join(target.directory, 'linked.png');
83+
await symlink(target.path, link);
84+
const tool = new CaptureScreenContextTool(
85+
async () => ({
86+
appName: 'Finder',
87+
accessibilityText: '',
88+
screenshotPath: link,
89+
}),
90+
target.directory,
91+
);
8892

89-
const result = await tool.build({}).execute(new AbortController().signal);
93+
const result = await tool.build({}).execute(new AbortController().signal);
9094

91-
expect(result.error?.message).toBeTruthy();
92-
await expect(readFile(target.path)).resolves.toEqual(PNG);
93-
await expect(readFile(link)).rejects.toThrow();
94-
});
95+
expect(result.error?.message).toBeTruthy();
96+
await expect(readFile(target.path)).resolves.toEqual(PNG);
97+
await expect(readFile(link)).rejects.toThrow();
98+
},
99+
);
95100

96101
it('rejects a screenshot outside the Host private directory', async () => {
97102
const outside = await captureFile();

packages/cli/src/commands/review/cleanup.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ const mocks = vi.hoisted(() => ({
5151
aoneWhoamiAccount: vi.fn(() => 'reviewer'),
5252
}));
5353

54+
// The fixtures below key on POSIX path literals (`/repo/.qwen/tmp/…`), but
55+
// cleanup.ts and the helpers it calls (redirectedAncestor, promptRecordDir,
56+
// the deadline readers) run those strings through node:path. On Windows that
57+
// would spell them with a drive letter and backslashes, so no literal-keyed
58+
// mock or assertion could ever match. Pin POSIX semantics for this module
59+
// graph; on POSIX hosts this is the identity.
60+
vi.mock('node:path', async (importOriginal) => {
61+
const actual = await importOriginal<typeof import('node:path')>();
62+
return { ...actual, ...actual.posix, default: actual.posix };
63+
});
64+
5465
vi.mock('node:child_process', async (importOriginal) => {
5566
const actual = await importOriginal<typeof import('node:child_process')>();
5667
return {

packages/cli/src/commands/review/drive.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -935,7 +935,7 @@ describe("the verify brief's bound-address recipe actually captures", () => {
935935

936936
it.skipIf(!have('curl') || !have('mktemp'))(
937937
"puts the service's own address in the drive log, not the response body",
938-
() => {
938+
async () => {
939939
const { pattern, script } = recipe();
940940
const dir = mkdtempSync(join(tmpdir(), 'drv-recipe-'));
941941
// A service whose RESPONSE BODY also carries a listening-on line: if the
@@ -948,7 +948,7 @@ describe("the verify brief's bound-address recipe actually captures", () => {
948948
"import http from 'node:http';",
949949
"const s=http.createServer((_q,r)=>{r.writeHead(200);r.end('listening on http://127.0.0.1:59999\\n')});",
950950
"s.listen(0,'127.0.0.1',()=>console.log(`svc listening on http://127.0.0.1:${s.address().port}`));",
951-
'setTimeout(()=>process.exit(0),20000);',
951+
'setTimeout(()=>process.exit(0),5000);',
952952
].join('\n'),
953953
);
954954

@@ -990,7 +990,18 @@ describe("the verify brief's bound-address recipe actually captures", () => {
990990
readdirSync(dir).filter((f) => f.endsWith('.log') && f !== 'drive.log'),
991991
).toEqual([]);
992992

993-
rmSync(dir, { recursive: true, force: true });
993+
// On Windows the backgrounded service keeps the working directory
994+
// busy (EBUSY) until its self-exit timer fires; retry the removal
995+
// until the handle is released.
996+
for (let attempt = 0; ; attempt++) {
997+
try {
998+
rmSync(dir, { recursive: true, force: true });
999+
break;
1000+
} catch (error) {
1001+
if (attempt >= 40) throw error;
1002+
await new Promise((resolve) => setTimeout(resolve, 250));
1003+
}
1004+
}
9941005
},
9951006
);
9961007
});

packages/cli/src/commands/review/fetch-pr.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import { describe, it, expect, vi, beforeEach } from 'vitest';
88
import { createHash } from 'node:crypto';
99
import type { Argv, CommandModule } from 'yargs';
10-
import { resolve } from 'node:path';
10+
import { tmpdir } from 'node:os';
11+
import { join, resolve } from 'node:path';
1112
import {
1213
fetchPrCommand,
1314
countDiffChangedLines,
@@ -4183,7 +4184,11 @@ describe('fetch-pr --resume', () => {
41834184
});
41844185

41854186
describe('fetch-pr --resume bookkeeping is counted, not merely called', () => {
4186-
const OUT = '/tmp/fetch-report.json';
4187+
// The deadline helpers derive the record dir through resolve(OUT), so OUT
4188+
// must be a native absolute path: a POSIX literal gains a drive letter and
4189+
// backslashes on Windows, the disk-routing mock below stops matching, and
4190+
// /tmp also maps to an unwritable drive root there.
4191+
const OUT = join(tmpdir(), 'fetch-report.json');
41874192
const DIFF_BYTES = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n';
41884193

41894194
function prevReport(over: Record<string, unknown> = {}): string {
@@ -4474,7 +4479,7 @@ describe('fetch-pr --resume bookkeeping is counted, not merely called', () => {
44744479
actualDeadline.writeRoundCapStop(OUT, 2, 2, now + 2000);
44754480

44764481
await run();
4477-
const stampsFile = `${recordDir}/budget-rounds.json`;
4482+
const stampsFile = join(recordDir, 'budget-rounds.json');
44784483
// Resume 1 kept the stop — and the stamps that price its rounds.
44794484
expect(actualDeadline.readBudgetStop(OUT)).not.toBeNull();
44804485
expect(realFs.existsSync(stampsFile)).toBe(true);

packages/cli/src/commands/review/lib/git.integration.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ function git(...args: string[]): string {
3232
return execFileSync('git', args, { cwd: repo, encoding: 'utf8' });
3333
}
3434

35+
// Git prints worktree paths with forward slashes on Windows, while
36+
// `join`/`realpathSync` build backslash spellings there; compare both sides
37+
// slash-normalized (the identity on POSIX).
38+
const fwd = (value: string): string => value.replace(/\\/g, '/');
39+
3540
beforeEach(() => {
3641
repo = mkdtempSync(join(tmpdir(), 'review-wt-'));
3742

@@ -75,7 +80,7 @@ describe('releaseWorktree', () => {
7580

7681
expect(existsSync(join(repo, 'wt'))).toBe(false);
7782
// Not `.not.toContain('wt')` — the fixture's own path holds that substring.
78-
expect(git('worktree', 'list')).not.toContain(join(repo, 'wt'));
83+
expect(fwd(git('worktree', 'list'))).not.toContain(fwd(join(repo, 'wt')));
7984
});
8085

8186
it('removes an unregistered non-empty leftover git no longer tracks', () => {
@@ -86,7 +91,7 @@ describe('releaseWorktree', () => {
8691
mkdirSync(join(repo, 'wt', 'junk'), { recursive: true });
8792
writeFileSync(join(repo, 'wt', 'junk', 'f'), 'x');
8893
// Negative control: it is not a registered worktree.
89-
expect(git('worktree', 'list')).not.toContain(join(repo, 'wt'));
94+
expect(fwd(git('worktree', 'list'))).not.toContain(fwd(join(repo, 'wt')));
9095

9196
expect(releaseWorktree(join(repo, 'wt'))).toMatchObject({
9297
existed: true,
@@ -169,8 +174,8 @@ describe('releaseWorktree', () => {
169174
// macOS (`/var` → `/private/var`): the raw spelling passes there only by
170175
// accident — the canonical path happens to contain it as a substring —
171176
// and would not on a Linux fixture reached through a symlinked ancestor.
172-
expect(git('worktree', 'list')).toContain(
173-
join(realpathSync(repo), 'victim'),
177+
expect(fwd(git('worktree', 'list'))).toContain(
178+
fwd(join(realpathSync(repo), 'victim')),
174179
);
175180
expect(existsSync(join(repo, 'victim', 'keep.txt'))).toBe(true);
176181
});
@@ -193,8 +198,8 @@ describe('releaseWorktree', () => {
193198
expect(got.freed).toBe(false);
194199
expect(got.reason).toContain('symlink');
195200
// Registered and on disk, both.
196-
expect(git('worktree', 'list')).toContain(
197-
join(realpathSync(repo), 'real', 'victim'),
201+
expect(fwd(git('worktree', 'list'))).toContain(
202+
fwd(join(realpathSync(repo), 'real', 'victim')),
198203
);
199204
expect(existsSync(join(repo, 'real', 'victim', 'keep.txt'))).toBe(true);
200205
});

packages/cli/src/commands/review/lib/same-file.test.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,63 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
88
import {
99
linkSync,
1010
mkdirSync,
1111
mkdtempSync,
1212
realpathSync,
1313
rmSync,
14+
statSync,
1415
symlinkSync,
1516
writeFileSync,
1617
} from 'node:fs';
1718
import { tmpdir } from 'node:os';
18-
import { join } from 'node:path';
19+
import { basename, dirname, join } from 'node:path';
1920
import { isSameFile } from './same-file.js';
2021

22+
// Lets a test pose as a volume that exposes no inode numbers: statSync
23+
// reports ino 0 while enabled, everything else delegates to the real thing.
24+
const inoZeroVolume = vi.hoisted(() => ({ enabled: false }));
25+
// Lets a test pose as a case-insensitive volume (FAT/exFAT/SMB): every
26+
// registered case-variant spelling stats and canonicalises as the file it
27+
// names. The NATIVE canonicaliser reports the on-disk spelling — that is
28+
// what GetFinalPathNameByHandleW does on Windows — while the JS walker
29+
// echoes the caller's own spelling back.
30+
const caseInsensitiveVolume = vi.hoisted(() => ({
31+
aliases: new Map<string, string>(),
32+
}));
33+
34+
vi.mock('node:fs', async (importOriginal) => {
35+
const actual = await importOriginal<typeof import('node:fs')>();
36+
const resolveAlias = (filePath: string): string =>
37+
caseInsensitiveVolume.aliases.get(filePath) ?? filePath;
38+
const statSync = ((filePath: string) => {
39+
const stats = actual.statSync(resolveAlias(String(filePath)));
40+
if (inoZeroVolume.enabled) stats.ino = 0;
41+
return stats;
42+
}) as typeof actual.statSync;
43+
const realpathSync = Object.assign(
44+
(filePath: Parameters<typeof actual.realpathSync>[0]) => {
45+
const caller = String(filePath);
46+
const resolved = actual.realpathSync(resolveAlias(caller));
47+
return caseInsensitiveVolume.aliases.has(caller)
48+
? join(dirname(resolved), basename(caller))
49+
: resolved;
50+
},
51+
{
52+
native: (filePath: Parameters<typeof actual.realpathSync>[0]) =>
53+
actual.realpathSync(resolveAlias(String(filePath))),
54+
},
55+
) as unknown as typeof actual.realpathSync;
56+
return {
57+
...actual,
58+
statSync,
59+
realpathSync,
60+
default: { ...actual, statSync, realpathSync },
61+
};
62+
});
63+
2164
describe('isSameFile', () => {
2265
let dir: string;
2366

@@ -31,15 +74,70 @@ describe('isSameFile', () => {
3174
rmSync(dir, { recursive: true, force: true });
3275
});
3376

34-
it('treats two hard links to one file as the same file', () => {
77+
it('treats two hard links to one file as the same file', (ctx) => {
3578
const original = join(dir, 'original.json');
3679
writeFileSync(original, '{}');
3780
const linked = join(dir, 'linked.json');
3881
linkSync(original, linked);
82+
// Hard-link identity rides dev/ino; on volumes that expose no inode
83+
// numbers (ino 0) the comparison degrades to canonical spellings by
84+
// design and cannot see through a hard link.
85+
if (Number(statSync(original).ino) === 0) {
86+
ctx.skip();
87+
return;
88+
}
3989
expect(isSameFile(original, linked)).toBe(true);
4090
expect(isSameFile(linked, original)).toBe(true);
4191
});
4292

93+
it('decides by canonical spelling when inodes are unverifiable', () => {
94+
// FAT/exFAT-style volumes report ino 0 for every file; the comparison
95+
// must fall back to canonical spellings there — never equating distinct
96+
// files through a shared zero, never missing two spellings of one path.
97+
const left = join(dir, 'ino-left.json');
98+
const right = join(dir, 'ino-right.json');
99+
writeFileSync(left, '{}');
100+
writeFileSync(right, '{}');
101+
mkdirSync(join(dir, 'ino-real'));
102+
writeFileSync(join(dir, 'ino-real', 'aliased.json'), '{}');
103+
symlinkSync(join(dir, 'ino-real'), join(dir, 'ino-link'));
104+
const aliased = join(dir, 'ino-real', 'aliased.json');
105+
const throughLink = join(dir, 'ino-link', 'aliased.json');
106+
inoZeroVolume.enabled = true;
107+
try {
108+
expect(isSameFile(left, right)).toBe(false);
109+
expect(isSameFile(aliased, throughLink)).toBe(true);
110+
expect(isSameFile(throughLink, aliased)).toBe(true);
111+
} finally {
112+
inoZeroVolume.enabled = false;
113+
}
114+
});
115+
116+
it('equates case-variant spellings when inodes are unverifiable', () => {
117+
// FAT/exFAT/SMB volumes are case-insensitive AND report ino 0 for every
118+
// file, so both spellings of one file stat there. The fallback must
119+
// compare through the canonicaliser that folds case (realpathSync.native
120+
// — GetFinalPathNameByHandleW on Windows), not the JS walker that echoes
121+
// the caller's spelling: a false `false` silently disables the
122+
// anti-clobber guards that consume this predicate.
123+
const real = join(dir, 'Report.md');
124+
writeFileSync(real, '{}');
125+
const variant = join(dir, 'report.md');
126+
caseInsensitiveVolume.aliases.set(variant, real);
127+
inoZeroVolume.enabled = true;
128+
try {
129+
expect(isSameFile(real, variant)).toBe(true);
130+
expect(isSameFile(variant, real)).toBe(true);
131+
// Two genuinely distinct files stay distinct under the same pose.
132+
const other = join(dir, 'other.md');
133+
writeFileSync(other, '{}');
134+
expect(isSameFile(real, other)).toBe(false);
135+
} finally {
136+
inoZeroVolume.enabled = false;
137+
caseInsensitiveVolume.aliases.clear();
138+
}
139+
});
140+
43141
it('treats two distinct files as different files', () => {
44142
const left = join(dir, 'left.json');
45143
const right = join(dir, 'right.json');

0 commit comments

Comments
 (0)