[GH-ISSUE #6471] [Bug] A corrupt Zip64 end record returns the unsupported-target error family #3880

Closed
opened 2026-10-31 00:44:53 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6471

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

inspect-apk-signature exits 1 for a ZIP with a corrupted Zip64 end-record signature, returning code/category unsupported_target and recovery to select a supported target format. Restoring that signature makes the same archive succeed. Classic ZIP tail damage instead returns invalid_request / invalid_input. The refusal is correct; the error family and recovery are wrong for damage to a supported structure.

Reproduction

From the repository root of the stated source baseline, use its pinned Node toolchain, locked dependencies and prepared compiled runtime, plus Python 3. The supplied runtime was used without rebuilding or installing dependencies. Run this block; it generates five inert ZIP/text fixtures, constructs an allowlisted HOME/XDG environment for each CLI child, records nonzero exits without aborting the loop, verifies the intact/restored ZIP entry and CRC with Python, and removes its temporary root even if an assertion fails. All fixture bytes are inline; no Android SDK, device, native signing tool or target execution is involved.

python3 - <<'PYTHON'
import io, json, os, pathlib, shutil, struct, subprocess, tempfile, zipfile, zlib
repo = pathlib.Path.cwd().resolve()
node = shutil.which('node')
assert node
print('node', subprocess.check_output([node, '--version'], text=True).strip())
with tempfile.TemporaryDirectory(prefix='zip64-review-', dir=repo) as temporary:
    root = pathlib.Path(temporary)
    env = {'PATH': os.path.dirname(node) + os.pathsep + '/usr/bin:/bin', 'NO_COLOR': '1'}
    for key, name in [('HOME', 'home'), ('XDG_CONFIG_HOME', 'config'),
                      ('XDG_DATA_HOME', 'data'), ('XDG_CACHE_HOME', 'cache'),
                      ('XDG_STATE_HOME', 'state'), ('XDG_RUNTIME_DIR', 'runtime'),
                      ('TMPDIR', 'temp')]:
        directory = root / name
        directory.mkdir(mode=0o700)
        env[key] = str(directory)
    name, data = b'entry.txt', b'inert'
    crc = zlib.crc32(data) & 0xffffffff
    local = struct.pack('<IHHHHHIIIHH', 0x04034b50, 45, 0, 0, 0, 33, crc,
                        len(data), len(data), len(name), 0) + name + data
    central = struct.pack('<IHHHHHHIIIHHHHHII', 0x02014b50, 45, 45, 0, 0, 0, 33,
                          crc, len(data), len(data), len(name), 0, 0, 0, 0, 0, 0) + name
    offset = len(local) + len(central)
    record = struct.pack('<IQHHIIQQQQ', 0x06064b50, 44, 45, 45, 0, 0, 1, 1,
                         len(central), len(local))
    locator = struct.pack('<IIQI', 0x07064b50, 0, offset, 1)
    end = struct.pack('<IHHHHIIH', 0x06054b50, 0, 0, 0xffff, 0xffff,
                      0xffffffff, 0xffffffff, 0)
    valid = local + central + record + locator + end
    corrupt = bytearray(valid)
    struct.pack_into('<I', corrupt, offset, 0x06064b51)
    restored = bytearray(corrupt)
    struct.pack_into('<I', restored, offset, 0x06064b50)
    assert bytes(restored) == valid
    assert sum(a != b for a, b in zip(valid, corrupt)) == 1
    classic = local + central + struct.pack('<IHHHHIIH', 0x06054b50, 0, 0, 1, 1,
                                            len(central), 0x7fffffff, 0)
    for label, content in [('valid', valid), ('corrupt', corrupt), ('restored', restored),
                           ('classic-damage', classic), ('not-zip', b'inert plain text with no ZIP records')]:
        path = root / (label + '.zip')
        path.write_bytes(content)
        if label in ('valid', 'restored'):
            with zipfile.ZipFile(io.BytesIO(content)) as archive:
                assert archive.testzip() is None
                assert archive.namelist() == ['entry.txt']
                assert archive.read('entry.txt') == data
                assert archive.infolist()[0].extract_version == 45
            print(label, 'Python zipfile: entry and CRC OK; Zip64 versions=45/45')
        result = subprocess.run([node, str(repo / 'scripts/rea.mjs'), 'inspect-apk-signature',
                                 str(path), '--format', 'json'], env=env, cwd=repo,
                                capture_output=True, text=True, timeout=45)
        payload = json.loads(result.stdout)
        print(label, json.dumps({'exit': result.returncode, 'stderr_bytes': len(result.stderr),
            'code': payload.get('code'), 'category': payload.get('category'),
            'reason': payload.get('details', {}).get('reason'),
            'issues': payload.get('details', {}).get('issues'),
            'remediation': payload.get('remediation'),
            'has_report': 'normalized_result' in payload}, sort_keys=True))
        expected = {'valid': (0, None, None), 'restored': (0, None, None),
                    'corrupt': (1, 'unsupported_target', 'unsupported_target'),
                    'classic-damage': (1, 'invalid_request', 'invalid_input'),
                    'not-zip': (1, 'unsupported_target', 'unsupported_target')}[label]
        assert (result.returncode, payload.get('code'), payload.get('category')) == expected
        assert not result.stderr
print('temporary root removed:', not root.exists())
PYTHON

The corrected fixture reproduced in both complete CLI runs. Valid and restored are independently readable ZIPs with version 45 in both Zip64 version fields; this is not an Android-installability or cryptographic-signature test.

Expected behavior

The operation accepts local ZIP files and follows Zip64 end records. Damage to that supported ZIP structure should be refused as an input error, preserving the malformed-record cause, rather than reporting an unsupported target format. For this fixture that means code invalid_request, category invalid_input, and input-oriented recovery. The documented damaged-ZIP distinction and the error algebra's unsupported-format definition apply: the intact Zip64 layout works, and corrupting its required signature damages that layout without introducing a new format. This report asks for the existing refusal contract, not successful parsing of damaged input or broader Zip64 support.

Impact and scope

P2: an agent or program branching on the typed code/category receives unsupported-format recovery for damaged ZIP input. The precise malformed-record cause survives in details.reason, so parsing that detail is an available workaround. No successful signing fact is wrong, and no security boundary crossing was observed. Only the corrupt-signature branch was exercised; real-world frequency, missing-locator behavior, unsafe-range behavior and earlier known-good source are NOT_ENOUGH_INFO.

REA version

Source baseline e32a0494d4 on main; package and CLI report 6.7.0. Used the supplied prepared compiled runtime. The published rea-agents-6.7.0 tag lacks this operation and its implementation/docs by source inspection; a published-release runtime was not tested. This report concerns post-release source.

Runtime and platform

Node.js v24.18.0 (checkout pin), npm 11.16.0, Python 3; macOS 27.0.1 arm64. No dependency install or build was run during verification.

Target and artifact context

Synthetic single-disk Zip64 ZIP with one inert entry.txt member, valid CRC, a 56-byte Zip64 end record with versions 45/45, locator and saturated classic end fields. Corrupt differs from valid at one byte inside the four-byte Zip64 end-record signature. Restored reconstructs valid exactly. Controls include an out-of-file classic directory offset and non-ZIP plain text.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Two complete CLI runs reproduced the classification for the corrected fixtures. Each intact/restored ZIP exited 0 with Evidence; corrupt, classic-damage and non-ZIP fixtures exited 1. Every CLI stderr was empty. The corrupt and intact inputs differ at one byte inside the four-byte Zip64 end-record signature; restoring that signature reconstructs the intact bytes exactly. The independently readable controls set the Zip64 version-made-by and version-needed fields to 45 (ZIP 4.5).

valid:          exit=0, code=null,               category=null,               report=true
corrupt:        exit=1, code=unsupported_target, category=unsupported_target, report=false
restored:       exit=0, code=null,               category=null,               report=true
classic-damage: exit=1, code=invalid_request,    category=invalid_input,      report=false
not-zip:        exit=1, code=unsupported_target, category=unsupported_target, report=false

The corrupt diagnostic retains details.reason: "This archive uses a Zip64 structure this reader does not decode (malformed Zip64 EOCD record)." and remediation.action: "Select a target supported by this operation or choose an operation supporting the reported target format.". Classic damage instead gives an input issue naming a central-directory extent past the ZIP end records. The plain-text case says it is not a ZIP/APK. Both valid controls pass Python zipfile.testzip() and return the original entry.txt bytes.

Contract breadcrumbs: docs/apk-signature.md:237-247 defines damaged-input versus non-ZIP refusal; docs/specs/apk-signature/spec.md:41-42 specifies Zip64 traversal. src/domain/analysisErrorCore.ts:8-9 defines unsupported target in terms of formats outside support. Input and unsupported failures project to different code/category pairs in src/domain/analysisErrorProjection.ts:256-260 and src/domain/analysisErrorPresentation.ts:252-256.

An additional executed stdio MCP check initialized protocol 2025-06-18, listed the tool, and called inspect_apk_signature with each fixture's absolute path once. For the three refused fixtures it returned the same error families, using isError: true, no structured content, and text JSON containing the diagnostic. Valid/restored returned structured Evidence. The primary reproduction above exercises the CLI. Existing focused source tests passed: 38 tests in the ZIP-tail and APK-signature-provider test files, with one worker.

Typed outcome or recovery expected

For the corrupt signature, keep refusal with code invalid_request, category invalid_input, a cause naming the damaged Zip64 end record, and input-oriented recovery. Preserve successful intact/restored inspection and the distinct genuinely non-ZIP refusal.

Analysis or suspected cause

Source-derived: src/domain/apkSignature/apkSigningBlock.ts:149-157 tags the invalid Zip64 signature zip64-unsupported; src/apkSignature/apkSignatureFailures.ts:46-51 translates that tag to AnalysisUnsupportedTargetError. The public adapters expose that translation. No repair was attempted.

Additional context

Proposed, not executed against a fix: rerun the inline reproduction with the corrupt case expected to remain exit 1 but return invalid_request / invalid_input, name the malformed Zip64 end record, and offer input-oriented recovery. Keep valid/restored at exit 0 with complete Evidence, classic damage at invalid_request / invalid_input, and genuinely non-ZIP text at unsupported_target. Through MCP, retain isError: true and the canonical input-error diagnostic for the corrupt case. Changing only the wording while retaining the wrong machine-readable code/category does not restore this contract.

Open and closed searches found no matching report. #6439 concerns issuer encoding and #6440 concerns signer comparison with an empty v2 scheme. #3953 concerns a separate artifact-reader missing-header failure, and #5836 concerns resource-boundary diagnostics. Those restoration signals are independent. Feature issue #4537 requires malformed archives to remain refused, which this report preserves.

Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6471 ## Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ## Area Evidence, artifacts, or reconstruction ## Observed behavior `inspect-apk-signature` exits 1 for a ZIP with a corrupted Zip64 end-record signature, returning code/category `unsupported_target` and recovery to select a supported target format. Restoring that signature makes the same archive succeed. Classic ZIP tail damage instead returns `invalid_request` / `invalid_input`. The refusal is correct; the error family and recovery are wrong for damage to a supported structure. ## Reproduction From the repository root of the stated source baseline, use its pinned Node toolchain, locked dependencies and prepared compiled runtime, plus Python 3. The supplied runtime was used without rebuilding or installing dependencies. Run this block; it generates five inert ZIP/text fixtures, constructs an allowlisted HOME/XDG environment for each CLI child, records nonzero exits without aborting the loop, verifies the intact/restored ZIP entry and CRC with Python, and removes its temporary root even if an assertion fails. All fixture bytes are inline; no Android SDK, device, native signing tool or target execution is involved. ```sh python3 - <<'PYTHON' import io, json, os, pathlib, shutil, struct, subprocess, tempfile, zipfile, zlib repo = pathlib.Path.cwd().resolve() node = shutil.which('node') assert node print('node', subprocess.check_output([node, '--version'], text=True).strip()) with tempfile.TemporaryDirectory(prefix='zip64-review-', dir=repo) as temporary: root = pathlib.Path(temporary) env = {'PATH': os.path.dirname(node) + os.pathsep + '/usr/bin:/bin', 'NO_COLOR': '1'} for key, name in [('HOME', 'home'), ('XDG_CONFIG_HOME', 'config'), ('XDG_DATA_HOME', 'data'), ('XDG_CACHE_HOME', 'cache'), ('XDG_STATE_HOME', 'state'), ('XDG_RUNTIME_DIR', 'runtime'), ('TMPDIR', 'temp')]: directory = root / name directory.mkdir(mode=0o700) env[key] = str(directory) name, data = b'entry.txt', b'inert' crc = zlib.crc32(data) & 0xffffffff local = struct.pack('<IHHHHHIIIHH', 0x04034b50, 45, 0, 0, 0, 33, crc, len(data), len(data), len(name), 0) + name + data central = struct.pack('<IHHHHHHIIIHHHHHII', 0x02014b50, 45, 45, 0, 0, 0, 33, crc, len(data), len(data), len(name), 0, 0, 0, 0, 0, 0) + name offset = len(local) + len(central) record = struct.pack('<IQHHIIQQQQ', 0x06064b50, 44, 45, 45, 0, 0, 1, 1, len(central), len(local)) locator = struct.pack('<IIQI', 0x07064b50, 0, offset, 1) end = struct.pack('<IHHHHIIH', 0x06054b50, 0, 0, 0xffff, 0xffff, 0xffffffff, 0xffffffff, 0) valid = local + central + record + locator + end corrupt = bytearray(valid) struct.pack_into('<I', corrupt, offset, 0x06064b51) restored = bytearray(corrupt) struct.pack_into('<I', restored, offset, 0x06064b50) assert bytes(restored) == valid assert sum(a != b for a, b in zip(valid, corrupt)) == 1 classic = local + central + struct.pack('<IHHHHIIH', 0x06054b50, 0, 0, 1, 1, len(central), 0x7fffffff, 0) for label, content in [('valid', valid), ('corrupt', corrupt), ('restored', restored), ('classic-damage', classic), ('not-zip', b'inert plain text with no ZIP records')]: path = root / (label + '.zip') path.write_bytes(content) if label in ('valid', 'restored'): with zipfile.ZipFile(io.BytesIO(content)) as archive: assert archive.testzip() is None assert archive.namelist() == ['entry.txt'] assert archive.read('entry.txt') == data assert archive.infolist()[0].extract_version == 45 print(label, 'Python zipfile: entry and CRC OK; Zip64 versions=45/45') result = subprocess.run([node, str(repo / 'scripts/rea.mjs'), 'inspect-apk-signature', str(path), '--format', 'json'], env=env, cwd=repo, capture_output=True, text=True, timeout=45) payload = json.loads(result.stdout) print(label, json.dumps({'exit': result.returncode, 'stderr_bytes': len(result.stderr), 'code': payload.get('code'), 'category': payload.get('category'), 'reason': payload.get('details', {}).get('reason'), 'issues': payload.get('details', {}).get('issues'), 'remediation': payload.get('remediation'), 'has_report': 'normalized_result' in payload}, sort_keys=True)) expected = {'valid': (0, None, None), 'restored': (0, None, None), 'corrupt': (1, 'unsupported_target', 'unsupported_target'), 'classic-damage': (1, 'invalid_request', 'invalid_input'), 'not-zip': (1, 'unsupported_target', 'unsupported_target')}[label] assert (result.returncode, payload.get('code'), payload.get('category')) == expected assert not result.stderr print('temporary root removed:', not root.exists()) PYTHON ``` The corrected fixture reproduced in both complete CLI runs. Valid and restored are independently readable ZIPs with version 45 in both Zip64 version fields; this is not an Android-installability or cryptographic-signature test. ## Expected behavior The operation accepts local ZIP files and follows Zip64 end records. Damage to that supported ZIP structure should be refused as an input error, preserving the malformed-record cause, rather than reporting an unsupported target format. For this fixture that means code `invalid_request`, category `invalid_input`, and input-oriented recovery. The documented damaged-ZIP distinction and the error algebra's unsupported-format definition apply: the intact Zip64 layout works, and corrupting its required signature damages that layout without introducing a new format. This report asks for the existing refusal contract, not successful parsing of damaged input or broader Zip64 support. ## Impact and scope P2: an agent or program branching on the typed code/category receives unsupported-format recovery for damaged ZIP input. The precise malformed-record cause survives in `details.reason`, so parsing that detail is an available workaround. No successful signing fact is wrong, and no security boundary crossing was observed. Only the corrupt-signature branch was exercised; real-world frequency, missing-locator behavior, unsafe-range behavior and earlier known-good source are NOT_ENOUGH_INFO. ## REA version Source baseline e32a0494d42dedd5e79ca341d20c53f07479d9bb on `main`; package and CLI report 6.7.0. Used the supplied prepared compiled runtime. The published `rea-agents-6.7.0` tag lacks this operation and its implementation/docs by source inspection; a published-release runtime was not tested. This report concerns post-release source. ## Runtime and platform Node.js v24.18.0 (checkout pin), npm 11.16.0, Python 3; macOS 27.0.1 arm64. No dependency install or build was run during verification. ## Target and artifact context Synthetic single-disk Zip64 ZIP with one inert `entry.txt` member, valid CRC, a 56-byte Zip64 end record with versions 45/45, locator and saturated classic end fields. Corrupt differs from valid at one byte inside the four-byte Zip64 end-record signature. Restored reconstructs valid exactly. Controls include an out-of-file classic directory offset and non-ZIP plain text. ## Interface CLI ## Earliest failure stage Native analysis or reconstruction ## Relevant output or evidence Two complete CLI runs reproduced the classification for the corrected fixtures. Each intact/restored ZIP exited 0 with Evidence; corrupt, classic-damage and non-ZIP fixtures exited 1. Every CLI stderr was empty. The corrupt and intact inputs differ at one byte inside the four-byte Zip64 end-record signature; restoring that signature reconstructs the intact bytes exactly. The independently readable controls set the Zip64 version-made-by and version-needed fields to 45 (ZIP 4.5). ```text valid: exit=0, code=null, category=null, report=true corrupt: exit=1, code=unsupported_target, category=unsupported_target, report=false restored: exit=0, code=null, category=null, report=true classic-damage: exit=1, code=invalid_request, category=invalid_input, report=false not-zip: exit=1, code=unsupported_target, category=unsupported_target, report=false ``` The corrupt diagnostic retains `details.reason: "This archive uses a Zip64 structure this reader does not decode (malformed Zip64 EOCD record)."` and `remediation.action: "Select a target supported by this operation or choose an operation supporting the reported target format."`. Classic damage instead gives an input issue naming a central-directory extent past the ZIP end records. The plain-text case says it is not a ZIP/APK. Both valid controls pass Python `zipfile.testzip()` and return the original `entry.txt` bytes. Contract breadcrumbs: `docs/apk-signature.md:237-247` defines damaged-input versus non-ZIP refusal; `docs/specs/apk-signature/spec.md:41-42` specifies Zip64 traversal. `src/domain/analysisErrorCore.ts:8-9` defines unsupported target in terms of formats outside support. Input and unsupported failures project to different code/category pairs in `src/domain/analysisErrorProjection.ts:256-260` and `src/domain/analysisErrorPresentation.ts:252-256`. An additional executed stdio MCP check initialized protocol `2025-06-18`, listed the tool, and called `inspect_apk_signature` with each fixture's absolute path once. For the three refused fixtures it returned the same error families, using `isError: true`, no structured content, and text JSON containing the diagnostic. Valid/restored returned structured Evidence. The primary reproduction above exercises the CLI. Existing focused source tests passed: 38 tests in the ZIP-tail and APK-signature-provider test files, with one worker. ## Typed outcome or recovery expected For the corrupt signature, keep refusal with code `invalid_request`, category `invalid_input`, a cause naming the damaged Zip64 end record, and input-oriented recovery. Preserve successful intact/restored inspection and the distinct genuinely non-ZIP refusal. ## Analysis or suspected cause Source-derived: `src/domain/apkSignature/apkSigningBlock.ts:149-157` tags the invalid Zip64 signature `zip64-unsupported`; `src/apkSignature/apkSignatureFailures.ts:46-51` translates that tag to `AnalysisUnsupportedTargetError`. The public adapters expose that translation. No repair was attempted. ## Additional context Proposed, not executed against a fix: rerun the inline reproduction with the corrupt case expected to remain exit 1 but return `invalid_request` / `invalid_input`, name the malformed Zip64 end record, and offer input-oriented recovery. Keep valid/restored at exit 0 with complete Evidence, classic damage at `invalid_request` / `invalid_input`, and genuinely non-ZIP text at `unsupported_target`. Through MCP, retain `isError: true` and the canonical input-error diagnostic for the corrupt case. Changing only the wording while retaining the wrong machine-readable code/category does not restore this contract. Open and closed searches found no matching report. #6439 concerns issuer encoding and #6440 concerns signer comparison with an empty v2 scheme. #3953 concerns a separate artifact-reader missing-header failure, and #5836 concerns resource-boundary diagnostics. Those restoration signals are independent. Feature issue #4537 requires malformed archives to remain refused, which this report preserves.
Author
Owner

@iamdgarcia commented on GitHub (Oct 23, 2026):

Confirmed and covered with a fix (fix/zip64-eocdr-family on my fork, happy to open the PR).

The three Zip64 branches in centralDirectoryOffset (apkSigningBlock.ts) all return zip64-unsupported, but two of them are damage to a structure the reader supports, not a structure it doesn't:

  1. locator present, record missing/damaged (recordOffset null, out of range, or signature mismatch): the locator's existence declares a supported layout; inconsistent bytes are malformed-zip territory. Now returns { kind: "malformed-zip", offset: locator }.
  2. Zip64 EOCD record present, u64 fields out of safe range: same reasoning, now { kind: "malformed-zip", offset: recordOffset + 32 }.

Both therefore project to the invalid_request family with the inspect_input_contract recovery, matching the classic tail-damage cases instead of telling the user to select a different target format.

The no-locator case (saturated fields, no locator at all) deliberately stays zip64-unsupported: whether the writer omitted the locator or corruption removed it is undecidable from the bytes alone, and the recovery ("this reader doesn't decode Zip64") is accurate for the writer-omitted variant.

Tests: two parser-level cases (damaged record signature in place; locator pointing past the record) plus an end-to-end provider case asserting the AnalysisInputError surface for a corrupted-record APK. Area suite: 135 passed.

<!-- gh-comment-id:6358405207 --> @iamdgarcia commented on GitHub (Oct 23, 2026): Confirmed and covered with a fix (`fix/zip64-eocdr-family` on my fork, happy to open the PR). The three Zip64 branches in `centralDirectoryOffset` (`apkSigningBlock.ts`) all return `zip64-unsupported`, but two of them are damage to a structure the reader supports, not a structure it doesn't: 1. **locator present, record missing/damaged** (`recordOffset` null, out of range, or signature mismatch): the locator's existence declares a supported layout; inconsistent bytes are `malformed-zip` territory. Now returns `{ kind: "malformed-zip", offset: locator }`. 2. **Zip64 EOCD record present, u64 fields out of safe range**: same reasoning, now `{ kind: "malformed-zip", offset: recordOffset + 32 }`. Both therefore project to the `invalid_request` family with the `inspect_input_contract` recovery, matching the classic tail-damage cases instead of telling the user to select a different target format. The no-locator case (saturated fields, no locator at all) deliberately stays `zip64-unsupported`: whether the writer omitted the locator or corruption removed it is undecidable from the bytes alone, and the recovery ("this reader doesn't decode Zip64") is accurate for the writer-omitted variant. Tests: two parser-level cases (damaged record signature in place; locator pointing past the record) plus an end-to-end provider case asserting the `AnalysisInputError` surface for a corrupted-record APK. Area suite: 135 passed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3880
No description provided.