resolver: backtrack past a trust-refused version instead of failing the install - #819
resolver: backtrack past a trust-refused version instead of failing the install#819colinhacks wants to merge 3 commits into
Conversation
…he install `trustPolicy=no-downgrade` refused the version a range resolved to and aborted, even when an older version satisfying the same range kept its trust evidence. The age gate already behaves the other way: `pick_version` scans the whole satisfying set and keeps the newest release clearing the cutoff, so a too-new publish costs an older pick rather than the install. The two gates therefore deadlock on any package that publishes one version manually between two attested ones. `fast-glob@^3.3.2` -> `@nodelib/fs.walk` -> `fastq` is live today: `fastq@1.20.3` is too new for the release-age floor, `1.20.2` was hand-published with no attestation, and `1.20.1` — 249 days old, trusted publisher, SLSA provenance — satisfies the range and was never considered. On a refusal the resolver now walks down the satisfying versions and takes the newest that clears both gates, warning with the version it skipped and why. Bounded at the refused pick in the pick's own direction, so a re-pick can never climb above a `dist-tags.latest` the publisher has moved off. Known-vulnerable candidates rank second rather than being excluded, matching `prefer_non_vulnerable_pick`. When nothing satisfying clears both gates the original refusal still stands, so neither gate is weakened — only the order of "refuse" and "keep looking" changes. A deliberate divergence from pnpm, whose `failIfTrustDowngraded` throws; pnpm implements its age gate as a packument filter and its trust gate as a post-pick throw, so the same asymmetry is baked into upstream. The `latest`-was-steered notice dlx prints names `minimumReleaseAge` by wording, so it is now gated on `dist-tags.latest` actually failing the age cutoff — a trust re-pick must not be reported as an age-gate fallback. The install docs' trust-downgrade section is updated to match.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ The backtracking logic holds up — every candidate independently re-runs
check_no_downgradeandversion_clears_cutoff, so neither gate is weakened and the refusal still stands when nothing qualifies. Two docs accuracy points and one warning-fidelity nit below.
Reviewed changes
- Read all 5 files end-to-end from the formatted diff (731 lines): the docs page, the new warning code, the driver call site,
repick_past_downgrade+ its 5 unit tests, and the 2 driver-level integration tests. - Traced the new function against
pick_version,prefer_non_vulnerable_pick, andcheck_no_downgradehunting for a weakened gate or an attacker-steerable input. Found none: the looserlatestwidening only expands the candidate set, and the loop's own directional bound plus the per-candidate trust and age checks still gate every admission. Non-latestdist-tags stay un-widened, andMissingTimestaying fatal is stricter than before, not weaker. - Checked the known-vulnerable second tier (
best.or(best_vulnerable)). It is unreachable duringinstall—vulnerable_rangesis only ever populated byaudit --fix=update(audit.rs:701) and defaults empty (builder.rs:48,builder.rs:96), sois_vulnerableis always false andbest_vulnerablestaysNone. Underaudit --fixit matches the fallbackprefer_non_vulnerable_pickalready has, and still-vulnerable rows are reported bywrite_fix_lockfile_update. - Checked determinism and graph consistency.
repick_past_downgradeis a pure function over aBTreeMap, so it is order-independent across concurrent tasks; sibling dedupe readsresolved_versions, populated after thepicked_ref = metareassignment, so a deduping task always sees the post-repick version; the peer passes reuse settled graph entries rather than re-deriving from the packument; and because criterion (d) guarantees the substitute itself passescheck_no_downgrade, what gets written to the lockfile always re-validates cleanly undervalidate_lockfile_trust_policyon a later frozen install. No graph split or round-trip rejection found. - Confirmed the new age-gate-notice guard is a strict improvement:
published_byderives fromminimum_release_age.cutoff()(driver.rs:312), socutoff_for_pkgisSomewhenever the notice is armed and the guard cannot silently suppress a genuine age-gate fallback. - Verified the 7 new tests are falsifiable — each asserts against the specific guard it covers, and the negative cases (
>=2.0.2still erroring, no age-gate downgrade recorded) are real controls rather than tautologies.
ℹ️ The docs don't mention that a lockfiled refusal still hard-fails
The PR body is explicit that this is deliberate — a lockfile pins an exact version, there is no range to walk, and validate_lockfile_trust_policy is untouched. That reasoning is sound. The problem is that the new docs paragraph doesn't carry it, and the surrounding page actively points the reader the other way.
site/content/docs/install/index.mdx:648, two paragraphs above the changed text, already tells the reader that frozen reinstalls "still enforce the cooling and provenance gates on every install." A reader who then hits "Nub keeps walking down the versions the range admits" has no reason to suspect the backtrack is fresh-resolution only. The practical consequence is the one worth documenting: a developer whose local nub install now succeeds via a substitution can still watch CI fail on nub ci against a lockfile written before the fix, with no hint as to why the two disagree.
A sentence after the ERR_NUB_TRUST_DOWNGRADE line would close it — something to the effect that a lockfile names one exact version rather than a range, so nub ci and --frozen-lockfile still abort on a refused pin, and the remedy is a fresh resolve.
ℹ️ Nitpicks
trust_repicks keys on the refused version rather than the pair, so the warning can misdescribe a second parent's result. Details inline on driver.rs:1188. The resolved graph is correct either way — this is warning fidelity only, and the trigger is narrow enough that it is reasonable to leave as-is if you'd rather keep the key simple.
Claude Opus | 𝕏
…ct the docs The notice deduped on the refused version alone while the printed line names the substitute, so two tasks sharing a refused head under disjunctive ranges would suppress the second and leave the one printed line naming a version the other parent is not on. Key on the pair. The docs claimed the substitution never climbs above the refused version. That holds for an ordinary highest-wins pick and inverts under `resolution-mode=time-based`, where a direct dependency resolves to its range floor and the backtrack walks up. The invariant in both modes is that it never crosses the refusal. The section also now says a lockfile pins one exact version, so a frozen reinstall still aborts on a refused pin.
|
Both docs points and the dedupe nit are fixed in c4e5478. The directional claim was wrong — The notice now dedupes on |
There was a problem hiding this comment.
ℹ️ Both prior comments are properly addressed — no resolver-behavior changes in this delta. One accuracy point on the new docs paragraph, inline.
Reviewed changes
Reviewed the two-file delta since eb1cba0 (c4e5478): a docs rewrite of the trust-downgrade section and a dedup-key change in the driver. repick_past_downgrade and the rest of the resolution path are untouched.
- Corrected the directional claim in the docs. "never climbs above the refused version" became "never crosses the refused version — down to an older release for an ordinary pick, and up to a newer one when
resolution-mode=time-basedresolves a direct dependency to its range floor," and line 654 dropped its matching "walking down" assumption. Verified againstdriver.rs:815(pick_lowest = resolution_mode == TimeBased && task.is_root) and the comment atsemver_util.rs:357— "TimeBased mode wants the floor of the range" — so both the direction and the "direct dependency" scoping are accurate. - Documented the lockfile carve-out. A new paragraph states that a lockfile pins one version so there is nothing to walk, and that a refused pin still aborts. The carve-out is real:
validate_lockfile_trust_policy(mod.rs:1485) sits on the lockfile-reuse branch and is untouched by this PR. - Keyed the substitution notice on the outcome.
trust_repicksnow inserts{name}@{refused} -> {substitute}(driver.rs:1192-1195), so two disjunctive ranges sharing a refused head but landing on different substitutes each print a line whoseresolved to …is true for that parent. The field doc (driver.rs:200-203) was updated to the same shape, and the key's substitute matches themeta.versionthe message prints atdriver.rs:1208. Same-outcome duplicates still collapse to one line, so the once-per-package intent is preserved. - Checked the dedup change for regressions. The only behavior difference is one extra warning per distinct substitute;
picked_ref = metais unchanged, so the resolved graph is identical either way.
Claude Opus | 𝕏
The lockfile carve-out put a plain `nub install` on the succeeding side. Lockfile reuse is the default whenever the lockfile is undrifted, so `install` aborts exactly like `ci` and `--frozen-lockfile`; only a fresh resolve has a range to substitute within. Measured all four modes against a lockfile pinning fastq@1.20.2: install, --frozen-lockfile and ci exit 23, --no-frozen-lockfile exits 0 and re-resolves to 1.20.1. Name that remedy rather than leaving the reader to guess it.
|
Fixed in 8635f57. Measured all four modes against a lockfile pinning |
There was a problem hiding this comment.
✅ No new issues found. The one-sentence docs correction is accurate, and I traced the non-obvious half of it — the
--no-frozen-lockfileremedy — through the resolver rather than taking it on the prior review's word.
Reviewed changes
Reviewed the single-file delta since c4e5478 (8635f57): one rewritten sentence in the trust-downgrade section of the install docs. No code changed, so repick_past_downgrade, the driver call site, and the tests are untouched from the prior round.
- Widened the lockfile carve-out to every reuse path. "Frozen reinstalls still abort … which is why a fresh
nub installcan succeed whilenub cifails" became "Any install that reuses an undrifted lockfile —nub ci,--frozen-lockfile, or a plainnub install— still aborts on a pin the check refuses." Correct:validate_lockfile_trust_policy(mod.rs:1485) sits on the whole lockfile-reuse branch, which the defaultFrozenMode::Preferreaches on an undrifted lockfile — the old wording put a plainnub installon the succeeding side. - Named the remedy, and I verified it independently. The claim only holds if a
--no-frozen-lockfileresolve really has a range to substitute within, and the resolver's own reuse path would defeat it:try_lockfile_reuse(driver.rs:2607) applies neither the trust gate nor the age cutoff, so a reused pin would come back untouched. It never gets the chance —pre_parse_lockfile(resolve.rs:27) returnsNonefor any mode outsideFix | Prefer, so underFrozenMode::Noexisting_for_resolverisNone(mod.rs:1134-1139), theLockedIndexis empty, and every task goes through the packument pick andrepick_past_downgrade. The intent is stated outright atmod.rs:1085-1089. - Retired the prior round's thread. The
index.mdx:664comment was applied verbatim; replied and resolved. No open Pullfrog threads remain.
Claude Opus | 𝕏

trustPolicy=no-downgraderefused the version a range resolved to and aborted the whole install, even when an older version satisfying the same range kept its trust evidence. The age gate already behaves the other way —pick_versionscans the satisfying set and keeps the newest release clearing the cutoff — so the two gates deadlock on any package that publishes one version manually between two attested ones.fast-glob@^3.3.2is that case today, and shipped nub cannot install it:fastq@1.20.3is too new for the release-age floor,1.20.2was hand-published with no attestation, and1.20.1— 249 days old, trusted publisher, SLSA provenance — satisfies the range and was never considered.What changed
On a refusal the resolver walks down the versions the range admits and takes the newest that clears both gates, naming what it skipped:
resolution-mode=time-based. A re-pick can never climb above adist-tags.latestthe publisher has moved off.latestrange widens to<=the tag, the same wideningpick_versionapplies to an age-gatedlatest, sodlxand an unversionedaddget the fix too. Other tags name exactly one version and stay un-widened.prefer_non_vulnerable_pick— a worse answer than a clean version, a better answer than failing.timeentry stays fatal. That is a metadata anomaly, not a refused candidate, and every other candidate would be judged on the same shape.nub ciand a lockfile carrying a refused version still abort rather than silently install something the lockfile does not name.Neither gate is weakened. When nothing the range admits clears both, the original refusal stands, and a version the attacker published is never installed either way — the change is that the user is told rather than blocked.
This is a deliberate divergence from pnpm, whose
failIfTrustDowngradedthrows. pnpm implements its age gate as a packument filter (filterPkgMetadataByPublishDate) and its trust gate as a post-pick throw, so the same asymmetry is baked into upstream.Verification
latestwidening each fail their own test when reverted.fast-glob@^3.3.2goes 23 → 0 and locksfastq@1.20.1.fastq@1.20.2pinned exactly still exits 23;~1.20.2and>=1.20.2still exit 23;fastq@1.20.3still exits 21 on the age gate.express,typescript,vite,eslint,chokidar,webpack,@babel/core,axios,rollup,prettier) produce byte-identical lockfiles on both builds.cargo clippy --all-targets --all-features,cargo fmt,cargo test -p nub-cli, andcargo test --workspaceinvendor/aubeare green.Refs #270