{"record":{"id":"725c63224e9c80f5","repo":"Stirling-Tools/Stirling-PDF","slug":"member-not-found-in-asset","errorCode":null,"errorMessage":"{member} not found in {asset}","messagePattern":"(.+?) not found in (.+?)","errorType":"exception","errorClass":"SystemExit","httpStatus":null,"severity":"error","filePath":"scripts/pre-commit/install_gitleaks.py","lineNumber":97,"sourceCode":"    asset = f\"gitleaks_{VERSION}_{key}.{suffix}\"\n    url = f\"https://github.com/gitleaks/gitleaks/releases/download/v{VERSION}/{asset}\"\n    print(f\"Downloading gitleaks {VERSION} ({asset})\", flush=True)\n\n    BIN.parent.mkdir(parents=True, exist_ok=True)\n    archive, _ = urllib.request.urlretrieve(url)\n    digest = hashlib.sha256(Path(archive).read_bytes()).hexdigest()\n    if digest != expected:\n        raise SystemExit(f\"gitleaks checksum mismatch: expected {expected}, got {digest}\")\n\n    member = \"gitleaks.exe\" if IS_WINDOWS else \"gitleaks\"\n    if suffix == \"zip\":\n        with zipfile.ZipFile(archive) as zf:\n            data = zf.read(member)\n    else:\n        with tarfile.open(archive) as tf:\n            extracted = tf.extractfile(member)\n            if extracted is None:\n                raise SystemExit(f\"{member} not found in {asset}\")\n            data = extracted.read()\n    BIN.write_bytes(data)\n    BIN.chmod(0o755)\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n","sourceCodeStart":79,"sourceCodeEnd":106,"githubUrl":"https://github.com/Stirling-Tools/Stirling-PDF/blob/9ef20dcab80b85041912f045e17a6aea1d08f969/scripts/pre-commit/install_gitleaks.py#L79-L106","documentation":"install_gitleaks.py downloads a pinned gitleaks release archive, verifies its SHA-256, then extracts the single binary named `gitleaks` (or `gitleaks.exe`) from it via tarfile/zipfile. This SystemExit fires when tarfile.extractfile(member) returns None on line 97 -- i.e. the verified archive contains no top-level entry with that exact name. Because the checksum already passed, the archive is intact and the correct version; the mismatch is purely the expected path inside the tarball. It almost always means gitleaks changed how it packages its release assets (e.g. nesting the binary in a directory) after a VERSION bump in this script.","triggerScenarios":"Bumping VERSION (install_gitleaks.py:26) without confirming the new release's internal tarball layout; gitleaks upstream starts nesting the binary under a top-level directory; running on a platform whose asset name resolves to a differently structured archive. Note the checksum on line 86 already validated the bytes, so this is a layout problem, not corruption.","commonSituations":"A maintainer updates the gitleaks pin to a new minor release and upstream packaging changed in the same release; CI runs `task pre-commit` for the first time on a newly added platform; the windows zip uses a different internal name than `gitleaks.exe`.","solutions":["Inspect the archive contents (`tar -tzf gitleaks_<ver>_<key>.tar.gz`) to find the actual binary path and update the `member` assignment at install_gitleaks.py:89, or resolve it dynamically from tf.getnames()","Confirm the VERSION and the matching SHA256[key] both come from the same release whose tarball layout you verified locally","If upstream packaging is unstable across releases, resolve the member by basename: `member = next(m for m in tf.getnames() if Path(m).name == wanted)` so directory-prefixed entries are tolerated"],"exampleFix":"# before (install_gitleaks.py:94-98)\nmember = \"gitleaks.exe\" if IS_WINDOWS else \"gitleaks\"\nwith tarfile.open(archive) as tf:\n    extracted = tf.extractfile(member)\n    if extracted is None:\n        raise SystemExit(f\"{member} not found in {asset}\")\n    data = extracted.read()\n\n# after\nwanted = \"gitleaks.exe\" if IS_WINDOWS else \"gitleaks\"\nwith tarfile.open(archive) as tf:\n    candidates = [m for m in tf.getnames() if Path(m).name == wanted]\n    if not candidates:\n        raise SystemExit(f\"{wanted} not found in {asset}; members: {tf.getnames()}\")\n    extracted = tf.extractfile(candidates[0])\n    data = extracted.read()","handlingStrategy":"validation","validationCode":"import tarfile, zipfile\nfrom pathlib import Path\n\ndef archive_has_member(archive: str, wanted: str) -> bool:\n    if archive.endswith(\".zip\"):\n        with zipfile.ZipFile(archive) as zf:\n            return any(Path(n).name == wanted for n in zf.namelist())\n    with tarfile.open(archive) as tf:\n        return any(Path(m).name == wanted for m in tf.getnames())","typeGuard":null,"tryCatchPattern":"try:\n    extracted = tf.extractfile(member)\n    if extracted is None:\n        # fall back to basename discovery rather than aborting\n        member = next(m for m in tf.getnames() if Path(m).name == wanted)\n        extracted = tf.extractfile(member)\n    data = extracted.read()\nexcept (StopIteration, KeyError) as exc:\n    raise SystemExit(f\"{wanted} not found in {asset}\") from exc","preventionTips":["When bumping VERSION, download the asset locally and run `tar -tzf` / unzip -l to confirm the binary's path before updating the pin","Resolve the member by basename from tf.getnames() instead of hard-coding an exact path","Treat any gitleaks minor bump as a packaging-change risk and verify on every platform in SHA256"],"tags":["gitleaks","pre-commit","archive","tarfile","release-asset"],"backgroundTag":null,"analyzedSha":"9ef20dcab80b85041912f045e17a6aea1d08f969","analyzedAt":"2026-08-13T22:11:39.827Z","schemaVersion":2},"datasetVersion":"2026-08-14T00:17:13.853Z"}