@napi-rs/cli 3.10.3 → 3.10.5

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.
@@ -22,3 +22,9 @@ test('build rejects unsupported formats', (t) => {
22
22
  message: /Invalid value for --format/,
23
23
  })
24
24
  })
25
+
26
+ test('build self-signs OpenHarmony artifacts unless --no-ohos-sign', (t) => {
27
+ t.true(createBuildCommand([]).ohosSign)
28
+ t.true(createBuildCommand(['--ohos-sign']).ohosSign)
29
+ t.false(createBuildCommand(['--no-ohos-sign']).ohosSign)
30
+ })
package/src/def/build.ts CHANGED
@@ -163,6 +163,11 @@ export abstract class BaseBuildCommand extends Command {
163
163
  description: 'Do not activate the `default` feature',
164
164
  })
165
165
 
166
+ ohosSign = Option.Boolean('--ohos-sign', true, {
167
+ description:
168
+ 'Whether to self-sign the built binary for OpenHarmony targets by injecting a `.codesign` fs-verity section, so the `.so` can be loaded on HarmonyOS devices. Only works with `*-unknown-linux-ohos` targets',
169
+ })
170
+
166
171
  /**
167
172
  * Return the parsed build options.
168
173
  */
@@ -201,6 +206,7 @@ export abstract class BaseBuildCommand extends Command {
201
206
  features: this.features,
202
207
  allFeatures: this.allFeatures,
203
208
  noDefaultFeatures: this.noDefaultFeatures,
209
+ ohosSign: this.ohosSign,
204
210
  }
205
211
  }
206
212
  }
@@ -343,11 +349,18 @@ export interface BuildOptions {
343
349
  * Do not activate the `default` feature
344
350
  */
345
351
  noDefaultFeatures?: boolean
352
+ /**
353
+ * Whether to self-sign the built binary for OpenHarmony targets by injecting a `.codesign` fs-verity section, so the `.so` can be loaded on HarmonyOS devices. Only works with `*-unknown-linux-ohos` targets
354
+ *
355
+ * @default true
356
+ */
357
+ ohosSign?: boolean
346
358
  }
347
359
 
348
360
  export function applyDefaultBuildOptions(options: BuildOptions) {
349
361
  return {
350
362
  dtsCache: true,
363
+ ohosSign: true,
351
364
  ...options,
352
365
  }
353
366
  }
@@ -0,0 +1,285 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import test from 'ava'
7
+
8
+ import {
9
+ checkSelfsign,
10
+ hasCodesignSection,
11
+ merkleRootHash,
12
+ parseElfHeader,
13
+ selfsignMain,
14
+ signElf,
15
+ signFileAtomic,
16
+ stripCodesign,
17
+ } from '../ohos-selfsign.js'
18
+
19
+ // Builds a deterministic minimal ELF64 shared object: a `.text` section,
20
+ // a `.data` section of `dataSize` bytes and the section name string table.
21
+ // `shentsize` widens every section header entry (ELF64 permits entries
22
+ // larger than 64 bytes) and `trailing` appends that many bytes of junk
23
+ // after the section header table — both are legal upstream edge cases.
24
+ // The golden hashes below were produced by signing this exact fixture with
25
+ // the upstream `selfsign.js` from https://github.com/hqzing/ohos-selfsign.
26
+ function makeElf64(dataSize = 16, shentsize = 64, trailing = 0): Buffer {
27
+ const text = Buffer.alloc(32).fill(0xcc)
28
+ const data = Buffer.alloc(dataSize)
29
+ for (let i = 0; i < dataSize; i++) data[i] = (i * 31 + 7) & 0xff
30
+ const shstr = Buffer.from('\0.text\0.data\0.shstrtab\0', 'latin1')
31
+ // name offsets inside shstr: .text=1, .data=7, .shstrtab=13
32
+ const nsec = 4
33
+ const textOff = 64
34
+ const dataOff = textOff + text.length
35
+ const shstrOff = dataOff + data.length
36
+ const shoff = Math.ceil((shstrOff + shstr.length) / 8) * 8
37
+ const total = shoff + nsec * shentsize + trailing
38
+
39
+ const elf = Buffer.alloc(total)
40
+ // ELF64 header
41
+ elf.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0], 0)
42
+ elf.writeUInt16LE(2, 16) // e_type = ET_EXEC
43
+ elf.writeUInt16LE(0x3e, 18) // e_machine = x86_64
44
+ elf.writeUInt32LE(1, 20) // e_version
45
+ elf.writeBigUInt64LE(0n, 24) // e_entry
46
+ elf.writeBigUInt64LE(0n, 32) // e_phoff
47
+ elf.writeBigUInt64LE(BigInt(shoff), 40) // e_shoff
48
+ elf.writeUInt32LE(0, 48) // e_flags
49
+ elf.writeUInt16LE(64, 52) // e_ehsize
50
+ elf.writeUInt16LE(56, 54) // e_phentsize
51
+ elf.writeUInt16LE(0, 56) // e_phnum
52
+ elf.writeUInt16LE(shentsize, 58) // e_shentsize
53
+ elf.writeUInt16LE(nsec, 60) // e_shnum
54
+ elf.writeUInt16LE(3, 62) // e_shstrndx
55
+
56
+ text.copy(elf, textOff)
57
+ data.copy(elf, dataOff)
58
+ shstr.copy(elf, shstrOff)
59
+
60
+ const sh = (
61
+ i: number,
62
+ name: number,
63
+ type: number,
64
+ flags: number,
65
+ off: number,
66
+ size: number,
67
+ align: number,
68
+ ) => {
69
+ const e = shoff + i * shentsize
70
+ elf.writeUInt32LE(name, e)
71
+ elf.writeUInt32LE(type, e + 4)
72
+ elf.writeBigUInt64LE(BigInt(flags), e + 8)
73
+ elf.writeBigUInt64LE(0n, e + 16) // sh_addr
74
+ elf.writeBigUInt64LE(BigInt(off), e + 24)
75
+ elf.writeBigUInt64LE(BigInt(size), e + 32)
76
+ elf.writeBigUInt64LE(BigInt(align), e + 48)
77
+ }
78
+ // sh[0] stays SHT_NULL (all zeros)
79
+ sh(1, 1, 1, 6, textOff, text.length, 16) // .text PROGBITS AX
80
+ sh(2, 7, 1, 3, dataOff, data.length, 8) // .data PROGBITS WA
81
+ sh(3, 13, 3, 0, shstrOff, shstr.length, 1) // .shstrtab STRTAB
82
+
83
+ if (trailing > 0) {
84
+ elf.fill(0xa5, shoff + nsec * shentsize) // recognizable trailing payload
85
+ }
86
+ return elf
87
+ }
88
+
89
+ const sha256hex = (b: Buffer) => createHash('sha256').update(b).digest('hex')
90
+
91
+ test('signElf output is byte-identical to upstream selfsign.js (small file)', (t) => {
92
+ const signed = signElf(makeElf64(16))
93
+ t.true(hasCodesignSection(signed))
94
+ t.is(
95
+ sha256hex(signed),
96
+ '7913969989b797580aeb3554b78343b1a456845f01c9aedd6f5b9dddeb787c77',
97
+ )
98
+ })
99
+
100
+ test('signElf output is byte-identical to upstream selfsign.js (multi-level merkle)', (t) => {
101
+ // >128 pages of data forces the merkle loop past one level
102
+ const signed = signElf(makeElf64(600 * 1024))
103
+ t.true(hasCodesignSection(signed))
104
+ t.is(
105
+ sha256hex(signed),
106
+ 'b936fed982e6b8d89b9e39c4cfd11258a0522152788ca76a7126f75a3e8d5e3a',
107
+ )
108
+ })
109
+
110
+ test('signElf output is byte-identical to upstream (intermediate merkle levels)', (t) => {
111
+ // >128*128 pages produces merkle intermediate levels, which upstream
112
+ // stores in the .codesign section after the 296B payload
113
+ const signed = signElf(makeElf64(65 * 1024 * 1024))
114
+ t.true(hasCodesignSection(signed))
115
+ t.is(
116
+ sha256hex(signed),
117
+ '9534aeb8c5e42c5fe78d691ef717aa291179286440ef12df16886f130eb7e7a2',
118
+ )
119
+ })
120
+
121
+ test('signElf supports section entries larger than 64 bytes', (t) => {
122
+ const signed = signElf(makeElf64(16, 72))
123
+ t.true(hasCodesignSection(signed))
124
+ t.is(
125
+ sha256hex(signed),
126
+ '81b6c9ae7cf071c6ad56e5a6ab7929c35ae46fc3081575503f37bf521e1b842e',
127
+ )
128
+ })
129
+
130
+ test('signElf preserves trailing data after the section header table', (t) => {
131
+ const signed = signElf(makeElf64(16, 64, 512))
132
+ t.true(hasCodesignSection(signed))
133
+ t.is(
134
+ sha256hex(signed),
135
+ '22dd5f1231f00ef9691fa5bfeaa843c0322879d775f37d834e159de423c2a7c7',
136
+ )
137
+ // the 0xa5 trailing block survives inside the signed output
138
+ const idx = signed.indexOf(Buffer.alloc(512, 0xa5))
139
+ t.true(idx > 0)
140
+ })
141
+
142
+ test('merkleRootHash exposes intermediate levels only for deep trees', (t) => {
143
+ // 128 leaf hashes pack to exactly 4096B, so a <=128-page file has no
144
+ // intermediate levels
145
+ const { mid: shallow } = merkleRootHash(Buffer.alloc(128 * 4096, 0x11), 0, 0)
146
+ t.is(shallow.length, 0)
147
+
148
+ // 128*128+1 pages => the second level packs 129*32B > 4096B, so one
149
+ // intermediate level of 129*32 = 4128B exists
150
+ const deep = merkleRootHash(Buffer.alloc((128 * 128 + 1) * 4096, 0x22), 0, 0)
151
+ t.is(deep.mid.length, 129 * 32)
152
+ })
153
+
154
+ test('checkSelfsign validates a signed artifact', (t) => {
155
+ const signed = signElf(makeElf64(16))
156
+ t.deepEqual(checkSelfsign(signed), { ok: true, reason: null })
157
+ })
158
+
159
+ test('checkSelfsign reports the upstream failure reasons', (t) => {
160
+ t.is(checkSelfsign(Buffer.from('MZ....')).reason, 'not ELF64')
161
+ t.is(checkSelfsign(makeElf64(16)).reason, 'no .codesign section')
162
+
163
+ const signed = signElf(makeElf64(16))
164
+ const tampered = Buffer.from(signed)
165
+ tampered[100] ^= 0xff // flip a .text byte
166
+ t.is(checkSelfsign(tampered).reason, 'merkle root mismatch')
167
+
168
+ // corrupt the descriptor's signSize field: find the 296B payload by its
169
+ // ElfSignInfo header (type=1, length=288), the descriptor starts 8B later
170
+ // and signSize is the descriptor's second u32
171
+ const payloadOff = signed.indexOf(Buffer.from([1, 0, 0, 0, 0x20, 0x01, 0, 0]))
172
+ t.true(payloadOff > 0)
173
+ const badDesc = Buffer.from(signed)
174
+ badDesc.writeUInt32LE(24, payloadOff + 8 + 4)
175
+ t.is(checkSelfsign(badDesc).reason, 'signSize mismatch')
176
+ })
177
+
178
+ test('hasCodesignSection detects signed and unsigned ELFs', (t) => {
179
+ const elf = makeElf64()
180
+ t.false(hasCodesignSection(elf))
181
+ t.true(hasCodesignSection(signElf(elf)))
182
+ t.false(hasCodesignSection(Buffer.from('not an elf')))
183
+ })
184
+
185
+ test('stripCodesign removes the .codesign section', (t) => {
186
+ const elf = makeElf64()
187
+ const unsigned = stripCodesign(elf)
188
+ t.false(unsigned.removed)
189
+
190
+ const signed = signElf(elf)
191
+ const stripped = stripCodesign(signed)
192
+ t.true(stripped.removed)
193
+ t.false(hasCodesignSection(stripped.out))
194
+ // the stripped ELF still has a valid section header table
195
+ t.notThrows(() => parseElfHeader(stripped.out))
196
+ })
197
+
198
+ test('stripCodesign is byte-identical to upstream (shentsize 72 + trailing data)', (t) => {
199
+ const signed = signElf(makeElf64(16, 72, 512))
200
+ const stripped = stripCodesign(signed)
201
+ t.true(stripped.removed)
202
+ t.is(
203
+ sha256hex(stripped.out),
204
+ '0161662254321d607bdc487f20ca2c72692dea52bcfa2462a63a4acbe2602421',
205
+ )
206
+ })
207
+
208
+ test('signElf refuses to re-sign without force', (t) => {
209
+ const signed = signElf(makeElf64())
210
+ t.throws(() => signElf(signed), { message: /already has a \.codesign/ })
211
+ const reSigned = signElf(signed, true)
212
+ t.true(hasCodesignSection(reSigned))
213
+ t.true(checkSelfsign(reSigned).ok)
214
+ })
215
+
216
+ test('signElf rejects non-ELF64 input', (t) => {
217
+ t.throws(() => signElf(Buffer.from('MZ....')), { message: 'not ELF64' })
218
+ // ELF32 is rejected too
219
+ const elf32 = makeElf64()
220
+ elf32[4] = 1
221
+ t.throws(() => signElf(elf32), { message: 'not ELF64' })
222
+ })
223
+
224
+ test('signFileAtomic signs in place and preserves the file mode', async (t) => {
225
+ const dir = await mkdtemp(join(os.tmpdir(), 'napi-rs-ohos-selfsign-'))
226
+ try {
227
+ const path = join(dir, 'libfoo.so')
228
+ await writeFile(path, makeElf64())
229
+ // 0o755 takes effect on POSIX; on Windows chmod only maps the
230
+ // read-only bit, so assert the mode survives signing rather than
231
+ // asserting the absolute value.
232
+ await chmod(path, 0o755)
233
+ const { mode: modeBefore } = await stat(path)
234
+ const signed = signElf(makeElf64())
235
+ signFileAtomic(path)
236
+ const { mode } = await stat(path)
237
+ t.is(mode & 0o777, modeBefore & 0o777)
238
+ t.deepEqual(await readFile(path), signed)
239
+ } finally {
240
+ await rm(dir, { recursive: true, force: true })
241
+ }
242
+ })
243
+
244
+ test('signFileAtomic force mode re-signs an already signed file', async (t) => {
245
+ const dir = await mkdtemp(join(os.tmpdir(), 'napi-rs-ohos-selfsign-'))
246
+ try {
247
+ const path = join(dir, 'libfoo.so')
248
+ await writeFile(path, makeElf64())
249
+ signFileAtomic(path)
250
+ t.true(checkSelfsign(await readFile(path)).ok)
251
+ // a second pass without force throws; force re-signs cleanly
252
+ t.throws(() => signFileAtomic(path), {
253
+ message: /already has a \.codesign/,
254
+ })
255
+ signFileAtomic(path, true)
256
+ t.true(checkSelfsign(await readFile(path)).ok)
257
+ } finally {
258
+ await rm(dir, { recursive: true, force: true })
259
+ }
260
+ })
261
+
262
+ test('selfsignMain signs, strips, checks and reports exit codes', async (t) => {
263
+ const dir = await mkdtemp(join(os.tmpdir(), 'napi-rs-ohos-selfsign-'))
264
+ try {
265
+ const path = join(dir, 'libfoo.so')
266
+ await writeFile(path, makeElf64())
267
+ // --check on an unsigned file fails
268
+ t.is(selfsignMain(['--check', path]), 1)
269
+ t.is(selfsignMain([path]), 0)
270
+ t.true(hasCodesignSection(await readFile(path)))
271
+ // --check on the signed file passes
272
+ t.is(selfsignMain(['--check', path]), 0)
273
+ // --check combined with --force/--strip/an output path is a usage error
274
+ t.is(selfsignMain(['--check', '--force', path]), 1)
275
+ // re-signing without --force errors with exit code 2
276
+ t.is(selfsignMain([path]), 2)
277
+ t.is(selfsignMain(['--force', path]), 0)
278
+ t.is(selfsignMain(['--strip', path]), 0)
279
+ t.false(hasCodesignSection(await readFile(path)))
280
+ // usage error
281
+ t.is(selfsignMain([]), 1)
282
+ } finally {
283
+ await rm(dir, { recursive: true, force: true })
284
+ }
285
+ })
@@ -19,6 +19,7 @@ import ava, { type TestFn } from 'ava'
19
19
 
20
20
  import {
21
21
  createProcessIncarnationGetter,
22
+ getCurrentProcessExecutionIdentity,
22
23
  type ProcessExecutionIdentity,
23
24
  resolveProcessExecutionIdentityForLocking,
24
25
  withFileSystemReconciliation,
@@ -439,6 +440,24 @@ test('degraded acquisition respects an existing lock it cannot evaluate', async
439
440
  })
440
441
 
441
442
  test('degraded acquisition heals when identity probes recover and reclaims a stale lock', async (t) => {
443
+ // Phase 1 below relies on the real host identity; a host whose probes fail
444
+ // (e.g. a CI runner where powershell's Get-CimInstance boot probe times
445
+ // out) degrades to lock-free operation by design, writes no owner, and can
446
+ // prove nothing here. Give the guard the same 10s retry budget a normal
447
+ // acquisition uses, so only a host whose probes stay unavailable is
448
+ // skipped; then report it and pass instead of failing on the host.
449
+ const hostIdentity = await resolveProcessExecutionIdentityForLocking(
450
+ getCurrentProcessExecutionIdentity,
451
+ { expiresAt: performance.now() + 10_000, timeout: 10_000 },
452
+ )
453
+ if (!hostIdentity.complete) {
454
+ t.log(
455
+ `skipping: host identity incomplete (${JSON.stringify(hostIdentity.identity)})`,
456
+ )
457
+ t.pass()
458
+ return
459
+ }
460
+
442
461
  const anchor = join(t.context.tmpDir, 'pkg')
443
462
  await mkdir(anchor)
444
463
 
@@ -144,12 +144,16 @@ export interface UserNapiConfig {
144
144
  * Whether the generated WASI loaders bootstrap the `napi-async-runtime`
145
145
  * CurrentThread JavaScript hosts.
146
146
  *
147
- * A binding built with the `napi-async-runtime` crate runs the
148
- * `CurrentThread` flavor on every WebAssembly target (it is the only
149
- * flavor there). Its futures make no progress until a task host is
150
- * registered, and its timers never fire until a timer host is, so the
151
- * loader must install both right after instantiation and unregister them
152
- * before the environment is destroyed.
147
+ * A binding built with the `napi-async-runtime` crate defaults to the
148
+ * `CurrentThread` flavor on every WebAssembly target. It is the only
149
+ * flavor on threadless `wasm32-wasip1`; `wasm32-wasip1-threads` also
150
+ * accepts an explicitly configured `MultiThread`. Under `CurrentThread`
151
+ * its futures make no progress until a task host is registered, and its
152
+ * timers never fire until a timer host is, so the loader must install
153
+ * both right after instantiation and unregister them before the
154
+ * environment is destroyed. A `MultiThread` binding uses neither host and
155
+ * the installed task host is unref'd, so leaving this enabled is also
156
+ * correct for an addon that picks its flavor at runtime.
153
157
  *
154
158
  * Enabling this emits that bootstrap in the node, browser and deferred
155
159
  * (workerd) loaders, and declares `@napi-rs/async-runtime` in the
@@ -8,3 +8,4 @@ export * from './config.js'
8
8
  export * from './cargo.js'
9
9
  export * from './typegen.js'
10
10
  export * from './read-config.js'
11
+ export * from './ohos-selfsign.js'
package/src/utils/misc.ts CHANGED
@@ -3667,10 +3667,11 @@ type ProcessExecutionIdentityResolution = Awaited<
3667
3667
  ReturnType<typeof resolveProcessExecutionIdentityForLocking>
3668
3668
  >
3669
3669
 
3670
- const getCurrentProcessExecutionIdentity = createProcessExecutionIdentityGetter(
3671
- readProcessExecutionIdentity,
3672
- incompleteProcessExecutionIdentityCacheDuration,
3673
- )
3670
+ export const getCurrentProcessExecutionIdentity =
3671
+ createProcessExecutionIdentityGetter(
3672
+ readProcessExecutionIdentity,
3673
+ incompleteProcessExecutionIdentityCacheDuration,
3674
+ )
3674
3675
 
3675
3676
  const degradedProcessExecutionIdentityMemoDuration = 30_000
3676
3677
  // One build pays the probe-failure wait once: a degraded outcome is memoized