@deeeed/metamask-harness 0.42.0 → 0.44.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/CHANGELOG.md +42 -0
- package/README.md +5 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
- package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
- package/adapters/mobile/reload-app.mjs +99 -1
- package/adapters/mobile/start-console-forwarder.sh +5 -1
- package/dist/adapters/extension/browser-cdp.js +174 -0
- package/dist/adapters/extension/network-observer.js +19 -110
- package/dist/adapters/extension/performance-observer.js +75 -0
- package/dist/adapters/mobile/frame-metrics.js +45 -0
- package/dist/adapters/mobile/performance-observer.js +43 -0
- package/dist/adapters/mobile/prepare.js +12 -0
- package/dist/adapters/performance/cdp-trace.js +342 -0
- package/dist/adapters/performance/js-task-metrics.js +35 -0
- package/dist/adapters.js +39 -5
- package/dist/artifact-files.js +92 -0
- package/dist/async.js +19 -0
- package/dist/commands/call.js +63 -4
- package/dist/commands/run-engine.js +265 -139
- package/dist/commands/run-report.js +68 -0
- package/dist/commands/run.js +67 -3
- package/dist/execution-provenance.js +342 -0
- package/dist/network-observation.js +59 -47
- package/dist/performance-observation.js +465 -0
- package/dist/run-diagnostics.js +36 -11
- package/dist/runner.js +44 -13
- package/docs/PERFORMANCE-CAPTURE.md +33 -0
- package/docs/RECIPES.md +7 -0
- package/library/actions/mobile/perps/performance-capture.mjs +570 -189
- package/library/actions/mobile/perps/perps.mjs +122 -0
- package/library/actions/mobile/platform/bridge.mjs +43 -8
- package/library/actions/mobile/platform/native-session.mjs +1 -1
- package/library/actions/mobile/wallet/lock.mjs +1 -4
- package/library/actions/mobile/wallet/select_account.mjs +129 -17
- package/library/manifests/extension.action-manifest.json +85 -0
- package/library/manifests/mobile.action-manifest.json +125 -3
- package/library/recipes/mobile/perps/performance.recipe.json +73 -47
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +43 -27
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
import { isRecord } from "./parse-args.js";
|
|
4
5
|
function writeRunReport(result) {
|
|
5
6
|
const summaryPath = String(result.summaryPath);
|
|
@@ -19,6 +20,72 @@ function writeRunReport(result) {
|
|
|
19
20
|
preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
|
|
20
21
|
};
|
|
21
22
|
}
|
|
23
|
+
function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
24
|
+
const artifactsDir = path.dirname(artifactManifestPath);
|
|
25
|
+
let gitRef = "unknown";
|
|
26
|
+
let branch = "unknown";
|
|
27
|
+
let dirty = true;
|
|
28
|
+
try {
|
|
29
|
+
gitRef = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
30
|
+
cwd: target,
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
33
|
+
}).trim();
|
|
34
|
+
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
35
|
+
cwd: target,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
38
|
+
}).trim() || "detached";
|
|
39
|
+
dirty = execFileSync(
|
|
40
|
+
"git",
|
|
41
|
+
["status", "--porcelain", "--untracked-files=normal"],
|
|
42
|
+
{
|
|
43
|
+
cwd: target,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
46
|
+
}
|
|
47
|
+
).trim().length > 0;
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
const provenance = {
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
adapter,
|
|
53
|
+
repository: path.basename(path.resolve(target)),
|
|
54
|
+
git_ref: gitRef,
|
|
55
|
+
branch,
|
|
56
|
+
dirty
|
|
57
|
+
};
|
|
58
|
+
fs.writeFileSync(
|
|
59
|
+
path.join(artifactsDir, "product-provenance.json"),
|
|
60
|
+
`${JSON.stringify(provenance, null, 2)}
|
|
61
|
+
`
|
|
62
|
+
);
|
|
63
|
+
let manifest;
|
|
64
|
+
try {
|
|
65
|
+
manifest = JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!isRecord(manifest)) return;
|
|
70
|
+
const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
|
|
71
|
+
manifest.artifacts = [
|
|
72
|
+
...artifacts.filter(
|
|
73
|
+
(artifact) => !isRecord(artifact) || artifact.path !== "product-provenance.json"
|
|
74
|
+
),
|
|
75
|
+
{
|
|
76
|
+
path: "product-provenance.json",
|
|
77
|
+
type: "json",
|
|
78
|
+
label: "Product checkout provenance",
|
|
79
|
+
category: "system",
|
|
80
|
+
metadata: { adapter, git_ref: gitRef, branch, dirty }
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
artifactManifestPath,
|
|
85
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
86
|
+
`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
22
89
|
function renderRunReport(summary, entries) {
|
|
23
90
|
const lines = [
|
|
24
91
|
"# MetaMask Recipe Run",
|
|
@@ -120,5 +187,6 @@ function indexRunReportArtifact(artifactManifestPath) {
|
|
|
120
187
|
`);
|
|
121
188
|
}
|
|
122
189
|
export {
|
|
190
|
+
indexProductProvenanceArtifact,
|
|
123
191
|
writeRunReport
|
|
124
192
|
};
|
package/dist/commands/run.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
executeWithHealBounds,
|
|
22
22
|
preflightRecipe,
|
|
23
23
|
prepareHeal,
|
|
24
|
+
persistRunEffects,
|
|
24
25
|
recoverRunInfra,
|
|
25
26
|
runRecipe,
|
|
26
27
|
validateRunRecipeStatic
|
|
@@ -30,11 +31,15 @@ import { applyDeviceTargeting } from "./device-target.js";
|
|
|
30
31
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
31
32
|
import { coreDependencyBlock } from "./core-readiness.js";
|
|
32
33
|
import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
indexProductProvenanceArtifact,
|
|
36
|
+
writeRunReport
|
|
37
|
+
} from "./run-report.js";
|
|
34
38
|
import { acquireCheckoutLock } from "../checkout-lock.js";
|
|
35
39
|
import { JsonStreamWriter } from "../json-stream.js";
|
|
36
40
|
import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
|
|
37
41
|
import { recipeTrustFailure } from "../recipe-security.js";
|
|
42
|
+
import { ProvenanceDriftError } from "../execution-provenance.js";
|
|
38
43
|
import { recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
|
|
39
44
|
import {
|
|
40
45
|
actionLibraryContextArgs,
|
|
@@ -44,6 +49,9 @@ import {
|
|
|
44
49
|
import {
|
|
45
50
|
startRunNetworkObservation
|
|
46
51
|
} from "../network-observation.js";
|
|
52
|
+
import {
|
|
53
|
+
startRunPerformanceObservation
|
|
54
|
+
} from "../performance-observation.js";
|
|
47
55
|
async function validationCapabilityRefusals(adapter, findings, librarySources) {
|
|
48
56
|
const actionNames = findings.flatMap((finding) => {
|
|
49
57
|
if (finding.code !== "recipe.action_not_declared_by_manifest") return [];
|
|
@@ -72,6 +80,31 @@ async function handleRun(parsed) {
|
|
|
72
80
|
stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
|
|
73
81
|
return exitCode;
|
|
74
82
|
} catch (error) {
|
|
83
|
+
if (error instanceof ProvenanceDriftError) {
|
|
84
|
+
const failure = {
|
|
85
|
+
code: error.code,
|
|
86
|
+
message: error.message,
|
|
87
|
+
userAction: error.userAction,
|
|
88
|
+
provenancePath: error.provenancePath,
|
|
89
|
+
drift: error.drift
|
|
90
|
+
};
|
|
91
|
+
if (stream.enabled) {
|
|
92
|
+
stream.error(failure);
|
|
93
|
+
stream.complete("fail", error.exitCode);
|
|
94
|
+
} else if (optionFlag(parsed.options, "json")) {
|
|
95
|
+
console.log(JSON.stringify({
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
command: "run",
|
|
98
|
+
status: "fail",
|
|
99
|
+
error: failure,
|
|
100
|
+
exitCode: error.exitCode
|
|
101
|
+
}, null, 2));
|
|
102
|
+
} else {
|
|
103
|
+
console.error(`\u2717 mm-harness run: ${error.message}`);
|
|
104
|
+
console.error(` Next: ${error.userAction}`);
|
|
105
|
+
}
|
|
106
|
+
return error.exitCode;
|
|
107
|
+
}
|
|
75
108
|
const trustFailure = recipeTrustFailure(error);
|
|
76
109
|
if (trustFailure) {
|
|
77
110
|
if (stream.enabled) {
|
|
@@ -231,6 +264,7 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
231
264
|
recordCommandEvidence(artifactsDir);
|
|
232
265
|
const librarySources = validated.librarySources;
|
|
233
266
|
let networkObservation;
|
|
267
|
+
let performanceObservation;
|
|
234
268
|
const runtimeOptions = {
|
|
235
269
|
...runtimeOptionsFromCli(options),
|
|
236
270
|
params,
|
|
@@ -239,6 +273,7 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
239
273
|
onActionEvent: ({ nodeId, action, status }) => {
|
|
240
274
|
stream.node(nodeId, action, status);
|
|
241
275
|
networkObservation?.onActionEvent({ nodeId, action, status });
|
|
276
|
+
performanceObservation?.onActionEvent({ nodeId, action, status });
|
|
242
277
|
}
|
|
243
278
|
};
|
|
244
279
|
stream.phase("authorize");
|
|
@@ -270,6 +305,14 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
270
305
|
return prepared;
|
|
271
306
|
}
|
|
272
307
|
const { state, heal } = prepared;
|
|
308
|
+
preflightedExecution = await preflightRecipe(
|
|
309
|
+
adapter,
|
|
310
|
+
validated.recipeFile,
|
|
311
|
+
artifactsDir,
|
|
312
|
+
target,
|
|
313
|
+
optionString(options, "actionManifest"),
|
|
314
|
+
runtimeOptions
|
|
315
|
+
);
|
|
273
316
|
networkObservation = await startRunNetworkObservation(
|
|
274
317
|
adapter,
|
|
275
318
|
target,
|
|
@@ -280,6 +323,16 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
280
323
|
watcherPort: runtimeOptions.watcherPort
|
|
281
324
|
}
|
|
282
325
|
);
|
|
326
|
+
performanceObservation = startRunPerformanceObservation(
|
|
327
|
+
adapter,
|
|
328
|
+
target,
|
|
329
|
+
artifactsDir,
|
|
330
|
+
process.env,
|
|
331
|
+
{
|
|
332
|
+
cdpPort: runtimeOptions.cdpPort,
|
|
333
|
+
watcherPort: runtimeOptions.watcherPort
|
|
334
|
+
}
|
|
335
|
+
);
|
|
283
336
|
stream.phase("execute");
|
|
284
337
|
let executionResult;
|
|
285
338
|
try {
|
|
@@ -296,7 +349,8 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
296
349
|
target,
|
|
297
350
|
optionString(options, "actionManifest"),
|
|
298
351
|
runtimeOptions,
|
|
299
|
-
execution
|
|
352
|
+
execution,
|
|
353
|
+
state
|
|
300
354
|
);
|
|
301
355
|
},
|
|
302
356
|
adapter,
|
|
@@ -307,15 +361,25 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
307
361
|
(code) => stream.phase("recover", { code })
|
|
308
362
|
);
|
|
309
363
|
} catch (error) {
|
|
310
|
-
await networkObservation?.finalize();
|
|
364
|
+
await networkObservation?.finalize().catch(() => void 0);
|
|
311
365
|
networkObservation = void 0;
|
|
366
|
+
await performanceObservation?.finalize().catch(() => void 0);
|
|
367
|
+
performanceObservation = void 0;
|
|
312
368
|
throw error;
|
|
313
369
|
}
|
|
314
370
|
const { result, violation } = executionResult;
|
|
315
371
|
await networkObservation?.finalize(result.artifactManifestPath);
|
|
316
372
|
networkObservation = void 0;
|
|
373
|
+
await performanceObservation?.finalize(result.artifactManifestPath);
|
|
374
|
+
performanceObservation = void 0;
|
|
317
375
|
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
318
376
|
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
377
|
+
persistRunEffects(result.summaryPath, result.artifactManifestPath, state);
|
|
378
|
+
indexProductProvenanceArtifact(
|
|
379
|
+
result.artifactManifestPath,
|
|
380
|
+
target,
|
|
381
|
+
adapter
|
|
382
|
+
);
|
|
319
383
|
if (violation !== null) {
|
|
320
384
|
const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
|
|
321
385
|
stream.error({
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs, { constants as fsConstants } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { mobileSourceFingerprint } from "./adapters/mobile/source-freshness.js";
|
|
6
|
+
import { importRecipeProtocol, runnerDir } from "./paths.js";
|
|
7
|
+
const gitContexts = /* @__PURE__ */ new Map();
|
|
8
|
+
let protocolPromise;
|
|
9
|
+
class ProvenanceDriftError extends Error {
|
|
10
|
+
code = "PROVENANCE_DRIFT";
|
|
11
|
+
exitCode = 5;
|
|
12
|
+
userAction;
|
|
13
|
+
provenancePath;
|
|
14
|
+
drift;
|
|
15
|
+
constructor(provenancePath, drift, cause) {
|
|
16
|
+
const fields = [...new Set(drift.map((entry) => entry.field))].join(", ");
|
|
17
|
+
super(
|
|
18
|
+
`Execution inputs changed after preparation (${fields}); evidence is invalid.`,
|
|
19
|
+
cause === void 0 ? void 0 : { cause }
|
|
20
|
+
);
|
|
21
|
+
this.name = "ProvenanceDriftError";
|
|
22
|
+
this.provenancePath = provenancePath;
|
|
23
|
+
this.drift = drift;
|
|
24
|
+
this.userAction = `restore the prepared recipe, library, and product source, then rerun; inspect ${provenancePath}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function captureExecutionProvenance(input, phase) {
|
|
28
|
+
const protocol = await (protocolPromise ??= importRecipeProtocol());
|
|
29
|
+
const recipeDigest = input.recipePath ? recipeFileDigest(protocol, input.recipePath) : protocol.digestRecipeDocument(input.recipeDocument);
|
|
30
|
+
const libraries = (input.librarySources ?? []).map((source, index) => ({
|
|
31
|
+
name: source.name ?? `library-${index + 1}`,
|
|
32
|
+
...sourceSnapshot(source.root)
|
|
33
|
+
}));
|
|
34
|
+
const runnerRoot = input.runnerRoot ?? runnerDir;
|
|
35
|
+
return {
|
|
36
|
+
phase,
|
|
37
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
38
|
+
recipeDigest,
|
|
39
|
+
product: sourceSnapshot(
|
|
40
|
+
input.projectRoot,
|
|
41
|
+
input.adapter,
|
|
42
|
+
input.excludedProductRoots
|
|
43
|
+
),
|
|
44
|
+
runner: sourceSnapshot(runnerRoot, void 0, [], [
|
|
45
|
+
"src",
|
|
46
|
+
"adapters",
|
|
47
|
+
"library",
|
|
48
|
+
"bin",
|
|
49
|
+
"dist",
|
|
50
|
+
"package.json"
|
|
51
|
+
]),
|
|
52
|
+
libraries,
|
|
53
|
+
helpers: (input.helperPaths ?? []).map((helperPath) => ({
|
|
54
|
+
path: displayPath(input.projectRoot, helperPath),
|
|
55
|
+
sourceFingerprint: fileFingerprint(helperPath)
|
|
56
|
+
}))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function executionProvenanceDrift(start, current) {
|
|
60
|
+
const drift = [];
|
|
61
|
+
if (current.phase === "start") return drift;
|
|
62
|
+
const phase = current.phase;
|
|
63
|
+
compare(drift, phase, "recipeDigest", start.recipeDigest, current.recipeDigest);
|
|
64
|
+
compareSource(drift, phase, "product", start.product, current.product);
|
|
65
|
+
compareSource(drift, phase, "runner", start.runner, current.runner);
|
|
66
|
+
if (start.libraries.length !== current.libraries.length) {
|
|
67
|
+
compare(
|
|
68
|
+
drift,
|
|
69
|
+
phase,
|
|
70
|
+
"libraries.length",
|
|
71
|
+
String(start.libraries.length),
|
|
72
|
+
String(current.libraries.length)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const count = Math.min(start.libraries.length, current.libraries.length);
|
|
76
|
+
for (let index = 0; index < count; index += 1) {
|
|
77
|
+
const expected = start.libraries[index];
|
|
78
|
+
const actual = current.libraries[index];
|
|
79
|
+
const prefix = `libraries[${index}]`;
|
|
80
|
+
compare(drift, phase, `${prefix}.name`, expected.name, actual.name);
|
|
81
|
+
compareSource(drift, phase, prefix, expected, actual);
|
|
82
|
+
}
|
|
83
|
+
if (start.helpers.length !== current.helpers.length) {
|
|
84
|
+
compare(
|
|
85
|
+
drift,
|
|
86
|
+
phase,
|
|
87
|
+
"helpers.length",
|
|
88
|
+
String(start.helpers.length),
|
|
89
|
+
String(current.helpers.length)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const helperCount = Math.min(start.helpers.length, current.helpers.length);
|
|
93
|
+
for (let index = 0; index < helperCount; index += 1) {
|
|
94
|
+
compare(drift, phase, `helpers[${index}].path`, start.helpers[index].path, current.helpers[index].path);
|
|
95
|
+
compare(
|
|
96
|
+
drift,
|
|
97
|
+
phase,
|
|
98
|
+
`helpers[${index}].sourceFingerprint`,
|
|
99
|
+
start.helpers[index].sourceFingerprint,
|
|
100
|
+
current.helpers[index].sourceFingerprint
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return drift;
|
|
104
|
+
}
|
|
105
|
+
function writeExecutionProvenance(artifactsDir, snapshots, drift, artifactManifestPath) {
|
|
106
|
+
const provenancePath = path.join(path.resolve(artifactsDir), "execution-provenance.json");
|
|
107
|
+
const record = {
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
valid: drift.length === 0,
|
|
110
|
+
snapshots,
|
|
111
|
+
drift
|
|
112
|
+
};
|
|
113
|
+
fs.mkdirSync(path.dirname(provenancePath), { recursive: true });
|
|
114
|
+
fs.writeFileSync(provenancePath, `${JSON.stringify(record, null, 2)}
|
|
115
|
+
`);
|
|
116
|
+
if (artifactManifestPath) indexProvenanceArtifact(artifactManifestPath, record);
|
|
117
|
+
return provenancePath;
|
|
118
|
+
}
|
|
119
|
+
function recipeFileDigest(protocol, recipePath) {
|
|
120
|
+
const source = readRegularFileNoFollow(recipePath);
|
|
121
|
+
try {
|
|
122
|
+
return protocol.digestRecipeDocument(JSON.parse(source.toString("utf8")));
|
|
123
|
+
} catch {
|
|
124
|
+
return `invalid:sha256:${createHash("sha256").update(source).digest("hex")}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function sourceSnapshot(root, adapter, excludedRoots = [], includedRoots = []) {
|
|
128
|
+
const git = gitContext(root);
|
|
129
|
+
if (!git) {
|
|
130
|
+
return {
|
|
131
|
+
head: null,
|
|
132
|
+
status: "not-a-git-checkout",
|
|
133
|
+
sourceFingerprint: directoryFingerprint(root, excludedRoots, includedRoots)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const pathspecs = sourcePathspecs(git, excludedRoots, includedRoots);
|
|
137
|
+
const status = gitText(git.topLevel, [
|
|
138
|
+
"status",
|
|
139
|
+
"--porcelain=v1",
|
|
140
|
+
"--untracked-files=normal",
|
|
141
|
+
"--",
|
|
142
|
+
...pathspecs
|
|
143
|
+
]);
|
|
144
|
+
return {
|
|
145
|
+
head: gitText(git.topLevel, ["rev-parse", "HEAD"]).trim() || null,
|
|
146
|
+
status,
|
|
147
|
+
sourceFingerprint: adapter === "mobile" && path.resolve(root) === git.topLevel ? mobileSourceFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots) {
|
|
151
|
+
const gitFingerprint = gitSourceFingerprint(git.topLevel, pathspecs);
|
|
152
|
+
if (git.pathspec === "." && includedRoots.length === 0) return gitFingerprint;
|
|
153
|
+
return createHash("sha256").update(gitFingerprint).update("\0nested-source\0").update(directoryFingerprint(root, excludedRoots, includedRoots)).digest("hex");
|
|
154
|
+
}
|
|
155
|
+
function gitContext(root) {
|
|
156
|
+
const cacheKey = path.resolve(root);
|
|
157
|
+
if (gitContexts.has(cacheKey)) return gitContexts.get(cacheKey) ?? null;
|
|
158
|
+
try {
|
|
159
|
+
const resolved = cacheKey;
|
|
160
|
+
const topLevel = fs.realpathSync(
|
|
161
|
+
gitText(resolved, ["rev-parse", "--show-toplevel"]).trim()
|
|
162
|
+
);
|
|
163
|
+
const relative = path.relative(topLevel, fs.realpathSync(resolved));
|
|
164
|
+
const context = relative === ".." || relative.startsWith(`..${path.sep}`) ? null : { topLevel, pathspec: relative || "." };
|
|
165
|
+
gitContexts.set(cacheKey, context);
|
|
166
|
+
return context;
|
|
167
|
+
} catch {
|
|
168
|
+
gitContexts.set(cacheKey, null);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function gitSourceFingerprint(topLevel, pathspecs) {
|
|
173
|
+
const hash = createHash("sha256");
|
|
174
|
+
hash.update(gitBuffer(topLevel, ["rev-parse", "HEAD"]));
|
|
175
|
+
hash.update("\0diff\0");
|
|
176
|
+
hash.update(
|
|
177
|
+
gitBuffer(topLevel, [
|
|
178
|
+
"diff",
|
|
179
|
+
"--no-ext-diff",
|
|
180
|
+
"--binary",
|
|
181
|
+
"HEAD",
|
|
182
|
+
"--",
|
|
183
|
+
...pathspecs
|
|
184
|
+
])
|
|
185
|
+
);
|
|
186
|
+
const untracked = gitText(topLevel, [
|
|
187
|
+
"ls-files",
|
|
188
|
+
"--others",
|
|
189
|
+
"--exclude-standard",
|
|
190
|
+
"-z",
|
|
191
|
+
"--",
|
|
192
|
+
...pathspecs
|
|
193
|
+
]).split("\0").filter(Boolean).sort();
|
|
194
|
+
for (const relative of untracked) {
|
|
195
|
+
hash.update(`\0untracked\0${relative}\0`);
|
|
196
|
+
const absolute = path.join(topLevel, relative);
|
|
197
|
+
hash.update(readPathIdentity(absolute));
|
|
198
|
+
}
|
|
199
|
+
return hash.digest("hex");
|
|
200
|
+
}
|
|
201
|
+
function sourcePathspecs(git, excludedRoots, includedRoots) {
|
|
202
|
+
const exclusions = excludedRoots.flatMap((root) => {
|
|
203
|
+
const relative = path.relative(git.topLevel, path.resolve(root));
|
|
204
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? [`:(exclude)${relative}`] : [];
|
|
205
|
+
});
|
|
206
|
+
const inclusions = includedRoots.length > 0 ? includedRoots.map((root) => path.join(git.pathspec === "." ? "" : git.pathspec, root)) : [git.pathspec];
|
|
207
|
+
return [...inclusions, ...exclusions];
|
|
208
|
+
}
|
|
209
|
+
function directoryFingerprint(root, excludedRoots, includedRoots) {
|
|
210
|
+
const hash = createHash("sha256");
|
|
211
|
+
const resolvedRoot = path.resolve(root);
|
|
212
|
+
const roots = includedRoots.length > 0 ? includedRoots : [""];
|
|
213
|
+
for (const included of roots) {
|
|
214
|
+
hashDirectory(
|
|
215
|
+
hash,
|
|
216
|
+
resolvedRoot,
|
|
217
|
+
included,
|
|
218
|
+
new Set(excludedRoots.map((entry) => path.resolve(entry)))
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return hash.digest("hex");
|
|
222
|
+
}
|
|
223
|
+
function hashDirectory(hash, root, relative, excludedRoots) {
|
|
224
|
+
const absolute = path.join(root, relative);
|
|
225
|
+
if ([...excludedRoots].some(
|
|
226
|
+
(excluded) => absolute === excluded || absolute.startsWith(`${excluded}${path.sep}`)
|
|
227
|
+
)) return;
|
|
228
|
+
let stat;
|
|
229
|
+
try {
|
|
230
|
+
stat = fs.lstatSync(absolute);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error.code === "ENOENT") {
|
|
233
|
+
hash.update(`absent\0${relative}\0`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
if (stat.isDirectory()) {
|
|
239
|
+
hash.update(`directory\0${relative}\0`);
|
|
240
|
+
for (const name of fs.readdirSync(absolute).sort()) {
|
|
241
|
+
if (relative === "" && (name === ".git" || name === "node_modules" || name === "temp")) continue;
|
|
242
|
+
hashDirectory(hash, root, path.join(relative, name), excludedRoots);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (stat.isSymbolicLink()) {
|
|
247
|
+
throw new Error(`Execution provenance source must not be a symbolic link: ${absolute}`);
|
|
248
|
+
}
|
|
249
|
+
hash.update(`file\0${relative}\0`);
|
|
250
|
+
hash.update(readRegularFileNoFollow(absolute));
|
|
251
|
+
hash.update("\0");
|
|
252
|
+
}
|
|
253
|
+
function fileFingerprint(filePath) {
|
|
254
|
+
return createHash("sha256").update(readRegularFileNoFollow(filePath)).digest("hex");
|
|
255
|
+
}
|
|
256
|
+
function readPathIdentity(filePath) {
|
|
257
|
+
const stat = fs.lstatSync(filePath);
|
|
258
|
+
if (stat.isSymbolicLink()) {
|
|
259
|
+
throw new Error(`Execution provenance source must not be a symbolic link: ${filePath}`);
|
|
260
|
+
}
|
|
261
|
+
return readRegularFileNoFollow(filePath);
|
|
262
|
+
}
|
|
263
|
+
function readRegularFileNoFollow(filePath) {
|
|
264
|
+
let fd;
|
|
265
|
+
try {
|
|
266
|
+
fd = fs.openSync(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error.code === "ELOOP") {
|
|
269
|
+
throw new Error(`Execution provenance source must not be a symbolic link: ${filePath}`);
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
if (!fs.fstatSync(fd).isFile()) {
|
|
275
|
+
throw new Error(`Execution provenance source is not a regular file: ${filePath}`);
|
|
276
|
+
}
|
|
277
|
+
return fs.readFileSync(fd);
|
|
278
|
+
} finally {
|
|
279
|
+
fs.closeSync(fd);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function displayPath(projectRoot, filePath) {
|
|
283
|
+
const relative = path.relative(path.resolve(projectRoot), path.resolve(filePath));
|
|
284
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? relative || "." : path.resolve(filePath);
|
|
285
|
+
}
|
|
286
|
+
function gitText(cwd, args) {
|
|
287
|
+
return gitBuffer(cwd, args).toString("utf8");
|
|
288
|
+
}
|
|
289
|
+
function gitBuffer(cwd, args) {
|
|
290
|
+
return execFileSync("git", args, {
|
|
291
|
+
cwd,
|
|
292
|
+
encoding: "buffer",
|
|
293
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
294
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function compareSource(drift, phase, prefix, start, current) {
|
|
298
|
+
compare(drift, phase, `${prefix}.head`, start.head, current.head);
|
|
299
|
+
compare(drift, phase, `${prefix}.status`, start.status, current.status);
|
|
300
|
+
compare(
|
|
301
|
+
drift,
|
|
302
|
+
phase,
|
|
303
|
+
`${prefix}.sourceFingerprint`,
|
|
304
|
+
start.sourceFingerprint,
|
|
305
|
+
current.sourceFingerprint
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
function compare(drift, phase, field, start, current) {
|
|
309
|
+
if (start !== current) drift.push({ phase, field, start, current });
|
|
310
|
+
}
|
|
311
|
+
function indexProvenanceArtifact(artifactManifestPath, record) {
|
|
312
|
+
let manifest;
|
|
313
|
+
try {
|
|
314
|
+
manifest = JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"));
|
|
315
|
+
} catch {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return;
|
|
319
|
+
const document = manifest;
|
|
320
|
+
const artifacts = Array.isArray(document.artifacts) ? document.artifacts : [];
|
|
321
|
+
document.artifacts = [
|
|
322
|
+
...artifacts.filter((artifact) => !artifact || typeof artifact !== "object" || Array.isArray(artifact) || artifact.path !== "execution-provenance.json"),
|
|
323
|
+
{
|
|
324
|
+
path: "execution-provenance.json",
|
|
325
|
+
type: "json",
|
|
326
|
+
label: "Execution provenance",
|
|
327
|
+
category: "system",
|
|
328
|
+
metadata: {
|
|
329
|
+
valid: record.valid,
|
|
330
|
+
errorCode: record.valid ? null : "PROVENANCE_DRIFT"
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
];
|
|
334
|
+
fs.writeFileSync(artifactManifestPath, `${JSON.stringify(document, null, 2)}
|
|
335
|
+
`);
|
|
336
|
+
}
|
|
337
|
+
export {
|
|
338
|
+
ProvenanceDriftError,
|
|
339
|
+
captureExecutionProvenance,
|
|
340
|
+
executionProvenanceDrift,
|
|
341
|
+
writeExecutionProvenance
|
|
342
|
+
};
|