[GH-ISSUE #7683] [Bug] ZIP application graph applies the IPA-only Payload root rule #4604

Closed
opened 2026-10-31 00:51:13 +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/7683

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

For a Sample.zip containing only Payload/App.app/Info.plist and Extras/Other.app/Info.plist, the public workflow

node scripts/rea.mjs inspect-artifact <Sample.zip> --format json
node scripts/rea.mjs project-apple-application-graph <request.json> --format json

returns root_format: "zip" together with application_roots: ["Payload/App.app"] and platforms: ["ios"]. The Payload bundle receives role application and layout shallow. Neither app has a Contents/ directory, and the Extras bundle receives no application root or bundle row.

Both calls exit successfully. The ZIP-specific documented discovery rule requires an app with Contents/; only the IPA rule grants roots based on Payload/<Name>.app.

Reproduction

  1. Use the prepared source build at the baseline below, with Node.js v24.22.1 selected on PATH. Python 3 creates the fixtures. No native analysis provider is required.
  2. Run this script from the repository root. It makes all four archives inline, passes the real inspector Evidence to the public projection command, isolates HOME/XDG, blocks package runners through fixtures first on PATH, and deletes its temporary inputs afterward.
import json, os, pathlib, subprocess, tempfile, zipfile

node_bin = str(pathlib.Path(subprocess.check_output(
    ['node', '-p', 'process.execPath'], text=True).strip()).parent)
with tempfile.TemporaryDirectory(prefix='rea-zip-root-') as directory:
    work = pathlib.Path(directory)
    runners = work / 'bin'
    runners.mkdir()
    for name in ('npx', 'npm', 'bun'):
        stub = runners / name
        stub.write_text('#!/bin/sh\necho "fixture package runner blocked" >&2\nexit 97\n')
        stub.chmod(0o755)
    env = {'PATH': str(runners) + os.pathsep + node_bin + os.pathsep + os.environ['PATH'],
           'HOME': str(work / 'home'), 'TMPDIR': str(work)}
    for key in ('XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'):
        env[key] = str(work / key.lower())
    for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'):
        pathlib.Path(env[key]).mkdir()
    def cli(*args):
        result = subprocess.run(['node', 'scripts/rea.mjs', *args], env=env,
                                capture_output=True, text=True, check=True)
        return result.stdout
    print('REA version:', cli('--version').strip())
    plist = '<?xml version="1.0"?><plist version="1.0"><dict/></plist>'
    cases = [
        ('Sample.zip', ['Payload/App.app/Info.plist', 'Extras/Other.app/Info.plist']),
        ('Control.zip', ['Extras/Other.app/Info.plist']),
        ('Sample.ipa', ['Payload/App.app/Info.plist', 'Extras/Other.app/Info.plist']),
        ('Deep.zip', ['App.app/Contents/Info.plist']),
    ]
    for name, members in cases:
        archive = work / name
        with zipfile.ZipFile(archive, 'w') as out:
            for member in members:
                out.writestr(member, plist)
        inventory = json.loads(cli('inspect-artifact', str(archive), '--format', 'json'))
        request = work / 'request.json'
        request.write_text(json.dumps({'inventory_evidence': [inventory]}))
        graph = json.loads(cli('project-apple-application-graph', str(request),
                               '--format', 'json'))['normalized_result']
        print(name, json.dumps({
            'root_format': graph['root_format'], 'platforms': graph['platforms'],
            'application_roots': graph['application_roots'],
            'bundles': [{k: b[k] for k in ('path', 'role', 'layout', 'info_plist_path')}
                        for b in graph['bundles']],
        }, separators=(',', ':')))
  1. Compare the printed fields. The flat Payload app receives the same application/iOS classification in Sample.zip as in Sample.ipa, even though root_format distinguishes the two. Control.zip has no application root, and Deep.zip has the expected macOS root.

This exact reduced script ran once. A separate expanded replay ran all four layouts once with the same distinguishing results. No executable or native binary was needed.

Expected behavior

For Sample.zip, return application_roots: [], platforms: [] and bundles: []. Its two flat plist paths remain outside application roots under the ZIP attribution rule. Keep the positive controls working: Sample.ipa has the shallow Payload application, and Deep.zip has the app-with-Contents macOS application.

The Apple application guide's Application roots table gives IPA roots as Each Payload/<Name>.app, and gives directory, ZIP and DMG container roots as Each outermost <Name>.app followed by Contents/. Its separate selected .app directory exception does not apply to a ZIP. It also says non-rooted directory/ZIP/DMG components are not attributed to the application.

A ZIP re-archived from an unpacked IPA still follows the ZIP row when its inventory reports root_format: "zip"; the guide grants no packaging-history exception. The correct outcome for this fixture is a supported ZIP projection with no qualifying application roots.

Impact and scope

The observed output assigns an application identity, bundle role and iOS platform to a bounded class of ZIP inputs outside the documented discovery rule. The flat Extras control is excluded, so the Payload path convention causes the differing treatment. The original inventory retains both paths. No data mutation or security boundary crossing was involved.

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The accepted synthetic ZIP returns consequential incorrect application/platform attribution; the ordinary IPA and deep ZIP controls continue to work, and source inventory remains available.
  • Workaround status: available
  • Workaround basis: For this fixture, a caller can compare the reported ZIP format and retained plist paths with the documented Contents requirement and withhold the false application attribution. This manual interpretation does not repair the stored projection.

Only synthetic archives and the public CLI were exercised. Real package prevalence, runtime MCP behavior, directory/DMG variants, downstream bridge or runtime-family effects, and the published npm package were not tested.

REA version

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

6.8.0

Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a. The prepared build was used without rebuilding or changing dependencies.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1; arm64. Python 3 generated the fixtures. The in-process artifact inventory reader and graph projection were sufficient; no native analysis provider or GUI application was launched.

Target and artifact context

Authored ZIP archives containing readable XML plists only. The flat app layouts imply real directories through deeper member paths and contain no Contents/. The IPA control uses the same flat member layout; the deep ZIP control has App.app/Contents/Info.plist. No member is executed.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

The reduced reproduction printed:

REA version: 6.8.0
Sample.zip {"root_format":"zip","platforms":["ios"],"application_roots":["Payload/App.app"],"bundles":[{"path":"Payload/App.app","role":"application","layout":"shallow","info_plist_path":"Payload/App.app/Info.plist"}]}
Control.zip {"root_format":"zip","platforms":[],"application_roots":[],"bundles":[]}
Sample.ipa {"root_format":"ipa","platforms":["ios"],"application_roots":["Payload/App.app"],"bundles":[{"path":"Payload/App.app","role":"application","layout":"shallow","info_plist_path":"Payload/App.app/Info.plist"}]}
Deep.zip {"root_format":"zip","platforms":["macos"],"application_roots":["App.app"],"bundles":[{"path":"App.app","role":"application","layout":"macos-deep","info_plist_path":"App.app/Contents/Info.plist"}]}

Baseline contract and implementation locators:

  • docs/apple-application-analysis.md:74-78 specifies roots per inventory format; :95-105 specifies outside-root attribution and platform inference.
  • src/domain/apple/appleBundleAnatomy.ts:114-147 discovers roots. The Payload branch at :123-133 does not test the inventory format. :149-169 assigns application roles, and :364-376 derives platforms from those application bundles.
  • src/domain/apple/appleApplication.ts:110-144 passes the authenticated root format into anatomy and uses discovered roots for component attribution and classification.

Typed outcome or recovery expected

Keep the ZIP inventory accepted, but omit the flat Payload application root and its application/iOS claims. Preserve the separate documented IPA and deep ZIP discovery rules and the source inventory facts.

Analysis or suspected cause

Source-derived: root discovery applies both the Payload and app/Contents patterns without scoping either branch to its documented inventory formats. Only the explicitly selected .app directory shortcut checks the format. Once a Payload directory is admitted in a ZIP, role and platform inference follow from that false root.

A test complication should stay visible: src/domain/apple/appleApplication.test.ts:675-710 uses a flat Payload-shaped ZIP to exercise bridge-candidate budget omission. It relies on the broad admission, but does not assert a root-format exception. Correcting discovery will require giving that budget test a contract-valid root while preserving its omission checks. The explicit format table and the originating feature description support the narrower root rule.

Additional context

Searched open/closed issues, all PR states, and issue comments for Payload, ZIP, shallow apps, application roots and bundle anatomy.

Open issue #7615 reports the reverse format leak: a macOS-shaped extra outside Payload becomes an application root in an IPA. Its closure condition is to leave that helper unrooted in an IPA and restore its unrooted candidate pairs. Restricting the Contents branch to non-IPA inputs resolves that issue while leaving this ZIP defect reproducible. This report requires the separate ZIP-side Payload exclusion. The same semantic owner may reasonably be fixed once for both, but the two observable closure conditions differ.

Closed issue #4779, #3359, and superseded PR #3505 concern leaf files or symlinks masquerading as structural directories. Their fix 149a0fc06c3c67ee45624e6da14dfb0754473758 is present in this baseline; the current fixture supplies actual implied directories, so this is a different defect. Merged PR #922 states the same distinct IPA and ZIP root rules as the guide. Other related histories concern case policy, unread members, ordering, component retention or pairing rather than this ZIP root admission. No direct duplicate was found.

Originally created by @coygeek on GitHub (Oct 28, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7683 ### 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 For a `Sample.zip` containing only `Payload/App.app/Info.plist` and `Extras/Other.app/Info.plist`, the public workflow ```sh node scripts/rea.mjs inspect-artifact <Sample.zip> --format json node scripts/rea.mjs project-apple-application-graph <request.json> --format json ``` returns `root_format: "zip"` together with `application_roots: ["Payload/App.app"]` and `platforms: ["ios"]`. The Payload bundle receives role `application` and layout `shallow`. Neither app has a `Contents/` directory, and the Extras bundle receives no application root or bundle row. Both calls exit successfully. The ZIP-specific documented discovery rule requires an app with `Contents/`; only the IPA rule grants roots based on `Payload/<Name>.app`. ### Reproduction 1. Use the prepared source build at the baseline below, with Node.js v24.22.1 selected on PATH. Python 3 creates the fixtures. No native analysis provider is required. 2. Run this script from the repository root. It makes all four archives inline, passes the real inspector Evidence to the public projection command, isolates HOME/XDG, blocks package runners through fixtures first on PATH, and deletes its temporary inputs afterward. ```python import json, os, pathlib, subprocess, tempfile, zipfile node_bin = str(pathlib.Path(subprocess.check_output( ['node', '-p', 'process.execPath'], text=True).strip()).parent) with tempfile.TemporaryDirectory(prefix='rea-zip-root-') as directory: work = pathlib.Path(directory) runners = work / 'bin' runners.mkdir() for name in ('npx', 'npm', 'bun'): stub = runners / name stub.write_text('#!/bin/sh\necho "fixture package runner blocked" >&2\nexit 97\n') stub.chmod(0o755) env = {'PATH': str(runners) + os.pathsep + node_bin + os.pathsep + os.environ['PATH'], 'HOME': str(work / 'home'), 'TMPDIR': str(work)} for key in ('XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'): env[key] = str(work / key.lower()) for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'): pathlib.Path(env[key]).mkdir() def cli(*args): result = subprocess.run(['node', 'scripts/rea.mjs', *args], env=env, capture_output=True, text=True, check=True) return result.stdout print('REA version:', cli('--version').strip()) plist = '<?xml version="1.0"?><plist version="1.0"><dict/></plist>' cases = [ ('Sample.zip', ['Payload/App.app/Info.plist', 'Extras/Other.app/Info.plist']), ('Control.zip', ['Extras/Other.app/Info.plist']), ('Sample.ipa', ['Payload/App.app/Info.plist', 'Extras/Other.app/Info.plist']), ('Deep.zip', ['App.app/Contents/Info.plist']), ] for name, members in cases: archive = work / name with zipfile.ZipFile(archive, 'w') as out: for member in members: out.writestr(member, plist) inventory = json.loads(cli('inspect-artifact', str(archive), '--format', 'json')) request = work / 'request.json' request.write_text(json.dumps({'inventory_evidence': [inventory]})) graph = json.loads(cli('project-apple-application-graph', str(request), '--format', 'json'))['normalized_result'] print(name, json.dumps({ 'root_format': graph['root_format'], 'platforms': graph['platforms'], 'application_roots': graph['application_roots'], 'bundles': [{k: b[k] for k in ('path', 'role', 'layout', 'info_plist_path')} for b in graph['bundles']], }, separators=(',', ':'))) ``` 3. Compare the printed fields. The flat Payload app receives the same application/iOS classification in `Sample.zip` as in `Sample.ipa`, even though `root_format` distinguishes the two. `Control.zip` has no application root, and `Deep.zip` has the expected macOS root. This exact reduced script ran once. A separate expanded replay ran all four layouts once with the same distinguishing results. No executable or native binary was needed. ### Expected behavior For `Sample.zip`, return `application_roots: []`, `platforms: []` and `bundles: []`. Its two flat plist paths remain outside application roots under the ZIP attribution rule. Keep the positive controls working: `Sample.ipa` has the shallow Payload application, and `Deep.zip` has the app-with-Contents macOS application. The Apple application guide's Application roots table gives IPA roots as `Each Payload/<Name>.app`, and gives directory, ZIP and DMG container roots as `Each outermost <Name>.app followed by Contents/`. Its separate selected `.app` directory exception does not apply to a ZIP. It also says non-rooted directory/ZIP/DMG components are not attributed to the application. A ZIP re-archived from an unpacked IPA still follows the ZIP row when its inventory reports `root_format: "zip"`; the guide grants no packaging-history exception. The correct outcome for this fixture is a supported ZIP projection with no qualifying application roots. ### Impact and scope The observed output assigns an application identity, bundle role and iOS platform to a bounded class of ZIP inputs outside the documented discovery rule. The flat Extras control is excluded, so the Payload path convention causes the differing treatment. The original inventory retains both paths. No data mutation or security boundary crossing was involved. - Impact level: P2 - Assessment status: supported - Impact basis: The accepted synthetic ZIP returns consequential incorrect application/platform attribution; the ordinary IPA and deep ZIP controls continue to work, and source inventory remains available. - Workaround status: available - Workaround basis: For this fixture, a caller can compare the reported ZIP format and retained plist paths with the documented Contents requirement and withhold the false application attribution. This manual interpretation does not repair the stored projection. Only synthetic archives and the public CLI were exercised. Real package prevalence, runtime MCP behavior, directory/DMG variants, downstream bridge or runtime-family effects, and the published npm package were not tested. ### REA version Exact `node scripts/rea.mjs --version` output: ```text 6.8.0 ``` Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. The prepared build was used without rebuilding or changing dependencies. ### Runtime and platform Node.js v24.22.1; macOS 27.0.1; arm64. Python 3 generated the fixtures. The in-process artifact inventory reader and graph projection were sufficient; no native analysis provider or GUI application was launched. ### Target and artifact context Authored ZIP archives containing readable XML plists only. The flat app layouts imply real directories through deeper member paths and contain no `Contents/`. The IPA control uses the same flat member layout; the deep ZIP control has `App.app/Contents/Info.plist`. No member is executed. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence The reduced reproduction printed: ```text REA version: 6.8.0 Sample.zip {"root_format":"zip","platforms":["ios"],"application_roots":["Payload/App.app"],"bundles":[{"path":"Payload/App.app","role":"application","layout":"shallow","info_plist_path":"Payload/App.app/Info.plist"}]} Control.zip {"root_format":"zip","platforms":[],"application_roots":[],"bundles":[]} Sample.ipa {"root_format":"ipa","platforms":["ios"],"application_roots":["Payload/App.app"],"bundles":[{"path":"Payload/App.app","role":"application","layout":"shallow","info_plist_path":"Payload/App.app/Info.plist"}]} Deep.zip {"root_format":"zip","platforms":["macos"],"application_roots":["App.app"],"bundles":[{"path":"App.app","role":"application","layout":"macos-deep","info_plist_path":"App.app/Contents/Info.plist"}]} ``` Baseline contract and implementation locators: - `docs/apple-application-analysis.md:74-78` specifies roots per inventory format; `:95-105` specifies outside-root attribution and platform inference. - `src/domain/apple/appleBundleAnatomy.ts:114-147` discovers roots. The Payload branch at `:123-133` does not test the inventory format. `:149-169` assigns application roles, and `:364-376` derives platforms from those application bundles. - `src/domain/apple/appleApplication.ts:110-144` passes the authenticated root format into anatomy and uses discovered roots for component attribution and classification. ### Typed outcome or recovery expected Keep the ZIP inventory accepted, but omit the flat Payload application root and its application/iOS claims. Preserve the separate documented IPA and deep ZIP discovery rules and the source inventory facts. ### Analysis or suspected cause Source-derived: root discovery applies both the Payload and app/Contents patterns without scoping either branch to its documented inventory formats. Only the explicitly selected `.app` directory shortcut checks the format. Once a Payload directory is admitted in a ZIP, role and platform inference follow from that false root. A test complication should stay visible: `src/domain/apple/appleApplication.test.ts:675-710` uses a flat Payload-shaped ZIP to exercise bridge-candidate budget omission. It relies on the broad admission, but does not assert a root-format exception. Correcting discovery will require giving that budget test a contract-valid root while preserving its omission checks. The explicit format table and the originating feature description support the narrower root rule. ### Additional context Searched open/closed issues, all PR states, and issue comments for Payload, ZIP, shallow apps, application roots and bundle anatomy. [Open issue #7615](https://github.com/morluto/rea/issues/7615) reports the reverse format leak: a macOS-shaped extra outside Payload becomes an application root in an IPA. Its closure condition is to leave that helper unrooted in an IPA and restore its unrooted candidate pairs. Restricting the Contents branch to non-IPA inputs resolves that issue while leaving this ZIP defect reproducible. This report requires the separate ZIP-side Payload exclusion. The same semantic owner may reasonably be fixed once for both, but the two observable closure conditions differ. [Closed issue #4779](https://github.com/morluto/rea/issues/4779), [#3359](https://github.com/morluto/rea/issues/3359), and [superseded PR #3505](https://github.com/morluto/rea/pull/3505) concern leaf files or symlinks masquerading as structural directories. Their fix `149a0fc06c3c67ee45624e6da14dfb0754473758` is present in this baseline; the current fixture supplies actual implied directories, so this is a different defect. [Merged PR #922](https://github.com/morluto/rea/pull/922) states the same distinct IPA and ZIP root rules as the guide. Other related histories concern case policy, unread members, ordering, component retention or pairing rather than this ZIP root admission. No direct duplicate was found.
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#4604
No description provided.