@particle/tachyon-image 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * Qualcomm UEFI A/B slot selection via GPT partition-entry attribute bits.
4
+ * Canonical home (shared by particle-cli and the on-device OTA service).
5
+ *
6
+ * bits 48-49 PRIORITY (higher preferred; 0 = not selected by priority)
7
+ * bit 50 ACTIVE (slot the loader currently selects)
8
+ * bits 51-53 RETRY/tries (decremented per boot; 0 + !success => give up)
9
+ * bit 54 SUCCESS (booted OK; commit)
10
+ * bit 55 UNBOOTABLE (never select)
11
+ *
12
+ * NOTE: confirm exact positions vs the BP QcomPkg/.../PartitionTableUpdate.h
13
+ * before trusting on hardware — wrong bits brick slot selection. These match
14
+ * the standard Qualcomm boot_control layout. Pure BigInt helpers: unit-testable
15
+ * with no device.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.MAX_RETRY = exports.MAX_PRIORITY = exports.UNBOOTABLE_BIT = exports.SUCCESS_BIT = exports.RETRY_MASK = exports.RETRY_SHIFT = exports.ACTIVE_BIT = exports.PRIORITY_MASK = exports.PRIORITY_SHIFT = void 0;
19
+ exports.getSlotState = getSlotState;
20
+ exports.makeActivePending = makeActivePending;
21
+ exports.makeInactiveFallback = makeInactiveFallback;
22
+ exports.markSuccessful = markSuccessful;
23
+ exports.markUnbootable = markUnbootable;
24
+ exports.activeSlot = activeSlot;
25
+ exports.planSlotSwitch = planSlotSwitch;
26
+ exports.PRIORITY_SHIFT = 48n;
27
+ exports.PRIORITY_MASK = 0x3n;
28
+ exports.ACTIVE_BIT = 50n;
29
+ exports.RETRY_SHIFT = 51n;
30
+ exports.RETRY_MASK = 0x7n;
31
+ exports.SUCCESS_BIT = 54n;
32
+ exports.UNBOOTABLE_BIT = 55n;
33
+ exports.MAX_PRIORITY = 3;
34
+ exports.MAX_RETRY = 7;
35
+ const bit = (attr, n) => (attr >> n) & 1n;
36
+ const setBit = (attr, n, v) => (v ? attr | (1n << n) : attr & ~(1n << n));
37
+ const getField = (attr, shift, mask) => Number((attr >> shift) & mask);
38
+ const setField = (attr, shift, mask, value) => (attr & ~(mask << shift)) | ((BigInt(value) & mask) << shift);
39
+ function getSlotState(attr) {
40
+ const a = BigInt(attr);
41
+ return {
42
+ priority: getField(a, exports.PRIORITY_SHIFT, exports.PRIORITY_MASK),
43
+ active: bit(a, exports.ACTIVE_BIT) === 1n,
44
+ retries: getField(a, exports.RETRY_SHIFT, exports.RETRY_MASK),
45
+ success: bit(a, exports.SUCCESS_BIT) === 1n,
46
+ unbootable: bit(a, exports.UNBOOTABLE_BIT) === 1n,
47
+ };
48
+ }
49
+ function makeActivePending(attr, opts = {}) {
50
+ let a = BigInt(attr);
51
+ a = setField(a, exports.PRIORITY_SHIFT, exports.PRIORITY_MASK, opts.priority ?? exports.MAX_PRIORITY);
52
+ a = setField(a, exports.RETRY_SHIFT, exports.RETRY_MASK, opts.retries ?? exports.MAX_RETRY);
53
+ a = setBit(a, exports.ACTIVE_BIT, true);
54
+ a = setBit(a, exports.SUCCESS_BIT, false);
55
+ a = setBit(a, exports.UNBOOTABLE_BIT, false);
56
+ return a;
57
+ }
58
+ function makeInactiveFallback(attr, opts = {}) {
59
+ let a = BigInt(attr);
60
+ a = setField(a, exports.PRIORITY_SHIFT, exports.PRIORITY_MASK, opts.priority ?? exports.MAX_PRIORITY - 1);
61
+ a = setBit(a, exports.ACTIVE_BIT, false);
62
+ return a;
63
+ }
64
+ function markSuccessful(attr, opts = {}) {
65
+ let a = BigInt(attr);
66
+ a = setBit(a, exports.SUCCESS_BIT, true);
67
+ a = setBit(a, exports.UNBOOTABLE_BIT, false);
68
+ a = setField(a, exports.RETRY_SHIFT, exports.RETRY_MASK, opts.retries ?? exports.MAX_RETRY);
69
+ return a;
70
+ }
71
+ function markUnbootable(attr) {
72
+ let a = BigInt(attr);
73
+ a = setBit(a, exports.UNBOOTABLE_BIT, true);
74
+ a = setBit(a, exports.ACTIVE_BIT, false);
75
+ a = setField(a, exports.PRIORITY_SHIFT, exports.PRIORITY_MASK, 0);
76
+ return a;
77
+ }
78
+ function activeSlot(attrA, attrB) {
79
+ const a = getSlotState(attrA);
80
+ const b = getSlotState(attrB);
81
+ if (a.active && !b.active)
82
+ return 'a';
83
+ if (b.active && !a.active)
84
+ return 'b';
85
+ const aBootable = !a.unbootable && a.priority > 0;
86
+ const bBootable = !b.unbootable && b.priority > 0;
87
+ if (aBootable && (!bBootable || a.priority >= b.priority))
88
+ return 'a';
89
+ if (bBootable)
90
+ return 'b';
91
+ return 'a';
92
+ }
93
+ function planSlotSwitch(slotParts, target) {
94
+ if (target !== 'a' && target !== 'b') {
95
+ throw new Error(`slot target must be 'a' or 'b' (got '${target}')`);
96
+ }
97
+ const sys = slotParts.filter((p) => /^system_[ab]$/.test(p.label));
98
+ const ref = sys.length ? sys : slotParts;
99
+ const a = ref.find((p) => p.slot === 'a');
100
+ const b = ref.find((p) => p.slot === 'b');
101
+ const current = activeSlot(a ? a.attr : 0n, b ? b.attr : 0n);
102
+ const changes = [];
103
+ for (const p of slotParts) {
104
+ const fromAttr = BigInt(p.attr);
105
+ const toAttr = p.slot === target ? makeActivePending(fromAttr) : makeInactiveFallback(fromAttr);
106
+ if (toAttr !== fromAttr) {
107
+ changes.push({ label: p.label, lun: p.lun, slot: p.slot, fromAttr, toAttr });
108
+ }
109
+ }
110
+ return { current, target, changes };
111
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Android sparse image decoder. Yields an ordered op stream so a writer can
3
+ * stream straight to a block device (seek past holes) and a hasher can count
4
+ * holes as zeros — without ever materializing the whole expanded image.
5
+ *
6
+ * Reference (not a dependency): AOSP libsparse sparse_format.h.
7
+ */
8
+ export declare const SPARSE_MAGIC = 3978755898;
9
+ export type SparseOp = {
10
+ kind: 'data';
11
+ data: Buffer;
12
+ } | {
13
+ kind: 'skip';
14
+ length: number;
15
+ };
16
+ export interface SparseHeader {
17
+ blockSize: number;
18
+ totalBlocks: number;
19
+ totalChunks: number;
20
+ }
21
+ export declare function isSparse(buf: Buffer): boolean;
22
+ export declare function readSparseHeader(buf: Buffer): SparseHeader;
23
+ /**
24
+ * Decode a sparse image buffer into an ordered op stream.
25
+ * FILL is expanded into data (the fill word repeated); DONT_CARE becomes skip.
26
+ */
27
+ export declare function decodeSparse(buf: Buffer): Generator<SparseOp, void, unknown>;
28
+ /** Expand a sparse image to its full raw buffer (holes become zeros). Tests/small images only. */
29
+ export declare function sparseToRaw(buf: Buffer): Buffer;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SPARSE_MAGIC = void 0;
4
+ exports.isSparse = isSparse;
5
+ exports.readSparseHeader = readSparseHeader;
6
+ exports.decodeSparse = decodeSparse;
7
+ exports.sparseToRaw = sparseToRaw;
8
+ /**
9
+ * Android sparse image decoder. Yields an ordered op stream so a writer can
10
+ * stream straight to a block device (seek past holes) and a hasher can count
11
+ * holes as zeros — without ever materializing the whole expanded image.
12
+ *
13
+ * Reference (not a dependency): AOSP libsparse sparse_format.h.
14
+ */
15
+ exports.SPARSE_MAGIC = 0xed26ff3a;
16
+ const CHUNK_RAW = 0xcac1;
17
+ const CHUNK_FILL = 0xcac2;
18
+ const CHUNK_DONT_CARE = 0xcac3;
19
+ const CHUNK_CRC32 = 0xcac4;
20
+ function isSparse(buf) {
21
+ return buf.length >= 4 && buf.readUInt32LE(0) === exports.SPARSE_MAGIC;
22
+ }
23
+ function readSparseHeader(buf) {
24
+ if (!isSparse(buf))
25
+ throw new Error('not a sparse image (bad magic)');
26
+ const fileHdrSize = buf.readUInt16LE(8);
27
+ const blockSize = buf.readUInt32LE(12);
28
+ const totalBlocks = buf.readUInt32LE(16);
29
+ const totalChunks = buf.readUInt32LE(20);
30
+ if (fileHdrSize < 28)
31
+ throw new Error('sparse: short file header');
32
+ return { blockSize, totalBlocks, totalChunks };
33
+ }
34
+ /**
35
+ * Decode a sparse image buffer into an ordered op stream.
36
+ * FILL is expanded into data (the fill word repeated); DONT_CARE becomes skip.
37
+ */
38
+ function* decodeSparse(buf) {
39
+ if (!isSparse(buf)) {
40
+ // already raw
41
+ yield { kind: 'data', data: buf };
42
+ return;
43
+ }
44
+ const fileHdrSize = buf.readUInt16LE(8);
45
+ const chunkHdrSize = buf.readUInt16LE(10);
46
+ const blockSize = buf.readUInt32LE(12);
47
+ const totalChunks = buf.readUInt32LE(20);
48
+ let off = fileHdrSize;
49
+ for (let i = 0; i < totalChunks; i++) {
50
+ const chunkType = buf.readUInt16LE(off);
51
+ const chunkBlocks = buf.readUInt32LE(off + 4);
52
+ const totalSz = buf.readUInt32LE(off + 8);
53
+ const dataOff = off + chunkHdrSize;
54
+ const dataLen = totalSz - chunkHdrSize;
55
+ const outBytes = chunkBlocks * blockSize;
56
+ switch (chunkType) {
57
+ case CHUNK_RAW:
58
+ yield { kind: 'data', data: buf.subarray(dataOff, dataOff + outBytes) };
59
+ break;
60
+ case CHUNK_FILL: {
61
+ const fill = buf.subarray(dataOff, dataOff + 4);
62
+ if (fill.readUInt32LE(0) === 0) {
63
+ yield { kind: 'skip', length: outBytes }; // zero fill == hole
64
+ }
65
+ else {
66
+ const out = Buffer.allocUnsafe(outBytes);
67
+ for (let p = 0; p < outBytes; p += 4)
68
+ fill.copy(out, p);
69
+ yield { kind: 'data', data: out };
70
+ }
71
+ break;
72
+ }
73
+ case CHUNK_DONT_CARE:
74
+ yield { kind: 'skip', length: outBytes };
75
+ break;
76
+ case CHUNK_CRC32:
77
+ break; // metadata only
78
+ default:
79
+ throw new Error(`sparse: unknown chunk type 0x${chunkType.toString(16)}`);
80
+ }
81
+ off = dataOff + dataLen;
82
+ }
83
+ }
84
+ /** Expand a sparse image to its full raw buffer (holes become zeros). Tests/small images only. */
85
+ function sparseToRaw(buf) {
86
+ const parts = [];
87
+ for (const op of decodeSparse(buf)) {
88
+ parts.push(op.kind === 'data' ? op.data : Buffer.alloc(op.length));
89
+ }
90
+ return Buffer.concat(parts);
91
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared validator — used by composer CI, the cloud server, and the CLI so the
3
+ * checks live in one place. Operates on a parsed particle_image_v1 manifest;
4
+ * optionally also checks the embedded signature and the zip entry order.
5
+ */
6
+ import { KeyObject } from 'node:crypto';
7
+ import { type ParticleImageV1 } from '../manifest/particleImageV1';
8
+ export interface CheckResult {
9
+ name: string;
10
+ ok: boolean;
11
+ errors: string[];
12
+ }
13
+ export interface ValidateReport {
14
+ ok: boolean;
15
+ checks: CheckResult[];
16
+ }
17
+ export interface ValidateOptions {
18
+ publicKey?: string | Buffer | KeyObject;
19
+ /** entry names in stored order (from listImageZip) to verify manifest-first. */
20
+ zipEntryNames?: string[];
21
+ /** require payload_sha256 on program ops. Default true; false for a no-hash dir inspection. */
22
+ requireHashes?: boolean;
23
+ }
24
+ export declare function validateManifest(m: ParticleImageV1, opts?: ValidateOptions): ValidateReport;
25
+ /** Resolve a PEM string/buffer to a KeyObject (helper for the CLI). */
26
+ export declare function loadPublicKey(pem: string | Buffer): KeyObject;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateManifest = validateManifest;
4
+ exports.loadPublicKey = loadPublicKey;
5
+ /**
6
+ * Shared validator — used by composer CI, the cloud server, and the CLI so the
7
+ * checks live in one place. Operates on a parsed particle_image_v1 manifest;
8
+ * optionally also checks the embedded signature and the zip entry order.
9
+ */
10
+ const node_crypto_1 = require("node:crypto");
11
+ const sign_1 = require("../signing/sign");
12
+ const particleImageV1_1 = require("../manifest/particleImageV1");
13
+ function check(name, fn) {
14
+ let errors;
15
+ try {
16
+ errors = fn();
17
+ }
18
+ catch (e) {
19
+ errors = [e.message];
20
+ }
21
+ return { name, ok: errors.length === 0, errors };
22
+ }
23
+ function programOps(p) {
24
+ return p.ops.filter((o) => o.op === 'program');
25
+ }
26
+ function validateManifest(m, opts = {}) {
27
+ const checks = [];
28
+ const bySlot = (slot) => m.partitions.filter((p) => p.slot === slot);
29
+ checks.push(check('manifest-first', () => {
30
+ if (!opts.zipEntryNames)
31
+ return [];
32
+ return opts.zipEntryNames[0] === particleImageV1_1.MANIFEST_ENTRY_NAME
33
+ ? []
34
+ : [`first entry is '${opts.zipEntryNames[0]}', expected '${particleImageV1_1.MANIFEST_ENTRY_NAME}'`];
35
+ }));
36
+ checks.push(check('signature', () => {
37
+ if (!opts.publicKey)
38
+ return [];
39
+ if (m.signing.profile === 'none')
40
+ return [];
41
+ return (0, sign_1.verifyManifest)(m, opts.publicKey) ? [] : ['manifest signature does not verify against the supplied key'];
42
+ }));
43
+ checks.push(check('groups-present', () => {
44
+ if (m.format.kind !== 'factory')
45
+ return [];
46
+ const required = ['BOOT', 'FIRMWARE', 'A', 'B', 'NVM'];
47
+ const have = new Set(m.partitions.map((p) => p.group));
48
+ const missing = required.filter((g) => !have.has(g));
49
+ return missing.length ? [`factory image missing groups: ${missing.join(', ')}`] : [];
50
+ }));
51
+ checks.push(check('slot-symmetry', () => {
52
+ const errs = [];
53
+ const a = bySlot('a');
54
+ const b = bySlot('b');
55
+ const bByBase = new Map(b.map((p) => [p.label.replace(/_b$/, ''), p]));
56
+ for (const pa of a) {
57
+ const base = pa.label.replace(/_a$/, '');
58
+ const pb = bByBase.get(base);
59
+ if (!pb) {
60
+ errs.push(`slot A partition '${pa.label}' has no slot B peer`);
61
+ continue;
62
+ }
63
+ if (pa.size !== undefined && pb.size !== undefined && pa.size !== pb.size) {
64
+ errs.push(`'${pa.label}'/'${pb.label}' size mismatch (${pa.size} vs ${pb.size})`);
65
+ }
66
+ if (pa.type_guid && pb.type_guid && pa.type_guid === pb.type_guid) {
67
+ errs.push(`'${pa.label}'/'${pb.label}' share a type GUID (${pa.type_guid}); A/B slots must differ`);
68
+ }
69
+ }
70
+ return errs;
71
+ }));
72
+ checks.push(check('sizes-fit', () => {
73
+ const errs = [];
74
+ for (const p of m.partitions) {
75
+ if (!p.num_partition_sectors || !p.size)
76
+ continue;
77
+ const cap = p.num_partition_sectors * m.sector_size;
78
+ for (const op of programOps(p)) {
79
+ if (op.uncompressed_size && op.uncompressed_size > cap) {
80
+ errs.push(`'${p.label}': image ${op.uncompressed_size}B exceeds partition ${cap}B`);
81
+ }
82
+ }
83
+ }
84
+ return errs;
85
+ }));
86
+ checks.push(check('program-hashes', () => {
87
+ const errs = [];
88
+ const requireHashes = opts.requireHashes !== false;
89
+ for (const p of m.partitions) {
90
+ for (const op of programOps(p)) {
91
+ if (!op.source && !op.chunks)
92
+ errs.push(`'${p.label}': program op has neither source nor chunks`);
93
+ if (requireHashes && op.source && !op.payload_sha256) {
94
+ errs.push(`'${p.label}': program op missing payload_sha256`);
95
+ }
96
+ }
97
+ }
98
+ return errs;
99
+ }));
100
+ checks.push(check('emittable', () => {
101
+ const errs = [];
102
+ const groups = new Set(m.partitions.map((p) => p.group));
103
+ const need = {
104
+ factory: ['BOOT', 'FIRMWARE', 'A', 'B'],
105
+ 'ota-image': ['A'],
106
+ 'ota-boot': ['BOOT', 'FIRMWARE', 'A'],
107
+ };
108
+ for (const kind of m.format.emittable) {
109
+ const missing = (need[kind] ?? []).filter((g) => !groups.has(g));
110
+ if (missing.length)
111
+ errs.push(`declared emittable '${kind}' but missing groups: ${missing.join(', ')}`);
112
+ }
113
+ return errs;
114
+ }));
115
+ return { ok: checks.every((c) => c.ok), checks };
116
+ }
117
+ /** Resolve a PEM string/buffer to a KeyObject (helper for the CLI). */
118
+ function loadPublicKey(pem) {
119
+ return (0, node_crypto_1.createPublicKey)(pem);
120
+ }
@@ -0,0 +1,34 @@
1
+ export type RawEntryKind = 'program' | 'erase' | 'read';
2
+ /** A numeric LBA/size, or a verbatim expression string (e.g. "NUM_DISK_SECTORS-5."). */
3
+ export type SectorValue = number | string;
4
+ export interface RawEntry {
5
+ kind: RawEntryKind;
6
+ label: string;
7
+ physicalPartitionNumber: number;
8
+ startSector: SectorValue;
9
+ numPartitionSectors: SectorValue;
10
+ sectorSizeInBytes: number;
11
+ filename: string;
12
+ fileSectorOffset?: number;
13
+ sizeKB?: number;
14
+ partofsingleimage?: boolean;
15
+ readbackverify?: boolean;
16
+ sparse?: boolean;
17
+ startByteHex?: string;
18
+ }
19
+ export interface PatchEntry {
20
+ label?: string;
21
+ physicalPartitionNumber: number;
22
+ startSector: SectorValue;
23
+ byteOffset: number;
24
+ sizeInBytes: number;
25
+ value: string;
26
+ filename: string;
27
+ sectorSizeInBytes: number;
28
+ what?: string;
29
+ }
30
+ export declare function parseRawProgram(xml: string): RawEntry[];
31
+ export declare function parsePatch(xml: string): PatchEntry[];
32
+ /** Emit a rawprogram XML matching the Qualcomm attribute layout. */
33
+ export declare function buildRawProgram(entries: RawEntry[]): string;
34
+ export declare function buildPatch(entries: PatchEntry[]): string;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseRawProgram = parseRawProgram;
4
+ exports.parsePatch = parsePatch;
5
+ exports.buildRawProgram = buildRawProgram;
6
+ exports.buildPatch = buildPatch;
7
+ /**
8
+ * Qualcomm rawprogram / patch XML reader + writer.
9
+ *
10
+ * The grammar is attribute-only elements under a <data> (rawprogram) or
11
+ * <patches> (patch) root. `start_sector` / `num_partition_sectors` may be
12
+ * literal LBAs OR expressions like "NUM_DISK_SECTORS-5." — kept verbatim as
13
+ * strings so they round-trip losslessly. Reference (not a dependency):
14
+ * linux-msm/qdl (BSD) and bkerler/edl (GPL) for edge cases.
15
+ */
16
+ const fast_xml_parser_1 = require("fast-xml-parser");
17
+ const RAW_KINDS = ['program', 'erase', 'read'];
18
+ function toArray(v) {
19
+ if (v === undefined)
20
+ return [];
21
+ return Array.isArray(v) ? v : [v];
22
+ }
23
+ /** Parse a string attribute into number if it is a pure number, else keep verbatim. */
24
+ function sectorVal(s) {
25
+ if (s === undefined || s === '')
26
+ return 0;
27
+ // pure integer or float (no letters/operators) -> number; else expression string
28
+ if (/^-?\d+(\.\d+)?\.?$/.test(s) && !s.endsWith('.'))
29
+ return Number(s);
30
+ if (/^-?\d+$/.test(s))
31
+ return Number(s);
32
+ return s;
33
+ }
34
+ function num(s) {
35
+ if (s === undefined || s === '')
36
+ return undefined;
37
+ const n = Number(s);
38
+ return Number.isNaN(n) ? undefined : n;
39
+ }
40
+ function bool(s) {
41
+ if (s === undefined)
42
+ return undefined;
43
+ return s === 'true' || s === 'TRUE' || s === '1';
44
+ }
45
+ function mkParser() {
46
+ return new fast_xml_parser_1.XMLParser({
47
+ ignoreAttributes: false,
48
+ attributeNamePrefix: '',
49
+ parseAttributeValue: false, // keep all attributes as raw strings; we coerce explicitly
50
+ allowBooleanAttributes: true,
51
+ trimValues: true,
52
+ });
53
+ }
54
+ function parseRawProgram(xml) {
55
+ const doc = mkParser().parse(xml);
56
+ const data = doc.data ?? {};
57
+ const out = [];
58
+ for (const kind of RAW_KINDS) {
59
+ for (const a of toArray(data[kind])) {
60
+ out.push({
61
+ kind,
62
+ label: a.label ?? '',
63
+ physicalPartitionNumber: num(a.physical_partition_number) ?? 0,
64
+ startSector: sectorVal(a.start_sector),
65
+ numPartitionSectors: sectorVal(a.num_partition_sectors),
66
+ sectorSizeInBytes: num(a.SECTOR_SIZE_IN_BYTES) ?? 4096,
67
+ filename: a.filename ?? '',
68
+ fileSectorOffset: num(a.file_sector_offset),
69
+ sizeKB: num(a.size_in_KB),
70
+ partofsingleimage: bool(a.partofsingleimage),
71
+ readbackverify: bool(a.readbackverify),
72
+ sparse: bool(a.sparse),
73
+ startByteHex: a.start_byte_hex,
74
+ });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ function parsePatch(xml) {
80
+ const doc = mkParser().parse(xml);
81
+ const patches = doc.patches ?? {};
82
+ return toArray(patches.patch).map((a) => ({
83
+ label: a.label,
84
+ physicalPartitionNumber: num(a.physical_partition_number) ?? 0,
85
+ startSector: sectorVal(a.start_sector),
86
+ byteOffset: num(a.byte_offset) ?? 0,
87
+ sizeInBytes: num(a.size_in_bytes) ?? 0,
88
+ value: a.value ?? '',
89
+ filename: a.filename ?? '',
90
+ sectorSizeInBytes: num(a.SECTOR_SIZE_IN_BYTES) ?? 4096,
91
+ what: a.what,
92
+ }));
93
+ }
94
+ function attr(name, v) {
95
+ if (v === undefined)
96
+ return '';
97
+ return ` ${name}="${v}"`;
98
+ }
99
+ /** Emit a rawprogram XML matching the Qualcomm attribute layout. */
100
+ function buildRawProgram(entries) {
101
+ const lines = ['<?xml version="1.0" ?>', '<data>'];
102
+ lines.push(' <!--NOTE: This is an ** Autogenerated file **-->');
103
+ lines.push(' <!--NOTE: Sector size is 4096bytes-->');
104
+ for (const e of entries) {
105
+ let s = ` <${e.kind}`;
106
+ s += attr('start_sector', e.startSector);
107
+ if (e.sizeKB !== undefined)
108
+ s += attr('size_in_KB', e.sizeKB);
109
+ s += attr('physical_partition_number', e.physicalPartitionNumber);
110
+ if (e.partofsingleimage !== undefined)
111
+ s += attr('partofsingleimage', e.partofsingleimage);
112
+ if (e.fileSectorOffset !== undefined)
113
+ s += attr('file_sector_offset', e.fileSectorOffset);
114
+ s += attr('num_partition_sectors', e.numPartitionSectors);
115
+ if (e.readbackverify !== undefined)
116
+ s += attr('readbackverify', e.readbackverify);
117
+ s += attr('filename', e.filename);
118
+ if (e.sparse !== undefined)
119
+ s += attr('sparse', e.sparse);
120
+ if (e.startByteHex !== undefined)
121
+ s += attr('start_byte_hex', e.startByteHex);
122
+ s += attr('SECTOR_SIZE_IN_BYTES', e.sectorSizeInBytes);
123
+ s += attr('label', e.label);
124
+ s += '/>';
125
+ lines.push(s);
126
+ }
127
+ lines.push('</data>');
128
+ return lines.join('\n') + '\n';
129
+ }
130
+ function buildPatch(entries) {
131
+ const lines = ['<?xml version="1.0" ?>', '<patches>'];
132
+ lines.push(' <!--NOTE: This is an ** Autogenerated file **-->');
133
+ for (const e of entries) {
134
+ let s = ' <patch';
135
+ s += attr('start_sector', e.startSector);
136
+ s += attr('byte_offset', e.byteOffset);
137
+ s += attr('physical_partition_number', e.physicalPartitionNumber);
138
+ s += attr('size_in_bytes', e.sizeInBytes);
139
+ s += attr('value', e.value);
140
+ s += attr('filename', e.filename);
141
+ s += attr('SECTOR_SIZE_IN_BYTES', e.sectorSizeInBytes);
142
+ if (e.what !== undefined)
143
+ s += attr('what', e.what);
144
+ s += '/>';
145
+ lines.push(s);
146
+ }
147
+ lines.push('</patches>');
148
+ return lines.join('\n') + '\n';
149
+ }
@@ -0,0 +1,18 @@
1
+ import { type ParticleImageV1 } from '../manifest/particleImageV1';
2
+ export interface PayloadEntry {
3
+ /** entry name inside the zip (matches a partition op `source`) */
4
+ name: string;
5
+ /** already-prepared (compressed/sparse) bytes, or a source file path */
6
+ buffer?: Buffer;
7
+ path?: string;
8
+ }
9
+ /** Write a manifest-first zip. Entries are written in the given order. */
10
+ export declare function writeImageZip(outPath: string, manifest: ParticleImageV1, payloads: PayloadEntry[]): Promise<void>;
11
+ export interface ZipEntryInfo {
12
+ name: string;
13
+ uncompressedSize: number;
14
+ }
15
+ /** List entries in stored (central-directory) order. */
16
+ export declare function listImageZip(zipPath: string): Promise<ZipEntryInfo[]>;
17
+ /** Read + parse the first entry as the particle_image_v1 manifest. */
18
+ export declare function readManifestFromZip(zipPath: string): Promise<ParticleImageV1>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.writeImageZip = writeImageZip;
7
+ exports.listImageZip = listImageZip;
8
+ exports.readManifestFromZip = readManifestFromZip;
9
+ /**
10
+ * Manifest-first image ZIP. `particle-image.json` is always the FIRST entry,
11
+ * followed by payload entries in flash order — so a sequential reader can verify
12
+ * the manifest before any payload, and decode each partition as it streams. The
13
+ * central directory still allows random-access extraction of a single entry.
14
+ */
15
+ const node_fs_1 = require("node:fs");
16
+ const yazl_1 = __importDefault(require("yazl"));
17
+ const yauzl_1 = __importDefault(require("yauzl"));
18
+ const particleImageV1_1 = require("../manifest/particleImageV1");
19
+ /** Write a manifest-first zip. Entries are written in the given order. */
20
+ function writeImageZip(outPath, manifest, payloads) {
21
+ return new Promise((resolve, reject) => {
22
+ const zip = new yazl_1.default.ZipFile();
23
+ const out = (0, node_fs_1.createWriteStream)(outPath);
24
+ out.on('close', resolve);
25
+ out.on('error', reject);
26
+ zip.outputStream.on('error', reject);
27
+ zip.outputStream.pipe(out);
28
+ // manifest FIRST; store uncompressed (it is small and we want it byte-stable)
29
+ const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2) + '\n', 'utf8');
30
+ zip.addBuffer(manifestBuf, particleImageV1_1.MANIFEST_ENTRY_NAME, { compress: false });
31
+ for (const p of payloads) {
32
+ if (p.buffer)
33
+ zip.addBuffer(p.buffer, p.name, { compress: false });
34
+ else if (p.path)
35
+ zip.addFile(p.path, p.name, { compress: false });
36
+ else
37
+ throw new Error(`writeImageZip: entry ${p.name} has no buffer or path`);
38
+ }
39
+ zip.end();
40
+ });
41
+ }
42
+ /** List entries in stored (central-directory) order. */
43
+ function listImageZip(zipPath) {
44
+ return new Promise((resolve, reject) => {
45
+ const entries = [];
46
+ yauzl_1.default.open(zipPath, { lazyEntries: true }, (err, zf) => {
47
+ if (err || !zf)
48
+ return reject(err ?? new Error('open failed'));
49
+ zf.on('entry', (e) => {
50
+ entries.push({ name: e.fileName, uncompressedSize: e.uncompressedSize });
51
+ zf.readEntry();
52
+ });
53
+ zf.on('end', () => resolve(entries));
54
+ zf.on('error', reject);
55
+ zf.readEntry();
56
+ });
57
+ });
58
+ }
59
+ /** Read + parse the first entry as the particle_image_v1 manifest. */
60
+ function readManifestFromZip(zipPath) {
61
+ return new Promise((resolve, reject) => {
62
+ yauzl_1.default.open(zipPath, { lazyEntries: true }, (err, zf) => {
63
+ if (err || !zf)
64
+ return reject(err ?? new Error('open failed'));
65
+ let handled = false;
66
+ zf.on('entry', (e) => {
67
+ if (handled)
68
+ return;
69
+ handled = true;
70
+ if (e.fileName !== particleImageV1_1.MANIFEST_ENTRY_NAME) {
71
+ return reject(new Error(`first zip entry is '${e.fileName}', expected '${particleImageV1_1.MANIFEST_ENTRY_NAME}'`));
72
+ }
73
+ zf.openReadStream(e, (er, rs) => {
74
+ if (er || !rs)
75
+ return reject(er ?? new Error('read failed'));
76
+ const chunks = [];
77
+ rs.on('data', (c) => chunks.push(c));
78
+ rs.on('end', () => {
79
+ try {
80
+ resolve((0, particleImageV1_1.parseParticleImageV1)(Buffer.concat(chunks).toString('utf8')));
81
+ }
82
+ catch (e2) {
83
+ reject(e2);
84
+ }
85
+ });
86
+ rs.on('error', reject);
87
+ });
88
+ });
89
+ zf.on('error', reject);
90
+ zf.readEntry();
91
+ });
92
+ });
93
+ }