Skip to content

don't use an unsafe ino as a hardlink identity - #464

Open
askalf wants to merge 4 commits into
isaacs:mainfrom
sprayberry-code:fix/link-cache-unsafe-ino
Open

askalf wants to merge 4 commits into
isaacs:mainfrom
sprayberry-code:fix/link-cache-unsafe-ino

Conversation

@askalf

@askalf askalf commented Sep 14, 2026

Copy link
Copy Markdown

Fixes #431

Summary

  • src/write-entry.ts [FILE]() keys the hardlink cache on ${stat.dev}:${stat.ino}. fs.Stats reports ino as a double, but Windows file indexes are 64 bits wide, so two distinct files whose indexes differ below the 53-bit mantissa round to the same Number and collide on that key.
  • When they collide, the second file is emitted as a Link entry pointing at the first and its contents are silently dropped from the archive. This is the mechanism behind open issue Link cache collision in write-entry due to erroneous 'ino' #431, whose reporter shows ino: 9570149211882252 for two unrelated .scss files while statSync(f, {bigint: true}) returns ...252n and ...253n.
  • Fix: require the inode to be representable before trusting it as an identity — if (this.stat.nlink > 1 && Number.isSafeInteger(this.stat.ino)). When we cannot tell two files apart, archive both in full rather than guess. 7 insertions / 1 deletion in one source file.
  • Eleven regression tests across test/write-entry.js and test/pack.js cover sync and async WriteEntry, Pack and PackSync, the MAX_SAFE_INTEGER + 1 boundary, the cache read side at both the WriteEntry and the Pack level, the cwd fall-through, and a cache holding safe and unsafe inodes at once. Eight fail on base; the three that pass on both arms are declared controls.
  • The change is a strict narrowing of when a Link entry is produced: no hardlink that is correctly detected today stops being detected. Measured over 20 boundary inputs, only unsafe/non-integer inodes behave differently.
$ # ---------- BASE ARM: 2a22bfc, all eleven tests present, fix reverted, rebuilt ----------
$ git checkout 2a22bfc -- src/write-entry.ts && npm run prepare
$ npx tap test/pack.js test/write-entry.js --disable-coverage
    not ok 38 - unsafe ino does not defer or collapse entries in Pack # time=61.919ms
    not ok 39 - unsafe ino does not collapse entries in PackSync # time=11.646ms
    not ok 40 - unsafe ino does not consume an inherited Pack link cache # time=9.548ms
    not ok 10 - unsafe ino is not used to identify hardlinks # time=86.803ms
    not ok 11 - unsafe ino is not used to identify hardlinks, async # time=28.103ms
    not ok 13 - ino one past the safe limit is not used to identify hardlinks # time=30.661ms
    not ok 14 - unsafe ino does not consume a link cache entry it did not write # time=56.277ms
    not ok 16 - safe and unsafe inos share one link cache # time=26.173ms
# { total: 1020, pass: 1000, fail: 20 }
# time=7268.264ms

$ # ---------- FIXED ARM, head fd6c270 ----------
$ npx tap test/pack.js test/write-entry.js --disable-coverage
# { total: 1020, pass: 1020 }
# time=6756.431ms

The eight top-level failures on the base arm are exactly the eight discriminating tests; the other twelve fail entries in the count are their individual assertions. The three controls (safe ino still identifies hardlinks, ino of 0 still identifies hardlinks (control), safe ino outside the cwd still falls through to File (control)) are green on both arms — that is their job.

Decisions

The bug is that an unusable identity is used as an identity. The guard is placed on the single predicate that decides whether ino is treated as an identity at all, so both the read (linkCache.get) and the write (linkCache.set) are suppressed together — leaving a poisoned key in the cache would move the corruption to a later file rather than remove it. It is one added conjunct, no new imports, no new dependency, no signature or option change, and it fails safe: the worst case is that a genuine hardlink is archived as a full copy, which is larger but correct.

Alternatives considered and rejected:

  • Switch to fs.stat(..., {bigint: true}) / BigIntStats — the complete fix, but the maintainer already ruled on it in the issue thread: "yes, switching to using BigIntStats would solve this, but it would also be a breaking API change, since the statCache is exposed as an option." statCache: Map<string, Stats> is public in TarOptions, and linkCache's key type `${number}:${number}` is a public type alias — a semver-major change, not a bug fix.
  • Drop hardlink support on Windows entirely — the maintainer's own "maybe the only way around it" in the same comment. Rejected as strictly worse: it discards correct hardlink detection for every Windows user whose inodes are representable, and needs a process.platform check that this fix does not require.
  • Include stat.size/mtime in the cache key — makes collisions less likely without making them impossible, and would break real hardlinks whose size or mtime is observed at different moments. Heuristic where a correctness guard is available.
  • Emit a warning when an unsafe ino is seen — considered as an addition, left out to keep the change to one bug: it would fire on every nlink > 1 file on affected Windows volumes, which is noise, and the warning taxonomy is a maintainer's call.

Not run: the CI matrix (Node 22/24/26 × ubuntu/macos via .github/workflows/ci.yml) — no Actions on this PR's originating environment; the tests themselves are platform-independent and were run locally with npx tap test/pack.js test/write-entry.js --disable-coverage on both arms.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

fs.Stats reports ino as a double, but Windows file indexes are 64 bits
wide, so any value above Number.MAX_SAFE_INTEGER may be shared by several
distinct files. Keying linkCache on such a value archives unrelated files
as hardlinks to one another, silently dropping their contents.

Fixes isaacs#431
Adds four cases to test/write-entry.js and one to test/pack.js:

- ino exactly one past Number.MAX_SAFE_INTEGER, the first value the guard
  rejects, pinning the comparison as isSafeInteger rather than an off-by-one
  bound against MAX_SAFE_INTEGER itself
- an unsafe ino must not consume a linkCache entry it did not write; linkCache
  is a public option, so it can arrive already carrying an unsafe key, and a
  guard on the write alone would still hardlink against it
- ino 0, falsy but a perfectly good identity, still identifies hardlinks
  (control: the guard is isSafeInteger, not a truthiness check)
- Pack keys PENDINGLINKS on the same dev:ino string and gates deferral on a
  linkCache miss, so suppressing the cache sends every unsafe-ino file down
  the deferral branch rather than only the first; check end to end that the
  stream still ends and all three files are packed with their own contents
Two paths the existing cases leave open:

- PackSync takes the other side of the `!this.sync` condition at pack.ts:285.
  The async test covers the deferral branch; a sync pack never defers, so the
  link cache alone decides whether a later file becomes a Link.
- every other case uses a single ino per cache, so nothing pinned that the
  guard is decided per entry rather than per cache. A safe and an unsafe ino
  sharing one Map: the safe pair still links, the unsafe pair does not, and
  only the safe key is cached.

Both fail on 2a22bfc and pass with the fix.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Link cache collision in write-entry due to erroneous 'ino'

1 participant