Skip to content

Add safeguards against corrupt repodata - #909

Draft
maresb wants to merge 11 commits into
conda:mainfrom
maresb:fix-896
Draft

Add safeguards against corrupt repodata#909
maresb wants to merge 11 commits into
conda:mainfrom
maresb:fix-896

Conversation

@maresb

@maresb maresb commented May 6, 2026

Copy link
Copy Markdown
Contributor

Description

Closes #896

claude added 2 commits May 6, 2026 19:11
Capture a real ``mamba 2.6.0 create --dry-run --json`` output so
later commits in this series have a concrete fixture to test
against. The notable shape is that mamba 2.6.0 emits a rich
``LINK`` action (with ``url`` / ``md5`` / ``sha256`` / ``depends`` /
... already populated) and an empty ``FETCH`` list whenever the
target packages are already present in ``CONDA_PKGS_DIRS``.

This is the failure surface for conda#906: conda-lock's
package-plan extraction reads from ``FETCH``, so a LINK-only
dryrun produces an empty plan. The next commit adds the
LINK->FETCH normalization that recovers the plan from rich LINK
metadata.
Mamba/micromamba 2.6.0 emits LINK actions that already carry every
field conda-lock reads from a FETCH: ``url``, ``fn``, ``md5``,
``sha256``, ``depends``, ``constrains``, ``subdir``, ``timestamp``
... When that is the case there is no need to crack open
``repodata_record.json`` on disk -- and when the package is already
present in ``CONDA_PKGS_DIRS`` mamba 2.6.0 emits an empty FETCH
list, so the previous "always go to disk for any LINK without a
matching FETCH" path produced an empty package plan.

Add ``conda_lock.solver.dry_run.link_action_as_fetch`` which
reuses a rich LINK action as a FETCH when every required field is
present. Synthesis is rejected unless ``depends`` is present *and*
a list (an absent or null value would silently erase the package's
runtime dependencies) and unless every identity field is set.

Wire ``_reconstruct_fetch_actions`` in ``conda_lock.conda_solver``
to try the fast path before falling through to the existing disk
lookup, so older mamba/conda solvers that emit sparse LINKs are
unaffected. ``LinkAction`` becomes ``total=False`` to model that
the field set varies by solver.

Resolves conda#906.
@netlify

netlify Bot commented May 6, 2026

Copy link
Copy Markdown

Deploy Preview for conda-lock ready!

Name Link
🔨 Latest commit 2ea9812
🔍 Latest deploy log https://app.netlify.com/projects/conda-lock/deploys/69fbb078977b540008fdffa1
😎 Deploy Preview https://deploy-preview-909--conda-lock.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

claude added 9 commits May 6, 2026 21:15
Mamba/micromamba 2.6.0 reorganized the package cache from the
flat layout (``<pkgs_dir>/<dist_name>/info/...``) to a hierarchical
one keyed on channel and subdir
(``<pkgs_dir>/<scheme>/<host>/<channel>/<subdir>/<dist_name>/info/...``).
See mamba-org/mamba#4163. Without matching that layout, the
disk-fallback path hit by sparse-LINK dryruns silently misses
records that mamba just wrote.

Add ``conda_lock.solver.repodata_cache`` with:

- ``hierarchical_cache_subpath``: derive the per-channel subpath
  from the LINK action's ``url`` (preferred) or
  ``base_url`` + ``platform`` (fallback).
- ``candidate_record_paths``: hierarchical path first, legacy flat
  path second. Computed from LINK metadata rather than walking the
  cache, so the lookup is bounded.
- ``get_repodata_record``: open the first viable candidate, with
  the existing 10-attempt + 0.1s retry loop preserved for the
  CI race condition the inline implementation handled.

Move the orchestration ``reconstruct_fetch_actions`` from
``conda_solver.py`` into ``conda_lock.solver.dry_run``: the fast
path was already there, and folding the disk fallback alongside it
gives ``conda_solver.py`` a single import for dryrun normalization.

Promote ``extract_json_object`` and ``get_pkgs_dirs`` from
private helpers in ``conda_solver.py`` to public functions in
``conda_lock.invoke_conda``. They are CLI-probe / JSON-parse
concerns, not cache-record concerns; ``repodata_cache`` and
``dry_run`` consume them via that boundary so the layering stays
``models`` -> ``invoke_conda`` -> ``solver/*``.

Completes the conda#906 cache-layout fix.
The bare cache-layout fix gets us close but is not safe alone:
``<pkgs_dir>/<dist_name>/info/repodata_record.json`` is keyed on
``dist_name`` only, so the legacy flat layout still admits a
cross-channel ``dist_name`` collision. Without identity checks
the lookup can silently return a stale or impostor record. The
hierarchical layout is meant to disambiguate collisions, but for
that to work we have to walk it the way libmamba walks it -- and
libmamba normalizes URLs before mapping to a cache path.

Add to ``conda_lock.solver.repodata_cache``:

- ``libmamba_strip_url_secrets`` -- libmamba-compat removal of
  user:pass@ credentials and ``/t/<token>`` path segments. The
  ``/t/<chars>`` regex is intentionally as overbroad as
  libmamba's; the cache lookup must derive bit-for-bit the same
  path mamba 2.6.0 wrote.
- ``normalize_url_for_compare`` -- libmamba's
  ``compare_cleaned_url`` semantics (force https, strip
  credentials, strip trailing slash). Two URLs differing only by
  scheme, slash, or credentials must compare equal.
- ``record_matches_link`` -- positive name/version equality plus
  contradiction checks on subdir / fn / md5 / sha256 / url. URL
  comparison goes through ``normalize_url_for_compare``; falls
  back to channel-string compare when only one side has a URL.

Wire ``hierarchical_cache_subpath`` to feed
``libmamba_strip_url_secrets`` before normalizing the path
separators, and ``get_repodata_record`` to call
``record_matches_link`` on every loaded candidate. Distinct
failure reasons (missing file, JSON corruption, identity
mismatch) are tracked per-retry so the final ``not found``
log surfaces the most actionable diagnostic.

Continues conda#906: with the cache layout in place
and now identity-checked, conda-lock cannot silently grab a
wrong-channel record sharing a ``dist_name``.
Capture the failure-mode data from PR conda#862 so subsequent
behavioral commits can run against a known-bad cache without
needing live conda-forge or Docker. Three pinned-metadata
archives represent the three relevant mamba versions:

- ``2.1.0-pkgs.tar.gz``: clean baseline.
- ``2.1.1-pkgs.tar.gz``: corrupt (the bug's first appearance).
- ``2.3.3-pkgs.tar.gz``: corrupt (the bug's last appearance
  before mamba 2.6.0's fix).

Each archive contains ``info/index.json`` plus
``info/repodata_record.json`` exactly as that mamba version
wrote them when installing the same explicit lockfile, so
overlaying the metadata on a freshly warmed cache produces a
"hybrid" cache: real package files + bug-faithful metadata.
That is what later end-to-end tests will drive ``conda-lock
lock`` against. The ``05-render-explicit-lockfiles.py`` harness
plus the per-version ``lockfile-...yml`` outputs lock the
reproduction inputs.

Add ``tests/support/corrupt_repodata.py`` with the helpers a
component test needs to drive the harness: warmed-cache fixture
factory, corrupt-metadata overlay, ``conda-lock lock`` /
``conda-lock render --kind=explicit`` driver wrappers, and
explicit-lockfile-URL extraction. None of this calls into new
production code yet -- it is pure test scaffolding so the next
commits can focus on behavior.

Exclude ``tests/test-corrupt-repodata/`` from mypy / ty /
pre-commit since the stage scripts are Docker-based one-shot
data generators, not runtime code. The committed fixtures
(.tar.gz / .lock / .yml) are what tests consume.

References conda#896, mamba-org/mamba#4052,
mamba-org/mamba#4110.
Mamba/micromamba 2.1.1 through 2.3.3 wrote URL-stub metadata
directly to ``repodata_record.json`` instead of merging with
``info/index.json`` (mamba-org/mamba#4052, fixed in 2.6.0 via
mamba-org/mamba#4110). The fingerprint is ``timestamp == 0`` and
empty ``license``; ``depends``, ``constrains``, ``build_number``
are zeroed too. A conda-lock dryrun against such a cache produces
empty-deps lockfile entries that silently drop dependencies from
later solves and installs. conda#896.

This commit adds the cache-side mitigation. Subsequent commits
add the lockfile-side carry-forward heal and the strict
graph-integrity check that catches the silent-vanishing variant.

Changes:

- ``is_mamba_2_1_to_2_3_stub_record`` in ``solver/repodata_cache``:
  pin the corruption fingerprint as a function so callers across
  the dryrun pipeline can recognize a stub record consistently.
- ``heal_corrupt_record``: overlay the corrupt record with the
  sibling ``info/index.json``. ``info/index.json`` is extracted
  from the package tarball at install time and is never affected
  by the bug, so it carries the canonical ``depends`` / ``license``
  / ``timestamp``. Cache-derived fields (``url``, ``md5``,
  ``sha256``, ``size``) are kept from the record where non-empty.
- ``RepodataLookup`` dataclass: ``get_repodata_record`` now returns
  a structured outcome -- ``found`` / ``healed`` /
  ``unhealable_corrupt`` / ``not_found`` -- so the cache layer
  reports facts and the orchestration layer
  (``solver.dry_run.reconstruct_fetch_actions``) translates each
  outcome to a user-facing WARNING with the right remediation
  text. Eliminates the previous in-cache ``logger.warning`` calls
  that mixed policy with I/O.
- Two-stage identity gate before classifying a stub as
  ``unhealable_corrupt``:
    1. ``record_matches_link`` rejects on any contradiction.
    2. ``_stub_has_strong_identity_match`` requires positive match
       on at least one of url/md5/sha256/fn (the artifact-identity
       fields). ``subdir`` is excluded -- it narrows platform, not
       artifact, and would license a "no contradictions"
       overreach.
  An impostor stub at a flat-fallback path falls through to
  ``not_found`` rather than misclaiming the operator's cache is
  corrupt.
- ``link_action_as_fetch`` rejects a LINK that itself carries the
  stub-record fingerprint, so the rich-LINK fast path can't
  smuggle a corrupt record into the synthesized FETCH on a solver
  that emits stubs in LINK without going through cache heal.
- Outcome priority: ``unhealable_corrupt`` always wins over
  ``not_found``. ``last_unhealable`` is set only after both
  identity gates pass, so a real corrupt-cache claim is never
  demoted by trivia from later candidate noise.
- ``warn_on_pkgs_dirs_leak`` in ``solver.dry_run``: surface when
  the user's ``.condarc`` leaks extra ``pkgs_dirs`` past the
  ``CONDA_PKGS_DIRS`` isolation conda-lock requests, since stale
  or corrupt entries in those leaked dirs are step three of the
  conda#896 chain.

Tests in ``tests/unit/test_repodata_cache.py`` and
``tests/unit/test_dry_run_actions.py`` cover: the corruption
fingerprint, ``info/index.json`` heal, the strong-identity gate
(both directions: weak-identity stubs fall through; one matching
strong field is enough), outcome priority (unhealable beats
identity-rejection on a later candidate), and the orchestration
boundary's WARNING-translation contract for each outcome.
Cache-side heal alone is not enough to close conda#896:
once a lockfile has been *generated* against a corrupt mamba
2.1.1-2.3.3 cache, the entries it carries already have empty
``dependencies: {}``. Every subsequent ``conda-lock lock --update``
then reads that lockfile back via ``fake_conda_environment``,
``to_fetch_action()`` emits a FETCH with empty ``depends``,
``apply_categories`` cannot reach the package via the dependency
graph, and the freshly-emitted lockfile inherits the empty deps --
forever, even after the user upgrades to mamba 2.6.0+.

Add ``conda_lock.solver.lockfile_heal`` to break the carry-forward.

The strategy is "consult the cache on the way in." For each
lockfile entry with empty ``dependencies``:

- Project the entry into a LinkAction-shaped dict and reuse the
  existing ``candidate_record_paths`` machinery to look up the
  package in the local cache. Hierarchical layout (mamba 2.6.0+)
  is preferred, legacy flat layout is the fallback.
- Read ``info/index.json`` (not ``repodata_record.json``):
  ``info/index.json`` is extracted directly from the package
  tarball at install time and is unaffected by the cache
  corruption bug, so it carries the canonical ``depends``.
- Identity-check the ``info/index.json`` against the LINK before
  trusting it. The legacy flat path is keyed on ``dist_name``
  alone and can hold same-dist-name packages from different
  channels; an unchecked match would silently heal the lockfile
  with metadata from the wrong artifact.

Some packages legitimately have no runtime dependencies (``tzdata``,
``python_abi``, ``_libgcc_mutex``, ...) so empty ``dependencies``
alone is not proof of corruption. ``LockfileHealReport`` exposes
the per-entry classification as three explicit tuples:

- ``healed``: cache contradicted the lockfile -> proof of
  corruption; depends recovered in place.
- ``confirmed_legit_empty``: cache agreed empty deps are correct.
- ``ambiguous``: cache has no entry to adjudicate.

Mutually-exclusive tuples make it structurally impossible for the
orchestration layer to accidentally collapse "legit-empty" with
"corrupt-but-unprovable evidence," which an earlier integer
return value did.

Wire ``update_specs_for_arch`` in ``conda_lock.conda_solver`` to
call the heal before ``fake_conda_environment``, log a WARNING
when entries were healed (with the list and the conda-lock#896
reference), and log a separate prescriptive WARNING when
ambiguous entries remain. The orchestration layer owns log
policy; the heal layer is silent at WARNING level by contract.

Tests in ``tests/component/test_lockfile_heal.py`` cover the
three classification states (healed, confirmed_legit_empty,
ambiguous), the partial-cache case where one matched entry
must NOT promote ambiguous entries, the legacy-flat
cross-channel rejection, the noarch / concrete-subdir
cross-validation, and the end-to-end ``update_specs_for_arch``
WARNING translations.
After ``apply_categories`` walks forward from each requested input
spec, every planned package should have at least one category. A
package without any category is an orphan: the lockfile dependency
graph cannot explain why this package is in the plan.

Orphans are not tolerated. They would silently vanish from the v1
lockfile output (which emits one entry per category, so a package
with no category produces zero entries) and the resulting
environment would install fewer packages than the solver actually
planned. This is the silent-vanishing variant of the
conda#896 chain that cache-side and lockfile-side heal
do not catch -- it surfaces only after categorization.

Add ``conda_lock.solver.graph_integrity.assert_no_orphaned_conda_packages``
which owns the entire orphan policy:

- Definition of an orphaned planned package.
- No reverse-propagation of categories. ``apply_categories``'
  forward walk is the only categorization path. Reverse-prop
  hides the broken-graph signal in single-category projects
  (every orphan with any categorized dependency would silently
  inherit ``main``, and the hard-fail would never fire on real
  conda#896-style breakage), and ``"P depends on X (main)"`` does not
  prove ``"main needs P"``; a permissive variant would launder
  dev-only solver artifacts into main installs.
- Why ``pip`` is not normally an orphan even though nothing the
  user explicitly requests transitively depends on it: both
  conda's ``add_pip_as_python_dependency`` and libmamba's
  repo-load injection mutate ``python``'s declared dependencies
  to include ``pip`` at metadata-load time. So a healthy
  forward walk from ``python`` reaches ``pip`` in normal
  operation, and an orphaned ``pip`` indicates broken solver
  metadata rather than the expected shape.
- ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` escape hatch:
  intentionally ugly, demotes the hard-fail to a loud WARNING and
  assigns orphans to ``main`` so they survive v1 serialization.
  This is for implementation-bug emergencies (a buggy custom
  solver, missing ``add_pip_as_python_dependency`` injection on
  an exotic channel) not a configuration knob. The WARNING
  spells out the over-install consequence: every ``conda-lock
  install`` invocation -- even ones without
  ``--dev-dependencies`` or ``-e <category>`` -- will install
  the orphans, potentially over-installing dev-only solver
  artifacts into production environments.

Add ``OrphanLockedDependencyError`` in ``conda_lock.errors``;
``solve_conda`` calls
``assert_no_orphaned_conda_packages(planned, platform)`` after
``apply_categories``. The module docstring on
``conda_lock.conda_solver`` introduces the ``solver/*`` split as
a whole so a new reader sees the layering on first opening the
file.

Six component tests in ``tests/component/test_graph_integrity.py``
pin the policy contract:

- ``test_solve_conda_accepts_pip_via_python_add_pip_dependency``:
  the *normal* shape. ``python.depends == ["pip"]`` makes ``pip``
  forward-reachable; ``pip`` inherits ``python``'s requested
  category without rescue.
- ``test_solve_conda_hard_fails_when_python_metadata_omits_pip``:
  the abnormal-metadata shape. ``python.depends == []`` makes
  ``pip`` unreachable; the orphan check hard-fails. The test
  docstring names this as broken solver metadata, not normal
  solver auto-install behavior.
- ``test_solve_conda_envvar_demotes_orphan_to_warning``: pins
  ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` semantics. WARNING
  fires (explicitly mentioning ``main``,
  ``--dev-dependencies`` / ``-e <category>``, and the envvar
  name so the message can't silently weaken), orphans are
  assigned to ``main``, ``solve_conda`` returns instead of
  raising.
- ``test_solve_conda_hard_fails_on_unrecoverable_orphan``: the
  classic conda#896-corrupt-cache shape -- ``zlib`` planned with
  empty depends, no categorized package transitively requires
  it -- hard-fails with regenerate-from-sources guidance and
  the envvar escape named in the error message.
- ``test_solve_conda_orphan_via_dep_breakage_still_hard_fails``:
  multi-category corrupt-cache variant.
- ``test_solve_conda_passes_when_dependency_graph_is_intact``:
  positive sanity check.
The unit + component tests exercise the cache-side heal,
lockfile-side heal, and graph-integrity invariants in isolation
with hand-crafted fixtures. They cannot reach the silent-vanishing
path that conda#896 actually reports, because that path
is only reachable through the full pipeline:
``LINK``-only dryrun -> ``reconstruct_fetch_actions`` ->
``get_repodata_record`` -> cache-side heal ->
``apply_categories`` forward walk ->
``assert_no_orphaned_conda_packages`` -> v1 serialization.

Add ``tests/e2e/test_corrupt_repodata_repro.py`` to drive that
whole pipeline against the corrupt-metadata fixtures committed in
``tests/test-corrupt-repodata/``. The test parametrizes over
``mamba`` and ``conda-standalone`` (both solvers were affected by
conda#896 because both read leaked ``pkgs_dirs``) and asserts:

1. *Structural lockfile equivalence*: parsing both unified
   lockfiles via ``parse_conda_lock_file`` produces the same set
   of ``(name, version, category)`` tuples. Catches silent drops
   and any "rescue into main" miscategorization.
2. *Explicit-URL equivalence*: ``conda-lock render
   --kind=explicit`` against both unified lockfiles produces
   identical URL sets. PR conda#862 stage 05's whole point is that
   "packages are missing from the resulting environment if and
   only if they are missing from the explicit lockfile," so this
   is the user-visible bug surface.
3. *Empty-deps regression*: corrupt-cache run does not produce
   significantly more empty-``dependencies`` entries than the
   clean-cache run, proving the heal pipeline recovered them.

The supporting fixture factory and harness wrappers live in
``tests.support.corrupt_repodata`` (added with the fixture data),
so the test body stays focused on the assertions. The test is
marked ``corrupt_cache_repro`` for CI isolation in the next
commit; locally it runs in ~2-5 minutes per parametrization
because it warms a real conda-forge cache and drives full
``conda-lock lock`` and ``conda-lock render`` invocations.
The ``corrupt_cache_repro`` end-to-end test drives live
conda-forge in two independent solves (corrupt-cache vs.
clean-control) and runs ~2-5 minutes per parametrization. Putting
it in the default unit-test collection would dominate every
contributor's local pytest run and inflate every PR's CI wall
clock for negligible additional confidence on most changes.

Three pieces of CI plumbing:

1. ``pyproject.toml`` declares the ``corrupt_cache_repro`` marker
   and adds ``-m "not corrupt_cache_repro"`` to the default
   ``addopts``. ``pytest`` invocations from contributors,
   editors, and the unit-test CI shards exclude the e2e by
   default. Local opt-in is ``pytest -m corrupt_cache_repro``;
   bypass-everything is ``pytest -m ""``.

2. ``.github/workflows/test.yml`` adds a dedicated
   ``run-corrupt-cache-repro`` step that runs once per
   ``(os, py-version)`` on shard 1 / linux-64 only -- the
   fixture is a linux-64 cache snapshot from the PR conda#862
   harness, so other platforms have nothing to test against.
   The step uses ``-o "addopts=-rsx --verbose"`` to clear the
   project-default exclusion and ``-m corrupt_cache_repro`` to
   opt back into just the e2e parametrizations.

3. The CI step targets ``tests/e2e/test_corrupt_repodata_repro.py``
   directly so a future test author who marks an unrelated test
   with ``corrupt_cache_repro`` doesn't accidentally end up in
   the heavy CI lane.

Behavior of the unit + component tests is unchanged.
Pull in ``conda-standalone`` in the dev environment so contributors
can run the dual-solver e2e parametrization locally without
standing up a separate conda installation, and refresh
``pixi.lock`` to match.

- ``pixi.toml`` and ``environments/dev-environment.yaml``: add
  ``conda-standalone``.
- ``environments/conda-lock-python-3.10.yaml``,
  ``environments/conda-lock-python-3.14.yaml``: regenerated from
  the updated source.
- ``pixi.lock``: re-resolved to match.

Pure environment / lockfile churn; no source code changes.
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.

Packages missing from environments installed via conda-lock due to corrupt mamba/micromamba cache

2 participants