Skip to content

fix(apply,rollback): reach the documented mismatch policy for singleton gem/pypi/maven variants; variant-scope the rollback before-blob gate - #176

Open
Mikola Lysenko (mikolalysenko) wants to merge 2 commits into
fix/gem-bundler-auditfrom
fix/gem-apply-variant-policy
Open

fix(apply,rollback): reach the documented mismatch policy for singleton gem/pypi/maven variants; variant-scope the rollback before-blob gate#176
Mikola Lysenko (mikolalysenko) wants to merge 2 commits into
fix/gem-bundler-auditfrom
fix/gem-apply-variant-policy

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

STACKED on PR #175 (fix/gem-bundler-audit) — base branch is fix/gem-bundler-audit at 10c0079, not main.

Two verified findings in apply/rollback variant handling, each RED-pinned against the base commit.

P1 — the variant gate blocked the documented mismatch policy (apply.rs)

For release-variant ecosystems (gem/pypi/maven), the variant loop verified each variant's representative file and — without --force — skipped any variant in HashMismatch/NotFound state. For a singleton base (the overwhelmingly common bare pkg:gem/name@ver manifest record) a locally-modified file therefore failed the run with no matching variant found and left the file untouched, while the documented default policy (npm precedent from PR #115: warn content_mismatch_overwritten + apply the full verified patched content, --strict opts out) was unreachable.

Fix: the installed-distribution gate now applies to multi-variant bases only. A singleton has no sibling to make a mismatch mean "different distribution", so it falls through to apply_package_patch, whose existing MismatchPolicy (Warn/Strict/Force) handles it exactly like the npm branch — same warning wording, same JSON envelope events, same --strict refusal. mismatch_blob_gaps mirrors the singleton exemption so the Warn fall-through's afterHash blobs are prefetched under --download-mode diff.

Multi-variant behavior is unchanged (sibling mismatch = skip, never overwrite; no variant matching = fail closed) and pinned by two guards.

P2 — the rollback/remove before-blob gate was variant-blind (rollback.rs)

rollback_patches_inner gated the whole run on get_missing_before_blobs over the ecosystem-scoped manifest before find_packages_for_rollback + select_installed_variants narrowed a broad multi-platform gem manifest to the installed platform. An unfetchable, uninstalled sibling ?platform= variant's before-blob aborted the entire rollback (--offline: wholesale; online: any download failure), and remove — which delegates here — then refused with rollback_failed.

Fix: discovery + variant narrowing run first; the gate then covers only the (purl, path) pairs that will actually be attempted, plus in-scope purls the crawler could not resolve at all — undiscovered purls deliberately keep the pre-existing fail-closed gate (that behavior is pinned by rollback_invariants.rs, cli_rollback_silent.rs, and remove_network.rs, which are outside this PR's scope). Local-redirect (local-go) purls stay excluded as before, and the manifest-key lookups keep using find_packages_for_rollback (qualified-purl invariant).

Which test pins which change (all RED on 10c0079)

Test Pins
cli_gem_variant_mismatch_policy::singleton_mismatch_default_warns_and_applies P1 default: exit 0, file == afterHash bytes (byte-exact), content_mismatch_overwritten warning on stderr + JSON applied event + warning event
cli_gem_variant_mismatch_policy::singleton_mismatch_strict_refuses P1 --strict: exit 1, file byte-identical, per-file hash-mismatch error (not the generic no-variant miss)
commands::apply::tests::mismatch_blob_gaps_singleton_mismatch_queued (unit) P1 blob prefetch for the singleton fall-through
in_process_gem_multi_platform::rollback_succeeds_when_uninstalled_sibling_before_blob_unfetchable P2 online: 404-mocked sibling blob, rollback exits 0, file restored byte-exact, sibling blob never requested
in_process_gem_multi_platform::rollback_offline_succeeds_with_only_installed_variant_blobs_cached P2 --offline: succeeds with only the installed variant's blobs cached, no blob endpoint hit
in_process_gem_multi_platform::remove_succeeds_when_uninstalled_sibling_before_blob_unfetchable P2 remove path: exits 0, both records cleared, file restored

Green-on-base guards (must NOT change): singleton_mismatch_force_still_applies, multi_variant_mismatched_sibling_is_skipped_not_overwritten, multi_variant_none_matching_still_fails_closed; the pre-existing mismatch_blob_gaps_skips_non_installed_variant_unless_forced was refitted with a genuine multi-variant group (its old singleton fixture's premise is what P1 changes) while keeping its assertions.

Verification

  • RED runs against 10c0079 (sources reverted, tests kept): 2/5 new policy tests fail, 3/3 new multi-platform tests fail, unit test fails with the old gate condition; all guards stay green.
  • Green: core 2460/0 (full), cli --lib 351/0, in_process_gem_apply 7/0, in_process_gem_multi_platform 7/0, in_process_get_manifest_path 5/0, in_process_get_update_count 3/0, in_process_scan 24/0, e2e_gem 8/0 (3 ignored live), docker_e2e_gem 2/2 (default non-forced apply path), plus shared-code sweep: apply_network 9/0, apply_invariants 6/0, rollback_invariants 14/0, cli_rollback_silent 5/0, cli_apply_silent 4/0, remove_network 2/2, remove_invariants 26/0, remove_rollback_api_overrides 2/0, in_process_rollback_all_ecosystems 8/0, in_process_pypi_multi_release 4/0, in_process_variant_apply_failure 2/0, in_process_edge_cases 9/0, in_process_remove_repair_lifecycle 14/0, output_modes_e2e 37/0, cli_dry_run_paths_e2e 8/0, cli_remove_silent 10/0, get_nested_apply_api_flags_e2e 16/0, scan_invariants 18/0, scan_sync_e2e 4/0, in_process_cargo_apply 3/0, in_process_pypi_apply 4/0, in_process_alternate_installers 12/0, in_process_remote_ecosystems_apply 12/0, in_process_vendor 27/0, in_process_get 21/0, get_invariants 22/0. cargo fmt + clippy --all-targets clean.
  • Empirical: platform-gem layout re-validated against real RubyGems (host ruby 3.4.10): gem install of nokogiri-1.18.10-x86_64-linux-gnu.gem lands in gems/<name>-<version>-<platform> — the exact leaf the fixtures synthesize.

Reviewer notes / deliberate scope choices

  • Qualified singletons fall through too. A narrow scan of a platform gem stores a single ?platform= record; after this PR a mismatch there warn-overwrites (npm parity) instead of erroring. If the user switched install platforms without rescanning, the default policy would write the recorded variant's verified bytes over the other platform's file — same trade-off npm already accepts for locally-replaced packages; --strict opts out. Called out in case a qualifier-aware carve-out is preferred.
  • Undiscovered purls still gate blobs. The mission's strictest reading ("gate only what will be attempted") would also skip gating manifest entries with no installed package — but that exact fail-closed behavior is pinned by rollback_invariants.rs, cli_rollback_silent.rs (tests 3–4) and remove_network.rs (both tests), which are outside this PR's owned files. The narrower fix keeps those suites green; relaxing the not-installed case would need fixture updates there first.
  • A NotFound representative on a singleton now surfaces the engine's per-file "File not found" error (Warn/Strict are hard errors on missing pre-existing files, --force skips) instead of the generic no matching variant found; exit codes are unchanged.

🤖 Generated with Claude Code


Note

Medium Risk
Behavior changes in apply/rollback for release-variant ecosystems affect real installs and rollback/remove paths, but scope is bounded to variant selection and blob gating with extensive regression tests.

Overview
Apply for gem/pypi/maven no longer treats a singleton manifest base (one record per package@version) like a multi-platform sibling set. The installed-distribution skip now runs only when variants.len() > 1, so a lone variant with locally modified bytes reaches apply_package_patch and the default Warn mismatch policy (npm-style content_mismatch_overwritten + full afterHash overwrite; --strict refuses). mismatch_blob_gaps mirrors that: singleton mismatches still queue afterHash prefetches; non-installed siblings stay excluded unless --force.

Rollback/remove runs package discovery and select_installed_variants narrowing before the before-blob gate. Missing before-blobs for narrowed-away sibling variants no longer abort the whole run online or under --offline; the gate still covers variants that will actually be rolled back and in-scope purls the crawler could not resolve (existing fail-closed behavior).

Reviewed by Cursor Bugbot for commit a2513a6. Configure here.

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Addressed the adversarial review findings (commit e0d06aa):

Major — qualified singletons no longer fall through the mismatch policy. Confirmed the finding is real: ruby_crawler drops the gem dir's platform suffix expecting the representative-hash gate to resolve the installed platform, and the Bundler plugin auto-applies with --silent, which suppresses warn_mismatch_overwrites — so a lone ?platform=x86_64-linux record would have silently overwritten a darwin install. The exemption is now restricted to UNQUALIFIED singletons (variants[0] == base_purl); qualified singletons keep the pre-PR fail-closed "no matching variant found". mismatch_blob_gaps mirrors the condition (records retain their manifest keys). Pinned by qualified_singleton_wrong_platform_fails_closed (integration, real binary + real crawler) and mismatch_blob_gaps_qualified_singleton_gated_unless_forced (unit) — both verified RED against the previous tip. The bare-singleton warn+apply, --strict, --force, and both multi-variant guards stay green unchanged. This also resolves the residual disclosed in the PR body.

Minor — vendored awareness in mismatch_blob_gaps. It now takes vendored_purls and skips vendor-owned bases (matched by crawled purl, stripped base, or any record key — the same shapes the apply loop skips), so a drifted vendored release-variant file no longer triggers a spurious blob download online or a spurious "will fail to apply" warning under --offline. Pinned by mismatch_blob_gaps_vendored_base_never_queued (RED-verified), with an in-test anti-vacuity contrast and a guard that --force doesn't resurrect the fetch (the loop's vendored skip has no force override).

Minor — stale rollback_invariants.rs comment updated: the offline missing-blob bail now aborts after discovery, before the rollback loop produces per-package results (the pinned contentless partial_failure shape is unchanged, test still green).

Verified: cli lib 353/0, cli_gem_variant_mismatch_policy 6/6, in_process_gem_multi_platform 9/9 non-ignored, apply/rollback/remove/vendor/variant integration suites green, docker_e2e_gem 2/2, e2e_gem 8/8 non-ignored, core green, clippy + fmt clean.

🤖 Generated with Claude Code

…ed rollback blob gate

apply (P1): for release-variant ecosystems (gem/pypi/maven) the variant
loop's installed-distribution gate skipped ANY variant whose
representative file mismatched — for a SINGLETON base (the common bare
pkg:gem/name@ver record) that made the documented default mismatch
policy (npm parity: warn content_mismatch_overwritten + apply the full
verified patched content, --strict refuses, --force unchanged)
unreachable: a locally-modified file failed the run with "no matching
variant found" and left the file untouched. The gate now applies to
multi-variant bases only — a singleton has no sibling to make a
mismatch mean "different distribution", so it falls through to
apply_package_patch's MismatchPolicy exactly like the npm branch.
mismatch_blob_gaps mirrors the same singleton exemption so the Warn
fall-through's afterHash blobs are prefetched under --download-mode
diff. Multi-variant behavior is UNCHANGED (sibling mismatch = skip;
no-variant-matches = fail closed), pinned by two guards.

rollback/remove (P2): the before-blob gate ran over the WHOLE
ecosystem-scoped manifest BEFORE select_installed_variants narrowed a
broad multi-platform gem manifest to the installed platform, so an
uninstalled sibling ?platform= variant's unfetchable before-blob
aborted the entire run (--offline: wholesale; online: on any download
failure) — and remove then refused with rollback_failed. Discovery +
variant narrowing now run first and the gate covers only the (purl,
path) pairs that will actually be attempted, plus in-scope purls the
crawler could not resolve at all — those keep the pre-existing
fail-closed gate (pinned by rollback_invariants / cli_rollback_silent /
remove_network), and local-redirect purls stay excluded as before.

Tests (each fails on the base commit without its fix):
- cli_gem_variant_mismatch_policy.rs (new): singleton default
  warn+apply (human + JSON envelope), singleton --strict refusal;
  guards: --force unchanged, multi-variant sibling never overwritten,
  multi-variant no-match still fails closed.
- in_process_gem_multi_platform.rs: rollback with unfetchable sibling
  before-blob succeeds online (404 mock, blob never requested) and
  --offline (installed variant's blobs only); remove ditto and clears
  both records.
- apply.rs unit: mismatch_blob_gaps_singleton_mismatch_queued (RED vs
  the old gate); the sibling-skip test now uses a genuine multi-variant
  group.

Verified: core 2460/0, cli lib 351/0, docker_e2e_gem 2/2, e2e_gem 8/8
non-ignored, gem/pypi variant + rollback/remove/apply suites green,
clippy+fmt clean. Platform-gem dir layout re-validated against real
RubyGems (ruby 3.4.10): gems/<name>-<version>-<platform> leaf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he mismatch fall-through

Review fixes for the singleton-variant mismatch policy:

Major: the singleton exemption covered QUALIFIED singletons too. A lone
`pkg:gem/name@ver?platform=x86_64-linux` record names one specific
distribution, and the representative-hash gate is the ONLY platform
resolution (the ruby crawler drops the gem dir's platform suffix
precisely because "the installed platform is resolved later" by that
hash check) — falling through let a linux-only record silently
overwrite a darwin install in the Bundler plugin's `--silent`
auto-apply, where the warn half of warn-and-apply is invisible, with a
later rollback restoring the LINUX before-bytes. The exemption now
applies to UNQUALIFIED singletons only (`variants[0] == base_purl`);
qualified singletons keep the pre-existing fail-closed "no matching
variant found". `mismatch_blob_gaps` mirrors the same condition (its
records now retain their manifest keys so the gate can see qualifiers).

Minor: `mismatch_blob_gaps` now skips vendor-owned bases. The apply
loop unconditionally skips them (their results are synthesized up
front), so a drifted vendored release-variant file queued a spurious
"Downloading N full patched blob(s)" fetch online and a spurious "will
fail to apply" warning under `--offline`.

Minor: refreshed the rollback_invariants comment made stale by the
gate reorder — the offline missing-blob bail now aborts after
discovery, before the rollback loop produces per-package results; the
pinned contentless partial_failure shape is unchanged.

Tests (each fails without its fix):
- cli_gem_variant_mismatch_policy::qualified_singleton_wrong_platform_fails_closed:
  lone ?platform= record + wrong-platform install → exit 1,
  "no matching variant found", file byte-identical, no overwrite warning.
- apply.rs unit mismatch_blob_gaps_qualified_singleton_gated_unless_forced:
  qualified singleton mismatch queues nothing by default, queues under
  --force.
- apply.rs unit mismatch_blob_gaps_vendored_base_never_queued: vendored
  base queues nothing (even under --force), same fixture queues without
  the vendor claim.

Verified: cli lib 353/0, cli_gem_variant_mismatch_policy 6/6,
in_process_gem_multi_platform 9/9 non-ignored, apply/rollback/remove/
vendor/variant suites green, docker_e2e_gem 2/2, e2e_gem 8/8
non-ignored, core green, clippy+fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Rebased onto the new fix/gem-bundler-audit tip (99f9cd0, itself freshly rebased onto main at b109645). Transplanted only this PR's two commits via git rebase --onto origin/fix/gem-bundler-audit d063cb1: a2513a674f7c12, e0d06aa55d71cd.

Conflicts: none — the rebase applied cleanly and range-diff shows both commits transplanted identically; all five PR-touched files are byte-identical to the pre-rebase tip.

Verification on the rebased tip (--features docker-e2e,setup-e2e):

  • cli_gem_variant_mismatch_policy 6/6, in_process_gem_apply 7/7, in_process_gem_multi_platform 7/7, rollback_invariants 14/14
  • --lib 370 passed / 1 ignored
  • docker_e2e_gem 2/2 (fresh socket-patch-test-gem:latest)
  • clippy clean on PR-touched code (3 pre-existing doc-indent warnings in main's e2e_vendored_production.rs only); cargo fmt --check clean on PR-touched files (the 6 fmt-dirty files under rustfmt 1.93.1 are identical on origin/main and the base branch — pre-existing, not from this PR).

🤖 Generated with Claude Code

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