Skip to content

save-artifact's isSameFile overwrite guard has no hard-link witness (follow-up from #11848) #12578

Description

@yiliang114

What happened?

#11848's triage comment names three consumers of isSameFile whose --out alias guard failed open on a volume with file ids above 2^53, and asks that the fix "be validated against all three":

  1. packages/cli/src/commands/review/findings.ts (--to-anchors)
  2. packages/cli/src/commands/review/repo-context.ts (--out vs --plan)
  3. packages/cli/src/commands/review/save-artifact.ts:654 — if (isSameFile(outputPath, inputPath)) { throw new Error(...) }, run for the findings input, the composed input and the Markdown report.

#12568 converts the shared comparator (review/lib/same-file.ts tryStat) to stat(..., { bigint: true }), so in production all three guards get the exact 64-bit id at once. Two of the three keep a hard-link witness:

  • findings.test.ts — refuses a --to-anchors hardlinked to a sibling file, gated if (inode <= 0) { ctx.skip(); return; }
  • repo-context.test.ts — rejects plan/out aliases and preserves the plan on artifact failure, same gate

save-artifact.test.ts has no hard-link case at all. Its overwrite coverage is the same-path it.each (refuses to overwrite the %s input), the symlinked-absent-input case (refuses an absent output spelled through a symlink onto an absent input) and the case-insensitive alias — all shapes the pre-fix realpathSync.native fallback already caught.

Why it matters

Nothing in the save-artifact suite goes red if isSameFile loses hard-link sight again. Measured during #12568's review round with the mutant isSameFile(outputPath, inputPath) → outputPath === inputPath:

save-artifact.test.ts   67 passed | 1 skipped   GREEN   <- the guard is inert here
findings.test.ts        'refuses a --to-anchors hardlinked to a sibling file'  RED
repo-context.test.ts    'rejects plan/out aliases ...'                           RED

So a future weakening of the shared comparator is caught by two suites and silently missed by the third — the artifact overwrite it protects ships broken with CI green.

Production behaviour is not broken today: this is a regression-witness gap, not a live fail-open.

Suggested fix

One case beside refuses to overwrite the %s input in save-artifact.test.ts:

  • build the fixture, then linkSync(paths.findings, paths.out);
  • assert saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' }) throws /must not overwrite the findings input/;
  • assert readFileSync(paths.findings, 'utf8') is unchanged (the refusal precedes every write).

Gate it exactly like the two sibling witnesses — const inode = statSync(paths.findings).ino; if (inode <= 0) { ctx.skip(); return; } — because on an ino-0 volume (FAT/exFAT/SMB) a hard link is undetectable by design and an ungated case would red there for a non-defect.

Acceptance: the new case must go red when isSameFile(outputPath, inputPath) at save-artifact.ts:654 is replaced with outputPath === inputPath, while every existing save-artifact alias case stays green.

Why this is tracked separately

#12568 is scoped to the two comparators it converts, and the remaining guard-by-guard work is being split one issue at a time: #11877 (conversation-workspace + acpAgent comparators) and #12574 / #12577 (repo-context's two plan-identity guards). Source: the R1-1 review thread on #12568.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions