Skip to content

resolver: backtrack past a trust-refused version instead of failing the install - #819

Open
colinhacks wants to merge 3 commits into
mainfrom
trust-gate-backtrack
Open

resolver: backtrack past a trust-refused version instead of failing the install#819
colinhacks wants to merge 3 commits into
mainfrom
trust-gate-backtrack

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

trustPolicy=no-downgrade refused 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_version scans 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.2 is that case today, and shipped nub cannot install it:

$ nub install                       # fast-glob -> @nodelib/fs.walk -> fastq
ERR_NUB_TRUST_DOWNGRADE

  × failed to resolve dependencies
  ╰─▶ trust downgrade for fastq@1.20.2 (trustPolicy=no-downgrade): earlier
      published version 1.20.0 had trusted publisher but this version has no
      trust evidence

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.

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:

$ nub install
WARN skipped fastq@1.20.2 (trustPolicy=no-downgrade): earlier published version 1.20.0 had trusted publisher but this version has no trust evidence; resolved to fastq@1.20.1 instead code=WARN_NUB_TRUST_DOWNGRADE_SKIPPED
  • Bounded at the refused pick, in the pick's own direction — downward for a highest-wins pick, upward under resolution-mode=time-based. A re-pick can never climb above a dist-tags.latest the publisher has moved off.
  • A literal latest range widens to <= the tag, the same widening pick_version applies to an age-gated latest, so dlx and an unversioned add get the fix too. Other tags name exactly one version and stay un-widened.
  • Known-vulnerable candidates rank second, matching prefer_non_vulnerable_pick — a worse answer than a clean version, a better answer than failing.
  • A missing time entry stays fatal. That is a metadata anomaly, not a refused candidate, and every other candidate would be judged on the same shape.
  • The lockfile-validation pass is unchanged. A lockfile pins an exact version, so there is no range to walk; nub ci and 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 failIfTrustDowngraded throws. 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

  • Every new test was driven red by removing the guard it covers — the backtrack itself, the range bound, the direction bound, the age filter, and the latest widening each fail their own test when reverted.
  • End to end against a build of this branch and a build of the merge base: fast-glob@^3.3.2 goes 23 → 0 and locks fastq@1.20.1. fastq@1.20.2 pinned exactly still exits 23; ~1.20.2 and >=1.20.2 still exit 23; fastq@1.20.3 still exits 21 on the age gate.
  • Ten real packages (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, and cargo test --workspace in vendor/aube are green.

Refs #270

…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.
Copilot AI lite review requested due to automatic review settings August 30, 2026 02:51
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 30, 2026 3:16am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The backtracking logic holds up — every candidate independently re-runs check_no_downgrade and version_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, and check_no_downgrade hunting for a weakened gate or an attacker-steerable input. Found none: the looser latest widening 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-latest dist-tags stay un-widened, and MissingTime staying fatal is stricter than before, not weaker.
  • Checked the known-vulnerable second tier (best.or(best_vulnerable)). It is unreachable during installvulnerable_ranges is only ever populated by audit --fix=update (audit.rs:701) and defaults empty (builder.rs:48, builder.rs:96), so is_vulnerable is always false and best_vulnerable stays None. Under audit --fix it matches the fallback prefer_non_vulnerable_pick already has, and still-vulnerable rows are reported by write_fix_lockfile_update.
  • Checked determinism and graph consistency. repick_past_downgrade is a pure function over a BTreeMap, so it is order-independent across concurrent tasks; sibling dedupe reads resolved_versions, populated after the picked_ref = meta reassignment, 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 passes check_no_downgrade, what gets written to the lockfile always re-validates cleanly under validate_lockfile_trust_policy on a later frozen install. No graph split or round-trip rejection found.
  • Confirmed the new age-gate-notice guard is a strict improvement: published_by derives from minimum_release_age.cutoff() (driver.rs:312), so cutoff_for_pkg is Some whenever 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.2 still 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread site/content/docs/install/index.mdx Outdated
Comment thread vendor/aube/crates/aube-resolver/src/resolve/driver.rs Outdated
…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.
@colinhacks

Copy link
Copy Markdown
Contributor Author

Both docs points and the dedupe nit are fixed in c4e5478.

The directional claim was wrong — pick_lowest is true for a direct dep under resolution-mode=time-based, so the backtrack walks up there, which this PR's own test asserts. The docs now state the invariant that holds in both modes: the substitution never crosses the refused version. The lockfile carve-out is documented in the same section, including why a local nub install can succeed while nub ci on an older lockfile still fails.

The notice now dedupes on name@refused -> substitute rather than the refusal alone, and the doc comment matches the key.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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-based resolves a direct dependency to its range floor," and line 654 dropped its matching "walking down" assumption. Verified against driver.rs:815 (pick_lowest = resolution_mode == TimeBased && task.is_root) and the comment at semver_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_repicks now 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 whose resolved 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 the meta.version the message prints at driver.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 = meta is unchanged, so the resolved graph is identical either way.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread site/content/docs/install/index.mdx Outdated
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.
@colinhacks

Copy link
Copy Markdown
Contributor Author

Fixed in 8635f57. Measured all four modes against a lockfile pinning fastq@1.20.2: nub install, --frozen-lockfile and nub ci all exit 23; nub install --no-frozen-lockfile exits 0 and re-resolves to 1.20.1. The paragraph now names all three aborting forms and the remedy.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The one-sentence docs correction is accurate, and I traced the non-obvious half of it — the --no-frozen-lockfile remedy — 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 install can succeed while nub ci fails" became "Any install that reuses an undrifted lockfile — nub ci, --frozen-lockfile, or a plain nub 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 default FrozenMode::Prefer reaches on an undrifted lockfile — the old wording put a plain nub install on the succeeding side.
  • Named the remedy, and I verified it independently. The claim only holds if a --no-frozen-lockfile resolve 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) returns None for any mode outside Fix | Prefer, so under FrozenMode::No existing_for_resolver is None (mod.rs:1134-1139), the LockedIndex is empty, and every task goes through the packument pick and repick_past_downgrade. The intent is stated outright at mod.rs:1085-1089.
  • Retired the prior round's thread. The index.mdx:664 comment was applied verbatim; replied and resolved. No open Pullfrog threads remain.

Pullfrog  | View workflow run | Using Claude Opus𝕏

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.

2 participants