[GH-ISSUE #2176] fix(apple): asset catalog inspection omits recognized NIBArchive resource keys #722

Closed
opened 2026-10-15 18:27:42 +00:00 by bsakaguchi · 1 comment
Owner

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

Summary

rea inspect-asset-catalog omits a recognized image key that rea decode-interface-builder has already decoded from a supported NIBArchive resource object. A rooted fixture produces an NSImage resource with NSImageName equal to Toolbar, yet the catalog result contains no corresponding resource_key_matches entry. With no catalog present, the result even states that resource keys are reported as unmatched. The equivalent AppKit keyed-archive control produces that unmatched entry.

Runtime or environment

Read-only checkout c22111eca2 and separately installed published rea-agents 6.4.0, both exercised through their public CLI dispatchers. Actual runtime was Node.js 24.21.0 on macOS arm64, within the advertised supported Node range; the checkout contributor pin is 24.18.0. Area: Evidence, artifacts, or reconstruction. Interface: CLI. Earliest observable failure stage: native analysis or reconstruction, during resource-reference collection. The target is a disposable macOS-layout app containing a parser-valid arm64 Mach-O executable header and a structurally valid, rooted NIBArchive. No user app, native UI, Apple compiler or real catalog was used.

Steps to reproduce

On macOS with a built checkout or the published package, save the following as resource-join-repro.mjs outside the repository and run node resource-join-repro.mjs ./scripts/rea.mjs from the REA package root. The script uses only Node built-ins, creates its own app and archive bytes, invokes the public commands with isolated provider configuration, and awaits child completion before removing the fixture. No compiled catalog is necessary for this case, and no assetutil call is made.

The embedded archive has format version 1 and coder version 10. Its four objects are NSIBObjectData with NSRoot referencing NSImageView, that view referencing NSImage, and the image's NSImageName referencing an NSString whose NS.bytes contain Toolbar. All object references and tables are in bounds. The small Mach-O header is for REA target admission; it is never executed.

import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

const run = promisify(execFile);
const entry = resolve(process.argv[2]);
const root = await mkdtemp(join(tmpdir(), 'rea-resource-join-'));
try {
  const app = join(root, 'Fixture.app');
  const home = join(root, 'home');
  await mkdir(home);
  await mkdir(join(app, 'Contents/MacOS'), {recursive: true});
  await mkdir(join(app, 'Contents/Resources'), {recursive: true});
  const mach = Buffer.alloc(32);
  mach.writeUInt32LE(0xfeedfacf, 0);
  mach.writeUInt32LE(0x0100000c, 4);
  mach.writeUInt32LE(2, 12);
  await writeFile(join(app, 'Contents/MacOS/App'), mach);
  await writeFile(join(app, 'Contents/Info.plist'), '<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>');
  const nib = 'TklCQXJjaGl2ZQEAAAAKAAAABAAAADIAAAAEAAAAPgAAAAQAAABgAAAABAAAAHwAAACAgIGBgYGCgoGDg4GGTlNSb290hWltYWdli05TSW1hZ2VOYW1liE5TLmJ5dGVzgAoBAAAAgQoCAAAAggoDAAAAgwiHVG9vbGJhco6ATlNJQk9iamVjdERhdGGLgE5TSW1hZ2VWaWV3h4BOU0ltYWdliIBOU1N0cmluZw==';
  await writeFile(join(app, 'Contents/Resources/Panel.nib'), Buffer.from(nib, 'base64'));
  const env = {PATH: process.env.PATH, HOME: home, TMPDIR: root, XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, REA_LOG_LEVEL: 'silent', REA_ANALYSIS_PROVIDER: 'auto', HOPPER_LAUNCHER_PATH: join(root, 'absent-hopper'), GHIDRA_INSTALL_DIR: join(root, 'absent-ghidra'), REA_IDA_MCP_CONFIG: join(root, 'absent-ida.json')};
  const call = async command => {
    const {stdout} = await run(process.execPath, [entry, command, app, '--json'], {env, timeout: 15000, maxBuffer: 4 * 1024 * 1024});
    const evidence = JSON.parse(stdout);
    assert(evidence.normalized_result, stdout);
    return evidence.normalized_result;
  };
  const decoded = await call('decode-interface-builder');
  assert.equal(decoded.documents.length, 1);
  assert.equal(decoded.graph.truncated, false);
  assert.equal(decoded.graph.coverage.find(x => x.facet === 'archive_decode').status, 'complete');
  const resource = decoded.graph.nodes.find(x => x.kind === 'resource' && x.attributes.nibValues?.NSImageName === 'Toolbar');
  assert(resource);
  const catalog = await call('inspect-asset-catalog');
  assert.deepEqual(catalog.catalogs, []);
  console.log(JSON.stringify({decoded_field: resource.attributes.nibValues.NSImageName, resource_key_matches: catalog.resource_key_matches, limitations: catalog.limitations}, null, 2));
} finally {
  await rm(root, {recursive: true, force: true});
}

Expected behavior

The documented catalog command returns exact UI resource-name matches. Its stated scope includes recognized image or asset key fields on decoded Interface Builder resource objects. NSImageName is recognized, the decoded object is a resource, and its value is a nonempty scalar string with archive provenance. This case therefore needs one NSImageName / Toolbar entry with status: "unmatched" and no renditions, because there are no catalogs. This expectation requires no inference about an arbitrary private field or unresolved object reference.

Actual behavior

Both tested builds exit successfully and return the following relevant values:

{
  "decoded_field": "Toolbar",
  "resource_key_matches": [],
  "limitations": [
    "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
    "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred."
  ]
}

The decoder reports one archive, complete archive/object/hierarchy coverage, and no graph truncation. Its image node has attributes.nibValues.NSImageName: "Toolbar". A rooted AppKit NSKeyedArchiver control with the same image class and name returns the field at the top level and one unmatched entry. A second NIBArchive using the recognized imageName spelling also loses its entry. A NIBArchive with only title: "Toolbar" correctly returns none.

Additional public CLI controls used inert catalog bytes and substituted only the assetutil --info process output with valid JSON record arrays. For zero records, one Name: "Toolbar" rendition and two such renditions, the AppKit keyed control returned unmatched, matched and ambiguous respectively. The NIBArchive cases still returned no resource-key entries even though their catalog records were returned. Those controls establish REA's behavior after supplied engine output; they do not establish native Assets.car validity or real assetutil behavior.

Evidence

  • Expected source: docs/native-investigation.md:52 promises exact UI resource-name matches; src/artifacts/apple/AppleAssetCatalogAnalysis.ts:19 recognizes the tested field names, and :113 / :121 state the empty-catalog and recognized-resource-field contracts. The delivered behavior is also described in the merged empty-catalog change.
  • Failure source: Independently executed public CLI fixture above on both tested builds. src/artifacts/apple/InterfaceBuilderAnalysis.ts:347 decodes referenced NSString bytes and :462 groups decoded values under nibValues; src/domain/apple/interfaceBuilderGraph.ts:72 attaches archive provenance and :115 preserves those attributes. src/artifacts/apple/AppleAssetCatalogAnalysis.ts:191 collects only top-level attribute entries before src/domain/apple/appleAssetCatalog.ts:131 projects matches.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

The production entrypoint and target guard are src/cli/artifactCommands.ts:106 and src/artifacts/ArtifactProvider.ts:264. The primary no-catalog reproduction crosses both through ordinary command invocation; it uses no helper injection or engine substitution. Published runtime code retains the same nested producer and top-level consumer behavior.

Additional context

The omission is bounded to recognized scalar fields in the decoded NIBArchive representation. The generic limitation about other archive references does not cover this demonstrated recognized field on a decoded resource object. No claim is made that every NIBArchive uses these fields, that current iOS archives use any particular encoding, or that UIKit keyed-archive semantics are supported. The observed control uses AppKit classes.

A same-artifact workaround is available. Decode the bundle, read the recognized field from the image node's nibValues, and compare the exact string to catalog asset_name records while preserving archive provenance and duplicate matches. The no-catalog case directly establishes an unmatched key. Under the modeled catalog controls, this manual comparison found zero, one and two records from the same returned artifacts. Neither input re-encoding nor source changes are needed.

Restoration check

For the inline fixture, the public catalog command must return an NSImageName / Toolbar row with status: "unmatched", its decoded source node, the archive path/digest, and no renditions. With one supplied matching catalog record the row must be matched; with two it must be ambiguous and retain both renditions. The rooted AppKit keyed-archive control must retain its existing rows. A resource containing only an unrecognized title field must still produce no row. These checks restore the existing recognized-key relationship and do not authorize matching arbitrary nested values.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The command silently omits a documented resource-key relationship for the demonstrated supported archive representation while preserving the decoded archive field and other catalog output. Reach beyond fixtures and native catalog behavior remain unmeasured.
  • Workaround status: available
  • Workaround basis: The public decoder returns the same recognized scalar and archive provenance, allowing an exact manual comparison with the catalog records. This directly restores the missing unmatched key in the no-catalog case and preserved zero, one and two matches in the modeled catalog controls without changing either artifact.
Originally created by @coygeek on GitHub (Oct 12, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2176 ## Summary `rea inspect-asset-catalog` omits a recognized image key that `rea decode-interface-builder` has already decoded from a supported NIBArchive resource object. A rooted fixture produces an `NSImage` resource with `NSImageName` equal to `Toolbar`, yet the catalog result contains no corresponding `resource_key_matches` entry. With no catalog present, the result even states that resource keys are reported as unmatched. The equivalent AppKit keyed-archive control produces that unmatched entry. ## Runtime or environment Read-only checkout c22111eca2c4bc84a084f12711f405b3e7422a40 and separately installed published `rea-agents` 6.4.0, both exercised through their public CLI dispatchers. Actual runtime was Node.js 24.21.0 on macOS arm64, within the advertised supported Node range; the checkout contributor pin is 24.18.0. Area: Evidence, artifacts, or reconstruction. Interface: CLI. Earliest observable failure stage: native analysis or reconstruction, during resource-reference collection. The target is a disposable macOS-layout app containing a parser-valid arm64 Mach-O executable header and a structurally valid, rooted NIBArchive. No user app, native UI, Apple compiler or real catalog was used. ## Steps to reproduce On macOS with a built checkout or the published package, save the following as `resource-join-repro.mjs` outside the repository and run `node resource-join-repro.mjs ./scripts/rea.mjs` from the REA package root. The script uses only Node built-ins, creates its own app and archive bytes, invokes the public commands with isolated provider configuration, and awaits child completion before removing the fixture. No compiled catalog is necessary for this case, and no `assetutil` call is made. The embedded archive has format version 1 and coder version 10. Its four objects are `NSIBObjectData` with `NSRoot` referencing `NSImageView`, that view referencing `NSImage`, and the image's `NSImageName` referencing an `NSString` whose `NS.bytes` contain `Toolbar`. All object references and tables are in bounds. The small Mach-O header is for REA target admission; it is never executed. ```js import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const run = promisify(execFile); const entry = resolve(process.argv[2]); const root = await mkdtemp(join(tmpdir(), 'rea-resource-join-')); try { const app = join(root, 'Fixture.app'); const home = join(root, 'home'); await mkdir(home); await mkdir(join(app, 'Contents/MacOS'), {recursive: true}); await mkdir(join(app, 'Contents/Resources'), {recursive: true}); const mach = Buffer.alloc(32); mach.writeUInt32LE(0xfeedfacf, 0); mach.writeUInt32LE(0x0100000c, 4); mach.writeUInt32LE(2, 12); await writeFile(join(app, 'Contents/MacOS/App'), mach); await writeFile(join(app, 'Contents/Info.plist'), '<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>'); const nib = 'TklCQXJjaGl2ZQEAAAAKAAAABAAAADIAAAAEAAAAPgAAAAQAAABgAAAABAAAAHwAAACAgIGBgYGCgoGDg4GGTlNSb290hWltYWdli05TSW1hZ2VOYW1liE5TLmJ5dGVzgAoBAAAAgQoCAAAAggoDAAAAgwiHVG9vbGJhco6ATlNJQk9iamVjdERhdGGLgE5TSW1hZ2VWaWV3h4BOU0ltYWdliIBOU1N0cmluZw=='; await writeFile(join(app, 'Contents/Resources/Panel.nib'), Buffer.from(nib, 'base64')); const env = {PATH: process.env.PATH, HOME: home, TMPDIR: root, XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, REA_LOG_LEVEL: 'silent', REA_ANALYSIS_PROVIDER: 'auto', HOPPER_LAUNCHER_PATH: join(root, 'absent-hopper'), GHIDRA_INSTALL_DIR: join(root, 'absent-ghidra'), REA_IDA_MCP_CONFIG: join(root, 'absent-ida.json')}; const call = async command => { const {stdout} = await run(process.execPath, [entry, command, app, '--json'], {env, timeout: 15000, maxBuffer: 4 * 1024 * 1024}); const evidence = JSON.parse(stdout); assert(evidence.normalized_result, stdout); return evidence.normalized_result; }; const decoded = await call('decode-interface-builder'); assert.equal(decoded.documents.length, 1); assert.equal(decoded.graph.truncated, false); assert.equal(decoded.graph.coverage.find(x => x.facet === 'archive_decode').status, 'complete'); const resource = decoded.graph.nodes.find(x => x.kind === 'resource' && x.attributes.nibValues?.NSImageName === 'Toolbar'); assert(resource); const catalog = await call('inspect-asset-catalog'); assert.deepEqual(catalog.catalogs, []); console.log(JSON.stringify({decoded_field: resource.attributes.nibValues.NSImageName, resource_key_matches: catalog.resource_key_matches, limitations: catalog.limitations}, null, 2)); } finally { await rm(root, {recursive: true, force: true}); } ``` ## Expected behavior The documented catalog command returns exact UI resource-name matches. Its stated scope includes recognized image or asset key fields on decoded Interface Builder resource objects. `NSImageName` is recognized, the decoded object is a resource, and its value is a nonempty scalar string with archive provenance. This case therefore needs one `NSImageName` / `Toolbar` entry with `status: "unmatched"` and no renditions, because there are no catalogs. This expectation requires no inference about an arbitrary private field or unresolved object reference. ## Actual behavior Both tested builds exit successfully and return the following relevant values: ```json { "decoded_field": "Toolbar", "resource_key_matches": [], "limitations": [ "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred." ] } ``` The decoder reports one archive, complete archive/object/hierarchy coverage, and no graph truncation. Its image node has `attributes.nibValues.NSImageName: "Toolbar"`. A rooted AppKit `NSKeyedArchiver` control with the same image class and name returns the field at the top level and one unmatched entry. A second NIBArchive using the recognized `imageName` spelling also loses its entry. A NIBArchive with only `title: "Toolbar"` correctly returns none. Additional public CLI controls used inert catalog bytes and substituted only the `assetutil --info` process output with valid JSON record arrays. For zero records, one `Name: "Toolbar"` rendition and two such renditions, the AppKit keyed control returned `unmatched`, `matched` and `ambiguous` respectively. The NIBArchive cases still returned no resource-key entries even though their catalog records were returned. Those controls establish REA's behavior after supplied engine output; they do not establish native `Assets.car` validity or real `assetutil` behavior. ## Evidence - Expected source: `docs/native-investigation.md:52` promises exact UI resource-name matches; `src/artifacts/apple/AppleAssetCatalogAnalysis.ts:19` recognizes the tested field names, and `:113` / `:121` state the empty-catalog and recognized-resource-field contracts. The delivered behavior is also described in the [merged empty-catalog change](https://github.com/morluto/rea/pull/1071). - Failure source: Independently executed public CLI fixture above on both tested builds. `src/artifacts/apple/InterfaceBuilderAnalysis.ts:347` decodes referenced NSString bytes and `:462` groups decoded values under `nibValues`; `src/domain/apple/interfaceBuilderGraph.ts:72` attaches archive provenance and `:115` preserves those attributes. `src/artifacts/apple/AppleAssetCatalogAnalysis.ts:191` collects only top-level attribute entries before `src/domain/apple/appleAssetCatalog.ts:131` projects matches. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete The production entrypoint and target guard are `src/cli/artifactCommands.ts:106` and `src/artifacts/ArtifactProvider.ts:264`. The primary no-catalog reproduction crosses both through ordinary command invocation; it uses no helper injection or engine substitution. Published runtime code retains the same nested producer and top-level consumer behavior. ## Additional context The omission is bounded to recognized scalar fields in the decoded NIBArchive representation. The generic limitation about other archive references does not cover this demonstrated recognized field on a decoded resource object. No claim is made that every NIBArchive uses these fields, that current iOS archives use any particular encoding, or that UIKit keyed-archive semantics are supported. The observed control uses AppKit classes. A same-artifact workaround is available. Decode the bundle, read the recognized field from the image node's `nibValues`, and compare the exact string to catalog `asset_name` records while preserving archive provenance and duplicate matches. The no-catalog case directly establishes an unmatched key. Under the modeled catalog controls, this manual comparison found zero, one and two records from the same returned artifacts. Neither input re-encoding nor source changes are needed. ## Restoration check For the inline fixture, the public catalog command must return an `NSImageName` / `Toolbar` row with `status: "unmatched"`, its decoded source node, the archive path/digest, and no renditions. With one supplied matching catalog record the row must be matched; with two it must be ambiguous and retain both renditions. The rooted AppKit keyed-archive control must retain its existing rows. A resource containing only an unrecognized `title` field must still produce no row. These checks restore the existing recognized-key relationship and do not authorize matching arbitrary nested values. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The command silently omits a documented resource-key relationship for the demonstrated supported archive representation while preserving the decoded archive field and other catalog output. Reach beyond fixtures and native catalog behavior remain unmeasured. - Workaround status: available - Workaround basis: The public decoder returns the same recognized scalar and archive provenance, allowing an exact manual comparison with the catalog records. This directly restores the missing unmatched key in the no-catalog case and preserved zero, one and two matches in the modeled catalog controls without changing either artifact.
bsakaguchi 2026-10-15 18:27:42 +00:00
Author
Owner

@morluto commented on GitHub (Oct 12, 2026):

Consolidation update: #2248 now retains the earlier contribution and folds #2256's producer-boundary normalization with credit; #2249's independent alternative was reviewed and acknowledged. Recognized scalar keys are normalized once, preserving nibValues and source node/archive provenance. Only parsed resource nodes join catalogs; a control-node regression prevents overbroad matching. Zero/one/multiple supplied renditions and public CLI/MCP/filesystem paths pass (49 tests), plus source/docs checks. This is supplied catalog metadata, not real assetutil validation. Keeping this issue open until #2248 merges.

<!-- gh-comment-id:6129996188 --> @morluto commented on GitHub (Oct 12, 2026): Consolidation update: #2248 now retains the earlier contribution and folds #2256's producer-boundary normalization with credit; #2249's independent alternative was reviewed and acknowledged. Recognized scalar keys are normalized once, preserving nibValues and source node/archive provenance. Only parsed resource nodes join catalogs; a control-node regression prevents overbroad matching. Zero/one/multiple supplied renditions and public CLI/MCP/filesystem paths pass (49 tests), plus source/docs checks. This is supplied catalog metadata, not real assetutil validation. Keeping this issue open until #2248 merges.
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#722
No description provided.