Skip to content

Commit 7ecbde4

Browse files
committed
detect conda#896 corruption per-entry, not by absolute threshold
The previous parse-time detector warned only when more than 10 empty-deps conda entries appeared on a single platform. The reviewer correctly pointed out two failure modes: 1. Small environments with fewer than 11 packages can be just as corrupt; the threshold silently passes them through. 2. In practice corruption affects only some fraction of entries, so any pure count-based heuristic is fundamentally fragile. Replace the threshold with a per-entry classifier that exploits a mathematical invariant: a conda package whose build string identifies it as built against a Python ABI MUST declare a python dependency. There is no legitimate ``pyhd*``, ``pypy*``, or ``py<digit>...`` build with empty ``depends``. So a single such entry with empty ``dependencies`` is proof of corruption -- threshold-free, works on lockfiles of any size. Verified against the committed PR conda#862 fixtures: 48 of 77 corrupt empty-deps entries match the python-built signal (zero false positives in the clean baseline -- ``_libgcc_mutex``, ``tzdata``, ``python_abi``, ``nlohmann_json-abi`` all have non-``py*`` builds). For the residual native-binary entries (e.g. ``openssl``, ``krb5``) where lockfile-only evidence cannot prove corruption per-entry, fall back to a small absolute count (>3) of unclassified empty-deps entries on a single platform after stripping the well-known legit-empty allowlist (``_*`` ABI mutexes, ``tzdata``, ``ca-certificates``, ``*-abi``, ``*_abi``). The threshold here is deliberately low so even a small carrier lockfile trips, with the allowlist absorbing the conservative side. Tests cover both signals and the negative cases: - ``test_detector_warns_on_single_pynoarch_empty_entry``: 1 ``babel-pyhd8ed1ab_0`` with empty deps, lockfile of size 1, triggers warning (the small-corrupt case the reviewer flagged). - ``test_detector_warns_on_single_arch_python_empty_entry``: same for ``mypy-py314h*``. - ``test_detector_quiet_on_legit_empty_leaves``: 5 well-known legit-empty packages, no warning. - ``test_detector_warns_on_small_native_lib_corruption``: 4 native libs (>3 unclassified threshold) trips the fallback. - ``test_detector_quiet_on_two_native_empties_below_threshold``: 2 unclassified empties stay quiet to avoid false positives in tiny envs. - The two existing fixture-based tests still pin the large-lockfile behavior end-to-end (corrupt and clean PR conda#862 fixtures). https://claude.ai/code/session_01F6kPbdaqoKb9XARYdbRVhd
1 parent 1b1b316 commit 7ecbde4

2 files changed

Lines changed: 350 additions & 47 deletions

File tree

conda_lock/lockfile/__init__.py

Lines changed: 154 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import pathlib
3+
import re
34

45
from collections import defaultdict
56
from collections.abc import Collection, Mapping, Sequence
@@ -169,63 +170,169 @@ def dep_name(*, manager: str, dep: str, mapping_url: str) -> str:
169170
_truncate_main_category(planned)
170171

171172

172-
# A handful of conda packages legitimately ship with no runtime
173-
# dependencies (``tzdata``, ``python_abi``, ``_libgcc_mutex``,
174-
# ``_openmp_mutex``, ``nlohmann_json-abi``, ``ca-certificates`` ...)
175-
# so seeing a small number of empty-deps entries per platform is
176-
# normal. The mamba/micromamba 2.1.1-2.3.3 cache corruption bug
177-
# (mamba-org/mamba#4052, conda/conda-lock#896) zeros ``depends`` for
178-
# *most* installed packages, producing many tens of empty-deps
179-
# entries per platform. Use that gap as a heuristic threshold so a
180-
# corrupt carrier lockfile is flagged everywhere it's parsed --
181-
# including read-only paths like ``conda-lock render`` and
182-
# ``conda-lock install`` that don't go through the cache-backed
183-
# heal in ``update_specs_for_arch``.
184-
_LEGIT_EMPTY_DEPS_PER_PLATFORM_THRESHOLD = 10
173+
# --- Detection of conda/conda-lock#896 corrupt-cache lockfiles ----------
174+
#
175+
# When mamba/micromamba 2.1.1-2.3.3 writes a corrupt ``repodata_record.json``
176+
# (mamba-org/mamba#4052, fix mamba-org/mamba#4110), every conda-lock entry
177+
# generated against that cache loses its ``depends`` (and other) metadata.
178+
# In the resulting lockfile, that surfaces as a conda package entry whose
179+
# ``dependencies`` mapping is empty.
180+
#
181+
# Empty ``dependencies`` *can* be legitimate -- a handful of packages
182+
# (``tzdata``, ``python_abi``, ``_libgcc_mutex``, ``_openmp_mutex``,
183+
# ``nlohmann_json-abi``, ``ca-certificates`` ...) really do ship with
184+
# no runtime deps. So we cannot just count empties; we have to classify
185+
# them. The strongest classifier is the build string in the package
186+
# URL: a build string starting with ``py`` (noarch ``pyhd...``,
187+
# ``pypy...``, or arch ``py310...``/``py314...``) identifies the
188+
# package as built against a specific Python ABI. Such a package is
189+
# *required* to declare a ``python`` dependency; an empty
190+
# ``dependencies`` mapping is therefore mathematically impossible for
191+
# a healthy entry. That gives us a per-entry hard signal that is
192+
# threshold-free and works on lockfiles of any size -- a single
193+
# ``babel-pyhd8ed1ab_0`` with empty deps is enough to flag.
194+
#
195+
# A second, weaker signal covers native-binary packages whose
196+
# corruption *is* impossible to distinguish from a legit-empty leaf
197+
# without external metadata (``openssl`` corrupted vs. ``_libgcc_mutex``
198+
# legit). For those we strip the well-known legit-empty allowlist and
199+
# fall back to a small absolute count so even a tiny carrier lockfile
200+
# trips the warning.
201+
202+
_PYTHON_BUILD_RE = re.compile(r"^(py|pypy)(\d|h)")
203+
"""Build strings starting with ``py<digit>`` (e.g. ``py314h5bd0f2a_0``)
204+
or ``pyh*`` / ``pypy*`` (e.g. ``pyhd8ed1ab_0``, ``pypy39_0``) identify
205+
a package as built against a specific Python ABI."""
206+
207+
_LEGIT_EMPTY_NAMES = frozenset({"tzdata", "ca-certificates"})
208+
_UNCLASSIFIED_EMPTY_DEPS_THRESHOLD = 3
209+
"""Fallback threshold for native-binary empty-deps entries that are not
210+
covered by the per-entry hard signal and not on the allowlist. Set
211+
small (3) so even a small corrupt lockfile trips the warning; the
212+
hard signal handles everything noarch-python."""
213+
214+
215+
def _build_string_from_url(url: str) -> str | None:
216+
"""Extract the build string from a conda package URL.
217+
218+
URLs look like ``.../<subdir>/<name>-<version>-<build>.{conda,tar.bz2}``.
219+
Returns ``None`` if the URL does not parse as a conda artifact.
220+
"""
221+
fn = url.rsplit("/", 1)[-1]
222+
for ext in (".conda", ".tar.bz2"):
223+
if fn.endswith(ext):
224+
stem = fn[: -len(ext)]
225+
break
226+
else:
227+
return None
228+
parts = stem.rsplit("-", 2)
229+
if len(parts) != 3:
230+
return None
231+
return parts[2]
232+
233+
234+
def _looks_like_legit_empty(name: str) -> bool:
235+
"""Names of well-known packages that legitimately ship with no
236+
runtime ``depends``.
237+
238+
These are data-only packages (``tzdata``, ``ca-certificates``),
239+
ABI-mutex packages (``_libgcc_mutex``, ``_openmp_mutex``), and
240+
ABI-marker packages (``python_abi``, ``nlohmann_json-abi``).
241+
"""
242+
if name in _LEGIT_EMPTY_NAMES:
243+
return True
244+
if name.startswith("_"):
245+
return True
246+
if name.endswith(("-abi", "_abi")) or "-abi-" in name or "_abi-" in name:
247+
return True
248+
return False
249+
250+
251+
def _is_python_built(entry: LockedDependency) -> bool:
252+
"""``True`` if the entry's build string identifies it as built
253+
against a Python ABI -- in which case empty ``dependencies`` is
254+
proof of corruption."""
255+
build = _build_string_from_url(entry.url)
256+
return build is not None and bool(_PYTHON_BUILD_RE.match(build))
185257

186258

187259
def _warn_if_lockfile_looks_corrupt(lockfile: Lockfile, source: pathlib.Path) -> None:
188-
"""Heuristic warning when a parsed lockfile has the empty-``depends``
189-
fingerprint of mamba/micromamba 2.1.1-2.3.3 cache corruption.
190-
191-
This is the *visibility* layer for the
192-
``conda-lock render``/``install``/lock-no-solve paths, where
193-
``update_specs_for_arch``'s cache-backed heal never runs. It's a
194-
heuristic, not proof: we cannot tell legit-empty (``tzdata``)
195-
from corrupt-empty (a package whose ``depends`` got zeroed) from
196-
the lockfile alone -- only the count gap distinguishes them.
197-
Warns; never raises (read-only callers can't necessarily
260+
"""Emit a single WARNING when a parsed lockfile shows the
261+
empty-``depends`` fingerprint of mamba/micromamba 2.1.1-2.3.3
262+
cache corruption (conda/conda-lock#896, mamba-org/mamba#4052,
263+
mamba-org/mamba#4110).
264+
265+
This is the *visibility* layer for read-only paths --
266+
``conda-lock render``, ``conda-lock install``, and a no-solve
267+
``conda-lock lock`` -- where ``update_specs_for_arch``'s
268+
cache-backed heal never runs.
269+
270+
Detection is two-tiered:
271+
272+
1. **Per-entry hard signal**: any conda entry whose build string
273+
starts with ``py`` (noarch ``pyhd*`` or arch ``py310*``...) and
274+
whose ``dependencies`` mapping is empty is *definitionally*
275+
corrupt -- no python ABI build can have zero declared
276+
dependencies. This signal is threshold-free; one such entry
277+
triggers the warning. It catches small corrupt lockfiles that
278+
a count-based heuristic would miss.
279+
280+
2. **Fallback for native-binary entries**: empty-deps entries
281+
that are *not* python-built and *not* on the well-known
282+
legit-empty allowlist (``_*``, ``*-abi``, ``tzdata``, ...) are
283+
counted; if more than ``_UNCLASSIFIED_EMPTY_DEPS_THRESHOLD`` of
284+
them appear on any single platform, we warn. This covers
285+
corruption of ``openssl``/``krb5``/etc., where lockfile
286+
evidence alone cannot prove corruption per-entry.
287+
288+
Warns; never raises (read-only callers cannot necessarily
198289
re-solve).
199290
"""
200-
by_platform: dict[str, list[str]] = defaultdict(list)
291+
definite_per_platform: dict[str, list[str]] = defaultdict(list)
292+
unclassified_per_platform: dict[str, list[str]] = defaultdict(list)
201293
for entry in lockfile.package:
202-
if entry.manager == "conda" and not entry.dependencies:
203-
by_platform[entry.platform].append(entry.name)
204-
suspicious = {
294+
if entry.manager != "conda" or entry.dependencies:
295+
continue
296+
if _is_python_built(entry):
297+
definite_per_platform[entry.platform].append(entry.name)
298+
elif not _looks_like_legit_empty(entry.name):
299+
unclassified_per_platform[entry.platform].append(entry.name)
300+
301+
has_definite = any(definite_per_platform.values())
302+
suspicious_unclassified = {
205303
p: ns
206-
for p, ns in by_platform.items()
207-
if len(ns) > _LEGIT_EMPTY_DEPS_PER_PLATFORM_THRESHOLD
304+
for p, ns in unclassified_per_platform.items()
305+
if len(ns) > _UNCLASSIFIED_EMPTY_DEPS_THRESHOLD
208306
}
209-
if not suspicious:
307+
if not has_definite and not suspicious_unclassified:
210308
return
211-
summary = "; ".join(
212-
f"{platform}: {len(names)} empty-deps entries (sample: {sorted(names)[:5]})"
213-
for platform, names in sorted(suspicious.items())
214-
)
309+
310+
parts: list[str] = []
311+
if has_definite:
312+
for platform, names in sorted(definite_per_platform.items()):
313+
parts.append(
314+
f"{platform}: {len(names)} python-built package(s) with "
315+
f"empty 'dependencies' (sample: {sorted(names)[:5]}) -- "
316+
f"these cannot legitimately have zero deps"
317+
)
318+
for platform, names in sorted(suspicious_unclassified.items()):
319+
parts.append(
320+
f"{platform}: {len(names)} non-allowlisted empty-deps "
321+
f"entries (sample: {sorted(names)[:5]})"
322+
)
323+
summary = "; ".join(parts)
324+
215325
logger.warning(
216-
"Lockfile %s contains far more empty-'dependencies' entries "
217-
"on at least one platform than the legitimate baseline of ~5 "
218-
"(tzdata, python_abi, _libgcc_mutex, _openmp_mutex, "
219-
"nlohmann_json-abi). This is the fingerprint of a lockfile "
220-
"generated against a corrupt mamba/micromamba 2.1.1-2.3.3 "
221-
"cache (see conda/conda-lock#896 / mamba-org/mamba#4110); "
222-
"some packages may already have vanished from the lockfile "
223-
"during v1 serialization. Per-platform: %s. The safe repair "
224-
"is to regenerate from sources on a known-clean cache "
225-
"(`mamba clean -a` then `conda-lock lock -f <your sources>`). "
226-
"Do NOT install this lockfile into a production environment "
227-
"without regenerating: packages already lost during v1 "
228-
"serialization are unrecoverable from it.",
326+
"Lockfile %s shows the fingerprint of a lockfile generated "
327+
"against a corrupt mamba/micromamba 2.1.1-2.3.3 cache "
328+
"(see conda/conda-lock#896 / mamba-org/mamba#4110): %s. "
329+
"Some packages may already have vanished from the lockfile "
330+
"during v1 serialization. The safe repair is to regenerate "
331+
"from sources on a known-clean cache (`mamba clean -a` then "
332+
"`conda-lock lock -f <your sources>`). Do NOT install this "
333+
"lockfile into a production environment without "
334+
"regenerating: packages already lost during v1 serialization "
335+
"are unrecoverable from it.",
229336
source,
230337
summary,
231338
)

0 commit comments

Comments
 (0)