@deeeed/metamask-harness 0.36.0 → 0.37.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +22 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +9 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +161 -20
  15. package/dist/adapters/extension/runtime.js +127 -0
  16. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  17. package/dist/adapters/mobile/release-artifact.js +295 -0
  18. package/dist/adapters.js +11 -4
  19. package/dist/command-contract.js +16 -0
  20. package/dist/commands/call.js +2 -1
  21. package/dist/commands/launch/extension.js +55 -6
  22. package/dist/commands/launch/mobile.js +2 -0
  23. package/dist/commands/provision.js +2 -0
  24. package/dist/commands/run-engine.js +89 -5
  25. package/dist/commands/run.js +3 -1
  26. package/dist/commands/runtime-launch.js +178 -10
  27. package/dist/heal-bounds.js +1 -1
  28. package/dist/live-adapter-contract.js +3 -1
  29. package/dist/metamask-action-validation.js +47 -1
  30. package/dist/mm-harness-cli.js +31 -1
  31. package/dist/run-diagnostics.js +1 -1
  32. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  33. package/library/actions/extension/perps/perps.mjs +2 -0
  34. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  35. package/library/actions/extension/platform/cdp.mjs +6 -3
  36. package/library/actions/extension/wallet/import.mjs +13 -46
  37. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  38. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  39. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  40. package/library/actions/mobile/ui/navigate.mjs +7 -0
  41. package/library/actions/mobile/wallet/import.mjs +71 -2
  42. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  43. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  44. package/library/actions/mobile/wallet/reset.mjs +17 -4
  45. package/library/actions/shared/ui/locators.mjs +7 -0
  46. package/library/manifests/extension.action-manifest.json +116 -0
  47. package/library/manifests/mobile.action-manifest.json +16 -0
  48. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  49. package/library/recipes/wallet/import.recipe.json +20 -1
  50. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  51. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { extensionTreeSha256 } from "./artifact-integrity.js";
5
6
  import {
6
7
  depsCheck,
7
8
  INSTALL_MARKERS
@@ -151,21 +152,106 @@ function latestDistMtime(target) {
151
152
  walk2(distDir);
152
153
  return latest;
153
154
  }
154
- function runtimeDistCheck(target) {
155
- const dist = path.join(target, "dist/chrome");
155
+ function releaseArtifactState(target) {
156
+ const runtimeRoot = path.join(target, recipeRuntimeDir());
157
+ const statePath = path.join(runtimeRoot, "extension-release-artifact.json");
158
+ if (!fs.existsSync(statePath)) return { status: "none" };
159
+ try {
160
+ const stateStat = fs.lstatSync(statePath);
161
+ if (!stateStat.isFile() || stateStat.isSymbolicLink() || stateStat.size <= 0 || stateStat.size > 64 * 1024) {
162
+ return { status: "invalid", reason: "artifact runtime identity is not a regular bounded file" };
163
+ }
164
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
165
+ const sourceDir = typeof state.sourceDir === "string" ? path.resolve(state.sourceDir) : "";
166
+ const runtimeDist = typeof state.runtimeDist === "string" ? path.resolve(state.runtimeDist) : "";
167
+ const provenancePath = typeof state.provenancePath === "string" ? path.resolve(state.provenancePath) : "";
168
+ const expectedRuntimeDist = path.join(
169
+ runtimeRoot,
170
+ process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
171
+ );
172
+ if (state.schemaVersion !== 1 || !sourceDir || runtimeDist !== expectedRuntimeDist || !provenancePath) {
173
+ return { status: "invalid", reason: "artifact runtime identity has invalid paths or schema" };
174
+ }
175
+ const provenanceStat = fs.lstatSync(provenancePath);
176
+ if (!provenanceStat.isFile() || provenanceStat.isSymbolicLink() || provenanceStat.size <= 0 || provenanceStat.size > 64 * 1024) {
177
+ return { status: "invalid", reason: "artifact provenance is not a regular bounded file" };
178
+ }
179
+ const provenance = JSON.parse(fs.readFileSync(provenancePath, "utf8"));
180
+ const relativeExtensionDir = typeof provenance.extensionDir === "string" ? provenance.extensionDir : "";
181
+ const boundSource = relativeExtensionDir && !path.isAbsolute(relativeExtensionDir) && !relativeExtensionDir.split(/[\\/]+/u).includes("..") ? path.resolve(path.dirname(provenancePath), relativeExtensionDir) : "";
182
+ const sha256 = typeof provenance.sha256 === "string" ? provenance.sha256 : "";
183
+ const expectedVersion = typeof provenance.expectedVersion === "string" ? provenance.expectedVersion : "";
184
+ const manifestVersion = typeof provenance.manifestVersion === "string" ? provenance.manifestVersion : "";
185
+ const treeSha256 = typeof provenance.treeSha256 === "string" ? provenance.treeSha256 : "";
186
+ if (provenance.schemaVersion !== 1 || boundSource !== sourceDir || !/^[a-f0-9]{64}$/u.test(sha256) || !/^[a-f0-9]{64}$/u.test(treeSha256) || !/^\d+\.\d+\.\d+$/u.test(expectedVersion) || state.sha256 !== sha256 || state.treeSha256 !== treeSha256 || state.expectedVersion !== expectedVersion || state.manifestVersion !== manifestVersion) {
187
+ return { status: "invalid", reason: "artifact runtime identity does not match its provenance" };
188
+ }
189
+ const manifest = JSON.parse(fs.readFileSync(path.join(runtimeDist, "manifest.json"), "utf8"));
190
+ const runtimeVersion = typeof manifest.version === "string" ? manifest.version : "";
191
+ if (normalizeReleaseVersion(runtimeVersion) !== expectedVersion || runtimeVersion !== manifestVersion) {
192
+ return { status: "invalid", reason: "artifact runtime manifest does not match its provenance" };
193
+ }
194
+ if (extensionTreeSha256(sourceDir) !== treeSha256 || extensionTreeSha256(runtimeDist) !== treeSha256) {
195
+ return { status: "invalid", reason: "artifact source or runtime tree does not match its provenance" };
196
+ }
197
+ return {
198
+ status: "valid",
199
+ sourceDir,
200
+ runtimeDist,
201
+ provenancePath,
202
+ sha256,
203
+ treeSha256,
204
+ expectedVersion,
205
+ manifestVersion
206
+ };
207
+ } catch (error) {
208
+ return {
209
+ status: "invalid",
210
+ reason: error instanceof Error ? error.message : String(error)
211
+ };
212
+ }
213
+ }
214
+ function normalizeReleaseVersion(version) {
215
+ if (!/^\d+\.\d+\.\d+(?:\.\d+)?$/u.test(version)) return null;
216
+ const parts = version.split(".");
217
+ if (parts.length === 4 && parts[3] !== "0") return null;
218
+ return parts.slice(0, 3).join(".");
219
+ }
220
+ function releaseArtifactDistCheck(artifact) {
221
+ return {
222
+ status: "fresh",
223
+ source: "release-artifact",
224
+ manifestPath: path.join(artifact.runtimeDist, "manifest.json"),
225
+ manifestVersion: artifact.manifestVersion,
226
+ artifactSha256: artifact.sha256
227
+ };
228
+ }
229
+ function runtimeDistCheck(target, artifact = releaseArtifactState(target)) {
230
+ if (artifact.status === "invalid") {
231
+ return {
232
+ status: "stale",
233
+ source: "release-artifact",
234
+ artifactError: artifact.reason,
235
+ modified: ["<artifact identity invalid>"]
236
+ };
237
+ }
238
+ const dist = artifact.status === "valid" ? artifact.sourceDir : path.join(target, "dist/chrome");
156
239
  const runtimeDist = path.join(
157
240
  target,
158
241
  recipeRuntimeDir(),
159
242
  process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"
160
243
  );
161
- if (!fs.existsSync(path.join(runtimeDist, "manifest.json"))) return { status: "missing" };
244
+ const identity = artifact.status === "valid" ? {
245
+ source: "release-artifact",
246
+ artifactSha256: artifact.sha256,
247
+ provenancePath: artifact.provenancePath
248
+ } : {};
249
+ if (!fs.existsSync(path.join(runtimeDist, "manifest.json"))) return { status: "missing", ...identity };
162
250
  let output;
163
251
  try {
164
- output = execFileSync("rsync", [
252
+ const rsyncArgs = [
165
253
  "-rnic",
166
254
  "--exclude",
167
- "_metadata",
168
- "--exclude",
169
255
  "home.html",
170
256
  "--exclude",
171
257
  "sidepanel.html",
@@ -174,21 +260,34 @@ function runtimeDistCheck(target) {
174
260
  "--out-format=%n",
175
261
  `${dist}${path.sep}`,
176
262
  `${runtimeDist}${path.sep}`
177
- ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
263
+ ];
264
+ if (artifact.status !== "valid") rsyncArgs.splice(1, 0, "--exclude", "_metadata");
265
+ output = execFileSync("rsync", rsyncArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
178
266
  } catch {
179
- return { status: "stale", modified: ["<snapshot comparison failed>"] };
267
+ return { status: "stale", modified: ["<snapshot comparison failed>"], ...identity };
180
268
  }
181
269
  const modified = output.split("\n").map((line) => line.trim()).filter(Boolean);
182
- for (const name of ["home.html", "sidepanel.html"]) {
183
- const source = path.join(dist, name);
184
- const loaded = path.join(runtimeDist, name);
185
- if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
186
- }
187
- if (normalizedRuntimeManifest(path.join(dist, "manifest.json")) !== normalizedRuntimeManifest(path.join(runtimeDist, "manifest.json"))) {
188
- modified.push("manifest.json");
270
+ if (artifact.status === "valid") {
271
+ for (const name of ["home.html", "sidepanel.html", "manifest.json"]) {
272
+ const source = path.join(dist, name);
273
+ const loaded = path.join(runtimeDist, name);
274
+ if (fileDigest(source) !== fileDigest(loaded)) modified.push(name);
275
+ }
276
+ } else {
277
+ for (const name of ["home.html", "sidepanel.html"]) {
278
+ const source = path.join(dist, name);
279
+ const loaded = path.join(runtimeDist, name);
280
+ if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
281
+ }
282
+ if (normalizedRuntimeManifest(path.join(dist, "manifest.json")) !== normalizedRuntimeManifest(path.join(runtimeDist, "manifest.json"))) {
283
+ modified.push("manifest.json");
284
+ }
189
285
  }
190
286
  const boundedModified = [...new Set(modified)].slice(0, 10);
191
- return boundedModified.length > 0 ? { status: "stale", modified: boundedModified } : { status: "fresh" };
287
+ return boundedModified.length > 0 ? { status: "stale", modified: boundedModified, ...identity } : { status: "fresh", ...identity };
288
+ }
289
+ function fileDigest(file) {
290
+ return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
192
291
  }
193
292
  function normalizedRuntimeHtml(file) {
194
293
  if (!fs.existsSync(file)) return null;
@@ -269,10 +368,11 @@ async function decideExtensionReadiness(target, options = {}) {
269
368
  const deps = depsCheck(resolved);
270
369
  const webpackCache = webpackCacheCheck(resolved);
271
370
  const buildLog = buildLogCheck(resolved, options.watchLog);
272
- const dist = distCheck(resolved);
273
- const runtimeDist = runtimeDistCheck(resolved);
371
+ const releaseArtifact = releaseArtifactState(resolved);
372
+ const dist = releaseArtifact.status === "valid" ? releaseArtifactDistCheck(releaseArtifact) : distCheck(resolved);
373
+ const runtimeDist = runtimeDistCheck(resolved, releaseArtifact);
274
374
  const cdp = await cdpCheck(resolved, options.cdpPort, options.pageMode);
275
- const checks = { deps, webpackCache, buildLog, dist, runtimeDist, cdp };
375
+ const checks = { deps, webpackCache, buildLog, dist, runtimeDist, releaseArtifact, cdp };
276
376
  const install = [{ id: "yarn-install", argv: ["yarn", "install", "--immutable"], cwd: resolved }];
277
377
  const relaunch = [{ id: "relaunch-browser" }];
278
378
  const cacheStale = buildLog.reason === "stale-cache";
@@ -355,7 +455,47 @@ async function decideExtensionReadiness(target, options = {}) {
355
455
  reasons: ["Build is fresh; browser liveness unverified (pass --cdp-port to confirm `ready`)."],
356
456
  actions: relaunch
357
457
  };
358
- const core = rules.find((rule) => rule.when) ?? fallback;
458
+ let core;
459
+ if (releaseArtifact.status === "invalid") {
460
+ core = {
461
+ decision: "blocked",
462
+ reasonCode: "release-artifact-identity-invalid",
463
+ reasons: [`The loaded release artifact identity is invalid: ${releaseArtifact.reason ?? "unknown reason"}.`],
464
+ actions: []
465
+ };
466
+ } else if (releaseArtifact.status === "valid") {
467
+ const artifactRules = [
468
+ {
469
+ when: runtimeDist.status !== "fresh",
470
+ decision: "relaunch",
471
+ reasonCode: runtimeDist.status === "missing" ? "release-artifact-runtime-missing" : "release-artifact-runtime-stale",
472
+ reasons: [runtimeDist.status === "missing" ? "The loaded release artifact snapshot is missing." : `The loaded runtime snapshot differs from release artifact ${releaseArtifact.sha256?.slice(0, 12)}.`],
473
+ actions: relaunch
474
+ },
475
+ {
476
+ when: cdp.status === "pass",
477
+ decision: "ready",
478
+ reasonCode: "release-artifact-healthy",
479
+ reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact and healthy over CDP.`],
480
+ actions: []
481
+ },
482
+ {
483
+ when: cdp.status === "fail",
484
+ decision: "relaunch",
485
+ reasonCode: "runtime-unhealthy",
486
+ reasons: ["The release artifact is intact but the live extension is unhealthy.", ...cdp.findings?.slice(0, 3) ?? []],
487
+ actions: relaunch
488
+ }
489
+ ];
490
+ core = artifactRules.find((rule) => rule.when) ?? {
491
+ decision: "relaunch",
492
+ reasonCode: "cdp-unknown",
493
+ reasons: [`Release artifact ${releaseArtifact.expectedVersion} is intact; browser liveness is unverified.`],
494
+ actions: relaunch
495
+ };
496
+ } else {
497
+ core = rules.find((rule) => rule.when) ?? fallback;
498
+ }
359
499
  return {
360
500
  schemaVersion: 1,
361
501
  adapter: "extension",
@@ -378,5 +518,6 @@ export {
378
518
  decideExtensionReadiness,
379
519
  isExtensionDistStale,
380
520
  recordWebpackBaseline,
521
+ releaseArtifactState,
381
522
  runtimeDistCheck
382
523
  };
@@ -147,6 +147,12 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
147
147
  };
148
148
  }
149
149
  const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
150
+ if (runtime.hasSubmitRequest !== true) {
151
+ const providerProbe = await probeUiEthereumProvider(session, cdpCallTimeoutMs);
152
+ runtime.evmRpcProbeOk = providerProbe.ok;
153
+ runtime.evmRpcProbeError = providerProbe.error;
154
+ runtime.evmRpcProbeSource = providerProbe.source;
155
+ }
150
156
  if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
151
157
  findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
152
158
  }
@@ -316,6 +322,127 @@ async function evaluateHealth(session, timeoutMs) {
316
322
  }
317
323
  return result.result?.value ?? {};
318
324
  }
325
+ async function probeUiEthereumProvider(session, timeoutMs) {
326
+ const bridgeKey = "mm-harness.runtime.evm-provider-probe";
327
+ const preload = await boundedCdpCall(
328
+ session,
329
+ "Page.addScriptToEvaluateOnNewDocument",
330
+ {
331
+ source: `(() => {
332
+ const bridgeKey = Symbol.for(${JSON.stringify(bridgeKey)});
333
+ let provider;
334
+ Object.defineProperty(globalThis, 'ethereumProvider', {
335
+ configurable: true,
336
+ get() { return provider; },
337
+ set(value) { provider = value; },
338
+ });
339
+ Object.defineProperty(globalThis, bridgeKey, {
340
+ configurable: true,
341
+ value: {
342
+ async probe() {
343
+ if (!provider || typeof provider.request !== 'function') {
344
+ return { ok: false, error: 'Ethereum provider is unavailable' };
345
+ }
346
+ try {
347
+ const code = await provider.request({
348
+ method: 'eth_getCode',
349
+ params: ['0x0000000000000000000000000000000000000000', 'latest'],
350
+ });
351
+ const ok = typeof code === 'string' && /^0x[0-9a-f]*$/iu.test(code);
352
+ return {
353
+ ok,
354
+ error: ok ? null : 'Ethereum provider returned invalid contract code',
355
+ };
356
+ } catch (error) {
357
+ return { ok: false, error: String(error?.message || error) };
358
+ }
359
+ },
360
+ cleanup() {
361
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'ethereumProvider');
362
+ if (descriptor?.configurable === true) {
363
+ Object.defineProperty(globalThis, 'ethereumProvider', {
364
+ configurable: true,
365
+ writable: true,
366
+ value: provider,
367
+ });
368
+ }
369
+ return delete globalThis[bridgeKey];
370
+ },
371
+ },
372
+ });
373
+ })();`
374
+ },
375
+ timeoutMs
376
+ );
377
+ if (typeof preload?.identifier !== "string" || preload.identifier.length === 0) {
378
+ return {
379
+ ok: false,
380
+ error: "Ethereum provider preload was not installed",
381
+ source: "ui-ethereum-provider"
382
+ };
383
+ }
384
+ let outcome = {
385
+ ok: false,
386
+ error: "Ethereum provider is unavailable",
387
+ source: "ui-ethereum-provider"
388
+ };
389
+ const cleanupErrors = [];
390
+ try {
391
+ await boundedCdpCall(session, "Page.reload", { ignoreCache: false }, timeoutMs);
392
+ const deadline = Date.now() + timeoutMs;
393
+ while (Date.now() <= deadline) {
394
+ try {
395
+ const result = await boundedCdpCall(session, "Runtime.evaluate", {
396
+ expression: `globalThis[Symbol.for(${JSON.stringify(bridgeKey)})]?.probe()`,
397
+ awaitPromise: true,
398
+ returnByValue: true
399
+ }, Math.max(1, deadline - Date.now()));
400
+ const value = result.result?.value;
401
+ if (value?.ok === true) {
402
+ outcome = { ok: true, error: null, source: "ui-ethereum-provider" };
403
+ break;
404
+ }
405
+ if (typeof value?.error === "string") outcome.error = value.error;
406
+ } catch (error) {
407
+ outcome.error = messageOf(error);
408
+ }
409
+ if (outcome.ok) break;
410
+ await sleep(100);
411
+ }
412
+ } catch (error) {
413
+ outcome = { ok: false, error: messageOf(error), source: "ui-ethereum-provider" };
414
+ } finally {
415
+ try {
416
+ const cleanup = await boundedCdpCall(session, "Runtime.evaluate", {
417
+ expression: `(() => {
418
+ const key = Symbol.for(${JSON.stringify(bridgeKey)});
419
+ const bridge = globalThis[key];
420
+ if (!bridge) return true;
421
+ return bridge.cleanup() === true && !(key in globalThis);
422
+ })()`,
423
+ returnByValue: true
424
+ }, timeoutMs);
425
+ if (cleanup.result?.value !== true) cleanupErrors.push("page bridge removal was not confirmed");
426
+ } catch (error) {
427
+ cleanupErrors.push(messageOf(error));
428
+ }
429
+ try {
430
+ await boundedCdpCall(
431
+ session,
432
+ "Page.removeScriptToEvaluateOnNewDocument",
433
+ { identifier: preload.identifier },
434
+ timeoutMs
435
+ );
436
+ } catch (error) {
437
+ cleanupErrors.push(messageOf(error));
438
+ }
439
+ }
440
+ return cleanupErrors.length > 0 ? {
441
+ ok: false,
442
+ error: `Ethereum provider probe cleanup failed: ${cleanupErrors.join("; ")}`,
443
+ source: "ui-ethereum-provider"
444
+ } : outcome;
445
+ }
319
446
  function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
320
447
  return Math.max(1, Math.floor(cdpCallTimeoutMs * 0.8));
321
448
  }
@@ -0,0 +1,124 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { recipeRuntimePath } from "../../paths.js";
5
+ import {
6
+ assertAndroidReleaseArtifactRuntime
7
+ } from "./release-artifact.js";
8
+ function mobileReleaseArtifactStatePath(target) {
9
+ return recipeRuntimePath(target, "mobile-release-artifact.json");
10
+ }
11
+ function writeMobileReleaseArtifactState(target, report) {
12
+ const state = {
13
+ schemaVersion: 1,
14
+ adapter: "mobile",
15
+ platform: "android",
16
+ packageId: report.packageId,
17
+ versionName: report.versionName,
18
+ versionCode: report.versionCode,
19
+ sha256: report.sha256,
20
+ deviceSerial: report.deviceSerial,
21
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
22
+ };
23
+ const file = mobileReleaseArtifactStatePath(target);
24
+ const directory = ensureSafeRuntimeDirectory(target, file);
25
+ const existing = fs.lstatSync(file, { throwIfNoEntry: false });
26
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw invalidState(file);
27
+ const temporary = path.join(
28
+ directory,
29
+ `.mobile-release-artifact.${process.pid}.${randomBytes(8).toString("hex")}.tmp`
30
+ );
31
+ let descriptor;
32
+ try {
33
+ descriptor = fs.openSync(temporary, "wx", 384);
34
+ fs.writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}
35
+ `, "utf8");
36
+ fs.fsyncSync(descriptor);
37
+ fs.closeSync(descriptor);
38
+ descriptor = void 0;
39
+ fs.renameSync(temporary, file);
40
+ } finally {
41
+ if (descriptor !== void 0) fs.closeSync(descriptor);
42
+ const temporaryStat = fs.lstatSync(temporary, { throwIfNoEntry: false });
43
+ if (temporaryStat) fs.unlinkSync(temporary);
44
+ }
45
+ return state;
46
+ }
47
+ function requireCurrentMobileReleaseArtifactState(target, verify = assertAndroidReleaseArtifactRuntime) {
48
+ const state = readMobileReleaseArtifactState(target);
49
+ if (state) verify(state);
50
+ return state;
51
+ }
52
+ function clearMobileReleaseArtifactState(target) {
53
+ const file = mobileReleaseArtifactStatePath(target);
54
+ const existing = fs.lstatSync(file, { throwIfNoEntry: false });
55
+ if (!existing) return;
56
+ ensureSafeRuntimeDirectory(target, file);
57
+ if (!existing.isFile() || existing.isSymbolicLink()) throw invalidState(file);
58
+ fs.unlinkSync(file);
59
+ }
60
+ function readMobileReleaseArtifactState(target) {
61
+ const file = mobileReleaseArtifactStatePath(target);
62
+ let stat;
63
+ try {
64
+ stat = fs.lstatSync(file);
65
+ } catch (error) {
66
+ if (isMissing(error)) return null;
67
+ throw invalidState(file);
68
+ }
69
+ try {
70
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16 * 1024) {
71
+ throw invalidState(file);
72
+ }
73
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
74
+ if (value.schemaVersion !== 1 || value.adapter !== "mobile" || value.platform !== "android" || !text(value.packageId) || !text(value.versionName) || !text(value.versionCode) || !/^[a-f0-9]{64}$/u.test(value.sha256 ?? "") || !text(value.deviceSerial) || !text(value.installedAt)) throw invalidState(file);
75
+ return value;
76
+ } catch (error) {
77
+ if (error instanceof Error && error.message.startsWith("The Mobile release artifact state is invalid")) {
78
+ throw error;
79
+ }
80
+ throw invalidState(file);
81
+ }
82
+ }
83
+ function text(value) {
84
+ return typeof value === "string" && value.trim().length > 0;
85
+ }
86
+ function isMissing(error) {
87
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
88
+ }
89
+ function invalidState(file) {
90
+ return new Error(`The Mobile release artifact state is invalid: ${file}. Next: rerun runtime-launch with the same artifact source.`);
91
+ }
92
+ function ensureSafeRuntimeDirectory(target, file) {
93
+ const root = path.resolve(target);
94
+ const directory = path.dirname(path.resolve(file));
95
+ const relative = path.relative(root, directory);
96
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
97
+ throw invalidState(file);
98
+ }
99
+ let current = root;
100
+ const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
101
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) throw invalidState(file);
102
+ for (const segment of relative.split(path.sep)) {
103
+ current = path.join(current, segment);
104
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
105
+ if (stat) {
106
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw invalidState(file);
107
+ } else {
108
+ fs.mkdirSync(current, { mode: 448 });
109
+ }
110
+ }
111
+ const realRoot = fs.realpathSync(root);
112
+ const realDirectory = fs.realpathSync(directory);
113
+ if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
114
+ throw invalidState(file);
115
+ }
116
+ return directory;
117
+ }
118
+ export {
119
+ clearMobileReleaseArtifactState,
120
+ mobileReleaseArtifactStatePath,
121
+ readMobileReleaseArtifactState,
122
+ requireCurrentMobileReleaseArtifactState,
123
+ writeMobileReleaseArtifactState
124
+ };