@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.
- package/README.md +35 -0
- package/dist/bin/particle-image.d.ts +2 -0
- package/dist/bin/particle-image.js +135 -0
- package/dist/format/expand.d.ts +18 -0
- package/dist/format/expand.js +49 -0
- package/dist/hash/index.d.ts +19 -0
- package/dist/hash/index.js +78 -0
- package/dist/import/factoryDir.d.ts +9 -0
- package/dist/import/factoryDir.js +226 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +53 -0
- package/dist/manifest/imageManifestV1.d.ts +36 -0
- package/dist/manifest/imageManifestV1.js +30 -0
- package/dist/manifest/particleImageV1.d.ts +78 -0
- package/dist/manifest/particleImageV1.js +21 -0
- package/dist/model/types.d.ts +140 -0
- package/dist/model/types.js +48 -0
- package/dist/server/index.d.ts +29 -0
- package/dist/server/index.js +35 -0
- package/dist/signing/jcs.d.ts +9 -0
- package/dist/signing/jcs.js +31 -0
- package/dist/signing/sign.d.ts +15 -0
- package/dist/signing/sign.js +44 -0
- package/dist/slots/index.d.ts +64 -0
- package/dist/slots/index.js +111 -0
- package/dist/sparse/decode.d.ts +29 -0
- package/dist/sparse/decode.js +91 -0
- package/dist/validate/index.d.ts +26 -0
- package/dist/validate/index.js +120 -0
- package/dist/xml/rawprogram.d.ts +34 -0
- package/dist/xml/rawprogram.js +149 -0
- package/dist/zip/index.d.ts +18 -0
- package/dist/zip/index.js +93 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @particle/tachyon-image
|
|
2
|
+
|
|
3
|
+
Shared library for Particle Tachyon system images. It is the **single home** for all image logic so
|
|
4
|
+
the composer, CLI, on-device OTA service, and cloud server never re-implement it:
|
|
5
|
+
|
|
6
|
+
- **operation-graph model** (`program` chunked/sparse · `erase` · `zero` · `patch` GPT-arith · `gpt`)
|
|
7
|
+
- **`particle_image_v1`** (Particle format) + legacy Qualcomm **`image_manifest_v1`** parse/build
|
|
8
|
+
- **rawprogram / patch / erase XML** round-trip (import a real factory image, emit qdl-ready XML)
|
|
9
|
+
- **ed25519 / JCS** manifest signing + verification
|
|
10
|
+
- **sha256 / payload_sha256** hashing (expanded-with-holes)
|
|
11
|
+
- **Android sparse** + multi-chunk decode
|
|
12
|
+
- **format expansion**: `factory` | `ota-image(slot)` | `ota-boot(slot)`
|
|
13
|
+
- **manifest-first ZIP** read/write (streaming, partition-decodable order)
|
|
14
|
+
- **validation** (shared by composer CI, cloud server, CLI)
|
|
15
|
+
|
|
16
|
+
Every module is unit-tested. Golden fixtures under `test/fixtures/factory/` are real
|
|
17
|
+
`rawprogram`/`patch`/`manifest.json` entries pulled from the published Tachyon factory image, used to
|
|
18
|
+
prove lossless round-trip.
|
|
19
|
+
|
|
20
|
+
## Prior art (reference, not a dependency)
|
|
21
|
+
|
|
22
|
+
The Qualcomm rawprogram/patch grammar is also implemented by [linux-msm/qdl](https://github.com/linux-msm/qdl)
|
|
23
|
+
(C, BSD-3 — authoritative) and [bkerler/edl](https://github.com/bkerler/edl) (Python, GPL-3 — most
|
|
24
|
+
complete parser). We read those for grammar edge cases (patch CRC ops, `partofsingleimage`,
|
|
25
|
+
sparse/erase) but implement our own in TypeScript: no JS/TS library exists, it avoids the GPL/Python
|
|
26
|
+
coupling, and we need a superset (op-graph + `particle_image_v1` + signing) they don't provide.
|
|
27
|
+
|
|
28
|
+
## Build & test
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install
|
|
32
|
+
npm run build # tsc -> dist/
|
|
33
|
+
npm test # vitest
|
|
34
|
+
npm run typecheck
|
|
35
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
/**
|
|
5
|
+
* particle-image — thin CLI over @particle/tachyon-image.
|
|
6
|
+
*
|
|
7
|
+
* particle-image generate --factory-dir <dir> --out <zip>
|
|
8
|
+
* [--emit factory|ota-image|ota-boot] [--slot a|b] [--include-userdata]
|
|
9
|
+
* [--sign-profile test|prod|none] [--key <ed25519-private.pem>]
|
|
10
|
+
*
|
|
11
|
+
* particle-image validate <image.zip | factory-dir> [--public-key <pem>]
|
|
12
|
+
*
|
|
13
|
+
* Used by tachyon-composer (generate + CI validate) and reusable anywhere.
|
|
14
|
+
*/
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
const factoryDir_1 = require("../import/factoryDir");
|
|
18
|
+
const expand_1 = require("../format/expand");
|
|
19
|
+
const sign_1 = require("../signing/sign");
|
|
20
|
+
const zip_1 = require("../zip");
|
|
21
|
+
const validate_1 = require("../validate");
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const out = { _: [] };
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
if (a.startsWith('--')) {
|
|
27
|
+
const key = a.slice(2);
|
|
28
|
+
const next = argv[i + 1];
|
|
29
|
+
if (next === undefined || next.startsWith('--'))
|
|
30
|
+
out[key] = true;
|
|
31
|
+
else {
|
|
32
|
+
out[key] = next;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
out._.push(a);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Collect the unique payload file names referenced by the manifest's ops. */
|
|
42
|
+
function collectPayloads(manifest, dir) {
|
|
43
|
+
const names = new Set();
|
|
44
|
+
for (const p of manifest.partitions) {
|
|
45
|
+
for (const op of p.ops) {
|
|
46
|
+
if (op.op === 'program') {
|
|
47
|
+
if (op.source)
|
|
48
|
+
names.add(op.source);
|
|
49
|
+
for (const c of op.chunks ?? [])
|
|
50
|
+
names.add(c.source);
|
|
51
|
+
}
|
|
52
|
+
else if (op.op === 'zero' && op.source)
|
|
53
|
+
names.add(op.source);
|
|
54
|
+
else if (op.op === 'gpt')
|
|
55
|
+
names.add(op.source);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const payloads = [];
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
const path = (0, node_path_1.join)(dir, name);
|
|
61
|
+
if ((0, node_fs_1.existsSync)(path) && (0, node_fs_1.statSync)(path).isFile())
|
|
62
|
+
payloads.push({ name, path });
|
|
63
|
+
}
|
|
64
|
+
return payloads;
|
|
65
|
+
}
|
|
66
|
+
async function cmdGenerate(args) {
|
|
67
|
+
const dir = args['factory-dir'];
|
|
68
|
+
const out = args.out;
|
|
69
|
+
if (!dir || !out) {
|
|
70
|
+
console.error('generate: --factory-dir and --out are required');
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
const profile = args['sign-profile'] ?? 'test';
|
|
74
|
+
let manifest = (0, factoryDir_1.importFactoryDir)(dir, { signingProfile: profile, keyId: args['key-id'] });
|
|
75
|
+
const emit = args.emit ?? 'factory';
|
|
76
|
+
if (emit !== 'factory') {
|
|
77
|
+
manifest = (0, expand_1.expand)(manifest, { kind: emit, slot: args.slot, includeUserdata: !!args['include-userdata'] });
|
|
78
|
+
}
|
|
79
|
+
if (args.key) {
|
|
80
|
+
manifest = (0, sign_1.signManifest)(manifest, (0, node_fs_1.readFileSync)(args.key));
|
|
81
|
+
}
|
|
82
|
+
else if (profile !== 'none') {
|
|
83
|
+
console.error(`warning: --sign-profile ${profile} but no --key given; manifest will be unsigned`);
|
|
84
|
+
}
|
|
85
|
+
const payloads = collectPayloads(manifest, dir);
|
|
86
|
+
await (0, zip_1.writeImageZip)(out, manifest, payloads);
|
|
87
|
+
console.error(`wrote ${out}: ${emit}, ${manifest.partitions.length} partitions, ${payloads.length} payloads`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
async function cmdValidate(args) {
|
|
91
|
+
const target = args._[0];
|
|
92
|
+
if (!target) {
|
|
93
|
+
console.error('validate: <image.zip | factory-dir> required');
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
const publicKey = args['public-key'] ? (0, node_fs_1.readFileSync)(args['public-key']) : undefined;
|
|
97
|
+
let manifest;
|
|
98
|
+
let zipEntryNames;
|
|
99
|
+
let requireHashes = true;
|
|
100
|
+
if ((0, node_fs_1.statSync)(target).isDirectory()) {
|
|
101
|
+
// structural inspection by default (skip multi-GB hashing); --hash to be strict.
|
|
102
|
+
const doHash = !!args.hash;
|
|
103
|
+
manifest = (0, factoryDir_1.importFactoryDir)(target, { hashPayloads: doHash });
|
|
104
|
+
requireHashes = doHash;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
manifest = await (0, zip_1.readManifestFromZip)(target);
|
|
108
|
+
zipEntryNames = (await (0, zip_1.listImageZip)(target)).map((e) => e.name);
|
|
109
|
+
}
|
|
110
|
+
const report = (0, validate_1.validateManifest)(manifest, { publicKey, zipEntryNames, requireHashes });
|
|
111
|
+
for (const c of report.checks) {
|
|
112
|
+
console.log(`${c.ok ? 'PASS' : 'FAIL'} ${c.name}`);
|
|
113
|
+
for (const e of c.errors)
|
|
114
|
+
console.log(` - ${e}`);
|
|
115
|
+
}
|
|
116
|
+
console.log(report.ok ? '\nOK: all checks passed' : '\nFAILED');
|
|
117
|
+
return report.ok ? 0 : 1;
|
|
118
|
+
}
|
|
119
|
+
async function main() {
|
|
120
|
+
const [, , cmd, ...rest] = process.argv;
|
|
121
|
+
const args = parseArgs(rest);
|
|
122
|
+
switch (cmd) {
|
|
123
|
+
case 'generate':
|
|
124
|
+
return cmdGenerate(args);
|
|
125
|
+
case 'validate':
|
|
126
|
+
return cmdValidate(args);
|
|
127
|
+
default:
|
|
128
|
+
console.error('usage: particle-image <generate|validate> [options]');
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
main().then((code) => process.exit(code), (err) => {
|
|
133
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format expansion: select the partition subset of a particle_image_v1 for one
|
|
3
|
+
* of the three emit formats. The source archive is normally a full `factory`
|
|
4
|
+
* image; expansion produces the smaller `ota-image` / `ota-boot` views.
|
|
5
|
+
*
|
|
6
|
+
* - factory : every partition.
|
|
7
|
+
* - ota-image : just the chosen slot's OS (group A or B = efi_<slot>+system_<slot>).
|
|
8
|
+
* - ota-boot : all BOOT + FIRMWARE (both slots) + the chosen slot's OS;
|
|
9
|
+
* USER (userdata/persist) only if includeUserdata.
|
|
10
|
+
*/
|
|
11
|
+
import type { FormatKind, Slot } from '../model/types';
|
|
12
|
+
import type { ParticleImageV1 } from '../manifest/particleImageV1';
|
|
13
|
+
export interface ExpandOpts {
|
|
14
|
+
kind: FormatKind;
|
|
15
|
+
slot?: Slot;
|
|
16
|
+
includeUserdata?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function expand(manifest: ParticleImageV1, opts: ExpandOpts): ParticleImageV1;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.expand = expand;
|
|
4
|
+
function slotGroup(slot) {
|
|
5
|
+
if (slot === 'a')
|
|
6
|
+
return 'A';
|
|
7
|
+
if (slot === 'b')
|
|
8
|
+
return 'B';
|
|
9
|
+
throw new Error(`expand: slot must be 'a' or 'b' for this format (got '${slot}')`);
|
|
10
|
+
}
|
|
11
|
+
function keep(p, opts) {
|
|
12
|
+
switch (opts.kind) {
|
|
13
|
+
case 'factory':
|
|
14
|
+
return true;
|
|
15
|
+
case 'ota-image':
|
|
16
|
+
return p.group === slotGroup(opts.slot);
|
|
17
|
+
case 'ota-boot': {
|
|
18
|
+
if (p.group === 'BOOT' || p.group === 'FIRMWARE')
|
|
19
|
+
return true;
|
|
20
|
+
if (p.group === slotGroup(opts.slot))
|
|
21
|
+
return true;
|
|
22
|
+
if (p.group === 'USER')
|
|
23
|
+
return !!opts.includeUserdata;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
default:
|
|
27
|
+
throw new Error(`expand: unknown format kind ${opts.kind}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function expand(manifest, opts) {
|
|
31
|
+
if (!manifest.format.emittable.includes(opts.kind)) {
|
|
32
|
+
throw new Error(`expand: archive cannot emit '${opts.kind}' (emittable: ${manifest.format.emittable.join(', ')})`);
|
|
33
|
+
}
|
|
34
|
+
if (opts.kind !== 'factory') {
|
|
35
|
+
const s = opts.slot;
|
|
36
|
+
if (s !== 'a' && s !== 'b')
|
|
37
|
+
throw new Error(`expand: '${opts.kind}' requires --slot a|b`);
|
|
38
|
+
if (!manifest.format.slots_present.includes(s)) {
|
|
39
|
+
throw new Error(`expand: slot '${s}' not present (have: ${manifest.format.slots_present.join(', ')})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const partitions = manifest.partitions.filter((p) => keep(p, opts));
|
|
43
|
+
const slots = opts.kind === 'factory' ? manifest.format.slots_present : [opts.slot];
|
|
44
|
+
return {
|
|
45
|
+
...manifest,
|
|
46
|
+
format: { ...manifest.format, kind: opts.kind, slots_present: slots },
|
|
47
|
+
partitions,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function sha256(buf: Buffer | Uint8Array): string;
|
|
2
|
+
/**
|
|
3
|
+
* sha256 of a file via chunked synchronous reads — handles files larger than
|
|
4
|
+
* the 2 GiB Buffer limit that defeats readFileSync (real system images are
|
|
5
|
+
* multi-GB). Returns the hash and the byte size.
|
|
6
|
+
*/
|
|
7
|
+
export declare function hashFileSync(path: string, chunkSize?: number): {
|
|
8
|
+
sha256: string;
|
|
9
|
+
size: number;
|
|
10
|
+
};
|
|
11
|
+
/** Read the first `n` bytes of a file without loading the whole thing. */
|
|
12
|
+
export declare function peekFileSync(path: string, n: number): Buffer;
|
|
13
|
+
export declare class PayloadHasher {
|
|
14
|
+
private h;
|
|
15
|
+
update(buf: Buffer | Uint8Array): this;
|
|
16
|
+
/** Hash `len` zero bytes (a sparse DONT_CARE hole) without allocating it. */
|
|
17
|
+
skip(len: number): this;
|
|
18
|
+
digest(): string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PayloadHasher = void 0;
|
|
4
|
+
exports.sha256 = sha256;
|
|
5
|
+
exports.hashFileSync = hashFileSync;
|
|
6
|
+
exports.peekFileSync = peekFileSync;
|
|
7
|
+
/**
|
|
8
|
+
* Hashing shared by emitter, validator, device writer, and delta compare.
|
|
9
|
+
*
|
|
10
|
+
* - `sha256(buf)` — integrity of a (possibly compressed) zip entry.
|
|
11
|
+
* - `PayloadHasher` — `payload_sha256` of the fully-EXPANDED raw partition
|
|
12
|
+
* image: the writer feeds real data via `update()` and signals sparse holes
|
|
13
|
+
* via `skip(len)` (hashed as zeros). This must match exactly between the
|
|
14
|
+
* composer (which expands the image to hash it) and the device (which reads
|
|
15
|
+
* the block device back), so both count holes as zero bytes.
|
|
16
|
+
*/
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const node_fs_1 = require("node:fs");
|
|
19
|
+
function sha256(buf) {
|
|
20
|
+
return (0, node_crypto_1.createHash)('sha256').update(buf).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* sha256 of a file via chunked synchronous reads — handles files larger than
|
|
24
|
+
* the 2 GiB Buffer limit that defeats readFileSync (real system images are
|
|
25
|
+
* multi-GB). Returns the hash and the byte size.
|
|
26
|
+
*/
|
|
27
|
+
function hashFileSync(path, chunkSize = 8 * 1024 * 1024) {
|
|
28
|
+
const fd = (0, node_fs_1.openSync)(path, 'r');
|
|
29
|
+
try {
|
|
30
|
+
const size = (0, node_fs_1.fstatSync)(fd).size;
|
|
31
|
+
const h = (0, node_crypto_1.createHash)('sha256');
|
|
32
|
+
const buf = Buffer.allocUnsafe(chunkSize);
|
|
33
|
+
let bytesRead;
|
|
34
|
+
while ((bytesRead = (0, node_fs_1.readSync)(fd, buf, 0, buf.length, null)) > 0) {
|
|
35
|
+
h.update(buf.subarray(0, bytesRead));
|
|
36
|
+
}
|
|
37
|
+
return { sha256: h.digest('hex'), size };
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
(0, node_fs_1.closeSync)(fd);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Read the first `n` bytes of a file without loading the whole thing. */
|
|
44
|
+
function peekFileSync(path, n) {
|
|
45
|
+
const fd = (0, node_fs_1.openSync)(path, 'r');
|
|
46
|
+
try {
|
|
47
|
+
const buf = Buffer.alloc(n);
|
|
48
|
+
const read = (0, node_fs_1.readSync)(fd, buf, 0, n, 0);
|
|
49
|
+
return buf.subarray(0, read);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
(0, node_fs_1.closeSync)(fd);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const ZERO_CHUNK = Buffer.alloc(64 * 1024); // reused zero buffer for skip()
|
|
56
|
+
class PayloadHasher {
|
|
57
|
+
constructor() {
|
|
58
|
+
this.h = (0, node_crypto_1.createHash)('sha256');
|
|
59
|
+
}
|
|
60
|
+
update(buf) {
|
|
61
|
+
this.h.update(buf);
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
/** Hash `len` zero bytes (a sparse DONT_CARE hole) without allocating it. */
|
|
65
|
+
skip(len) {
|
|
66
|
+
let remaining = len;
|
|
67
|
+
while (remaining > 0) {
|
|
68
|
+
const n = Math.min(remaining, ZERO_CHUNK.length);
|
|
69
|
+
this.h.update(n === ZERO_CHUNK.length ? ZERO_CHUNK : ZERO_CHUNK.subarray(0, n));
|
|
70
|
+
remaining -= n;
|
|
71
|
+
}
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
digest() {
|
|
75
|
+
return this.h.digest('hex');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
exports.PayloadHasher = PayloadHasher;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ParticleImageV1 } from '../manifest/particleImageV1';
|
|
2
|
+
export interface ImportOptions {
|
|
3
|
+
/** signing profile recorded in the manifest (signature applied separately). */
|
|
4
|
+
signingProfile?: 'test' | 'prod' | 'none';
|
|
5
|
+
keyId?: string;
|
|
6
|
+
/** compute payload hashes (sha256 + payload_sha256). Default true. */
|
|
7
|
+
hashPayloads?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function importFactoryDir(dir: string, opts?: ImportOptions): ParticleImageV1;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.importFactoryDir = importFactoryDir;
|
|
4
|
+
/**
|
|
5
|
+
* Import a built factory tree (the directory `make_factory_img.sh` produces, or
|
|
6
|
+
* the unpacked factory zip) into a particle_image_v1 manifest.
|
|
7
|
+
*
|
|
8
|
+
* It reads the legacy image_manifest_v1 (manifest.json) for the EDL block, parses
|
|
9
|
+
* every rawprogram/patch XML into the operation graph, enriches partitions from
|
|
10
|
+
* partition_ext.xml (type GUID, size) and image-map.tsv (signed/image-id), and
|
|
11
|
+
* hashes payload files that are present. The result is the single source of truth
|
|
12
|
+
* the emitter writes into the manifest-first zip.
|
|
13
|
+
*/
|
|
14
|
+
const node_fs_1 = require("node:fs");
|
|
15
|
+
const node_path_1 = require("node:path");
|
|
16
|
+
const imageManifestV1_1 = require("../manifest/imageManifestV1");
|
|
17
|
+
const rawprogram_1 = require("../xml/rawprogram");
|
|
18
|
+
const types_1 = require("../model/types");
|
|
19
|
+
const hash_1 = require("../hash");
|
|
20
|
+
const decode_1 = require("../sparse/decode");
|
|
21
|
+
const particleImageV1_1 = require("../manifest/particleImageV1");
|
|
22
|
+
const ZERO_RE = /(^|\/)(zero|zeros)[\w]*\.bin$/i;
|
|
23
|
+
function readMap(path) {
|
|
24
|
+
const m = new Map();
|
|
25
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
26
|
+
return m;
|
|
27
|
+
for (const line of (0, node_fs_1.readFileSync)(path, 'utf8').split('\n')) {
|
|
28
|
+
const t = line.trim();
|
|
29
|
+
if (!t || t.startsWith('#'))
|
|
30
|
+
continue;
|
|
31
|
+
const [file, signFlag, imageId] = t.split('\t');
|
|
32
|
+
if (file)
|
|
33
|
+
m.set(file, { signed: signFlag === 'yes', imageId: imageId || undefined });
|
|
34
|
+
}
|
|
35
|
+
return m;
|
|
36
|
+
}
|
|
37
|
+
function partitionExtGuids(path) {
|
|
38
|
+
const m = new Map();
|
|
39
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
40
|
+
return m;
|
|
41
|
+
const xml = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
42
|
+
// <partition label="x" type="GUID" size_in_kb="N" .../>
|
|
43
|
+
const re = /<partition\b([^>]*)\/?>/g;
|
|
44
|
+
let mm;
|
|
45
|
+
while ((mm = re.exec(xml))) {
|
|
46
|
+
const attrs = mm[1];
|
|
47
|
+
const label = /\blabel="([^"]*)"/.exec(attrs)?.[1];
|
|
48
|
+
if (!label)
|
|
49
|
+
continue;
|
|
50
|
+
const typeGuid = /\btype="([^"]*)"/.exec(attrs)?.[1];
|
|
51
|
+
const kb = /\bsize_in_kb="([^"]*)"/.exec(attrs)?.[1];
|
|
52
|
+
m.set(label, { typeGuid, sizeBytes: kb ? Number(kb) * 1024 : undefined });
|
|
53
|
+
}
|
|
54
|
+
return m;
|
|
55
|
+
}
|
|
56
|
+
function hashPayload(dir, filename) {
|
|
57
|
+
const p = (0, node_path_1.join)(dir, filename);
|
|
58
|
+
if (!(0, node_fs_1.existsSync)(p) || !(0, node_fs_1.statSync)(p).isFile())
|
|
59
|
+
return undefined;
|
|
60
|
+
// Sparse files are compact on disk; raw images (e.g. a 9GB rootfs.ext4) can
|
|
61
|
+
// exceed the 2GiB Buffer limit, so hash those via chunked streaming.
|
|
62
|
+
const magic = (0, hash_1.peekFileSync)(p, 4);
|
|
63
|
+
const sparse = magic.length === 4 && magic.readUInt32LE(0) === decode_1.SPARSE_MAGIC;
|
|
64
|
+
if (sparse) {
|
|
65
|
+
const buf = (0, node_fs_1.readFileSync)(p);
|
|
66
|
+
const ph = new hash_1.PayloadHasher();
|
|
67
|
+
let expanded = 0;
|
|
68
|
+
for (const op of (0, decode_1.decodeSparse)(buf)) {
|
|
69
|
+
if (op.kind === 'data') {
|
|
70
|
+
ph.update(op.data);
|
|
71
|
+
expanded += op.data.length;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
ph.skip(op.length);
|
|
75
|
+
expanded += op.length;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { sha256: (0, hash_1.sha256)(buf), payloadSha256: ph.digest(), uncompressedSize: expanded };
|
|
79
|
+
}
|
|
80
|
+
// raw, uncompressed: the on-disk bytes ARE the expanded image, so the two
|
|
81
|
+
// hashes coincide.
|
|
82
|
+
const { sha256: h, size } = (0, hash_1.hashFileSync)(p);
|
|
83
|
+
return { sha256: h, payloadSha256: h, uncompressedSize: size };
|
|
84
|
+
}
|
|
85
|
+
function importFactoryDir(dir, opts = {}) {
|
|
86
|
+
const hashPayloadsFlag = opts.hashPayloads !== false;
|
|
87
|
+
const legacy = (0, imageManifestV1_1.parseImageManifestV1)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, 'manifest.json'), 'utf8'));
|
|
88
|
+
const { platform, edl } = (0, imageManifestV1_1.edlOf)(legacy);
|
|
89
|
+
const rawEntries = [];
|
|
90
|
+
for (const f of edl.program_xml)
|
|
91
|
+
rawEntries.push(...(0, rawprogram_1.parseRawProgram)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, f), 'utf8')));
|
|
92
|
+
const patchEntries = [];
|
|
93
|
+
for (const f of edl.patch_xml)
|
|
94
|
+
patchEntries.push(...(0, rawprogram_1.parsePatch)((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, f), 'utf8')));
|
|
95
|
+
const guids = partitionExtGuids((0, node_path_1.join)(dir, 'partition_ext.xml'));
|
|
96
|
+
const signMap = readMap((0, node_path_1.join)(dir, 'image-map.tsv'));
|
|
97
|
+
// group raw entries by label, preserving first-seen order (= flash order)
|
|
98
|
+
const order = [];
|
|
99
|
+
const byLabel = new Map();
|
|
100
|
+
for (const e of rawEntries) {
|
|
101
|
+
if (!byLabel.has(e.label)) {
|
|
102
|
+
byLabel.set(e.label, []);
|
|
103
|
+
order.push(e.label);
|
|
104
|
+
}
|
|
105
|
+
byLabel.get(e.label).push(e);
|
|
106
|
+
}
|
|
107
|
+
const partitions = [];
|
|
108
|
+
for (const label of order) {
|
|
109
|
+
const entries = byLabel.get(label);
|
|
110
|
+
const lun = entries[0].physicalPartitionNumber;
|
|
111
|
+
const { slot, role, group } = (0, types_1.classify)(label, lun);
|
|
112
|
+
const ops = [];
|
|
113
|
+
const programChunks = [];
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
if (e.kind === 'erase') {
|
|
116
|
+
ops.push({ op: 'erase' });
|
|
117
|
+
}
|
|
118
|
+
else if (e.kind === 'program') {
|
|
119
|
+
if (label === 'PrimaryGPT' || label === 'BackupGPT') {
|
|
120
|
+
if (e.filename)
|
|
121
|
+
ops.push({ op: 'gpt', which: label === 'PrimaryGPT' ? 'primary' : 'backup', source: e.filename });
|
|
122
|
+
}
|
|
123
|
+
else if (!e.filename) {
|
|
124
|
+
// reserve only — no payload, nothing written
|
|
125
|
+
}
|
|
126
|
+
else if (ZERO_RE.test(e.filename)) {
|
|
127
|
+
ops.push({ op: 'zero', source: e.filename });
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
programChunks.push({
|
|
131
|
+
source: e.filename,
|
|
132
|
+
startSector: typeof e.startSector === 'number' ? e.startSector : 0,
|
|
133
|
+
numSectors: typeof e.numPartitionSectors === 'number' ? e.numPartitionSectors : 0,
|
|
134
|
+
fileSectorOffset: e.fileSectorOffset,
|
|
135
|
+
sparse: e.sparse,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (programChunks.length === 1) {
|
|
141
|
+
const c = programChunks[0];
|
|
142
|
+
const op = { op: 'program', source: c.source, sparse: c.sparse };
|
|
143
|
+
if (hashPayloadsFlag) {
|
|
144
|
+
const h = hashPayload(dir, c.source);
|
|
145
|
+
if (h)
|
|
146
|
+
Object.assign(op, { sha256: h.sha256, payload_sha256: h.payloadSha256, uncompressed_size: h.uncompressedSize });
|
|
147
|
+
}
|
|
148
|
+
ops.push(op);
|
|
149
|
+
}
|
|
150
|
+
else if (programChunks.length > 1) {
|
|
151
|
+
ops.push({ op: 'program', chunks: programChunks });
|
|
152
|
+
}
|
|
153
|
+
const sm = signMap.get(`${label}`) ?? (programChunks[0] ? signMap.get(programChunks[0].source) : undefined);
|
|
154
|
+
const g = guids.get(label) ?? guids.get(label.replace(/_[ab]$/, ''));
|
|
155
|
+
partitions.push({
|
|
156
|
+
label,
|
|
157
|
+
lun,
|
|
158
|
+
slot,
|
|
159
|
+
role,
|
|
160
|
+
group,
|
|
161
|
+
size: g?.sizeBytes,
|
|
162
|
+
type_guid: g?.typeGuid,
|
|
163
|
+
signed: sm?.signed || undefined,
|
|
164
|
+
image_id: sm?.imageId ?? null,
|
|
165
|
+
ops,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// attach GPT-header patches per LUN to that LUN's PrimaryGPT partition
|
|
169
|
+
if (patchEntries.length) {
|
|
170
|
+
const byLun = new Map();
|
|
171
|
+
for (const pe of patchEntries) {
|
|
172
|
+
const arr = byLun.get(pe.physicalPartitionNumber) ?? [];
|
|
173
|
+
arr.push(pe);
|
|
174
|
+
byLun.set(pe.physicalPartitionNumber, arr);
|
|
175
|
+
}
|
|
176
|
+
for (const [lun, patches] of byLun) {
|
|
177
|
+
const gptPart = partitions.find((p) => p.lun === lun && p.label === 'PrimaryGPT');
|
|
178
|
+
if (gptPart) {
|
|
179
|
+
gptPart.ops.push({
|
|
180
|
+
op: 'patch',
|
|
181
|
+
patches: patches.map((pe) => ({
|
|
182
|
+
startSector: pe.startSector,
|
|
183
|
+
byteOffset: pe.byteOffset,
|
|
184
|
+
sizeBytes: pe.sizeInBytes,
|
|
185
|
+
value: pe.value,
|
|
186
|
+
filename: pe.filename,
|
|
187
|
+
what: pe.what,
|
|
188
|
+
})),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const groups = buildGroups(partitions);
|
|
194
|
+
const slotsPresent = Array.from(new Set(partitions.map((p) => p.slot).filter((s) => s === 'a' || s === 'b')));
|
|
195
|
+
const emittable = ['factory', 'ota-image', 'ota-boot'];
|
|
196
|
+
return {
|
|
197
|
+
$schema: particleImageV1_1.PARTICLE_IMAGE_SCHEMA,
|
|
198
|
+
schema_version: 1,
|
|
199
|
+
release_name: legacy.release_name,
|
|
200
|
+
version: legacy.version,
|
|
201
|
+
platform,
|
|
202
|
+
board: legacy.board,
|
|
203
|
+
region: legacy.region,
|
|
204
|
+
variant: legacy.variant,
|
|
205
|
+
distribution: legacy.distribution,
|
|
206
|
+
distribution_version: legacy.distribution_version,
|
|
207
|
+
sector_size: 4096,
|
|
208
|
+
firehose: edl.firehose,
|
|
209
|
+
format: { kind: 'factory', emittable, slots_present: slotsPresent, groups },
|
|
210
|
+
partitions,
|
|
211
|
+
signing: {
|
|
212
|
+
profile: opts.signingProfile ?? 'test',
|
|
213
|
+
key_id: opts.keyId,
|
|
214
|
+
algorithm: 'ed25519',
|
|
215
|
+
digest_algorithm: 'sha256',
|
|
216
|
+
canonicalization: 'jcs',
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function buildGroups(partitions) {
|
|
221
|
+
const groups = {};
|
|
222
|
+
for (const p of partitions) {
|
|
223
|
+
(groups[p.group] ??= []).push(p.label);
|
|
224
|
+
}
|
|
225
|
+
return groups;
|
|
226
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './model/types';
|
|
2
|
+
export * from './xml/rawprogram';
|
|
3
|
+
export * from './manifest/imageManifestV1';
|
|
4
|
+
export * from './manifest/particleImageV1';
|
|
5
|
+
export * from './signing/jcs';
|
|
6
|
+
export * from './signing/sign';
|
|
7
|
+
export * from './hash';
|
|
8
|
+
export * from './sparse/decode';
|
|
9
|
+
export * from './format/expand';
|
|
10
|
+
export * from './zip';
|
|
11
|
+
export * from './validate';
|
|
12
|
+
export * from './import/factoryDir';
|
|
13
|
+
export * as slots from './slots';
|
|
14
|
+
export * as server from './server';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
19
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
20
|
+
};
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.server = exports.slots = void 0;
|
|
40
|
+
__exportStar(require("./model/types"), exports);
|
|
41
|
+
__exportStar(require("./xml/rawprogram"), exports);
|
|
42
|
+
__exportStar(require("./manifest/imageManifestV1"), exports);
|
|
43
|
+
__exportStar(require("./manifest/particleImageV1"), exports);
|
|
44
|
+
__exportStar(require("./signing/jcs"), exports);
|
|
45
|
+
__exportStar(require("./signing/sign"), exports);
|
|
46
|
+
__exportStar(require("./hash"), exports);
|
|
47
|
+
__exportStar(require("./sparse/decode"), exports);
|
|
48
|
+
__exportStar(require("./format/expand"), exports);
|
|
49
|
+
__exportStar(require("./zip"), exports);
|
|
50
|
+
__exportStar(require("./validate"), exports);
|
|
51
|
+
__exportStar(require("./import/factoryDir"), exports);
|
|
52
|
+
exports.slots = __importStar(require("./slots"));
|
|
53
|
+
exports.server = __importStar(require("./server"));
|