|
1 | 1 | import logging |
2 | 2 | import pathlib |
| 3 | +import re |
3 | 4 |
|
4 | 5 | from collections import defaultdict |
5 | 6 | from collections.abc import Collection, Mapping, Sequence |
@@ -169,63 +170,169 @@ def dep_name(*, manager: str, dep: str, mapping_url: str) -> str: |
169 | 170 | _truncate_main_category(planned) |
170 | 171 |
|
171 | 172 |
|
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)) |
185 | 257 |
|
186 | 258 |
|
187 | 259 | 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 |
198 | 289 | re-solve). |
199 | 290 | """ |
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) |
201 | 293 | 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 = { |
205 | 303 | 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 |
208 | 306 | } |
209 | | - if not suspicious: |
| 307 | + if not has_definite and not suspicious_unclassified: |
210 | 308 | 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 | + |
215 | 325 | 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.", |
229 | 336 | source, |
230 | 337 | summary, |
231 | 338 | ) |
|
0 commit comments