@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
@@ -9,9 +9,12 @@
9
9
  #
10
10
  # Inputs (flags):
11
11
  # --dist <dir> source dist (required, e.g. <repo>/dist/chrome)
12
+ # --target <dir> absolute checkout containment root
13
+ # --runtime-root <dir> absolute directory that owns the snapshot
12
14
  # --runtime-dist <dir> snapshot destination (required, recreated)
13
15
  # --wait-iterations <n> manifest wait loop length, 2s each (default 180)
14
16
  # --summary <file> optional standard summary.json
17
+ # --exact preserve every source entry (release artifacts)
15
18
  #
16
19
  # Outputs:
17
20
  # <runtime-dist>/ snapshot (excludes _metadata); optional --summary file
@@ -24,24 +27,32 @@
24
27
  set -euo pipefail
25
28
 
26
29
  DIST=""
30
+ TARGET_ROOT=""
31
+ RUNTIME_ROOT=""
27
32
  RUNTIME_DIST=""
28
33
  WAIT_ITERATIONS=180
29
34
  SUMMARY=""
35
+ EXACT=false
30
36
  require_value() { [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; }
31
37
  while [ "$#" -gt 0 ]; do
32
38
  case "$1" in
33
39
  --dist) require_value "$@"; DIST="$2"; shift 2 ;;
40
+ --target) require_value "$@"; TARGET_ROOT="$2"; shift 2 ;;
41
+ --runtime-root) require_value "$@"; RUNTIME_ROOT="$2"; shift 2 ;;
34
42
  --runtime-dist) require_value "$@"; RUNTIME_DIST="$2"; shift 2 ;;
35
43
  --wait-iterations) require_value "$@"; WAIT_ITERATIONS="$2"; shift 2 ;;
36
44
  --summary) require_value "$@"; SUMMARY="$2"; shift 2 ;;
45
+ --exact) EXACT=true; shift ;;
37
46
  -h|--help)
38
- echo "Usage: snapshot-dist.sh --dist <dir> --runtime-dist <dir> [--wait-iterations <n>] [--summary <file>]"
47
+ echo "Usage: snapshot-dist.sh --dist <dir> --target <dir> --runtime-root <dir> --runtime-dist <dir> [--wait-iterations <n>] [--summary <file>]"
39
48
  exit 0
40
49
  ;;
41
50
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
42
51
  esac
43
52
  done
44
53
  [ -n "$DIST" ] || { echo "Missing --dist" >&2; exit 2; }
54
+ [ -n "$TARGET_ROOT" ] || { echo "Missing --target" >&2; exit 2; }
55
+ [ -n "$RUNTIME_ROOT" ] || { echo "Missing --runtime-root" >&2; exit 2; }
45
56
  [ -n "$RUNTIME_DIST" ] || { echo "Missing --runtime-dist" >&2; exit 2; }
46
57
  case "$WAIT_ITERATIONS" in ''|*[!0-9]*) echo "Invalid --wait-iterations (must be numeric): $WAIT_ITERATIONS" >&2; exit 2 ;; esac
47
58
 
@@ -73,8 +84,82 @@ done
73
84
  test -f "$DIST/manifest.json" || { echo "snapshot-dist: no manifest at $DIST/manifest.json" >&2; exit 1; }
74
85
 
75
86
  echo "[recipe-harness] snapshotting dist -> runtime-dist: $RUNTIME_DIST" >&2
76
- rm -rf "$RUNTIME_DIST" && mkdir -p "$RUNTIME_DIST" \
77
- && rsync -a --delete --exclude _metadata "$DIST/" "$RUNTIME_DIST/" || exit 1
87
+ node - "$DIST" "$TARGET_ROOT" "$RUNTIME_ROOT" "$RUNTIME_DIST" <<'NODE'
88
+ const fs = require('node:fs');
89
+ const path = require('node:path');
90
+
91
+ const [sourceInput, targetInput, rootInput, destinationInput] = process.argv.slice(2);
92
+ const source = path.resolve(sourceInput);
93
+ const target = path.resolve(targetInput);
94
+ const root = path.resolve(rootInput);
95
+ const destination = path.resolve(destinationInput);
96
+
97
+ function refuse(message) {
98
+ throw new Error(`snapshot-dist: ${message}`);
99
+ }
100
+
101
+ if (![targetInput, rootInput, destinationInput].every(path.isAbsolute)) {
102
+ refuse('--target, --runtime-root, and --runtime-dist must be absolute paths.');
103
+ }
104
+ const targetStat = fs.lstatSync(target, { throwIfNoEntry: false });
105
+ if (!targetStat?.isDirectory() || targetStat.isSymbolicLink()) {
106
+ refuse(`target is not a regular directory: ${target}.`);
107
+ }
108
+ const rootRelative = path.relative(target, root);
109
+ if (!rootRelative || rootRelative === '..' || rootRelative.startsWith(`..${path.sep}`) || path.isAbsolute(rootRelative)) {
110
+ refuse(`runtime root must be a child of ${target}.`);
111
+ }
112
+ const relative = path.relative(root, destination);
113
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
114
+ refuse(`runtime destination must be a child of ${root}.`);
115
+ }
116
+ if (
117
+ source === destination ||
118
+ source.startsWith(`${destination}${path.sep}`) ||
119
+ destination.startsWith(`${source}${path.sep}`)
120
+ ) {
121
+ refuse('source and runtime destination must be separate directory trees.');
122
+ }
123
+
124
+ function ensureDirectoryTree(base, relativePath) {
125
+ let current = base;
126
+ const components = relativePath.split(path.sep).filter(Boolean);
127
+ for (const component of components) {
128
+ current = path.join(current, component);
129
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
130
+ if (!stat) {
131
+ fs.mkdirSync(current, { mode: 0o700 });
132
+ continue;
133
+ }
134
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
135
+ refuse(`runtime path contains an unsafe entry: ${current}.`);
136
+ }
137
+ }
138
+ }
139
+
140
+ ensureDirectoryTree(target, rootRelative);
141
+ ensureDirectoryTree(root, path.relative(root, path.dirname(destination)));
142
+ const realTarget = fs.realpathSync(target);
143
+ const realRoot = fs.realpathSync(root);
144
+ const realParent = fs.realpathSync(path.dirname(destination));
145
+ if (
146
+ !realRoot.startsWith(`${realTarget}${path.sep}`) ||
147
+ (realParent !== realRoot && !realParent.startsWith(`${realRoot}${path.sep}`))
148
+ ) {
149
+ refuse(`runtime destination escapes ${root}.`);
150
+ }
151
+ const destinationStat = fs.lstatSync(destination, { throwIfNoEntry: false });
152
+ if (destinationStat && (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())) {
153
+ refuse(`runtime destination is not a regular directory: ${destination}.`);
154
+ }
155
+ if (destinationStat) fs.rmSync(destination, { recursive: true });
156
+ fs.mkdirSync(destination, { mode: 0o700 });
157
+ NODE
158
+ if $EXACT; then
159
+ rsync -a --delete "$DIST/" "$RUNTIME_DIST/" || exit 1
160
+ else
161
+ rsync -a --delete --exclude _metadata "$DIST/" "$RUNTIME_DIST/" || exit 1
162
+ fi
78
163
  echo "[recipe-harness] runtime-dist snapshot complete" >&2
79
164
 
80
165
  # Freshness guard: the loaded runtime-dist must match dist/chrome's git id. A
@@ -344,14 +344,18 @@ let r = {};
344
344
  try { r = JSON.parse(fs.readFileSync(dir + "/runtime-decision.json", "utf8")); } catch {}
345
345
  const c = r.checks || {};
346
346
  const dist = c.dist || { status: "unknown" };
347
- const distMsg = dist.status === "fresh" ? "dist id matches HEAD; no uncommitted source."
347
+ const distMsg = dist.source === "release-artifact"
348
+ ? ("release artifact " + (dist.manifestVersion || "?") + " sha256=" + (dist.artifactSha256 || "?") + " matches the loaded runtime snapshot.")
349
+ : dist.status === "fresh" ? "dist id matches HEAD; no uncommitted source."
348
350
  : dist.status === "stale" ? (dist.reason === "uncommitted-source"
349
351
  ? ((dist.modified ? dist.modified.length : "some") + " uncommitted source file(s); rebuild or commit.")
350
352
  : ("dist id " + (dist.distGitId || "?") + " != HEAD " + (dist.head || "?") + "; rebuild."))
351
353
  : dist.status === "no-build" ? "no dist/chrome build."
352
354
  : "no git id in dist or not a git checkout; cannot prove parity.";
353
355
  fs.writeFileSync(dir + "/dist-freshness.json", JSON.stringify({ ...dist, message: distMsg }));
354
- const bl = c.buildLog || { status: "unknown" };
356
+ const bl = c.releaseArtifact?.status === "valid"
357
+ ? { status: "no-watch", source: "release-artifact" }
358
+ : (c.buildLog || { status: "unknown" });
355
359
  const blMsg = bl.status === "ok" ? "webpack compiled."
356
360
  : bl.status === "no-watch" ? "no webpack watch log; build-health n/a (e.g. one-shot build)."
357
361
  : bl.status === "building" ? "webpack has not reported a successful compile yet."
@@ -186,6 +186,14 @@
186
186
  "inputs": "--target --runtime-dist; optional --runtime-dir",
187
187
  "outputs": "home.html and sidepanel.html titles updated in the isolated runtime only; exit 0/1"
188
188
  },
189
+ {
190
+ "id": "extension/artifact-runtime-state",
191
+ "entry": "adapters/extension/artifact-runtime-state.cjs",
192
+ "kind": "node",
193
+ "purpose": "Record or clear the acquired release artifact identity bound to the loaded runtime snapshot.",
194
+ "inputs": "set|clear --target --runtime-dir; set also requires --source-dir --runtime-dist --provenance",
195
+ "outputs": "<runtime-dir>/extension-release-artifact.json or removal; exit 0/1/2"
196
+ },
189
197
  {
190
198
  "id": "extension/stop-viewers",
191
199
  "entry": "adapters/extension/stop-viewers.sh",
@@ -207,7 +215,7 @@
207
215
  "entry": "adapters/extension/snapshot-dist.sh",
208
216
  "kind": "bash",
209
217
  "purpose": "Runtime-dist snapshot (rsync, excludes _metadata) with from-git-id freshness guard.",
210
- "inputs": "--dist --runtime-dist --wait-iterations --summary",
218
+ "inputs": "--dist --target --runtime-root --runtime-dist --wait-iterations --summary",
211
219
  "outputs": "<runtime-dist>/ snapshot; optional summary; exit 0/1/2"
212
220
  },
213
221
  {
@@ -236,7 +236,7 @@ async function watchLog(options) {
236
236
  renderCompact({ label, logPath, events, quiet: mode === 'quiet' });
237
237
  if (mode !== 'quiet') {
238
238
  const elapsed = Math.round((Date.now() - started) / 1000);
239
- process.stderr.write(`${color('label', 'mm-harness', { stream: process.stderr })} ${color('dim', '|', { stream: process.stderr })} ${color('ok', `build complete (${elapsed}s)`, { stream: process.stderr })}\n`);
239
+ process.stderr.write(`${color('label', 'mm-harness', { stream: process.stderr })} ${color('dim', '|', { stream: process.stderr })} ${color('ok', `${label} complete (${elapsed}s)`, { stream: process.stderr })}\n`);
240
240
  }
241
241
  return 0;
242
242
  }
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ function extensionTreeSha256(root) {
5
+ const resolvedRoot = path.resolve(root);
6
+ const rootStat = fs.lstatSync(resolvedRoot);
7
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
8
+ throw new Error(`Extension artifact root must be a regular directory: ${resolvedRoot}`);
9
+ }
10
+ const digest = createHash("sha256");
11
+ walk(resolvedRoot, "");
12
+ return digest.digest("hex");
13
+ function walk(directory, relativeDirectory) {
14
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
15
+ for (const entry of entries) {
16
+ const absolute = path.join(directory, entry.name);
17
+ const relative = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
18
+ const stat = fs.lstatSync(absolute);
19
+ if (stat.isSymbolicLink()) {
20
+ throw new Error(`Extension artifact tree contains a symbolic link: ${relative}`);
21
+ }
22
+ if (stat.isDirectory()) {
23
+ digest.update(`directory\0${relative}\0`);
24
+ walk(absolute, relative);
25
+ continue;
26
+ }
27
+ if (!stat.isFile()) {
28
+ throw new Error(`Extension artifact tree contains an unsupported entry: ${relative}`);
29
+ }
30
+ digest.update(`file\0${relative}\0${stat.size}\0`);
31
+ digest.update(fs.readFileSync(absolute));
32
+ digest.update("\0");
33
+ }
34
+ }
35
+ }
36
+ export {
37
+ extensionTreeSha256
38
+ };
@@ -1,6 +1,8 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { recipeRuntimeDir } from "../../paths.js";
5
+ import { releaseArtifactState, runtimeDistCheck } from "./runtime-decision.js";
4
6
  const DIST_MANIFEST = "dist/chrome/manifest.json";
5
7
  function extensionIdFromKey(keyBase64) {
6
8
  const der = Buffer.from(keyBase64, "base64");
@@ -12,8 +14,8 @@ function extensionIdFromKey(keyBase64) {
12
14
  }
13
15
  return id;
14
16
  }
15
- function idFromDistManifest(target) {
16
- const manifestPath = path.join(target, DIST_MANIFEST);
17
+ function idFromManifest(manifestPath) {
18
+ if (!manifestPath) return null;
17
19
  if (!fs.existsSync(manifestPath)) return null;
18
20
  try {
19
21
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -39,7 +41,24 @@ async function cdpExtensionIds(cdpPort) {
39
41
  }
40
42
  async function resolveExtensionId(target, options = {}) {
41
43
  const resolved = path.resolve(target);
42
- const fromKey = idFromDistManifest(resolved);
44
+ const runtimeSnapshot = runtimeDistCheck(resolved, releaseArtifactState(resolved));
45
+ const releaseArtifact = runtimeSnapshot.source === "release-artifact";
46
+ if (releaseArtifact && runtimeSnapshot.status !== "fresh") {
47
+ return {
48
+ adapter: "extension",
49
+ target: resolved,
50
+ extensionId: null,
51
+ source: "none",
52
+ verified: false
53
+ };
54
+ }
55
+ const manifestPath = releaseArtifact ? runtimeSnapshot.status === "fresh" ? path.join(
56
+ resolved,
57
+ recipeRuntimeDir(),
58
+ process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist",
59
+ "manifest.json"
60
+ ) : null : path.join(resolved, DIST_MANIFEST);
61
+ const fromKey = idFromManifest(manifestPath);
43
62
  let cdpIds = null;
44
63
  if (options.cdpPort) cdpIds = await cdpExtensionIds(options.cdpPort);
45
64
  if (fromKey) {
@@ -47,7 +66,7 @@ async function resolveExtensionId(target, options = {}) {
47
66
  adapter: "extension",
48
67
  target: resolved,
49
68
  extensionId: fromKey,
50
- source: "manifest-key",
69
+ source: releaseArtifact ? "release-manifest-key" : "manifest-key",
51
70
  verified: cdpIds ? cdpIds.includes(fromKey) : null
52
71
  };
53
72
  }
@@ -0,0 +1,386 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { promises as fsp } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { inflateRawSync } from "node:zlib";
7
+ import { extensionTreeSha256 } from "./artifact-integrity.js";
8
+ const MAX_ARCHIVE_BYTES = 768 * 1024 * 1024;
9
+ const MAX_ENTRY_BYTES = 512 * 1024 * 1024;
10
+ const MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024;
11
+ const MAX_ENTRIES = 1e5;
12
+ async function acquireExtensionReleaseArtifact(options) {
13
+ const source = localSource(options.file);
14
+ const expectedVersion = expectedArtifactVersion(options.expectedVersion);
15
+ const expectedSha256 = expectedArtifactSha256(options.expectedSha256);
16
+ const requestedCacheRoot = path.resolve(
17
+ options.cacheRoot ?? path.join(os.homedir(), ".cache", "mm-harness", "extension-artifacts")
18
+ );
19
+ await fsp.mkdir(requestedCacheRoot, { recursive: true, mode: 448 });
20
+ const cacheRoot = await fsp.realpath(requestedCacheRoot);
21
+ const acquired = await inspectLocalArchive(source.path, cacheRoot);
22
+ if (acquired.sha256 !== expectedSha256) {
23
+ if (acquired.temporary) await fsp.rm(path.dirname(acquired.archivePath), { recursive: true });
24
+ throw new Error(`Extension artifact SHA-256 ${acquired.sha256} does not match expected ${expectedSha256}.`);
25
+ }
26
+ const artifactRoot = path.join(cacheRoot, expectedVersion, acquired.sha256);
27
+ const provenancePath = path.join(artifactRoot, "provenance.json");
28
+ const cached = await readCachedArtifact(artifactRoot, provenancePath, acquired.sha256, expectedVersion);
29
+ if (cached) {
30
+ if (acquired.temporary) {
31
+ await fsp.rm(path.dirname(acquired.archivePath), { recursive: true });
32
+ }
33
+ return {
34
+ ...cached,
35
+ source,
36
+ archiveBytes: acquired.bytes,
37
+ cache: "hit"
38
+ };
39
+ }
40
+ if (await pathExists(artifactRoot)) {
41
+ await assertRegularTree(artifactRoot);
42
+ await assertContainedDirectory(artifactRoot, cacheRoot);
43
+ await fsp.rm(artifactRoot, { recursive: true });
44
+ }
45
+ const versionRoot = path.dirname(artifactRoot);
46
+ await fsp.mkdir(versionRoot, { recursive: true, mode: 448 });
47
+ await assertContainedDirectory(versionRoot, cacheRoot);
48
+ const temporaryRoot = await fsp.mkdtemp(path.join(versionRoot, ".extract-"));
49
+ try {
50
+ const contentRoot = path.join(temporaryRoot, "content");
51
+ await fsp.mkdir(contentRoot, { mode: 448 });
52
+ const extensionDir = await extractExtensionArchive(acquired.archivePath, contentRoot);
53
+ const manifestVersion = await validateManifestVersion(extensionDir, expectedVersion);
54
+ const treeSha256 = extensionTreeSha256(extensionDir);
55
+ const relativeExtensionDir = path.relative(temporaryRoot, extensionDir) || ".";
56
+ const provenance = {
57
+ schemaVersion: 1,
58
+ source,
59
+ expectedVersion,
60
+ manifestVersion,
61
+ sha256: acquired.sha256,
62
+ treeSha256,
63
+ archiveBytes: acquired.bytes,
64
+ extensionDir: relativeExtensionDir,
65
+ acquiredAt: (/* @__PURE__ */ new Date()).toISOString()
66
+ };
67
+ await fsp.writeFile(
68
+ path.join(temporaryRoot, "provenance.json"),
69
+ `${JSON.stringify(provenance, null, 2)}
70
+ `,
71
+ { mode: 384 }
72
+ );
73
+ try {
74
+ await fsp.rename(temporaryRoot, artifactRoot);
75
+ } catch (error) {
76
+ if (!isAlreadyExists(error)) throw error;
77
+ await fsp.rm(temporaryRoot, { recursive: true });
78
+ }
79
+ const ready = await readCachedArtifact(artifactRoot, provenancePath, acquired.sha256, expectedVersion);
80
+ if (!ready) throw new Error(`Extension artifact cache could not be validated at ${artifactRoot}.`);
81
+ return {
82
+ ...ready,
83
+ source,
84
+ archiveBytes: acquired.bytes,
85
+ cache: "miss"
86
+ };
87
+ } catch (error) {
88
+ if (await pathExists(temporaryRoot)) await fsp.rm(temporaryRoot, { recursive: true });
89
+ throw error;
90
+ } finally {
91
+ if (acquired.temporary) {
92
+ await fsp.rm(path.dirname(acquired.archivePath), { recursive: true });
93
+ }
94
+ }
95
+ }
96
+ function expectedArtifactSha256(requested) {
97
+ if (!/^[a-f0-9]{64}$/u.test(requested)) {
98
+ throw new Error("--artifact-sha256 must be 64 lowercase hexadecimal characters.");
99
+ }
100
+ return requested;
101
+ }
102
+ async function inspectLocalArchive(archive, cacheRoot) {
103
+ const sourcePath = path.resolve(archive);
104
+ const downloadRoot = path.join(cacheRoot, ".downloads");
105
+ await fsp.mkdir(downloadRoot, { recursive: true, mode: 448 });
106
+ const temporaryRoot = await fsp.mkdtemp(path.join(downloadRoot, "artifact-"));
107
+ const archivePath = path.join(temporaryRoot, "artifact.zip");
108
+ let source;
109
+ try {
110
+ source = await fsp.open(sourcePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
111
+ const stat = await source.stat();
112
+ if (!stat.isFile()) throw new Error(`Extension artifact archive must be a regular file: ${sourcePath}`);
113
+ if (stat.size > MAX_ARCHIVE_BYTES) {
114
+ throw new Error(`Extension artifact exceeds the ${MAX_ARCHIVE_BYTES}-byte archive limit.`);
115
+ }
116
+ const destination = await fsp.open(archivePath, "wx", 384);
117
+ const digest = createHash("sha256");
118
+ let bytes = 0;
119
+ try {
120
+ const stream = source.createReadStream({ autoClose: false });
121
+ for await (const chunk of stream) {
122
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
123
+ bytes += buffer.length;
124
+ if (bytes > MAX_ARCHIVE_BYTES) {
125
+ throw new Error(`Extension artifact exceeds the ${MAX_ARCHIVE_BYTES}-byte archive limit.`);
126
+ }
127
+ digest.update(buffer);
128
+ await destination.write(buffer);
129
+ }
130
+ } finally {
131
+ await destination.close();
132
+ }
133
+ return { archivePath, sha256: digest.digest("hex"), bytes, temporary: true };
134
+ } catch (error) {
135
+ await fsp.rm(temporaryRoot, { recursive: true });
136
+ throw error;
137
+ } finally {
138
+ await source?.close();
139
+ }
140
+ }
141
+ function localSource(archive) {
142
+ return { kind: "local", path: path.resolve(archive) };
143
+ }
144
+ function expectedArtifactVersion(requested) {
145
+ if (!/^\d+\.\d+\.\d+$/u.test(requested)) {
146
+ throw new Error("--artifact-version must use X.Y.Z.");
147
+ }
148
+ return requested;
149
+ }
150
+ async function extractExtensionArchive(archivePath, outputRoot) {
151
+ const archive = await fsp.readFile(archivePath);
152
+ const entries = readZipEntries(archive);
153
+ const manifestEntries = entries.filter((entry) => !entry.directory && path.posix.basename(entry.name) === "manifest.json");
154
+ if (manifestEntries.length !== 1) {
155
+ throw new Error(`Extension artifact must contain exactly one manifest.json; found ${manifestEntries.length}.`);
156
+ }
157
+ const extensionPrefix = path.posix.dirname(manifestEntries[0].name);
158
+ if (extensionPrefix !== ".") {
159
+ const prefix = `${extensionPrefix}/`;
160
+ const outside = entries.find((entry) => entry.name !== extensionPrefix && !entry.name.startsWith(prefix));
161
+ if (outside) {
162
+ throw new Error(`Extension artifact has content outside its manifest root: ${outside.name}.`);
163
+ }
164
+ }
165
+ for (const entry of entries) {
166
+ const destination = path.join(outputRoot, ...entry.name.split("/"));
167
+ if (entry.directory) {
168
+ await fsp.mkdir(destination, { recursive: true, mode: 493 });
169
+ continue;
170
+ }
171
+ await fsp.mkdir(path.dirname(destination), { recursive: true, mode: 493 });
172
+ const compressedStart = localEntryDataOffset(archive, entry);
173
+ const compressed = archive.subarray(compressedStart, compressedStart + entry.compressedSize);
174
+ const content = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize });
175
+ if (content.length !== entry.uncompressedSize || crc32(content) !== entry.crc32) {
176
+ throw new Error(`Extension artifact entry failed integrity validation: ${entry.name}.`);
177
+ }
178
+ await fsp.writeFile(destination, content, { flag: "wx", mode: 420 });
179
+ }
180
+ return extensionPrefix === "." ? outputRoot : path.join(outputRoot, ...extensionPrefix.split("/"));
181
+ }
182
+ function readZipEntries(archive) {
183
+ const eocdOffset = findEndOfCentralDirectory(archive);
184
+ const disk = archive.readUInt16LE(eocdOffset + 4);
185
+ const centralDisk = archive.readUInt16LE(eocdOffset + 6);
186
+ const entriesOnDisk = archive.readUInt16LE(eocdOffset + 8);
187
+ const entryCount = archive.readUInt16LE(eocdOffset + 10);
188
+ const centralSize = archive.readUInt32LE(eocdOffset + 12);
189
+ const centralOffset = archive.readUInt32LE(eocdOffset + 16);
190
+ if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
191
+ throw new Error("Multi-disk ZIP archives are not supported.");
192
+ }
193
+ if (entryCount === 65535 || centralSize === 4294967295 || centralOffset === 4294967295) {
194
+ throw new Error("ZIP64 extension artifacts are not supported.");
195
+ }
196
+ if (entryCount === 0 || entryCount > MAX_ENTRIES || centralOffset + centralSize > eocdOffset) {
197
+ throw new Error("Extension artifact has an invalid central directory.");
198
+ }
199
+ const entries = [];
200
+ const names = /* @__PURE__ */ new Set();
201
+ const offsets = /* @__PURE__ */ new Set();
202
+ let expandedBytes = 0;
203
+ let offset = centralOffset;
204
+ for (let index = 0; index < entryCount; index += 1) {
205
+ if (offset + 46 > archive.length || archive.readUInt32LE(offset) !== 33639248) {
206
+ throw new Error("Extension artifact has a malformed central directory entry.");
207
+ }
208
+ const flags = archive.readUInt16LE(offset + 8);
209
+ const method = archive.readUInt16LE(offset + 10);
210
+ const crc = archive.readUInt32LE(offset + 16);
211
+ const compressedSize = archive.readUInt32LE(offset + 20);
212
+ const uncompressedSize = archive.readUInt32LE(offset + 24);
213
+ const nameLength = archive.readUInt16LE(offset + 28);
214
+ const extraLength = archive.readUInt16LE(offset + 30);
215
+ const commentLength = archive.readUInt16LE(offset + 32);
216
+ const madeBy = archive.readUInt16LE(offset + 4) >>> 8;
217
+ const externalAttributes = archive.readUInt32LE(offset + 38);
218
+ const localOffset = archive.readUInt32LE(offset + 42);
219
+ const nextOffset = offset + 46 + nameLength + extraLength + commentLength;
220
+ if (nextOffset > archive.length) throw new Error("Extension artifact has a truncated central directory entry.");
221
+ const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
222
+ validateEntryName(name);
223
+ const directory = name.endsWith("/");
224
+ const unixMode = madeBy === 3 ? externalAttributes >>> 16 : 0;
225
+ if ((unixMode & 61440) === 40960) {
226
+ throw new Error(`Extension artifact contains a symbolic link: ${name}.`);
227
+ }
228
+ if ((flags & 1) !== 0 || method !== 0 && method !== 8) {
229
+ throw new Error(`Extension artifact entry uses unsupported ZIP features: ${name}.`);
230
+ }
231
+ if (names.has(name) || offsets.has(localOffset)) {
232
+ throw new Error(`Extension artifact contains a duplicate entry: ${name}.`);
233
+ }
234
+ if (uncompressedSize > MAX_ENTRY_BYTES || compressedSize > MAX_ENTRY_BYTES) {
235
+ throw new Error(`Extension artifact entry is too large: ${name}.`);
236
+ }
237
+ if (compressedSize > 0 && uncompressedSize / compressedSize > 1e3) {
238
+ throw new Error(`Extension artifact entry has an unsafe compression ratio: ${name}.`);
239
+ }
240
+ expandedBytes += uncompressedSize;
241
+ if (expandedBytes > MAX_EXPANDED_BYTES) {
242
+ throw new Error(`Extension artifact exceeds the ${MAX_EXPANDED_BYTES}-byte expansion limit.`);
243
+ }
244
+ names.add(name);
245
+ offsets.add(localOffset);
246
+ entries.push({ name, directory, method, compressedSize, uncompressedSize, crc32: crc, localOffset });
247
+ offset = nextOffset;
248
+ }
249
+ if (offset !== centralOffset + centralSize) throw new Error("Extension artifact central directory size does not match.");
250
+ return entries;
251
+ }
252
+ function validateEntryName(name) {
253
+ const withoutSlash = name.endsWith("/") ? name.slice(0, -1) : name;
254
+ const segments = withoutSlash.split("/");
255
+ if (!withoutSlash || name.includes("\\") || name.includes("\0") || name.includes("\uFFFD") || name.startsWith("/") || /^[A-Za-z]:/u.test(name) || segments.some((segment) => !segment || segment === "." || segment === "..") || path.posix.normalize(withoutSlash) !== withoutSlash) {
256
+ throw new Error(`Extension artifact contains an unsafe path: ${JSON.stringify(name)}.`);
257
+ }
258
+ }
259
+ function localEntryDataOffset(archive, entry) {
260
+ const offset = entry.localOffset;
261
+ if (offset + 30 > archive.length || archive.readUInt32LE(offset) !== 67324752) {
262
+ throw new Error(`Extension artifact has a malformed local entry: ${entry.name}.`);
263
+ }
264
+ const flags = archive.readUInt16LE(offset + 6);
265
+ const method = archive.readUInt16LE(offset + 8);
266
+ const nameLength = archive.readUInt16LE(offset + 26);
267
+ const extraLength = archive.readUInt16LE(offset + 28);
268
+ const localName = archive.subarray(offset + 30, offset + 30 + nameLength).toString("utf8");
269
+ const dataOffset = offset + 30 + nameLength + extraLength;
270
+ if (localName !== entry.name || method !== entry.method || (flags & 1) !== 0 || dataOffset + entry.compressedSize > archive.length) {
271
+ throw new Error(`Extension artifact local entry does not match its directory record: ${entry.name}.`);
272
+ }
273
+ return dataOffset;
274
+ }
275
+ function findEndOfCentralDirectory(archive) {
276
+ const start = Math.max(0, archive.length - 65557);
277
+ for (let offset = archive.length - 22; offset >= start; offset -= 1) {
278
+ if (archive.readUInt32LE(offset) === 101010256) {
279
+ const commentLength = archive.readUInt16LE(offset + 20);
280
+ if (offset + 22 + commentLength === archive.length) return offset;
281
+ }
282
+ }
283
+ throw new Error("Extension artifact is not a valid ZIP archive.");
284
+ }
285
+ async function validateManifestVersion(extensionDir, expectedVersion) {
286
+ await assertRegularTree(extensionDir);
287
+ const manifestPath = path.join(extensionDir, "manifest.json");
288
+ const manifestStat = await fsp.lstat(manifestPath).catch(() => null);
289
+ if (!manifestStat?.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > 2 * 1024 * 1024) {
290
+ throw new Error("Extension artifact manifest.json must be a regular file smaller than 2 MiB.");
291
+ }
292
+ let manifest;
293
+ try {
294
+ manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
295
+ } catch {
296
+ throw new Error("Extension artifact manifest.json is not valid JSON.");
297
+ }
298
+ const version = typeof manifest === "object" && manifest !== null && "version" in manifest ? manifest.version : void 0;
299
+ if (typeof version !== "string" || normalizeManifestVersion(version) !== expectedVersion) {
300
+ throw new Error(`Extension artifact manifest version ${String(version ?? "missing")} does not match expected ${expectedVersion}.`);
301
+ }
302
+ return version;
303
+ }
304
+ function normalizeManifestVersion(version) {
305
+ if (!/^\d+\.\d+\.\d+(?:\.\d+)?$/u.test(version)) return null;
306
+ const parts = version.split(".");
307
+ if (parts.length === 4 && parts[3] !== "0") return null;
308
+ return parts.slice(0, 3).join(".");
309
+ }
310
+ async function assertRegularTree(root) {
311
+ const rootStat = await fsp.lstat(root).catch(() => null);
312
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
313
+ throw new Error(`Extension artifact root must be a regular directory: ${root}`);
314
+ }
315
+ const entries = await fsp.readdir(root, { withFileTypes: true });
316
+ for (const entry of entries) {
317
+ const child = path.join(root, entry.name);
318
+ const stat = await fsp.lstat(child);
319
+ if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) {
320
+ throw new Error(`Extension artifact cache contains an unsupported entry: ${child}`);
321
+ }
322
+ if (stat.isDirectory()) await assertRegularTree(child);
323
+ }
324
+ }
325
+ async function assertContainedDirectory(directory, root) {
326
+ const resolvedRoot = await fsp.realpath(root);
327
+ const resolvedDirectory = await fsp.realpath(directory);
328
+ if (resolvedDirectory !== resolvedRoot && !resolvedDirectory.startsWith(`${resolvedRoot}${path.sep}`)) {
329
+ throw new Error(`Extension artifact cache path escapes its root: ${directory}`);
330
+ }
331
+ let current = resolvedDirectory;
332
+ while (current !== resolvedRoot) {
333
+ const stat = await fsp.lstat(current);
334
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
335
+ throw new Error(`Extension artifact cache path is not a regular directory: ${current}`);
336
+ }
337
+ const parent = path.dirname(current);
338
+ if (parent === current) throw new Error(`Extension artifact cache path escapes its root: ${directory}`);
339
+ current = parent;
340
+ }
341
+ }
342
+ async function readCachedArtifact(artifactRoot, provenancePath, sha256, expectedVersion) {
343
+ try {
344
+ const provenanceStat = await fsp.lstat(provenancePath);
345
+ if (!provenanceStat.isFile() || provenanceStat.isSymbolicLink() || provenanceStat.size > 64 * 1024) return null;
346
+ const provenance = JSON.parse(await fsp.readFile(provenancePath, "utf8"));
347
+ if (provenance.schemaVersion !== 1 || provenance.sha256 !== sha256 || provenance.expectedVersion !== expectedVersion || typeof provenance.treeSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(provenance.treeSha256) || typeof provenance.extensionDir !== "string" || path.isAbsolute(provenance.extensionDir) || provenance.extensionDir.split(path.sep).includes("..")) return null;
348
+ const extensionDir = path.resolve(artifactRoot, provenance.extensionDir);
349
+ if (extensionDir !== artifactRoot && !extensionDir.startsWith(`${artifactRoot}${path.sep}`)) return null;
350
+ const manifestVersion = await validateManifestVersion(extensionDir, expectedVersion);
351
+ const treeSha256 = extensionTreeSha256(extensionDir);
352
+ if (treeSha256 !== provenance.treeSha256) return null;
353
+ return {
354
+ schemaVersion: 1,
355
+ expectedVersion,
356
+ manifestVersion,
357
+ sha256,
358
+ treeSha256,
359
+ extensionDir,
360
+ provenancePath
361
+ };
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+ function isAlreadyExists(error) {
367
+ return Boolean(
368
+ error && typeof error === "object" && "code" in error && ["EEXIST", "ENOTEMPTY"].includes(String(error.code))
369
+ );
370
+ }
371
+ async function pathExists(file) {
372
+ return fsp.access(file).then(() => true, () => false);
373
+ }
374
+ const CRC_TABLE = Array.from({ length: 256 }, (_, initial) => {
375
+ let value = initial;
376
+ for (let bit = 0; bit < 8; bit += 1) value = (value & 1) !== 0 ? 3988292384 ^ value >>> 1 : value >>> 1;
377
+ return value >>> 0;
378
+ });
379
+ function crc32(buffer) {
380
+ let value = 4294967295;
381
+ for (const byte of buffer) value = CRC_TABLE[(value ^ byte) & 255] ^ value >>> 8;
382
+ return (value ^ 4294967295) >>> 0;
383
+ }
384
+ export {
385
+ acquireExtensionReleaseArtifact
386
+ };