* feat(artifacts): inventory and extract ESP32 firmware images
ESP32-family app and bootloader images (magic 0xE9) had no reader, so
open_binary refused OTA downloads, app partitions and bootloaders.
Add the esp-image container format behind inspect_artifact and
extract_artifact. A pure decoder reads each byte once and keeps only
fixed-size fields. Derived facts cover the chip, from a table cited to
ESP-IDF v6.1; segment classification as ESP-IDF's should_map and
should_load; checksum and appended SHA-256 results; app and bootloader
descriptors; Secure Boot v2 signature blocks, observed and unverified;
and undescribed trailing byte ranges. Truncation and undescribed bytes
make coverage unknown. ESP8266 images are refused with a specific
reason, and ciphertext stays unrecognized.
Add the format to every target, Evidence, snapshot, live-summary and
artifact vocabulary, document it, and add an optional verify:esp-images
lane that compares esptool-built images with esptool image-info and
espsecure signature-info-v2.
Refs #3615
* test(artifacts): run the ESP image lane on esptool v5.6.0
The lane compared descriptor fields only when esptool image-info printed
one, so a descriptor that REA reports and esptool does not print passed
without comparison. Compare the descriptor kind in both directions.
Record that the lane passed for all 13 images with esptool and espsecure
v5.6.0, the version the layout facts cite, as well as with 4.12.0.
* refactor(artifacts): define ESP image layout sizes once
The decoder and the derived facts each defined the segment-header,
appended-digest and flash-sector sizes, the erased-byte scan and the
offset formatter. Define them once with the other fixed-layout structures
and import them in both places. The signature slot count is the one
constant that drives the slot scan.
Correct the observedEnd comment: it is the end of the last chunk read.
* fix(artifacts): report ESP-IDF's segment length limit by chip
ESP-IDF's verify_segment_header rejects a segment of 16 MiB or more
unless the chip maps flash through 32-bit cache addresses
(SOC_SPI_MEM_SUPPORT_CACHE_32BIT_ADDR_MAP: ESP32-S3, P4, C5, C61, H4 and
S31 in v6.1). Record that capability in the chip table and add a
limitation for such segments; unknown chips get none.
`loaded` repeats should_load. It does not mean the bootloader accepts the
segment: verify_load_addresses still rejects one outside the chip's RAM,
which REA does not model. Say so in the docs, the skill reference and the
inventory limitation.
* test(artifacts): check ESP lane tools first and verify extraction
The lane started work before checking its tools, so a missing RISC-V
target, ld.lld, esptool or espsecure surfaced as a raw spawn or command
error. Check each one before building and name the variable to set. Warn
when esptool is not v5.6.0, the release the layout facts cite.
Assert each segment's class for the ESP32-C3 app, its variants and the
bootloader, as the Xtensa image already did. Extract the app and the
signed image through the CLI and compare every file with the bytes at
esptool's segment offsets or in the appended signature sector; the
source's SHA-256 must be unchanged and equal the reported root_sha256.
* docs(artifacts): state what the ESP image lane does not establish
The Verification section listed the lane's checks but not its gaps: only
ESP32-C3 and ESP32 images come from a real producer, no real project
build, OTA download or flash dump is inspected, no signature is verified,
and the lane does not run in CI.
Record the timing of the final lane run on esptool v5.6.0.
* fix(artifacts): compare ESP segment declarations
Segment entries hash only their data, so compare_artifacts reported a
segment that moved to another load address with the same bytes as
unchanged; only the image itself changed. Compare each segment's
load_addr and data_len as its declaration, as Node SEA and PyInstaller
members are compared, and count a changed declaration as a metadata
change, also when content is unavailable.
Header offsets are layout and the classification is derived, so neither
is compared.
* test(artifacts): run ESP lane commands as owned process trees
The lane ran its tools through execFile. A timeout killed only the direct
child, so a linker or assembler that Clang or GCC had started could keep
running while the lane removed its workspace. Run every command through
createVerifierCommands, check the tools inside withVerifierCleanup, and
remove the workspace only after commands.close() has stopped every tree;
a failed close keeps the workspace.
Only a nonzero exit now counts as a refusal or an expected esptool
failure; timeouts and spawn failures propagate. Supervising each command
roughly doubles the lane's run time.
* test(artifacts): cancel ESP decoding after its first decoded chunk
The cancellation test requested cancel or close on setImmediate, which
can land while the file is still being opened and checked, so it never
showed that decoding had started. It also passed against a reader that
ignored both requests while decoding. Acknowledge the decoder's first
chunk, then cancel or close, join the operation, and assert that no
later chunk reaches the decoder and that the reader ends closed.
* docs(artifacts): record which ESP metadata comparison ignores
Segments are compared by their declaration. The image's and the
signature blocks' format_metadata are not: every field comes from their
own bytes, which content comparison already covers, and a block's
image_digest_matches follows the image's content.
* fix(artifacts): decide declaration comparison for every metadata kind
compare_artifacts listed the format_metadata kinds it compared by hand,
so a new kind went uncompared without notice; ESP segment load addresses
were first missed this way. One table beside the schemas now gives every
kind a declaration projection or the reason comparison uses none of its
facts, and a kind without an entry does not compile. Consumers read one
result per pair instead of naming formats.
- Godot PCK entries stay uncompared. Godot looks members up by the
simplified path without res://, which logical_path holds apart from
Unicode normalization. Godot 4.3 exports store res:// and 4.4.1
exports do not, so comparing source_path would mark every member
changed. The reader admits only the encryption flag, which the
occurrence's encrypted field records.
- A changed PyInstaller declaration is now a metadata change also when
the member's content is unavailable, as for ASAR, Node SEA and ESP
segments. It was unknown before, for no recorded reason.
- Root kinds and ESP signature blocks stay uncompared: their fields come
from their own bytes, which content comparison covers.
- A declaration that only one side records now adds one generic
limitation for every compared kind. Only Node SEA added one before,
with its own text.
* fix(artifacts): keep content comparison for metadata records with content
The record rule compared two metadata occurrences by their declaration
alone. A hand-built record that names content, with an equal declaration,
then hid a content change; PyInstaller records already did so before the
table. The rule now needs both records without an artifact_id, which is
every metadata record a reader emits.
Review changes in the same area:
- The unknown-declaration limitation also covers declarations of different
kinds and non-container observations, and tests compare it with its
constant.
- docs/godot-pck.md gives each Godot version's lookup path, the backslash
case, and that a member that becomes encrypted compares unknown. The
table points to it instead of restating it.
- The PyInstaller regression moves to an acceptance test whose MCP client
validates the advertised output schema, and the domain tests keep one
representative case per behavior.
* fix(artifacts): compare encryption changes when content is unavailable
An encrypted member has no content. Comparison then took the
content-unavailable fallback, which counted only ASAR storage and format
declarations, so a Godot PCK, ZIP or PYZ member that became encrypted
compared unknown although both sides record `encrypted`. The record rule
compared node-less metadata records by format declaration alone, so a PYZ
namespace record whose encryption flipped compared unchanged.
`OccurrenceMetadata` now owns the encryption comparison beside ASAR
storage and the format declaration. Every occurrence records `encrypted`,
so a pair's comparison is never unknown. The fallback, the record rule and
the metadata dimension read it there; `metadataChanged` no longer compares
`encrypted` itself. The record rule compares all of `OccurrenceMetadata`:
a changed value is changed in metadata, with availability when another is
unknown.
Decision: occurrence limitations still do not count for node-less
records. Readers derive them from the declared facts (the PYZ caveat
follows `encrypted`), and their wording can change between REA versions
while the record stays the same. The fallback does not compare them
either.
Behavior changes:
- A pair with unavailable content whose `encrypted` differs is changed
[metadata, availability], not unknown [availability].
- A node-less record whose `encrypted` differs is changed [metadata], not
unchanged.
- A hand-built node-less record that records ASAR storage now compares
it; no reader emits such records.
Remaining:
- The fallback still ignores `entry_kind` and `executable`, which readers
also declare, so a file that becomes a symlink, or a mode change on an
encrypted ZIP member, still compares unknown. `executable` has its own
dimension, so its fix needs content-free checks split from node checks.
Each is its own behavior decision.
- Node-bearing pairs still compare limitation wording; records do not.
Whether limitation wording is ever a comparison fact needs one decision.
* fix(artifacts): compare entry kind and executable when content is unavailable
When either side lacked verified content, comparison took a fallback that
counted only ASAR storage, encryption and format declarations. It compared
`entry_kind` only in the metadata check of pairs with content, and
`executable` only beside the node checks. A ZIP member that became a
symlink, whose target REA does not read, and an encrypted ZIP member whose
Unix mode gained an execute bit therefore compared unknown, although
readers declare both fields on every occurrence.
`OccurrenceMetadata` now owns the entry kind comparison beside encryption;
it is never unknown. Comparison checks fall into two groups: declared facts
that need no content (`executable` and the metadata declarations), and
facts that need both content nodes (content, kind, format, size, relations
and node metadata). The fallback, the record rule and pairs with content
share the declared checks and one rule for unknown declarations. Kind and
format stay content checks: without content they are guesses from the path.
Behavior changes:
- Unless both sides have content, a pair whose entry kind differs is
changed [metadata, availability], not unknown [availability].
- Such a pair whose `executable` differs is changed [executable,
availability], with metadata when another declared fact also differs.
- A hand-built node-less record whose `executable` differs is changed
[executable]. No reader emits such records.
Remaining: declared sizes and integrity digests still compare only through
observed bytes, so they do not count when content is unavailable.
* fix(artifacts): never mark a symlink executable
On POSIX hosts the directory reader recorded a symlink's own permission
bits as `executable`. Linux reports 0777 for every symlink and macOS
usually 0755, so directory, app bundle and DMG symlinks read true. zip.js,
the PyInstaller reader and the Windows branch record false. The previous
commit compares `executable` without content, so a symlink that two
readers or two hosts record differently compared changed in `executable`,
and a non-executable file that became a symlink also reported `executable`.
`hostExecutableMarking` now gives a symlink no marking on any host:
running a symlink runs its target, and the link's own bits do not control
that.
Behavior change: directory, app bundle and DMG inventories record
`executable: false` for symlinks. Occurrence IDs do not change, but the
graph digests and manifest IDs of inventories with symlinks do. Comparing
an inventory stored before this change with a new one therefore reports
changed [executable, availability] once for each such symlink.
* fix(artifacts): stop comparing limitation wording
Pairs with content compared node and occurrence limitations as metadata,
while node-less records, the content-unavailable fallback and inventory
limitations did not. Limitations explain what an observation could
establish. Readers mostly derive them from fields that comparison already
reads, from other occurrences or from what REA could read, and their
wording changes between REA versions. So adding `A` beside an unchanged
ZIP member `a` marked `a` changed in metadata through its case-collision
note, and an older and a newer inventory of equal bytes could differ
wherever a reader reworded a note.
No comparison path reads limitations now. Inventory limitations still
appear in the result, and occurrence limitations stay in the inventory
Evidence. Readers write no node limitations, so only hand-built inventories
compared them.
Behavior change: for a pair with content, limitation text no longer adds
metadata. A pair that differs only there compares unchanged, or unknown
[availability] when a declaration is unknown.
Remaining: a few facts appear only in limitation text, or also in fields
that comparison does not read, so their changes are no longer reported
while the compared bytes stay equal: an ASAR block integrity table or an
unsupported integrity declaration, a Godot member's all-zero MD5, a ZIP
name's leading `./` (also in `source_path`), and a PyInstaller PYZ image
digest (also in `pyz_sha256`; the archive root still reports the change).
Each needs a field or a comparison decision of its own if it matters.
* fix(artifacts): compare declared digests when content is unavailable
Without observed bytes on both sides, comparison ignored the containers'
declared integrity claims. Two encrypted Godot PCK members therefore
compared unknown even when their declared MD5s showed that the script had
changed. Godot's exporter stores the MD5 of a member's original bytes, so
the MD5 does not depend on the encryption IV. This comes from the 4.3,
4.4.1 and 4.7.1 exporter sources; no real encrypted export was compared.
When either side has no content, claims of one algorithm that differ now
make the path changed in metadata and availability. Equal claims add no
change, because they are unverified, and observed bytes still decide when
both sides have them. Godot PCK and ASAR readers record such claims.
For a readable member, a directory that declares or omits an MD5 only
decides whether REA can check its bytes, so such a member compares
unchanged. An encrypted member with an MD5 on only one side stays unknown.
Remaining: declared sizes still compare only through observed bytes.
* docs(artifacts): check the contract's compared kinds against the table
docs/mcp-contracts.md named the format_metadata kinds that
compare_artifacts compares in prose, by hand, so a kind added to the
declaration table could go unnamed. The integration branch already omits
esp-flash-partition, which the ESP flash layout branch adds to the table.
The paragraph now names each compared kind by its identifier. A
conformance test requires it to name exactly the kinds whose table entry
has a declaration, which artifactFormatMetadata.ts exports as
COMPARED_FORMAT_METADATA_KINDS, and no kind that comparison ignores.
* test(artifacts): share the comparison acceptance MCP fixture
Six artifact comparison acceptance tests each copied the same MCP
steps: open_binary, inspect_artifact, the inventory_artifact substep
Evidence, then compare_artifacts. The coverage test repeated the
inspect steps three times and kept its own inventory lookup.
tests/fixtures/artifactComparisonMcp.ts now owns these steps on the
schema-validating connectLocalToolsMcp client.
connectArtifactComparisonMcp() returns call, inspect(path) and
compare(left, right). inventoryEvidence() reads the inventory Evidence
that an inspection retains, from MCP or CLI output. Each test still
writes its own authored input and keeps its expectations.
The coverage lookup matched evidence.operation, not the substep
operation. The inspection schema fixes both to inventory_artifact, so
both select the same substep.
The plist acceptance test does not change. It compares the inspection
Evidence itself across CLI and MCP and passes the comparison Evidence
to verify_reconstruction and find_changed_behavior.
No assertion or scenario changes.
* fix(artifacts): leave .NET bundle member declarations uncompared
Upstream main compares no .NET bundle member facts. Comparing `file_type`
changed `compare_artifacts` output beyond this branch's fixes, so the kind
stays uncompared until that is decided. A changed manifest still shows as a
content change of the host.
* fix(artifacts): leave HEX region declarations uncompared
The comparison table compared a region's start_address and length. A
region's path names its start and end addresses, and its length follows
from them, so two regions that pair at one path always declare the same
facts: the entry could never find a change. It only made a region with
equal bytes unknown when the other side recorded no metadata, such as a
directory of extracted regions. The kind is not compared, as on main.
The contract paragraph also stops naming .NET bundle members, which
3e51a7760 left uncompared.
* test(artifacts): compare node-less records of any compared kind
The record rule follows the comparison table, not PyInstaller, but only
PyInstaller records exercised it. An ESP segment record shows both
outcomes: an equal declaration compares unchanged and a moved one changed
in metadata, without the availability that the fallback for unread
content would add.
* test(artifacts): compare a node-less record's recorded ASAR storage
The record rule compares ASAR storage beside the declaration and
`encrypted`, but no test recorded it on a metadata record. A changed
value is changed in metadata, and a value that only one side records
is unknown in availability, as for any occurrence.
* test(artifacts): compare UnityFS member declarations through MCP
The table compares a UnityFS member's declared source_path and raw
flags, but no comparison test covered the kind, so dropping either field
from the projection left every test passing. Bundles with equal member
bytes now show both: other flags, or a stored path in decomposed form,
compare changed in metadata, and reordered members, whose index and
stream offset are layout, compare unchanged.
* test(artifacts): use the shared comparison fixture for UnityFS
The UnityFS comparison test arrived from the bottom branch with its own
copy of the open_binary, inspect_artifact and compare_artifacts steps,
which tests/fixtures/artifactComparisonMcp.ts owns here. It now uses
connectArtifactComparisonMcp() like the other comparison acceptance
tests. No assertion changes.
* test(artifacts): find the contract's compared kinds by marker
The docs conformance test found its paragraph by an exact English phrase,
so rewording "also compares" failed it while the named kinds stayed
right. The paragraph now sits between BEGIN and END markers, as the
allocation report's sections do, and the test reads the kinds between
them. It still requires exactly the kinds whose table entry has a
declaration.
12 KiB
Python packaged applications
REA statically inventories PyInstaller executables with an appended CArchive.
inspect_artifact and extract_artifact share the same reader through CLI and
MCP. They read archive bytes without launching the application. Bytecode
source decompilation, encrypted PYZ recovery, zipapp and PEX analysis are outside
this capability.
extract_artifact.coverage preserves the inventory producer's source traversal
scope and certainty. Successful publication proves that the selected inventoried
regular files were written and verified; it does not prove that every source
entry was representable or every embedded payload was decoded. For example, a
directory containing an invalid UTF-8 filename can publish its representable
files successfully while coverage remains unknown. Extraction retains the
inventory's command provenance and limitations, plus the selected occurrences'
path-qualified derivation notices. A reconstructed .pyc header therefore stays
identified as derived bytes in the extraction result.
Facts and focused queries
The root occurrence's format_metadata records the CArchive layout (2.0 or
2.1), archive and cookie offsets, table extent, entry count, raw Python version,
normalized major/minor when known, library name and encryption marker. Member
metadata retains the original name, table index, type, compression flag,
relative offset and known directory record extent. Logical paths follow the
carrier's path dialect: backslashes separate components for PE carriers, while
they remain literal filename characters for POSIX carriers. The source name
always retains its stored spelling in occurrence source_path and
format_metadata.source_name. For PYZ rows, that source is the exact module or
data key in the PYZ table, not a reconstructed filesystem name;
format_metadata.container_name identifies the containing PYZ image. If the
carrier dialect is unconfirmed, REA preserves backslashes verbatim instead of
inferring POSIX semantics.
relative_offset is relative to the CArchive start for CArchive rows, or to
the decoded PYZ image for PYZ rows. A CArchive directory record starts at
archive_start + toc_offset + directory_record_offset; its size is
directory_record_size. PYZ row directory extents remain unknown.
CArchive type b retains PyInstaller's executable-member declaration in the
generic executable fact; type x remains non-executable. This comes from the
archive type code, not the filename or member contents.
Distinct CArchive rows retain their table-index identities even when they
share a logical path. Inventory can preserve both a file and a decoded PYZ
directory at that path, including the PYZ children; extraction refuses a
destination conflict instead of silently omitting those children.
Namespace packages remain metadata declarations at their projected query path;
they do not claim a destination directory. Their relative_offset and
compressed_size preserve any payload declared by older producers, while
declared_size is zero because no bytecode is materialized for a namespace.
Every declared span must end before the PYZ table of contents.
Malformed CArchive TOC rows identify their zero-based table index and byte offset
within the TOC, including truncated headers, invalid names, compression flags,
and type codes. The failure retains its original format reason.
PYZ compression_flag is derived from storage: nonempty payloads are compressed,
including legacy namespaces; an empty namespace has no compressed payload.
CArchive type o entries are runtime options. They are metadata occurrences,
with no content artifact or extractable file. Their query paths are
__pyinstaller__/runtime-options/<table index>; the option text is
format_metadata.source_name. Repeated declarations retain distinct occurrence
identities. These paths do not claim output filesystem names.
Type d entries are multipackage dependencies. They are also metadata, under
__pyinstaller__/dependencies/<table index>, and never materialize an empty file.
The reader preserves the raw reference and splits its first colon into
dependency_archive and dependency_member. dependency_status remains
unresolved, or invalid-reference with unknown parts for malformed references.
external_dependency_count summarizes these declarations. REA does not open
another executable or claim its contents are available. A real file with the
same logical path as metadata remains independently extractable.
Both declaration types require empty payloads and no compression. Their query
paths do not participate in filesystem collision checks, directory ancestry or
source-map target lookup; their parent identifies the declaring container.
Complete authenticated inventory page sets preserve producer identities in any
merge order, including metadata/file aliases. Malformed TOC diagnostics name the
table index; original names remain available in the supplied artifact.
Path failures use bounded escaped previews and report the original length;
accepted paths and retained source names are not shortened.
compare_artifacts compares node-less records, namely runtime options,
dependencies and namespace packages, as declarations using the container,
container name, source name and type code, and whether the record is
encrypted; their limitations are not compared. Equal declarations can
therefore compare unchanged without implying that an external dependency's
bytes were found or that unread legacy namespace payloads match. For
materialized PyInstaller members, the same fields participate in the metadata
delta independently of content identity, including when the member's bytes
are unavailable; archive offsets and table positions remain location facts,
not declaration identity. Missing declaration facts remain unknown, as does
unavailable non-metadata content whose declaration is unchanged.
Request detail: "summary" for a compact inspection, then query retained
Evidence with inspect_analysis_view. An inventory view can select
collection: "occurrences", entry_kinds: ["metadata"], exact logical paths or
occurrence IDs, requested fields, and offset/limit. Filters intersect;
identities, parent Evidence and coverage survive projection. Full inventory
Evidence remains available for export. A compact reply reduces returned context;
it still performs the complete member inventory and hashing work.
Focused views add calls and tool-schema costs. A complete small inventory can
cost less overall; prefer compact delivery when the full member graph would
overwhelm the answer.
rea inspect-artifact /absolute/path/hello --detail summary --evidence_output /absolute/new/evidence.json --json
rea extract-artifact /absolute/path/hello --json
Decoding and evidence limits
Inventory hashes members individually through bounded streams. Stored PYZ images use descriptor ranges; outer-compressed images stream to private files, which the reader owns through cleanup and any cleanup retry. Extraction binds the selected carrier to its inventoried digest and checks source identity and state before and after reading. It does not retain expanded module bodies. Opaque outer-compressed images reuse that verified private image when opened, instead of inflating the carrier again. Direct source spans describe stored CArchive bytes; decompressed content has no direct span even when its encoded and decoded sizes coincide. Original offsets and compression remain metadata.
Only the PYZ header and marshal directory are loaded for listing. PYZ tables
have a 16 MiB budget; CArchive tables have a 32 MiB budget. Parsed facts have a
32 MiB serialized budget. Projected entries share a 64 MiB retained budget,
including metadata, bookkeeping and conservative reservations for namespace
objects and possible case-collision annotations at each path component. These
reservations happen before normalization or trie allocation.
Outer-compressed PYZ images have a separate per-reader capacity of 128 private
files, bounding retained inodes and image identities. The complete image count
is admitted before any temporary file is created; excess images return a
capacity failure. This is a resource policy, not a PyInstaller format limit.
Artifact errors retain this distinction as details.boundary: "capacity", with
the actual allowance in the diagnosis and details.detail. Changing output
detail does not reduce that allowance's input scope; these operations do not
select a narrower member path during inventory.
Only a zero-byte read establishes EOF; short successful descriptor reads fill
the bounded range. Compressed members must consume their complete declared
input span; trailing junk and concatenated streams are refused.
Plaintext expansion has an aggregate archive-derived budget. Exceeding a budget
returns a resource failure.
Supported final Python bytecode magic values cover Python 3.7 through 3.14.
Supported marshal directory values are lists, tuples, strings, integers and
bytes, including interned Unicode strings used for module names. Unicode strings
use CPython's UTF-8 surrogatepass encoding, preserving lone surrogate code
points in decoded module names. Directory values follow the identified CPython marshal semantics; for
example, a long integer with a zero most-significant digit is malformed and is
not normalized into a valid table value. An unsupported magic or malformed or
unsupported directory stays an opaque member with a limitation and
pyz_status: "opaque"; no child entries from that table are projected, and
their module contents remain unknown. The original PYZ member and its digest
remain available independently. Healthy CArchive siblings are still inventoried.
Any opaque PYZ makes producer traversal coverage unknown, including retained
summaries and exhausted queries. Per-archive reasons remain on the opaque
occurrences; the coverage observation summarizes their count without repeating
every limitation. An empty result does not establish module absence.
Observed pyz_python_magic bytes remain available even when their Python version
is unknown.
The directory declares compressed module lengths, so uncompressed declared
sizes and source spans inside decoded data remain unknown.
PYZ modules receive a derived 16-byte .pyc header from their own supported
magic. CArchive scripts receive that header only when every sibling PYZ magic
and the cookie's Python version agree. Under the same agreement, CArchive
modules (m/M) receive a header only when their decompressed prefix identifies
headerless marshal code. Legacy modules that retain a .pyc header, and modules
whose prefix is unrecognized, preserve their original bytes and report
bytecode_header: "unknown". A header prefix does not establish that the entire
file is valid. bytecode_magic and graph derivation edges identify synthesized
headers; member digests cover the resulting derived files. These headers do not
establish successful execution or decompilation.
The nonempty CArchive Python module pyimod00_crypto_key (m/M) is recorded
as an encryption marker; a same-named data file or script is not. Affected
PYZ blobs are reported without decoding; extraction refuses encrypted entries.
The raw PYZ container remains readable and hashable, including when its module
table is opaque. Its marker stays in metadata and opaque child coverage stays
unknown; extracting the container does not decrypt its payloads.
The marker also applies to nonempty legacy namespace payloads. Empty namespaces
have no payload to encrypt and retain a null encryption source.
Detection searches the last 16 MiB for a cookie, allowing a trailing macOS code signature within that bound. Member offsets are relative to the archive start: cookie position plus cookie size minus package length. Native bootloader analysis remains available through the native tools.