[GH-ISSUE #3949] [Bug] Interface Builder decoding omits XML archives within the plist nesting limit #1557

Closed
opened 2026-10-16 21:07:06 +00:00 by bsakaguchi · 0 comments
Owner

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

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

Observed with the public CLI: the 123 case exits 0, returns one document and the Build control, reports complete archive_decode coverage with examined 1 and omitted 0, and sets graph.truncated to false. Both 124 and 125 also exit 0 but return no documents or nodes, set graph.truncated to true, and report { "facet": "archive_decode", "status": "partial", "reason": "aggregate_decode_budget_exhausted", "examined": 1, "omitted": 1 }. The result also states Contents/Resources/Main.nib: omitted because aggregate decode budget exhausted.

The archive sizes at 124 and 125 are 54,198 and 55,002 bytes. Keyed-archive inspection of each original file exits 0 and returns archive_format: "xml-plist" and total_objects: 3. Shared parsing followed by plist value projection also accepts both. At 126, keyed-archive inspection exits 1 with code truncated and a detail ending in Plist exceeds 128 nesting levels.

Source-derived diagnosis: the Interface Builder preflight compares open XML element depth against 128, while the common parser compares zero-based plist value depth. At 124, the deepest scalar has value depth 127 and XML element depth 129. At 125, those depths are 128 and 130. Dictionary keys close before their sibling values, so keys do not accumulate depth across the archive.

Reproduction

From the baseline checkout with its installed dependencies and compiled runtime, run the following. The script creates four otherwise equivalent app bundles, gives each CLI child an isolated home directory, and invokes both supported commands. The 32-byte executable header follows the repository's synthetic Mach-O fixture; no executable is launched.

export WORK="$(mktemp -d)"
node --input-type=module <<'NODE'
import { build } from 'plist';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const nested = depth => {
  let value = { leaf: 1 };
  for (let i = 0; i < depth; i++) value = { child: value };
  return value;
};
const home = join(process.env.WORK, 'home');
mkdirSync(home, { recursive: true });
const env = {
  PATH: process.env.PATH, HOME: home,
  REA_LOG_LEVEL: 'silent', REA_ANALYSIS_PROVIDER: 'auto'
};
for (const depth of [123, 124, 125, 126]) {
  const app = join(process.env.WORK, `Minimal${depth}.app`);
  mkdirSync(join(app, 'Contents', 'Resources'), { recursive: true });
  mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true });
  writeFileSync(join(app, 'Contents', 'Resources', 'Main.nib'), build({
    $archiver: 'NSKeyedArchiver', $version: 100000,
    $objects: [
      '$null',
      { $class: { UID: 2 }, title: 'Build', ...nested(depth) },
      { $classname: 'NSButton', $classes: ['NSButton', 'NSObject'] }
    ],
    $top: { root: { UID: 1 } }
  }));
  writeFileSync(join(app, 'Contents', 'Info.plist'),
    build({ CFBundleExecutable: 'Fixture' }));
  const executable = Buffer.alloc(32);
  executable.writeUInt32BE(0xfeedfacf, 0);
  executable.writeUInt32BE(0x0100000c, 4);
  writeFileSync(join(app, 'Contents', 'MacOS', 'Fixture'), executable);
  for (const kind of ['ib', 'keyed']) {
    const args = kind === 'ib'
      ? ['decode-interface-builder', app, '--json']
      : ['inspect-keyed-archive', app, 'Contents/Resources/Main.nib', '--json'];
    const p = spawnSync(process.execPath, ['scripts/rea.mjs', ...args], {
      env, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024
    });
    const output = JSON.parse(p.stdout);
    const r = output.normalized_result ?? output;
    console.log(JSON.stringify({ depth, kind, exit: p.status,
      ...(kind === 'ib' ? {
        documents: r.documents?.length,
        control: r.graph?.nodes.some(n => n.name === 'Build' && n.kind === 'control'),
        truncated: r.graph?.truncated,
        archive_decode: r.graph?.coverage.find(c => c.facet === 'archive_decode')
      } : {
        archive_format: r.archive_format, total_objects: r.total_objects,
        message: r.message
      })
    }));
  }
}
NODE

The chain length is the controlled variable. The fixture with 123 child dictionaries is the accepted comparison; 124 and 125 reproduce the premature omission; 126 checks the intended limit. These generated fixtures are the known executed cases, not evidence of prevalence in shipped applications.

Expected behavior

The XML preflight shares REA's 128-level plist value projection limit. Value nesting starts at zero for the root; the outer plist envelope and dictionary key names do not add value nesting. The 124 and 125 cases stay within that limit, so Interface Builder decoding should retain Contents/Resources/Main.nib, the Build control, and complete archive_decode coverage. The documented aggregate input and representation bounds should continue to omit genuinely oversized archives with explicit partial coverage.

Impact and scope

A specifically shaped deep XML archive loses its entire document and recognized NSButton control from Interface Builder decoding. The result explicitly reports partial coverage, and inspect-keyed-archive still returns the raw serialized values. That fallback does not provide the requested graph, so a graph-preserving workaround on the original bytes remains unverified. Frequency in shipped apps and other platforms is unknown.

REA version

rea 6.5.0 at source commit 4692bb3330

Runtime and platform

Node 24.18.0; macOS arm64

Target and artifact context

Synthetic app bundles with a 32-byte admitted Mach-O header and one XML keyed archive containing an NSButton named Build. No target code or analysis provider ran.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

  • Expected source: src/domain/apple/plistValue.ts:5-10 explicitly shares the XML preflight and value projection limit; src/domain/propertyListKeys.ts:247-280 defines value depth, envelope handling and key handling; docs/native-investigation.md:324-359 documents XML Interface Builder decoding and aggregate-budget omissions.
  • Failure source: the executed CLI comparison above returns no document or control at 124 and 125 while keyed-archive inspection accepts both. src/artifacts/apple/InterfaceBuilderDecodeBudget.ts:119-140 counts XML element depth; src/artifacts/apple/InterfaceBuilderAnalysis.ts:123-144 calls this preflight before decoding, and lines 165-170 convert its exception into the observed omission.
  • The original unclassified-object fixture was also reproduced. Adding the NSButton class and title demonstrated loss of a recognized graph control. Replacing a copied host executable with the synthetic header preserved the result.
  • npm run test:local -- src/domain/propertyListKeys.test.ts src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts passed 46 tests across two files. These existing tests confirm exact value-depth acceptance and internal-entity refusal; their success does not restore the failing CLI cases.

The failing preflight, decoder and shared value-depth files are unchanged at current upstream main 22ec8bdb2f7d4626587f23b1eed4a207df600386. Intervening cited documentation changes concern Objective-C properties, and the launcher change concerns output flags.

Typed outcome or recovery expected

Rerun the same public CLI comparison. The 124 and 125 cases must retain their original archive identities, the document and Build control, complete archive_decode coverage with omitted 0, and graph.truncated: false, matching the 123 control. The 126 case must remain subject to the existing value-depth limit. Keep the existing internal-entity refusal and aggregate representation reservation guarantees; accepting arbitrary depth or removing budget enforcement would not restore this contract. These are proposed acceptance checks for a future repair; no repair was made during verification.

Analysis or suspected cause

Source-derived: the preflight counts XML element depth, including the plist envelope, against the same numeric limit as zero-based plist value depth. At child-chain depths 124 and 125, scalar value depths are 127 and 128 while XML element depths are 129 and 130. Sibling dictionary keys do not accumulate depth.

Additional context

Related closed #2105 concerned missing preflight before excessive-depth keyed-archive decoding; this report concerns premature Interface Builder omission inside the supported value-depth limit. Closed #1503 addressed coverage propagation after partial decoding. Open PRs #3426 and #3475 concern XML character-data key names and hexadecimal integers, respectively, rather than this nesting mismatch. MCP was not executed.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3949 ### 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 Observed with the public CLI: the 123 case exits 0, returns one document and the Build control, reports complete `archive_decode` coverage with examined 1 and omitted 0, and sets `graph.truncated` to false. Both 124 and 125 also exit 0 but return no documents or nodes, set `graph.truncated` to true, and report `{ "facet": "archive_decode", "status": "partial", "reason": "aggregate_decode_budget_exhausted", "examined": 1, "omitted": 1 }`. The result also states `Contents/Resources/Main.nib: omitted because aggregate decode budget exhausted.` The archive sizes at 124 and 125 are 54,198 and 55,002 bytes. Keyed-archive inspection of each original file exits 0 and returns `archive_format: "xml-plist"` and `total_objects: 3`. Shared parsing followed by plist value projection also accepts both. At 126, keyed-archive inspection exits 1 with code `truncated` and a detail ending in `Plist exceeds 128 nesting levels`. Source-derived diagnosis: the Interface Builder preflight compares open XML element depth against 128, while the common parser compares zero-based plist value depth. At 124, the deepest scalar has value depth 127 and XML element depth 129. At 125, those depths are 128 and 130. Dictionary keys close before their sibling values, so keys do not accumulate depth across the archive. ### Reproduction From the baseline checkout with its installed dependencies and compiled runtime, run the following. The script creates four otherwise equivalent app bundles, gives each CLI child an isolated home directory, and invokes both supported commands. The 32-byte executable header follows the repository's synthetic Mach-O fixture; no executable is launched. ```sh export WORK="$(mktemp -d)" node --input-type=module <<'NODE' import { build } from 'plist'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; const nested = depth => { let value = { leaf: 1 }; for (let i = 0; i < depth; i++) value = { child: value }; return value; }; const home = join(process.env.WORK, 'home'); mkdirSync(home, { recursive: true }); const env = { PATH: process.env.PATH, HOME: home, REA_LOG_LEVEL: 'silent', REA_ANALYSIS_PROVIDER: 'auto' }; for (const depth of [123, 124, 125, 126]) { const app = join(process.env.WORK, `Minimal${depth}.app`); mkdirSync(join(app, 'Contents', 'Resources'), { recursive: true }); mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true }); writeFileSync(join(app, 'Contents', 'Resources', 'Main.nib'), build({ $archiver: 'NSKeyedArchiver', $version: 100000, $objects: [ '$null', { $class: { UID: 2 }, title: 'Build', ...nested(depth) }, { $classname: 'NSButton', $classes: ['NSButton', 'NSObject'] } ], $top: { root: { UID: 1 } } })); writeFileSync(join(app, 'Contents', 'Info.plist'), build({ CFBundleExecutable: 'Fixture' })); const executable = Buffer.alloc(32); executable.writeUInt32BE(0xfeedfacf, 0); executable.writeUInt32BE(0x0100000c, 4); writeFileSync(join(app, 'Contents', 'MacOS', 'Fixture'), executable); for (const kind of ['ib', 'keyed']) { const args = kind === 'ib' ? ['decode-interface-builder', app, '--json'] : ['inspect-keyed-archive', app, 'Contents/Resources/Main.nib', '--json']; const p = spawnSync(process.execPath, ['scripts/rea.mjs', ...args], { env, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 }); const output = JSON.parse(p.stdout); const r = output.normalized_result ?? output; console.log(JSON.stringify({ depth, kind, exit: p.status, ...(kind === 'ib' ? { documents: r.documents?.length, control: r.graph?.nodes.some(n => n.name === 'Build' && n.kind === 'control'), truncated: r.graph?.truncated, archive_decode: r.graph?.coverage.find(c => c.facet === 'archive_decode') } : { archive_format: r.archive_format, total_objects: r.total_objects, message: r.message }) })); } } NODE ``` The chain length is the controlled variable. The fixture with 123 child dictionaries is the accepted comparison; 124 and 125 reproduce the premature omission; 126 checks the intended limit. These generated fixtures are the known executed cases, not evidence of prevalence in shipped applications. ### Expected behavior The XML preflight shares REA's 128-level plist value projection limit. Value nesting starts at zero for the root; the outer plist envelope and dictionary key names do not add value nesting. The 124 and 125 cases stay within that limit, so Interface Builder decoding should retain `Contents/Resources/Main.nib`, the Build control, and complete `archive_decode` coverage. The documented aggregate input and representation bounds should continue to omit genuinely oversized archives with explicit partial coverage. ### Impact and scope A specifically shaped deep XML archive loses its entire document and recognized NSButton control from Interface Builder decoding. The result explicitly reports partial coverage, and `inspect-keyed-archive` still returns the raw serialized values. That fallback does not provide the requested graph, so a graph-preserving workaround on the original bytes remains unverified. Frequency in shipped apps and other platforms is unknown. ### REA version rea 6.5.0 at source commit 4692bb33300c7cb77b095b6cc0c33da861c1b860 ### Runtime and platform Node 24.18.0; macOS arm64 ### Target and artifact context Synthetic app bundles with a 32-byte admitted Mach-O header and one XML keyed archive containing an NSButton named Build. No target code or analysis provider ran. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence - Expected source: `src/domain/apple/plistValue.ts:5-10` explicitly shares the XML preflight and value projection limit; `src/domain/propertyListKeys.ts:247-280` defines value depth, envelope handling and key handling; `docs/native-investigation.md:324-359` documents XML Interface Builder decoding and aggregate-budget omissions. - Failure source: the executed CLI comparison above returns no document or control at 124 and 125 while keyed-archive inspection accepts both. `src/artifacts/apple/InterfaceBuilderDecodeBudget.ts:119-140` counts XML element depth; `src/artifacts/apple/InterfaceBuilderAnalysis.ts:123-144` calls this preflight before decoding, and lines 165-170 convert its exception into the observed omission. - The original unclassified-object fixture was also reproduced. Adding the NSButton class and title demonstrated loss of a recognized graph control. Replacing a copied host executable with the synthetic header preserved the result. - `npm run test:local -- src/domain/propertyListKeys.test.ts src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts` passed 46 tests across two files. These existing tests confirm exact value-depth acceptance and internal-entity refusal; their success does not restore the failing CLI cases. The failing preflight, decoder and shared value-depth files are unchanged at current upstream main `22ec8bdb2f7d4626587f23b1eed4a207df600386`. Intervening cited documentation changes concern Objective-C properties, and the launcher change concerns output flags. ### Typed outcome or recovery expected Rerun the same public CLI comparison. The 124 and 125 cases must retain their original archive identities, the document and Build control, complete `archive_decode` coverage with omitted 0, and `graph.truncated: false`, matching the 123 control. The 126 case must remain subject to the existing value-depth limit. Keep the existing internal-entity refusal and aggregate representation reservation guarantees; accepting arbitrary depth or removing budget enforcement would not restore this contract. These are proposed acceptance checks for a future repair; no repair was made during verification. ### Analysis or suspected cause Source-derived: the preflight counts XML element depth, including the plist envelope, against the same numeric limit as zero-based plist value depth. At child-chain depths 124 and 125, scalar value depths are 127 and 128 while XML element depths are 129 and 130. Sibling dictionary keys do not accumulate depth. ### Additional context Related closed #2105 concerned missing preflight before excessive-depth keyed-archive decoding; this report concerns premature Interface Builder omission inside the supported value-depth limit. Closed #1503 addressed coverage propagation after partial decoding. Open PRs #3426 and #3475 concern XML character-data key names and hexadecimal integers, respectively, rather than this nesting mismatch. MCP was not executed.
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#1557
No description provided.