@clossys/launcher 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1328 -60
- package/contracts/conversation-contract.md +2 -1
- package/contracts/product-ci-workflow.yml +74 -0
- package/contracts/repository-inventory.json +53 -0
- package/dist/admission-fixture.d.ts +168 -0
- package/dist/admission-fixture.d.ts.map +1 -0
- package/dist/admission-fixture.js +453 -0
- package/dist/admission-fixture.js.map +1 -0
- package/dist/admission.d.ts +124 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +799 -0
- package/dist/admission.js.map +1 -0
- package/dist/agents-guide.d.ts +9 -0
- package/dist/agents-guide.d.ts.map +1 -0
- package/dist/agents-guide.js +26 -0
- package/dist/agents-guide.js.map +1 -0
- package/dist/apply-command-options.check.d.ts +12 -0
- package/dist/apply-command-options.check.d.ts.map +1 -0
- package/dist/apply-command-options.check.js +20 -0
- package/dist/apply-command-options.check.js.map +1 -0
- package/dist/apply-plan-cli.d.ts +39 -1
- package/dist/apply-plan-cli.d.ts.map +1 -1
- package/dist/apply-plan-cli.js +432 -15
- package/dist/apply-plan-cli.js.map +1 -1
- package/dist/apply-plan.d.ts +46 -59
- package/dist/apply-plan.d.ts.map +1 -1
- package/dist/apply-plan.js +112 -97
- package/dist/apply-plan.js.map +1 -1
- package/dist/apply-step-fixture.d.ts +87 -0
- package/dist/apply-step-fixture.d.ts.map +1 -0
- package/dist/apply-step-fixture.js +199 -0
- package/dist/apply-step-fixture.js.map +1 -0
- package/dist/apply-store.d.ts +93 -0
- package/dist/apply-store.d.ts.map +1 -0
- package/dist/apply-store.js +625 -0
- package/dist/apply-store.js.map +1 -0
- package/dist/approval-sheet.d.ts +21 -0
- package/dist/approval-sheet.d.ts.map +1 -0
- package/dist/approval-sheet.js +157 -0
- package/dist/approval-sheet.js.map +1 -0
- package/dist/body-command.d.ts +42 -0
- package/dist/body-command.d.ts.map +1 -0
- package/dist/body-command.js +143 -0
- package/dist/body-command.js.map +1 -0
- package/dist/change-set-contract.d.ts +381 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +738 -0
- package/dist/change-set-contract.js.map +1 -0
- package/dist/change-set-digest.d.ts +28 -0
- package/dist/change-set-digest.d.ts.map +1 -0
- package/dist/change-set-digest.js +65 -0
- package/dist/change-set-digest.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +14 -3
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +17 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -23
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +79 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +843 -268
- package/dist/core.js.map +1 -1
- package/dist/dry-materialize.d.ts +63 -0
- package/dist/dry-materialize.d.ts.map +1 -0
- package/dist/dry-materialize.js +330 -0
- package/dist/dry-materialize.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/generated/package-scope.generated.d.ts +6 -0
- package/dist/generated/package-scope.generated.d.ts.map +1 -0
- package/dist/generated/package-scope.generated.js +10 -0
- package/dist/generated/package-scope.generated.js.map +1 -0
- package/dist/generated/plan-contracts.generated.d.ts +3 -0
- package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
- package/dist/generated/plan-contracts.generated.js +2840 -0
- package/dist/generated/plan-contracts.generated.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +11 -0
- package/dist/host.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +48 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +24 -5
- package/dist/inventory-adoption.d.ts.map +1 -1
- package/dist/inventory-adoption.js +70 -25
- package/dist/inventory-adoption.js.map +1 -1
- package/dist/inventory-choice.d.ts +40 -0
- package/dist/inventory-choice.d.ts.map +1 -0
- package/dist/inventory-choice.js +156 -0
- package/dist/inventory-choice.js.map +1 -0
- package/dist/inventory-contract.d.ts +89 -0
- package/dist/inventory-contract.d.ts.map +1 -0
- package/dist/inventory-contract.js +121 -0
- package/dist/inventory-contract.js.map +1 -0
- package/dist/key-editor.d.ts +30 -0
- package/dist/key-editor.d.ts.map +1 -0
- package/dist/key-editor.js +445 -0
- package/dist/key-editor.js.map +1 -0
- package/dist/ledger-contract.d.ts +187 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +532 -0
- package/dist/ledger-contract.js.map +1 -0
- package/dist/ledger-trust.d.ts +90 -0
- package/dist/ledger-trust.d.ts.map +1 -0
- package/dist/ledger-trust.js +198 -0
- package/dist/ledger-trust.js.map +1 -0
- package/dist/lockfile-invariants.d.ts +48 -0
- package/dist/lockfile-invariants.d.ts.map +1 -0
- package/dist/lockfile-invariants.js +375 -0
- package/dist/lockfile-invariants.js.map +1 -0
- package/dist/lockfile-readers.d.ts +72 -0
- package/dist/lockfile-readers.d.ts.map +1 -0
- package/dist/lockfile-readers.js +713 -0
- package/dist/lockfile-readers.js.map +1 -0
- package/dist/lockfile-regen.d.ts +106 -0
- package/dist/lockfile-regen.d.ts.map +1 -0
- package/dist/lockfile-regen.js +760 -0
- package/dist/lockfile-regen.js.map +1 -0
- package/dist/lockfile-tool-env.d.ts +29 -0
- package/dist/lockfile-tool-env.d.ts.map +1 -0
- package/dist/lockfile-tool-env.js +111 -0
- package/dist/lockfile-tool-env.js.map +1 -0
- package/dist/materialize.d.ts +113 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +840 -0
- package/dist/materialize.js.map +1 -0
- package/dist/observe-repository.d.ts +90 -0
- package/dist/observe-repository.d.ts.map +1 -0
- package/dist/observe-repository.js +1367 -0
- package/dist/observe-repository.js.map +1 -0
- package/dist/plan-bundle-setup-fixture.d.ts +68 -0
- package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
- package/dist/plan-bundle-setup-fixture.js +167 -0
- package/dist/plan-bundle-setup-fixture.js.map +1 -0
- package/dist/plan-bundle.d.ts +250 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +827 -0
- package/dist/plan-bundle.js.map +1 -0
- package/dist/plan-command.d.ts +29 -0
- package/dist/plan-command.d.ts.map +1 -0
- package/dist/plan-command.js +493 -0
- package/dist/plan-command.js.map +1 -0
- package/dist/plan-contract.d.ts +153 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +61 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +25 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +106 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +23 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +177 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/planned-bundle.d.ts +20 -0
- package/dist/planned-bundle.d.ts.map +1 -0
- package/dist/planned-bundle.js +191 -0
- package/dist/planned-bundle.js.map +1 -0
- package/dist/product-repository.d.ts +4 -0
- package/dist/product-repository.d.ts.map +1 -1
- package/dist/product-repository.js +9 -1
- package/dist/product-repository.js.map +1 -1
- package/dist/provenance-gate.d.ts +48 -0
- package/dist/provenance-gate.d.ts.map +1 -0
- package/dist/provenance-gate.js +324 -0
- package/dist/provenance-gate.js.map +1 -0
- package/dist/pull-request-body.d.ts +45 -0
- package/dist/pull-request-body.d.ts.map +1 -0
- package/dist/pull-request-body.js +232 -0
- package/dist/pull-request-body.js.map +1 -0
- package/dist/registry-snapshot.d.ts +141 -0
- package/dist/registry-snapshot.d.ts.map +1 -0
- package/dist/registry-snapshot.js +483 -0
- package/dist/registry-snapshot.js.map +1 -0
- package/dist/release-age-edit.d.ts +52 -0
- package/dist/release-age-edit.d.ts.map +1 -0
- package/dist/release-age-edit.js +413 -0
- package/dist/release-age-edit.js.map +1 -0
- package/dist/root-entries.d.ts +36 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +80 -0
- package/dist/root-entries.js.map +1 -0
- package/dist/setup-template-scripts.d.ts +34 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +557 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +54 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +427 -0
- package/dist/setup-templates.js.map +1 -0
- package/dist/skills.d.ts +34 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +129 -17
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +63 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +539 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +151 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -5
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +20 -16
- package/skill-catalogue/advisor/SKILL.md +100 -11
- package/skill-catalogue/architect/SKILL.md +2 -13
- package/skill-catalogue/bouncer/SKILL.md +2 -13
- package/skill-catalogue/builder/SKILL.md +2 -13
- package/skill-catalogue/butler/SKILL.md +2 -13
- package/skill-catalogue/controller/SKILL.md +2 -13
- package/skill-catalogue/customer/SKILL.md +4 -13
- package/skill-catalogue/designer/SKILL.md +9 -14
- package/skill-catalogue/giver/SKILL.md +2 -13
- package/skill-catalogue/influencer/SKILL.md +2 -13
- package/skill-catalogue/inspector/SKILL.md +2 -13
- package/skill-catalogue/integrator/SKILL.md +2 -13
- package/skill-catalogue/keeper/SKILL.md +2 -13
- package/skill-catalogue/launcher/SKILL.md +20 -16
- package/skill-catalogue/locksmith/SKILL.md +2 -13
- package/skill-catalogue/messenger/SKILL.md +2 -13
- package/skill-catalogue/observer/SKILL.md +2 -13
- package/skill-catalogue/publisher/SKILL.md +11 -17
- package/skill-catalogue/starter/SKILL.md +3 -13
- package/skill-catalogue/strategist/SKILL.md +14 -17
- package/skill-catalogue/writer/SKILL.md +7 -14
- package/src/admission-fixture.ts +572 -0
- package/src/admission.ts +816 -0
- package/src/agents-guide.ts +29 -0
- package/src/apply-command-options.check.ts +27 -0
- package/src/apply-plan-cli.ts +454 -14
- package/src/apply-plan.ts +112 -124
- package/src/apply-step-fixture.ts +236 -0
- package/src/apply-store.ts +584 -0
- package/src/approval-sheet.ts +164 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +937 -0
- package/src/change-set-digest.ts +70 -0
- package/src/check-cli.ts +14 -3
- package/src/cli.ts +90 -22
- package/src/core.ts +973 -275
- package/src/dry-materialize.ts +353 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/generated/package-scope.generated.ts +10 -0
- package/src/generated/plan-contracts.generated.ts +2840 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +72 -3
- package/src/inventory-adoption.ts +107 -29
- package/src/inventory-choice.ts +172 -0
- package/src/inventory-contract.ts +166 -0
- package/src/key-editor.ts +446 -0
- package/src/ledger-contract.ts +637 -0
- package/src/ledger-trust.ts +267 -0
- package/src/lockfile-invariants.ts +421 -0
- package/src/lockfile-readers.ts +749 -0
- package/src/lockfile-regen.ts +851 -0
- package/src/lockfile-tool-env.ts +131 -0
- package/src/materialize.ts +886 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +964 -0
- package/src/plan-command.ts +509 -0
- package/src/plan-contract.ts +179 -0
- package/src/plan-digest.ts +102 -0
- package/src/plan-rules.ts +188 -0
- package/src/planned-bundle.ts +211 -0
- package/src/product-repository.ts +10 -1
- package/src/provenance-gate.ts +352 -0
- package/src/pull-request-body.ts +261 -0
- package/src/registry-snapshot.ts +534 -0
- package/src/release-age-edit.ts +430 -0
- package/src/root-entries.ts +81 -0
- package/src/setup-template-scripts.ts +571 -0
- package/src/setup-templates.ts +471 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
- package/CHANGELOG.md +0 -131
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Dry materialization for `launcher-apply-plan plan` (issue #1178, RFC apply-approved-plan section 7, V6 and V9).
|
|
2
|
+
//
|
|
3
|
+
// The planner is pure and cannot regenerate a lockfile, so it reports every set that changes one as V6 indeterminate
|
|
4
|
+
// (`lockfile-not-run`). This module runs the part it cannot: it writes the base commit's tree and the set's changes into a
|
|
5
|
+
// temporary directory, regenerates the lockfile there with the same runner materialize uses, and, only when that passes,
|
|
6
|
+
// runs the hub's provenance check (V9) on that same tree. It reads the clone through git plumbing at the base commit
|
|
7
|
+
// (`ls-tree` and `cat-file`, see readCommittedFiles) and never writes it: no checkout, worktree, index or ref.
|
|
8
|
+
//
|
|
9
|
+
// Every ambiguity refuses. A rule is one of a fixed set of tokens and never carries tool output, a path, an id or a file
|
|
10
|
+
// name. The temporary tree is a directory of its own under the real operating-system temporary directory, removed before
|
|
11
|
+
// this returns, whatever happened. Nothing here changes a set, its digest or the bundle's digest: only the checks move.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join, sep } from "node:path";
|
|
17
|
+
import { canonicalOrder, contentDigest, lockfilePath, validateApplyBundle, validateRepositoryChangeSet, worstVerdict } from "./change-set-contract.js";
|
|
18
|
+
import type { ApplyBundle, ApplyBundleRepository, ApplyCheck, RepositoryChangeSet, WholeFileChange } from "./change-set-contract.js";
|
|
19
|
+
import { JsonEditUnstableError, editJsonPointer } from "./key-editor.js";
|
|
20
|
+
import { regenerateLockfile } from "./lockfile-regen.js";
|
|
21
|
+
import type { LockfileSpawn } from "./lockfile-regen.js";
|
|
22
|
+
import { derivedLockfile, packagesForLockfile, provesReleaseAgeEditWith, refuseReservedSymlinks, removePath, resolveFileText, textsFromChangeSet, writeRegularFile, writeSymlink } from "./materialize.js";
|
|
23
|
+
import type { ApplyStepResult } from "./materialize.js";
|
|
24
|
+
import { fold, readCommittedFiles } from "./observe-repository.js";
|
|
25
|
+
import type { CommittedFile } from "./observe-repository.js";
|
|
26
|
+
import type { PlanApplyBundleResult } from "./plan-bundle.js";
|
|
27
|
+
import { checkSetProvenance } from "./provenance-gate.js";
|
|
28
|
+
|
|
29
|
+
/** The rule the planner gives a set that changes a lockfile, and this module replaces. */
|
|
30
|
+
export const LOCKFILE_NOT_RUN = "lockfile-not-run";
|
|
31
|
+
/** The V9 rule of a set whose lockfile step did not pass: the provenance check is not run on a tree that is not the result. */
|
|
32
|
+
export const LOCKFILE_NOT_REGENERATED = "lockfile-not-regenerated";
|
|
33
|
+
/** The V6 rule of a repository whose dry tree could not be built or run, for any reason that is not one of the named ones. */
|
|
34
|
+
export const DRY_TREE_FAILED = "dry-tree-failed";
|
|
35
|
+
/** The V6 rule of a committed tree that is not safe to write: a path segment or a link that could reach outside the tree. */
|
|
36
|
+
export const DRY_TREE_UNSAFE = "tree-unsafe";
|
|
37
|
+
|
|
38
|
+
/** Ports. Neither is reachable from a CLI; `tempRoot` is a test-only stand-in for the operating-system temporary directory. */
|
|
39
|
+
export interface DryMaterializePorts {
|
|
40
|
+
/** Launches the package manager; the real runner by default. */
|
|
41
|
+
readonly lockfileSpawn?: LockfileSpawn;
|
|
42
|
+
/** Launches the hub's provenance check; the real runner by default. */
|
|
43
|
+
readonly provenanceSpawn?: LockfileSpawn;
|
|
44
|
+
readonly tempRoot?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DryMaterializeInput {
|
|
48
|
+
/** The repository's local clone: read at the set's base commit, never written. */
|
|
49
|
+
readonly clone: string;
|
|
50
|
+
/** The hub's real path, whose node_modules holds the pinned Integrator. */
|
|
51
|
+
readonly hubRoot: string;
|
|
52
|
+
readonly set: RepositoryChangeSet;
|
|
53
|
+
readonly now: () => Date;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
57
|
+
const TEMP_PREFIX = "launcher-dry-materialize-";
|
|
58
|
+
|
|
59
|
+
const v6 = (verdict: "violated" | "indeterminate", rule: string): ApplyCheck => ({ check: "V6", verdict, rule: TOKEN.test(rule) ? rule : DRY_TREE_FAILED });
|
|
60
|
+
const V6_SATISFIED: ApplyCheck = { check: "V6", verdict: "satisfied" };
|
|
61
|
+
const isWhole = (file: RepositoryChangeSet["files"][number]): file is WholeFileChange => !("derived" in file);
|
|
62
|
+
|
|
63
|
+
/** The V6 check a materialize step's result is: its exit code 1 is violated, anything else indeterminate; its reason is a fixed id. */
|
|
64
|
+
const fromStep = (step: ApplyStepResult): ApplyCheck => v6(step.exitCode === 1 ? "violated" : "indeterminate", step.reason ?? DRY_TREE_FAILED);
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// the committed tree, before anything is written
|
|
68
|
+
|
|
69
|
+
/** A path segment as a filesystem that folds case, normalization and format characters would read it. */
|
|
70
|
+
const folded = (segment: string): string => segment.normalize("NFKC").replace(/\p{Cf}/gu, "").toLowerCase();
|
|
71
|
+
const CONTROL = /\p{Cc}/u;
|
|
72
|
+
|
|
73
|
+
function segmentsSafe(path: string): boolean {
|
|
74
|
+
if (path.length === 0 || CONTROL.test(path)) return false;
|
|
75
|
+
return path.split("/").every((segment) => {
|
|
76
|
+
const key = folded(segment);
|
|
77
|
+
return segment.length > 0 && key !== "" && key !== "." && key !== ".." && key !== ".git";
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a link's target stays inside the tree however the links along it resolve: relative, no empty segment, no `..` after a
|
|
83
|
+
* name, no more leading `..` than the directories above the link, and no `.git` segment.
|
|
84
|
+
*/
|
|
85
|
+
function linkStaysInside(path: string, target: string): boolean {
|
|
86
|
+
if (target.length === 0 || CONTROL.test(target) || target.startsWith("/") || target.includes("\\")) return false;
|
|
87
|
+
const parts = target.split("/");
|
|
88
|
+
let up = 0;
|
|
89
|
+
while (up < parts.length && parts[up] === "..") up += 1;
|
|
90
|
+
if (up > path.split("/").length - 1) return false;
|
|
91
|
+
return parts.slice(up).every((segment) => {
|
|
92
|
+
const key = folded(segment);
|
|
93
|
+
return segment.length > 0 && key !== ".." && key !== ".git";
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether every path, and every link, of the committed tree is one the dry tree may hold. A filesystem that folds case or
|
|
99
|
+
* normalization reads two spellings as one name, so a link and a directory kept apart only by spelling would be the same entry
|
|
100
|
+
* there and a later write would go through the link. Every entry (link or file) is therefore folded as observeRepository folds
|
|
101
|
+
* it, and the tree is refused when that fold repeats, or is a proper prefix of another entry's fold.
|
|
102
|
+
*/
|
|
103
|
+
export function treeIsSafe(files: readonly CommittedFile[]): boolean {
|
|
104
|
+
const seen = [new Set<string>(), new Set<string>()];
|
|
105
|
+
const entries: string[] = [];
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (!segmentsSafe(file.path)) return false;
|
|
108
|
+
// A tree that keeps two paths apart only by case or normalization would be one path on a filesystem that folds them.
|
|
109
|
+
const keys = [file.path.normalize("NFC").toLowerCase(), fold(file.path)];
|
|
110
|
+
for (const [index, key] of keys.entries()) {
|
|
111
|
+
if (seen[index]!.has(key)) return false;
|
|
112
|
+
seen[index]!.add(key);
|
|
113
|
+
}
|
|
114
|
+
entries.push(fold(file.path));
|
|
115
|
+
if (file.mode === "120000" && !linkStaysInside(file.path, file.bytes.toString("utf8"))) return false;
|
|
116
|
+
}
|
|
117
|
+
const named = new Set(entries);
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const segments = entry.split("/");
|
|
120
|
+
for (let length = 1; length < segments.length; length += 1) if (named.has(segments.slice(0, length).join("/"))) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether `directory`'s real path is `root` or inside it. */
|
|
126
|
+
function insideRoot(root: string, directory: string): boolean {
|
|
127
|
+
const real = realpathSync(directory);
|
|
128
|
+
return real === root || real.startsWith(`${root}${sep}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Makes each directory of `relative` (a path inside `root`) one segment at a time, and throws when a segment already exists as
|
|
133
|
+
* anything but a real directory: nothing is created through a link, and nothing is created before the segment is looked at.
|
|
134
|
+
*/
|
|
135
|
+
function ensureDirectory(root: string, relative: string): string {
|
|
136
|
+
let current = root;
|
|
137
|
+
if (relative === "") return current;
|
|
138
|
+
for (const segment of relative.split("/")) {
|
|
139
|
+
current = join(current, segment);
|
|
140
|
+
let stat: ReturnType<typeof lstatSync> | null = null;
|
|
141
|
+
try {
|
|
142
|
+
stat = lstatSync(current);
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
|
|
145
|
+
}
|
|
146
|
+
if (stat === null) mkdirSync(current);
|
|
147
|
+
else if (!stat.isDirectory()) throw new Error("a directory of the base tree is not a directory");
|
|
148
|
+
}
|
|
149
|
+
return current;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Writes the committed tree under `root` (a real path of its own): directories and regular files first and links last, so no
|
|
154
|
+
* write of a file goes through a link, and before each write the real path of its parent must be inside `root`.
|
|
155
|
+
*/
|
|
156
|
+
export function writeBaseTree(root: string, files: readonly CommittedFile[]): void {
|
|
157
|
+
const ordered = [...files.filter((file) => file.mode !== "120000"), ...files.filter((file) => file.mode === "120000")];
|
|
158
|
+
for (const file of ordered) {
|
|
159
|
+
const path = join(root, file.path);
|
|
160
|
+
ensureDirectory(root, dirname(file.path) === "." ? "" : dirname(file.path));
|
|
161
|
+
if (!insideRoot(root, dirname(path))) throw new Error("a path of the base tree resolves outside the temporary directory");
|
|
162
|
+
if (file.mode === "120000") {
|
|
163
|
+
symlinkSync(file.bytes.toString("utf8"), path);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
writeFileSync(path, file.bytes, { flag: "wx" });
|
|
167
|
+
chmodSync(path, file.mode === "100755" ? 0o755 : 0o644);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// one repository
|
|
173
|
+
|
|
174
|
+
const digestOfBytes = (bytes: Buffer): string => contentDigest(bytes.toString("utf8"));
|
|
175
|
+
const sha256Hex = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* V6 and V9 for a set that changes a lockfile, or null when it changes none. V6 is the result of writing the set into a
|
|
179
|
+
* temporary copy of the base tree and regenerating the lockfile there; V9 is run on that same copy, and only when V6 is
|
|
180
|
+
* satisfied. The ledger is not written: it is bound by an approval that does not exist yet, and nothing here reads it.
|
|
181
|
+
*/
|
|
182
|
+
export async function dryMaterialize(input: DryMaterializeInput, ports: DryMaterializePorts = {}): Promise<readonly ApplyCheck[] | null> {
|
|
183
|
+
const { set } = input;
|
|
184
|
+
const lock = derivedLockfile(set);
|
|
185
|
+
if (lock === null) return null;
|
|
186
|
+
const held: { root: string | null } = { root: null };
|
|
187
|
+
try {
|
|
188
|
+
const outcome = await runDry(input, ports, lock, held);
|
|
189
|
+
if ("checks" in outcome) return outcome.checks;
|
|
190
|
+
return [outcome.v6, { check: "V9", verdict: "indeterminate", rule: LOCKFILE_NOT_REGENERATED }];
|
|
191
|
+
} catch {
|
|
192
|
+
return [v6("indeterminate", DRY_TREE_FAILED), { check: "V9", verdict: "indeterminate", rule: LOCKFILE_NOT_REGENERATED }];
|
|
193
|
+
} finally {
|
|
194
|
+
if (held.root !== null) {
|
|
195
|
+
try {
|
|
196
|
+
rmSync(held.root, { recursive: true, force: true });
|
|
197
|
+
} catch {
|
|
198
|
+
// The verdict does not depend on the removal, and the tree is under the operating-system temporary directory.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runDry(
|
|
205
|
+
input: DryMaterializeInput,
|
|
206
|
+
ports: DryMaterializePorts,
|
|
207
|
+
lock: NonNullable<ReturnType<typeof derivedLockfile>>,
|
|
208
|
+
held: { root: string | null },
|
|
209
|
+
): Promise<{ readonly v6: ApplyCheck } | { readonly checks: readonly ApplyCheck[] }> {
|
|
210
|
+
const { set } = input;
|
|
211
|
+
if (!validateRepositoryChangeSet(set).valid) return { v6: v6("indeterminate", "change-set-invalid") };
|
|
212
|
+
const lockpath = lockfilePath({ packageManager: set.observed.packageManager, lockfile: set.observed.lockfile });
|
|
213
|
+
if (lockpath === null || lockpath !== lock.path) return { v6: v6("indeterminate", "lockfile-format-unsupported") };
|
|
214
|
+
const packages = packagesForLockfile(set, lock.invariants);
|
|
215
|
+
if (packages === null) return { v6: v6("indeterminate", "lockfile-invariants") };
|
|
216
|
+
|
|
217
|
+
const committed = readCommittedFiles(input.clone, set.repository.baseCommit);
|
|
218
|
+
if (!committed.ok) return { v6: v6("indeterminate", committed.reason) };
|
|
219
|
+
if (!treeIsSafe(committed.files)) return { v6: v6("indeterminate", DRY_TREE_UNSAFE) };
|
|
220
|
+
const base = new Map(committed.files.map((file) => [file.path, file] as const));
|
|
221
|
+
const baseText = (path: string): string | null => base.get(path)?.bytes.toString("utf8") ?? null;
|
|
222
|
+
|
|
223
|
+
// The set's own claim about the base: every file it replaces is, at the base commit, the bytes it names as `before`.
|
|
224
|
+
const whole = set.files.filter(isWhole);
|
|
225
|
+
for (const file of whole) {
|
|
226
|
+
const at = base.get(file.path);
|
|
227
|
+
if ((at === undefined ? null : digestOfBytes(at.bytes)) !== file.before) return { v6: v6("violated", "base-mismatch") };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const parent = ports.tempRoot === undefined ? realpathSync(tmpdir()) : realpathSync(ports.tempRoot);
|
|
231
|
+
const root = mkdtempSync(join(parent, TEMP_PREFIX));
|
|
232
|
+
held.root = root;
|
|
233
|
+
// The runner and the provenance check both need a directory that is its own real path.
|
|
234
|
+
if (realpathSync(root) !== root) return { v6: v6("indeterminate", DRY_TREE_FAILED) };
|
|
235
|
+
writeBaseTree(root, committed.files);
|
|
236
|
+
|
|
237
|
+
// What every change writes is resolved and proved before the first write, from the tree as the base has it.
|
|
238
|
+
const texts = textsFromChangeSet(set);
|
|
239
|
+
const toWrite = whole.filter((file) => file.before !== file.after);
|
|
240
|
+
const resolved = new Map<string, string>();
|
|
241
|
+
for (const file of toWrite) {
|
|
242
|
+
const text = resolveFileText(root, set, file, texts);
|
|
243
|
+
if (typeof text !== "string") return { v6: fromStep(text) };
|
|
244
|
+
if (file.after !== null && contentDigest(text) !== file.after) return { v6: v6("violated", "content-mismatch") };
|
|
245
|
+
if (!provesReleaseAgeEditWith(baseText, set, file, text)) return { v6: v6("violated", "content-mismatch") };
|
|
246
|
+
resolved.set(file.path, text);
|
|
247
|
+
}
|
|
248
|
+
let packageText: string | undefined;
|
|
249
|
+
if (set.keys.length > 0) {
|
|
250
|
+
const existing = baseText("package.json");
|
|
251
|
+
if (existing === null) return { v6: v6("indeterminate", "manifest-absent") };
|
|
252
|
+
try {
|
|
253
|
+
packageText = editJsonPointer(existing, set.keys.map((key) => (key.after === null ? { pointer: key.pointer, remove: true } : { pointer: key.pointer, value: key.after })));
|
|
254
|
+
} catch (cause) {
|
|
255
|
+
if (cause instanceof JsonEditUnstableError) return { v6: v6("indeterminate", "json-edit-unstable") };
|
|
256
|
+
throw cause;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const touched = [...toWrite.map((file) => file.path), ...(packageText === undefined ? [] : ["package.json"])];
|
|
260
|
+
const symlinked = refuseReservedSymlinks(root, touched);
|
|
261
|
+
if (symlinked !== null) return { v6: fromStep(symlinked) };
|
|
262
|
+
|
|
263
|
+
for (const file of toWrite) {
|
|
264
|
+
if (file.after === null) {
|
|
265
|
+
const removed = removePath(root, file.path);
|
|
266
|
+
if (removed !== null) return { v6: fromStep(removed) };
|
|
267
|
+
} else if (file.mode === "120000") {
|
|
268
|
+
const linked = writeSymlink(root, file.path, resolved.get(file.path)!, file.after);
|
|
269
|
+
if (linked !== null) return { v6: fromStep(linked) };
|
|
270
|
+
} else {
|
|
271
|
+
const written = writeRegularFile(root, file.path, resolved.get(file.path)!, file.after);
|
|
272
|
+
if (written !== null) return { v6: fromStep(written) };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (packageText !== undefined) {
|
|
276
|
+
const written = writeRegularFile(root, "package.json", packageText, contentDigest(packageText));
|
|
277
|
+
if (written !== null) return { v6: fromStep(written) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The lockfile step, as materialize runs it: the tool version is not supplied here, so pnpm and Yarn are refused by the runner.
|
|
281
|
+
const regen = await regenerateLockfile(
|
|
282
|
+
{
|
|
283
|
+
root,
|
|
284
|
+
packageManager: set.observed.packageManager,
|
|
285
|
+
lockfile: set.observed.lockfile,
|
|
286
|
+
toolVersion: null,
|
|
287
|
+
baseLockfile: baseText(lock.path) ?? "",
|
|
288
|
+
packages,
|
|
289
|
+
now: input.now,
|
|
290
|
+
},
|
|
291
|
+
{ spawn: ports.lockfileSpawn },
|
|
292
|
+
);
|
|
293
|
+
// Only the runner's fixed rule or reason is kept, never its tool output or paths.
|
|
294
|
+
if (regen.verdict === "violated") return { v6: v6("violated", regen.rule) };
|
|
295
|
+
if (regen.verdict === "indeterminate") return { v6: v6("indeterminate", regen.reason) };
|
|
296
|
+
let onDisk: Buffer;
|
|
297
|
+
try {
|
|
298
|
+
onDisk = readFileSync(join(root, regen.path));
|
|
299
|
+
} catch {
|
|
300
|
+
return { v6: v6("violated", "lockfile-invariants") };
|
|
301
|
+
}
|
|
302
|
+
if (sha256Hex(onDisk) !== regen.after) return { v6: v6("violated", "lockfile-invariants") };
|
|
303
|
+
|
|
304
|
+
const provenance = await checkSetProvenance({ tree: root, hubRoot: input.hubRoot, items: set.items }, { spawn: ports.provenanceSpawn });
|
|
305
|
+
return { checks: [V6_SATISFIED, ...provenance] };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// the bundle
|
|
310
|
+
|
|
311
|
+
export interface DryMaterializeBundleOptions {
|
|
312
|
+
/** The hub's real path. */
|
|
313
|
+
readonly hub: string;
|
|
314
|
+
/** The local clone of a repository id. */
|
|
315
|
+
readonly cloneFor: (id: string) => string;
|
|
316
|
+
readonly now: () => Date;
|
|
317
|
+
readonly ports?: DryMaterializePorts;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const sortChecks = (checks: readonly ApplyCheck[]): ApplyCheck[] => canonicalOrder(checks, (check) => [check.check, check.rule ?? ""]);
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The planner's result with each dry-materializable repository's `lockfile-not-run` replaced by V6 and V9 as this module
|
|
324
|
+
* computes them. A repository is dry-materialized only when its set changes a lockfile, has no refused path or key, and every
|
|
325
|
+
* other check is already satisfied: a repository that cannot be applied anyway launches nothing. The sets, their digests and
|
|
326
|
+
* the bundle digest are unchanged (the digest does not cover checks); each repository's verdict is recomputed as the worst of
|
|
327
|
+
* its checks, and the bundle is validated again. Throws when it does not validate.
|
|
328
|
+
*/
|
|
329
|
+
export async function dryMaterializeBundle(result: PlanApplyBundleResult, options: DryMaterializeBundleOptions): Promise<PlanApplyBundleResult> {
|
|
330
|
+
const repositories: ApplyBundleRepository[] = [];
|
|
331
|
+
for (const entry of result.bundle.repositories) {
|
|
332
|
+
if (!("changeSet" in entry)) {
|
|
333
|
+
repositories.push(entry);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const others = entry.checks.filter((check) => !(check.check === "V6" && check.rule === LOCKFILE_NOT_RUN));
|
|
337
|
+
const set = result.changeSets.find((candidate) => candidate.changeSetDigest === entry.changeSet);
|
|
338
|
+
if (others.length === entry.checks.length || set === undefined || set.refused.length > 0 || others.some((check) => check.verdict !== "satisfied")) {
|
|
339
|
+
repositories.push(entry);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const replaced = await dryMaterialize({ clone: options.cloneFor(set.repository.id), hubRoot: options.hub, set, now: options.now }, options.ports);
|
|
343
|
+
if (replaced === null) {
|
|
344
|
+
repositories.push(entry);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const checks = sortChecks([...others, ...replaced]);
|
|
348
|
+
repositories.push({ ...entry, verdict: worstVerdict(checks.map((check) => check.verdict)), checks });
|
|
349
|
+
}
|
|
350
|
+
const bundle: ApplyBundle = { ...result.bundle, repositories };
|
|
351
|
+
if (!validateApplyBundle(bundle).valid) throw new Error("the bundle with its dry-materialization checks does not validate");
|
|
352
|
+
return { bundle, changeSets: result.changeSets };
|
|
353
|
+
}
|