[GH-ISSUE #7642] [Bug] APK signing-block pair refusal advertises a retry that cannot succeed #4569

Closed
opened 2026-10-31 00:50:51 +00:00 by bsakaguchi · 0 comments
Owner

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

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

With a synthetic APK-shaped ZIP containing 4097 minimal signing-block pairs, node scripts/rea.mjs inspect-apk-signature --format json <fixture>/pairs-4097.apk exits 1 and correctly reports a 4096-pair limit. However, its JSON response sets retryable: true and directs the caller to wait for an active analysis to complete. An immediate unchanged retry returns identical stdout. <fixture> denotes the generated directory, not a literal argument.

An otherwise equivalent one-pair archive succeeds. The problem is the retry classification and recovery action on the intentional refusal, not a request to accept more pairs.

Reproduction

  1. Use the baseline below with its prepared built CLI and Node.js v24.22.1 on PATH. Python 3 is the only fixture-building dependency.
  2. Run this script at the checkout root. It hand-builds both archives, isolates HOME/XDG and temporary storage, places blocking npx/npm/bun fixtures first on PATH, invokes the public CLI, and deletes its temporary environment. Optional VERIFY_WORK_ROOT selects an existing disposable parent directory.
python3 - <<'PYTHON'
import json, os, pathlib, shutil, struct, subprocess, tempfile, zlib

root = pathlib.Path.cwd()
node = pathlib.Path(shutil.which("node")).resolve()
with tempfile.TemporaryDirectory(prefix="rea-apk-pairs-",
                                 dir=os.environ.get("VERIFY_WORK_ROOT")) as work:
    work = pathlib.Path(work)
    env = {"PATH": str(work / "bin") + os.pathsep + str(node.parent)
                   + os.pathsep + os.defpath, "LANG": "C", "NO_COLOR": "1"}
    for key, part in [("HOME", "home"), ("XDG_CONFIG_HOME", "config"),
                      ("XDG_CACHE_HOME", "cache"), ("XDG_DATA_HOME", "data"),
                      ("XDG_STATE_HOME", "state"), ("XDG_RUNTIME_DIR", "runtime"),
                      ("TMPDIR", "temp"), ("npm_config_prefix", "npm-prefix")]:
        (work / part).mkdir()
        env[key] = str(work / part)
    (work / "bin").mkdir()
    for runner in ["npx", "npm", "bun"]:
        stub = work / "bin" / runner
        stub.write_text('#!/bin/sh\nprintf "called\\n" >> "$HOME/runner-calls"\nexit 97\n')
        stub.chmod(0o755)

    name, payload = b"AndroidManifest.xml", bytes([3, 0, 8, 0])
    crc = zlib.crc32(payload)
    local = struct.pack("<IHHHHHIIIHH", 0x04034b50, 20, 0, 0, 0, 0,
                        crc, len(payload), len(payload), len(name), 0) + name + payload
    central = struct.pack("<IHHHHHHIIIHHHHHII", 0x02014b50, 20, 20, 0, 0, 0, 0,
                          crc, len(payload), len(payload), len(name), 0, 0, 0, 0, 0, 0) + name
    for count in [1, 4097]:
        pairs = struct.pack("<QI", 4, 0xffffffff) * count
        size = len(pairs) + 24
        block = struct.pack("<Q", size) + pairs + struct.pack("<Q", size) + b"APK Sig Block 42"
        end = struct.pack("<IHHHHIIH", 0x06054b50, 0, 0, 1, 1,
                          len(central), len(local) + len(block), 0)
        (work / f"pairs-{count}.apk").write_bytes(local + block + central + end)

    def run(*args):
        result = subprocess.run([str(node), "scripts/rea.mjs", *args], cwd=root,
                                env=env, capture_output=True, text=True, timeout=60)
        assert not result.stderr, result.stderr
        return result

    print("version:", run("--version").stdout.strip())
    print("platform:", subprocess.check_output(
        [str(node), "-p", 'JSON.stringify({node:process.version,platform:process.platform,arch:process.arch,os:require("node:os").release()})'],
        env=env, text=True).strip())
    failures = []
    for count in [1, 4097, 4097]:
        result = run("inspect-apk-signature", "--format", "json", str(work / f"pairs-{count}.apk"))
        value = json.loads(result.stdout)
        assert result.returncode == (0 if count == 1 else 1)
        if count == 1:
            print("control:", json.dumps({"pairs": count, "exit": result.returncode,
                  "bytes": value["normalized_result"]["artifact"]["bytes"],
                  "strongest_scheme": value["normalized_result"]["posture"]["strongest_scheme"],
                  "runtime_execution": value["normalized_result"]["runtime_execution"]}))
        else:
            failures.append(result.stdout)
            print("over-limit:", result.stdout.strip())
    assert failures[0] == failures[1]
    assert not (work / "home" / "runner-calls").exists()
    print("unchanged refusals identical; package-runner calls: 0")
print("temporary fixture environment removed")
PYTHON
  1. The control exits 0. Both over-limit calls exit 1 with the JSON below and empty stderr; their stdout is identical. No package runner is invoked.

The exact embedded procedure was executed once by the independent verifier. A separate initial construction and CLI replay also produced two identical refusals and a successful control: four failing over-limit attempts and two successful controls in total.

Expected behavior

Keep refusing a signing block above the supported 4096-pair allowance and preserve the reported maximum. Mark this deterministic refusal non-retryable. Recovery should identify the fixed pair allowance, say that the current release offers no runtime override, and explain that waiting for an active inspection or repeating the unchanged request cannot admit these bytes.

The documented fixed-ceiling recovery contract requires naming the effective limit and ineffective adjustments. The error contract defines retryability by whether the same request can succeed. The exact contracts and fixed-count implementation are identified in Evidence below. This expectation does not require unlimited signing-block parsing or a particular replacement resource enum.

Impact and scope

The CLI returns a false machine-readable retry signal for an input that is already over a fixed parser count. A caller that uses this field receives the wrong recovery decision, alongside inapplicable wait advice. The refusal and numeric limit remain accurate. A caller can work around the diagnostic by reading the message and max_signing_block_pairs, recognizing the unsupported count, and suppressing unchanged retries.

Only this signing-block pair guard was exercised. No automated retry loop, data loss, incorrect successful signing result, or security-boundary crossing was observed. The control establishes accepted framing below the limit; it does not establish successful inspection of the original over-limit archive. Real-world frequency is unknown.

REA version

Exact output of node scripts/rea.mjs --version:

6.8.0

Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a.

Runtime and platform

Node.js v24.22.1; macOS, Darwin kernel 27.0.0, arm64. The built-in APK signature reader runs in process. No external analysis engine, device or GUI was used.

Target and artifact context

The 49,336-byte refusal fixture has one stored AndroidManifest.xml ZIP entry, an APK Signing Block with 4097 length-framed empty values under unknown ID 0xffffffff, a complete central directory, and a matching end record with the adjusted directory offset. The 184-byte control has one such pair. These are parser fixtures, not cryptographically signed applications. Neither file is executed.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

Both over-limit calls in the embedded procedure returned this JSON, exit 1 and empty stderr:

{"code":"resource_constraint","category":"resource_constraint","message":"APK Signing Block holds more signing-block-pairs than the 4096 limit.","retryable":true,"remediation":{"action":"Wait for an active analysis to complete, then retry within the reported admission limits."},"details":{"operation":"inspect_apk_signature","resource":"capacity","reason":"APK Signing Block holds more signing-block-pairs than the 4096 limit.","reported_limits":{"max_signing_block_pairs":4096}}}

The control returned exit 0 and empty stderr, with normalized_result.artifact.bytes: 184, posture.strongest_scheme: "none" and runtime_execution: "not-performed". All package-runner fixtures remained unused.

Source checks at the tested baseline:

  • docs/apk-signature.md:3-11,221-240 describes the explicit local APK/ZIP/AAB workflow, its in-process reader, occupied scan queue and bounded parsing/report work.
  • docs/design/limits-and-recovery.md:250-262 requires truthful fixed-ceiling recovery, including the effective allowance and absent runtime override.
  • src/domain/analysisErrorCore.ts:37-48 distinguishes fixed allowances from host resources and defines same-request retryability. src/domain/fixedAllowanceRemediation.ts:17-28,49-56 supplies non-retryable fixed-count recovery that excludes waiting as a remedy.
  • src/domain/apkSignature/apkSigningBlock.ts:30-31,309-325 fixes the pair count at 4096 and emits the budget refusal. src/apkSignature/ApkSignatureProvider.ts:404-411 reaches that framing step through the production reader and maps its error before scheme decoding.
  • src/apkSignature/apkSignatureFailures.ts:63-69 constructs this capacity error without fixed-allowance or retryability metadata. src/domain/analysisErrorProjection.ts:140-147 then defaults it to retryable, and src/domain/analysisErrorPresentation.ts:88-98 selects the observed wait sentence.

Evidence provenance: independently observed public CLI results, plus the separately identified source-derived explanation. No repaired runtime was tested.

Typed outcome or recovery expected

For the same bytes, preserve the typed resource refusal and 4096-pair maximum, return retryable: false, and describe the fixed pair allowance and ineffective unchanged retry. Preserve successful below-limit inspection and genuinely temporary occupied-capacity retries. These are proposed restoration checks, not claims that a fix has been applied.

Analysis or suspected cause

Source-derived explanation: the APK framing producer knows that a fixed count caused the refusal but maps it to the generic capacity error without carrying that distinction. Shared projection consequently interprets it as occupied capacity. The existing fixed-allowance metadata can express deterministic refusal separately from temporary occupancy; the reproduction does not justify globally disabling capacity retries or raising the pair maximum.

Additional context

Independent searches covered open and closed issues, all PR states including merged PRs, and issue comments. No matching APK pair-count report was found.

Issue #5556 and merged PR #5633 establish the fixed-allowance recovery distinction. That PR intentionally preserves generic recovery for genuine resource failures and does not modify the APK signature reader. #5506, #5504 and #6035 cover WASM comparison, Rust hash counts and native matching respectively. Their caller-specific fixes leave this APK response unchanged. This report does not claim those repaired callers regressed.

APK-related #7433, #7411 and #7316 concern ZIP disk metadata, an omitted comparison limitation and tool discovery, with different restoration conditions. #5808 is a general recovery audit rather than a report of this trigger.

MCP, an installed package, real signed APKs and other platforms were not exercised. No known-good revision or first-bad revision was established.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The independently executed CLI misclassifies a deterministic refusal as retryable in a machine-readable field and prescribes an ineffective wait. This is bounded to the exercised fixed pair-count guard; the refusal and maximum remain correct.
  • Workaround status: available
  • Workaround basis: The returned message and max_signing_block_pairs: 4096 identify the actual fixed count. Callers can suppress unchanged retries using those retained facts. This workaround does not make the intentionally unsupported over-limit input inspectable.
Originally created by @coygeek on GitHub (Oct 28, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7642 ### 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 With a synthetic APK-shaped ZIP containing 4097 minimal signing-block pairs, `node scripts/rea.mjs inspect-apk-signature --format json <fixture>/pairs-4097.apk` exits 1 and correctly reports a 4096-pair limit. However, its JSON response sets `retryable: true` and directs the caller to wait for an active analysis to complete. An immediate unchanged retry returns identical stdout. `<fixture>` denotes the generated directory, not a literal argument. An otherwise equivalent one-pair archive succeeds. The problem is the retry classification and recovery action on the intentional refusal, not a request to accept more pairs. ### Reproduction 1. Use the baseline below with its prepared built CLI and Node.js v24.22.1 on PATH. Python 3 is the only fixture-building dependency. 2. Run this script at the checkout root. It hand-builds both archives, isolates HOME/XDG and temporary storage, places blocking npx/npm/bun fixtures first on PATH, invokes the public CLI, and deletes its temporary environment. Optional `VERIFY_WORK_ROOT` selects an existing disposable parent directory. ```sh python3 - <<'PYTHON' import json, os, pathlib, shutil, struct, subprocess, tempfile, zlib root = pathlib.Path.cwd() node = pathlib.Path(shutil.which("node")).resolve() with tempfile.TemporaryDirectory(prefix="rea-apk-pairs-", dir=os.environ.get("VERIFY_WORK_ROOT")) as work: work = pathlib.Path(work) env = {"PATH": str(work / "bin") + os.pathsep + str(node.parent) + os.pathsep + os.defpath, "LANG": "C", "NO_COLOR": "1"} for key, part in [("HOME", "home"), ("XDG_CONFIG_HOME", "config"), ("XDG_CACHE_HOME", "cache"), ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state"), ("XDG_RUNTIME_DIR", "runtime"), ("TMPDIR", "temp"), ("npm_config_prefix", "npm-prefix")]: (work / part).mkdir() env[key] = str(work / part) (work / "bin").mkdir() for runner in ["npx", "npm", "bun"]: stub = work / "bin" / runner stub.write_text('#!/bin/sh\nprintf "called\\n" >> "$HOME/runner-calls"\nexit 97\n') stub.chmod(0o755) name, payload = b"AndroidManifest.xml", bytes([3, 0, 8, 0]) crc = zlib.crc32(payload) local = struct.pack("<IHHHHHIIIHH", 0x04034b50, 20, 0, 0, 0, 0, crc, len(payload), len(payload), len(name), 0) + name + payload central = struct.pack("<IHHHHHHIIIHHHHHII", 0x02014b50, 20, 20, 0, 0, 0, 0, crc, len(payload), len(payload), len(name), 0, 0, 0, 0, 0, 0) + name for count in [1, 4097]: pairs = struct.pack("<QI", 4, 0xffffffff) * count size = len(pairs) + 24 block = struct.pack("<Q", size) + pairs + struct.pack("<Q", size) + b"APK Sig Block 42" end = struct.pack("<IHHHHIIH", 0x06054b50, 0, 0, 1, 1, len(central), len(local) + len(block), 0) (work / f"pairs-{count}.apk").write_bytes(local + block + central + end) def run(*args): result = subprocess.run([str(node), "scripts/rea.mjs", *args], cwd=root, env=env, capture_output=True, text=True, timeout=60) assert not result.stderr, result.stderr return result print("version:", run("--version").stdout.strip()) print("platform:", subprocess.check_output( [str(node), "-p", 'JSON.stringify({node:process.version,platform:process.platform,arch:process.arch,os:require("node:os").release()})'], env=env, text=True).strip()) failures = [] for count in [1, 4097, 4097]: result = run("inspect-apk-signature", "--format", "json", str(work / f"pairs-{count}.apk")) value = json.loads(result.stdout) assert result.returncode == (0 if count == 1 else 1) if count == 1: print("control:", json.dumps({"pairs": count, "exit": result.returncode, "bytes": value["normalized_result"]["artifact"]["bytes"], "strongest_scheme": value["normalized_result"]["posture"]["strongest_scheme"], "runtime_execution": value["normalized_result"]["runtime_execution"]})) else: failures.append(result.stdout) print("over-limit:", result.stdout.strip()) assert failures[0] == failures[1] assert not (work / "home" / "runner-calls").exists() print("unchanged refusals identical; package-runner calls: 0") print("temporary fixture environment removed") PYTHON ``` 3. The control exits 0. Both over-limit calls exit 1 with the JSON below and empty stderr; their stdout is identical. No package runner is invoked. The exact embedded procedure was executed once by the independent verifier. A separate initial construction and CLI replay also produced two identical refusals and a successful control: four failing over-limit attempts and two successful controls in total. ### Expected behavior Keep refusing a signing block above the supported 4096-pair allowance and preserve the reported maximum. Mark this deterministic refusal non-retryable. Recovery should identify the fixed pair allowance, say that the current release offers no runtime override, and explain that waiting for an active inspection or repeating the unchanged request cannot admit these bytes. The documented fixed-ceiling recovery contract requires naming the effective limit and ineffective adjustments. The error contract defines retryability by whether the same request can succeed. The exact contracts and fixed-count implementation are identified in Evidence below. This expectation does not require unlimited signing-block parsing or a particular replacement resource enum. ### Impact and scope The CLI returns a false machine-readable retry signal for an input that is already over a fixed parser count. A caller that uses this field receives the wrong recovery decision, alongside inapplicable wait advice. The refusal and numeric limit remain accurate. A caller can work around the diagnostic by reading the message and `max_signing_block_pairs`, recognizing the unsupported count, and suppressing unchanged retries. Only this signing-block pair guard was exercised. No automated retry loop, data loss, incorrect successful signing result, or security-boundary crossing was observed. The control establishes accepted framing below the limit; it does not establish successful inspection of the original over-limit archive. Real-world frequency is unknown. ### REA version Exact output of `node scripts/rea.mjs --version`: ```text 6.8.0 ``` Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. ### Runtime and platform Node.js v24.22.1; macOS, Darwin kernel 27.0.0, arm64. The built-in APK signature reader runs in process. No external analysis engine, device or GUI was used. ### Target and artifact context The 49,336-byte refusal fixture has one stored `AndroidManifest.xml` ZIP entry, an APK Signing Block with 4097 length-framed empty values under unknown ID `0xffffffff`, a complete central directory, and a matching end record with the adjusted directory offset. The 184-byte control has one such pair. These are parser fixtures, not cryptographically signed applications. Neither file is executed. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence Both over-limit calls in the embedded procedure returned this JSON, exit 1 and empty stderr: ```json {"code":"resource_constraint","category":"resource_constraint","message":"APK Signing Block holds more signing-block-pairs than the 4096 limit.","retryable":true,"remediation":{"action":"Wait for an active analysis to complete, then retry within the reported admission limits."},"details":{"operation":"inspect_apk_signature","resource":"capacity","reason":"APK Signing Block holds more signing-block-pairs than the 4096 limit.","reported_limits":{"max_signing_block_pairs":4096}}} ``` The control returned exit 0 and empty stderr, with `normalized_result.artifact.bytes: 184`, `posture.strongest_scheme: "none"` and `runtime_execution: "not-performed"`. All package-runner fixtures remained unused. Source checks at the tested baseline: - `docs/apk-signature.md:3-11,221-240` describes the explicit local APK/ZIP/AAB workflow, its in-process reader, occupied scan queue and bounded parsing/report work. - `docs/design/limits-and-recovery.md:250-262` requires truthful fixed-ceiling recovery, including the effective allowance and absent runtime override. - `src/domain/analysisErrorCore.ts:37-48` distinguishes fixed allowances from host resources and defines same-request retryability. `src/domain/fixedAllowanceRemediation.ts:17-28,49-56` supplies non-retryable fixed-count recovery that excludes waiting as a remedy. - `src/domain/apkSignature/apkSigningBlock.ts:30-31,309-325` fixes the pair count at 4096 and emits the budget refusal. `src/apkSignature/ApkSignatureProvider.ts:404-411` reaches that framing step through the production reader and maps its error before scheme decoding. - `src/apkSignature/apkSignatureFailures.ts:63-69` constructs this capacity error without fixed-allowance or retryability metadata. `src/domain/analysisErrorProjection.ts:140-147` then defaults it to retryable, and `src/domain/analysisErrorPresentation.ts:88-98` selects the observed wait sentence. Evidence provenance: independently observed public CLI results, plus the separately identified source-derived explanation. No repaired runtime was tested. ### Typed outcome or recovery expected For the same bytes, preserve the typed resource refusal and 4096-pair maximum, return `retryable: false`, and describe the fixed pair allowance and ineffective unchanged retry. Preserve successful below-limit inspection and genuinely temporary occupied-capacity retries. These are proposed restoration checks, not claims that a fix has been applied. ### Analysis or suspected cause Source-derived explanation: the APK framing producer knows that a fixed count caused the refusal but maps it to the generic capacity error without carrying that distinction. Shared projection consequently interprets it as occupied capacity. The existing fixed-allowance metadata can express deterministic refusal separately from temporary occupancy; the reproduction does not justify globally disabling capacity retries or raising the pair maximum. ### Additional context Independent searches covered open and closed issues, all PR states including merged PRs, and issue comments. No matching APK pair-count report was found. [Issue #5556](https://github.com/morluto/rea/issues/5556) and merged [PR #5633](https://github.com/morluto/rea/pull/5633) establish the fixed-allowance recovery distinction. That PR intentionally preserves generic recovery for genuine resource failures and does not modify the APK signature reader. [#5506](https://github.com/morluto/rea/issues/5506), [#5504](https://github.com/morluto/rea/issues/5504) and [#6035](https://github.com/morluto/rea/issues/6035) cover WASM comparison, Rust hash counts and native matching respectively. Their caller-specific fixes leave this APK response unchanged. This report does not claim those repaired callers regressed. APK-related [#7433](https://github.com/morluto/rea/issues/7433), [#7411](https://github.com/morluto/rea/issues/7411) and [#7316](https://github.com/morluto/rea/issues/7316) concern ZIP disk metadata, an omitted comparison limitation and tool discovery, with different restoration conditions. [#5808](https://github.com/morluto/rea/issues/5808) is a general recovery audit rather than a report of this trigger. MCP, an installed package, real signed APKs and other platforms were not exercised. No known-good revision or first-bad revision was established. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The independently executed CLI misclassifies a deterministic refusal as retryable in a machine-readable field and prescribes an ineffective wait. This is bounded to the exercised fixed pair-count guard; the refusal and maximum remain correct. - Workaround status: available - Workaround basis: The returned message and `max_signing_block_pairs: 4096` identify the actual fixed count. Callers can suppress unchanged retries using those retained facts. This workaround does not make the intentionally unsupported over-limit input inspectable.
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#4569
No description provided.