Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #431
Summary
src/write-entry.ts[FILE]()keys the hardlink cache on${stat.dev}:${stat.ino}.fs.Statsreportsinoas 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 sameNumberand collide on that key.Linkentry 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 showsino: 9570149211882252for two unrelated.scssfiles whilestatSync(f, {bigint: true})returns...252nand...253n.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.test/write-entry.jsandtest/pack.jscover sync and asyncWriteEntry,PackandPackSync, theMAX_SAFE_INTEGER + 1boundary, the cache read side at both theWriteEntryand thePacklevel, 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.Linkentry is produced: no hardlink that is correctly detected today stops being detected. Measured over 20 boundary inputs, only unsafe/non-integer inodes behave differently.The eight top-level failures on the base arm are exactly the eight discriminating tests; the other twelve
failentries 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
inois 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:
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 inTarOptions, andlinkCache's key type`${number}:${number}`is a public type alias — a semver-major change, not a bug fix.process.platformcheck that this fix does not require.stat.size/mtimein 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.nlink > 1file 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 withnpx tap test/pack.js test/write-entry.js --disable-coverageon 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.