Skip to content

Nested interpreter JSON grows quadratically in DiskCache #204

Closed

Description

@gaborbernat

A 34 KB nested JSON answer produced a roughly 512 MB DiskCache entry and about 1 GiB of caller memory use on the reported commit. The indented write still inflates nested entries on current main, but the exact 16,000-level testcase now raises RecursionError during the cache write.

Affected code

src/python_discovery/_cache.py:97 at the reported commit. The reproducer also returned BUG on commit 004294957ed227fe7e52b2864952ba09fd28b220. At commit a2ff35d5458757fa006cb10f28eb8b20b6b5a2f3, the atomic cache write uses json.dump(..., indent=2). A scaled 800-level input of 1,600 bytes writes a 1,283,213-byte entry; the original 16,000-level input raises RecursionError at that write.

Mechanism

Indenting a deeply nested answer adds whitespace at every level when the cache serializes it.

Reproduction

Check out the reported commit or current main. Save the files below as named, then build and run:

docker build -t python-discovery-audit -f - . <<'DOCKERFILE'
FROM python:3.15-bookworm
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY . /src
WORKDIR /src
ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.9.1
RUN pip install -e . --group test
DOCKERFILE
docker run --rm --network=none --memory=2g -u 0 -w /src \
  -v "$PWD/repro:/tmp/repro:ro" -v "$PWD/repro.d:/tmp/repro.d:ro" \
  python-discovery-audit /tmp/repro

repro

#!/usr/bin/env python3
import hashlib
import json
import shutil
import subprocess
import tempfile
from pathlib import Path

VENV_PYTHON = "/opt/venv/bin/python"  # has python-discovery installed from /src
COLLECTOR = "/src/src/python_discovery/_py_info_collect.py"
work = Path(tempfile.mkdtemp(prefix="indent-amplification-"))


def install_candidate(name):
    candidate = work / name / "bin" / "python3.99"
    candidate.parent.mkdir(parents=True)
    shutil.copy(f"/tmp/repro.d/candidate_{name}.py", candidate)
    candidate.chmod(0o755)
    return candidate


def framed_answer(candidate):
    return subprocess.run([candidate, COLLECTOR, "s" * 32, "e" * 32], capture_output=True, check=True).stdout


def lookup(candidate, cache_root):
    command = [VENV_PYTHON, "/tmp/repro.d/find_python.py", str(candidate.parent), str(cache_root)]
    result = json.loads(subprocess.run(command, capture_output=True, text=True, check=True).stdout)
    for argv in result["spawned"]:
        print("  spawned ", *argv)
    print(f"  returned {result['returned']}, caller peak RSS {result['peak_rss_kib'] // 1024} MiB")
    return result


def cache_entry_size(candidate, cache_root):
    # DiskCache keeps it at <root>/py_info/4/<sha256 of the interpreter path>.json
    key = hashlib.sha256(str(candidate).encode()).hexdigest()
    return (cache_root / "py_info" / "4" / f"{key}.json").stat().st_size


# set up: each candidate alone on its own PATH directory, each with its own empty cache
# ("flat" and "deep" have the same length: the candidate's sys.path is part of its answer)
flat, deep = install_candidate("flat"), install_candidate("deep")
flat_cache, deep_cache = work / "flat" / "cache", work / "deep" / "cache"
flat_answer, deep_answer = framed_answer(flat), framed_answer(deep)
print(f"flat candidate answers {flat_answer[:50]} ... {flat_answer[-50:]}")
print(f"deep candidate answers {deep_answer[:50]} ... {deep_answer[-50:]}")

# control: padding is one 32000-byte string
print("lookup, flat candidate, empty cache")
flat_lookup = lookup(flat, flat_cache)
flat_entry = cache_entry_size(flat, flat_cache)

# attack: padding is 16000 nested empty lists, also 32000 bytes
print("lookup, deep candidate, empty cache")
deep_lookup = lookup(deep, deep_cache)
deep_entry = cache_entry_size(deep, deep_cache)

# the entry stays: the next lookup reads it instead of running the candidate
print("lookup again, deep candidate, the cache the first lookup left")
second_lookup = lookup(deep, deep_cache)
deep_spawned_again = any(argv[0] == str(deep) for argv in second_lookup["spawned"])
shutil.rmtree(work)

# check: a correct cache entry is about as big as the answer it stores
flat_rss, deep_rss, second_rss = (r["peak_rss_kib"] // 1024 for r in (flat_lookup, deep_lookup, second_lookup))
print(f"CONTROL  flat: answer {len(flat_answer)} B -> cache entry {flat_entry} B, caller peak RSS {flat_rss} MiB")
print(f"OBSERVED deep: answer {len(deep_answer)} B -> cache entry {deep_entry} B, caller peak RSS {deep_rss} MiB")
print(f"OBSERVED deep, second lookup: candidate spawned {deep_spawned_again}, caller peak RSS {second_rss} MiB")

same_size_answers = len(flat_answer) == len(deep_answer)
control_entry_ok = flat_entry < 2 * len(flat_answer)
deep_entry_amplified = deep_entry > 1000 * len(deep_answer)
deep_memory_amplified = deep_rss > 10 * flat_rss
paid_again_from_cache = not deep_spawned_again and second_rss > 10 * flat_rss
print(
    "BUG"
    if same_size_answers
    and control_entry_ok
    and deep_entry_amplified
    and deep_memory_amplified
    and paid_again_from_cache
    else "OK"
)

repro.d/candidate_deep.py

#!/usr/local/bin/python3.15
import json
import os
import sys

collector, start_cookie, end_cookie = sys.argv[1:4]
sys.path.insert(0, os.path.dirname(collector))
from _py_info_collect import PythonInfoCollector

padding = json.loads("[" * 16000 + "]" * 16000)  # 16000 nested empty lists, 32000 bytes as JSON

answer = PythonInfoCollector().to_dict()
answer["padding"] = padding
sys.stdout.write(start_cookie[::-1] + json.dumps(answer) + end_cookie[::-1])

repro.d/candidate_flat.py

#!/usr/local/bin/python3.15
import json
import os
import sys

collector, start_cookie, end_cookie = sys.argv[1:4]
sys.path.insert(0, os.path.dirname(collector))
from _py_info_collect import PythonInfoCollector

padding = "x" * 31998  # 32000 bytes as JSON, with the quotes

answer = PythonInfoCollector().to_dict()
answer["padding"] = padding
sys.stdout.write(start_cookie[::-1] + json.dumps(answer) + end_cookie[::-1])

repro.d/find_python.py

import json
import resource
import sys
from pathlib import Path

spawned = []
sys.addaudithook(lambda event, args: spawned.append([str(a) for a in args[1]]) if event == "subprocess.Popen" else None)

from python_discovery import DiskCache, get_interpreter

path_dir, cache_root = sys.argv[1:3]
found = get_interpreter("python3.99", env={"PATH": path_dir}, cache=DiskCache(Path(cache_root)))

print(
    json.dumps({
        "spawned": spawned,
        "returned": found and str(found.executable),
        "peak_rss_kib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
    })
)

Output on 004294957ed227fe7e52b2864952ba09fd28b220:

flat candidate answers b'ssssssssssssssssssssssssssssssss{"platform": "linu' ... b'xxxxxxxxxxxxxxxx"}eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
deep candidate answers b'ssssssssssssssssssssssssssssssss{"platform": "linu' ... b']]]]]]]]]]]]]]]]]}eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
lookup, flat candidate, empty cache
  spawned  /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py 6e72b544b6804e51f2b1c801dc94217b 02926683ee5cc8d1ef39582d0af9020b
  spawned  /tmp/indent-amplification-4xcf64xl/flat/bin/python3.99 /src/src/python_discovery/_py_info_collect.py 0b045cea9ca57de9597f4cd3d7a9a0b0 d0d38bcb283538af4be2e1a6eedc4bfc
  returned None, caller peak RSS 24 MiB
lookup, deep candidate, empty cache
  spawned  /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py 71e3d4cf5ea058fc248e0fe6f1069e07 be43b1b3c9e53bbcfa174aff5b841a3c
  spawned  /tmp/indent-amplification-4xcf64xl/deep/bin/python3.99 /src/src/python_discovery/_py_info_collect.py 29b92460e18c3c1e7721b2612c96cba7 025d5fd3b38c40c1b5ff5f12bba7fa99
  returned None, caller peak RSS 1007 MiB
lookup again, deep candidate, the cache the first lookup left
  returned None, caller peak RSS 1001 MiB
CONTROL  flat: answer 34653 B -> cache entry 35268 B, caller peak RSS 24 MiB
OBSERVED deep: answer 34653 B -> cache entry 512131260 B, caller peak RSS 1007 MiB
OBSERVED deep, second lookup: candidate spawned False, caller peak RSS 1001 MiB
BUG

Expected: Cache serialization should avoid quadratic growth from nesting depth.

Impact

Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is unlikely: a candidate executable must return deeply nested JSON and the caller must use DiskCache. The reproduced effect stays within the caller's configured cache, search path, or interpreter.

Credits

Reported by Anthropic's OSS Scanner: ANT-2026-ZO7AGAS5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions