@microck/canonfig 2.0.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/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/agent/agent-resolution.errors.js +42 -0
- package/dist/agent/agent-resolution.layer.js +204 -0
- package/dist/agent/agent-resolution.service.js +2259 -0
- package/dist/agent/agent-resolution.types.js +1 -0
- package/dist/agent/controlled-executor.js +704 -0
- package/dist/agent/harness-adapters.js +85 -0
- package/dist/cli/cli.js +618 -0
- package/dist/cli/exit-codes.js +28 -0
- package/dist/cli/follower-commands.js +3 -0
- package/dist/cli/render.js +56 -0
- package/dist/cli/source-commands.js +5 -0
- package/dist/domain/brand.js +29 -0
- package/dist/domain/identity.js +31 -0
- package/dist/domain/npm-package-spec.js +186 -0
- package/dist/domain/profile.js +950 -0
- package/dist/domain/recipe-versions.js +297 -0
- package/dist/domain/resource.js +259 -0
- package/dist/domain/synchronization.js +346 -0
- package/dist/enrollment/enrollment.errors.js +43 -0
- package/dist/enrollment/enrollment.layer.js +724 -0
- package/dist/enrollment/enrollment.service.js +3 -0
- package/dist/enrollment/enrollment.types.js +59 -0
- package/dist/enrollment/follower-client.js +585 -0
- package/dist/enrollment/source-server.js +313 -0
- package/dist/machine/linux.layer.js +1183 -0
- package/dist/machine/machine-state.errors.js +52 -0
- package/dist/machine/machine-state.service.js +3 -0
- package/dist/machine/machine-state.types.js +1 -0
- package/dist/machine/macos.layer.js +470 -0
- package/dist/machine/windows.layer.js +879 -0
- package/dist/profile/discovery.js +740 -0
- package/dist/profile/profile-catalog.errors.js +50 -0
- package/dist/profile/profile-catalog.layer.js +20 -0
- package/dist/profile/profile-catalog.service.js +7 -0
- package/dist/profile/profile-codec.js +153 -0
- package/dist/profile/publication.js +298 -0
- package/dist/profile/tool-catalog.js +384 -0
- package/dist/runtime/doctor.js +306 -0
- package/dist/runtime/layers.js +706 -0
- package/dist/runtime/main.js +38 -0
- package/dist/schedule/linux-schedule.js +24 -0
- package/dist/schedule/macos-schedule.js +25 -0
- package/dist/schedule/schedule-manager.errors.js +17 -0
- package/dist/schedule/schedule-manager.layer.js +205 -0
- package/dist/schedule/schedule-manager.service.js +3 -0
- package/dist/schedule/schedule-manager.types.js +114 -0
- package/dist/schedule/windows-schedule.js +25 -0
- package/dist/state/state-repository.errors.js +55 -0
- package/dist/state/state-repository.layer.js +1507 -0
- package/dist/state/state-repository.service.js +3 -0
- package/dist/state/state-repository.types.js +1 -0
- package/dist/state/state-schema.js +298 -0
- package/dist/synchronization/config-codec.js +97 -0
- package/dist/synchronization/executor.js +700 -0
- package/dist/synchronization/follower-orchestration.js +939 -0
- package/dist/synchronization/follower-sync-config.js +81 -0
- package/dist/synchronization/npm-artifact.js +670 -0
- package/dist/synchronization/planner.js +378 -0
- package/dist/synchronization/recovery.js +397 -0
- package/dist/synchronization/resource-executors.js +1198 -0
- package/dist/synchronization/resource-plans.js +645 -0
- package/dist/synchronization/synchronization.errors.js +102 -0
- package/dist/synchronization/synchronization.layer.js +97 -0
- package/dist/synchronization/synchronization.service.js +11 -0
- package/dist/synchronization/synchronization.types.js +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { chmod, mkdir, open, readFile, rename, rm, lstat, } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
6
|
+
import { Effect, Schema } from "effect";
|
|
7
|
+
const defaultMaximumBytes = 32 * 1024 * 1024;
|
|
8
|
+
const defaultTimeoutMilliseconds = 30_000;
|
|
9
|
+
const maximumArchiveEntries = 4_096;
|
|
10
|
+
const maximumArchiveBytes = 128 * 1024 * 1024;
|
|
11
|
+
const maximumManifestBytes = 1 * 1024 * 1024;
|
|
12
|
+
const tarBlockBytes = 512;
|
|
13
|
+
export class NpmArtifactError extends Schema.TaggedError()("NpmArtifactError", {
|
|
14
|
+
operation: Schema.String,
|
|
15
|
+
message: Schema.String,
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
18
|
+
const safeBase64 = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
19
|
+
const expectedIntegrity = (value) => {
|
|
20
|
+
const match = /^(sha256|sha512)-([A-Za-z0-9+/]+={0,2})$/u.exec(value);
|
|
21
|
+
if (match === null || !safeBase64.test(match[2]))
|
|
22
|
+
return undefined;
|
|
23
|
+
const algorithm = match[1];
|
|
24
|
+
if (algorithm !== "sha256" && algorithm !== "sha512")
|
|
25
|
+
return undefined;
|
|
26
|
+
const digest = Buffer.from(match[2], "base64");
|
|
27
|
+
if (digest.byteLength !== (algorithm === "sha256" ? 32 : 64))
|
|
28
|
+
return undefined;
|
|
29
|
+
return { algorithm, digest };
|
|
30
|
+
};
|
|
31
|
+
export const verifyNpmArtifactBytes = (bytes, integrity) => {
|
|
32
|
+
const expected = expectedIntegrity(integrity);
|
|
33
|
+
if (expected === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
const actual = createHash(expected.algorithm).update(bytes).digest();
|
|
36
|
+
return actual.length === expected.digest.length
|
|
37
|
+
&& actual.equals(expected.digest);
|
|
38
|
+
};
|
|
39
|
+
const NpmJsonObject = Schema.Record(Schema.String, Schema.MutableJson);
|
|
40
|
+
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
41
|
+
const tarString = (header, offset, length) => {
|
|
42
|
+
const end = header.subarray(offset, offset + length).indexOf(0);
|
|
43
|
+
const value = header.subarray(offset, offset + (end < 0 ? length : end));
|
|
44
|
+
return textDecoder.decode(value);
|
|
45
|
+
};
|
|
46
|
+
const tarOctal = (header, offset, length) => {
|
|
47
|
+
const raw = tarString(header, offset, length).replace(/^\s+|\s+$/gu, "");
|
|
48
|
+
if (raw.length === 0)
|
|
49
|
+
return 0;
|
|
50
|
+
if (!/^[0-7]+$/u.test(raw))
|
|
51
|
+
return undefined;
|
|
52
|
+
const value = Number.parseInt(raw, 8);
|
|
53
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
54
|
+
};
|
|
55
|
+
const tarChecksumValid = (header) => {
|
|
56
|
+
const expected = tarOctal(header, 148, 8);
|
|
57
|
+
if (expected === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
let actual = 0;
|
|
60
|
+
for (let index = 0; index < header.byteLength; index += 1) {
|
|
61
|
+
actual += index >= 148 && index < 156 ? 0x20 : header[index];
|
|
62
|
+
}
|
|
63
|
+
return actual === expected;
|
|
64
|
+
};
|
|
65
|
+
const normalizedArchivePath = (name, prefix) => {
|
|
66
|
+
const rawPath = `${prefix}${prefix.length > 0 && name.length > 0 ? "/" : ""}${name}`;
|
|
67
|
+
const path = rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath;
|
|
68
|
+
if (path.length === 0
|
|
69
|
+
|| path.includes("\0")
|
|
70
|
+
|| path.includes("\\")
|
|
71
|
+
|| path.startsWith("/")
|
|
72
|
+
|| path.split("/").some((part) => part.length === 0 || part === "." || part === "..")
|
|
73
|
+
|| (path !== "package" && !path.startsWith("package/"))) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return path;
|
|
77
|
+
};
|
|
78
|
+
const parseNpmArchive = (bytes) => {
|
|
79
|
+
let expanded;
|
|
80
|
+
try {
|
|
81
|
+
expanded = gunzipSync(bytes, { maxOutputLength: maximumArchiveBytes });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
message: "npm artifact is not a bounded gzip-compressed tar archive",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const entries = [];
|
|
90
|
+
const byPath = new Map();
|
|
91
|
+
let offset = 0;
|
|
92
|
+
let zeroBlocks = 0;
|
|
93
|
+
while (offset + tarBlockBytes <= expanded.byteLength) {
|
|
94
|
+
const header = expanded.subarray(offset, offset + tarBlockBytes);
|
|
95
|
+
if (header.every((byte) => byte === 0)) {
|
|
96
|
+
zeroBlocks += 1;
|
|
97
|
+
offset += tarBlockBytes;
|
|
98
|
+
if (zeroBlocks === 2)
|
|
99
|
+
break;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
zeroBlocks = 0;
|
|
103
|
+
if (entries.length >= maximumArchiveEntries || !tarChecksumValid(header)) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
message: "npm artifact has too many entries or an invalid tar header",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
let name;
|
|
110
|
+
let prefix;
|
|
111
|
+
try {
|
|
112
|
+
name = tarString(header, 0, 100);
|
|
113
|
+
prefix = tarString(header, 345, 155);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { ok: false, message: "npm artifact contains an invalid UTF-8 tar path" };
|
|
117
|
+
}
|
|
118
|
+
const path = normalizedArchivePath(name, prefix);
|
|
119
|
+
if (path === undefined)
|
|
120
|
+
return { ok: false, message: "npm artifact contains an unsafe tar path" };
|
|
121
|
+
const type = header[156];
|
|
122
|
+
const kind = type === 0 || type === 48
|
|
123
|
+
? "file"
|
|
124
|
+
: type === 5
|
|
125
|
+
? "directory"
|
|
126
|
+
: undefined;
|
|
127
|
+
if (kind === undefined) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
message: "npm artifact contains a symlink, hardlink, special file, or extended tar entry",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const size = tarOctal(header, 124, 12);
|
|
134
|
+
if (size === undefined || size > maximumArchiveBytes) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
message: "npm artifact contains an invalid or oversized tar entry",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const dataOffset = offset + tarBlockBytes;
|
|
141
|
+
const paddedSize = Math.ceil(size / tarBlockBytes) * tarBlockBytes;
|
|
142
|
+
if (dataOffset > expanded.byteLength
|
|
143
|
+
|| paddedSize > expanded.byteLength - dataOffset
|
|
144
|
+
|| entries.reduce((total, entry) => total + (entry.bytes?.byteLength ?? 0), 0) + size
|
|
145
|
+
> maximumArchiveBytes) {
|
|
146
|
+
return { ok: false, message: "npm artifact exceeds the archive decompression limit" };
|
|
147
|
+
}
|
|
148
|
+
const entry = {
|
|
149
|
+
path,
|
|
150
|
+
kind,
|
|
151
|
+
bytes: kind === "file"
|
|
152
|
+
? expanded.slice(dataOffset, dataOffset + size)
|
|
153
|
+
: undefined,
|
|
154
|
+
};
|
|
155
|
+
if (byPath.has(path)) {
|
|
156
|
+
return { ok: false, message: "npm artifact contains duplicate tar paths" };
|
|
157
|
+
}
|
|
158
|
+
if (kind === "file"
|
|
159
|
+
&& [...byPath.keys()].some((existing) => existing.startsWith(`${path}/`))) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
message: "npm artifact contains a file/descendant tar path collision",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const pathParts = path.split("/");
|
|
166
|
+
for (let index = 1; index < pathParts.length - 1; index += 1) {
|
|
167
|
+
const ancestorPath = pathParts.slice(0, index + 1).join("/");
|
|
168
|
+
if (byPath.get(ancestorPath)?.kind === "file") {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
message: "npm artifact contains a file/descendant tar path collision",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
byPath.set(path, entry);
|
|
176
|
+
entries.push(entry);
|
|
177
|
+
offset = dataOffset + paddedSize;
|
|
178
|
+
}
|
|
179
|
+
if (zeroBlocks < 2
|
|
180
|
+
|| expanded.subarray(offset).some((byte) => byte !== 0)) {
|
|
181
|
+
return { ok: false, message: "npm artifact has truncated or trailing tar data" };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, entries };
|
|
184
|
+
};
|
|
185
|
+
const dependencyObject = (value, field) => {
|
|
186
|
+
if (value === undefined)
|
|
187
|
+
return { ok: true, value: {} };
|
|
188
|
+
if (!Schema.is(NpmJsonObject)(value)) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
message: `npm package manifest field ${field} must be an object`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const result = {};
|
|
195
|
+
for (const [name, spec] of Object.entries(value)) {
|
|
196
|
+
if (!/^(?:[A-Za-z0-9][A-Za-z0-9._~-]*|@[A-Za-z0-9._~-]+\/[A-Za-z0-9._~-]+)$/u.test(name)
|
|
197
|
+
|| !Schema.is(Schema.String)(spec)
|
|
198
|
+
|| spec.length === 0) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
message: `npm package manifest has an invalid ${field} dependency`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
result[name] = spec;
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, value: result };
|
|
207
|
+
};
|
|
208
|
+
const dependencySpecificationError = (field, name, specification) => /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(specification)
|
|
209
|
+
|| specification.startsWith("git@")
|
|
210
|
+
|| specification.includes("\\")
|
|
211
|
+
|| /^(?:\.{1,2}[/]|[/]|~[/]|[A-Za-z]:[/])/u.test(specification)
|
|
212
|
+
? `npm package manifest ${field} dependency ${name} is an external or ambiguous specification`
|
|
213
|
+
: undefined;
|
|
214
|
+
const embeddedPackageManifest = (manifests, directory, name) => {
|
|
215
|
+
let current = directory;
|
|
216
|
+
while (current.startsWith("package")) {
|
|
217
|
+
const candidate = `${current}/node_modules/${name}/package.json`;
|
|
218
|
+
const manifest = manifests.get(candidate);
|
|
219
|
+
if (manifest !== undefined)
|
|
220
|
+
return manifest;
|
|
221
|
+
if (current === "package")
|
|
222
|
+
break;
|
|
223
|
+
const separator = current.lastIndexOf("/");
|
|
224
|
+
if (separator < 0)
|
|
225
|
+
break;
|
|
226
|
+
current = current.slice(0, separator);
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
};
|
|
230
|
+
const bundledDependencyNames = (manifest) => {
|
|
231
|
+
const bundled = manifest.value.bundledDependencies;
|
|
232
|
+
const bundle = manifest.value.bundleDependencies;
|
|
233
|
+
if (bundled !== undefined && bundle !== undefined) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
message: "npm package manifest has ambiguous bundledDependencies and bundleDependencies",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const value = bundled ?? bundle;
|
|
240
|
+
if (value === undefined)
|
|
241
|
+
return { ok: true, value: [] };
|
|
242
|
+
if (!Schema.is(Schema.Array(Schema.String))(value) || value.some((name) => !/^(?:[A-Za-z0-9][A-Za-z0-9._~-]*|@[A-Za-z0-9._~-]+\/[A-Za-z0-9._~-]+)$/u.test(name))) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
message: "npm package manifest has invalid bundled dependency metadata",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (new Set(value).size !== value.length) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
message: "npm package manifest has duplicate bundled dependency metadata",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { ok: true, value };
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Inspect the exact reviewed tarball before giving it to a package manager.
|
|
258
|
+
* The top-level SRI digest authenticates every byte, while this inspection
|
|
259
|
+
* proves that npm has no unreviewed dependency or package-manager indirection
|
|
260
|
+
* to resolve. Only dependency trees physically embedded in the same archive
|
|
261
|
+
* are accepted.
|
|
262
|
+
*/
|
|
263
|
+
export const validateNpmArtifactProvenance = (bytes, packageName, version) => {
|
|
264
|
+
const parsed = parseNpmArchive(bytes);
|
|
265
|
+
if (!parsed.ok)
|
|
266
|
+
return parsed.message;
|
|
267
|
+
const manifests = new Map();
|
|
268
|
+
for (const entry of parsed.entries) {
|
|
269
|
+
if (!entry.path.endsWith("/package.json") || entry.kind !== "file")
|
|
270
|
+
continue;
|
|
271
|
+
const content = entry.bytes;
|
|
272
|
+
if (content === undefined || content.byteLength > maximumManifestBytes) {
|
|
273
|
+
return "npm package manifest exceeds the size limit";
|
|
274
|
+
}
|
|
275
|
+
let value;
|
|
276
|
+
try {
|
|
277
|
+
value = Schema.decodeUnknownSync(Schema.MutableJson)(JSON.parse(textDecoder.decode(content)));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return "npm artifact contains invalid package manifest JSON";
|
|
281
|
+
}
|
|
282
|
+
if (!Schema.is(NpmJsonObject)(value)) {
|
|
283
|
+
return "npm package manifest must be a JSON object";
|
|
284
|
+
}
|
|
285
|
+
const directory = entry.path.slice(0, -"/package.json".length);
|
|
286
|
+
manifests.set(entry.path, {
|
|
287
|
+
path: entry.path,
|
|
288
|
+
directory,
|
|
289
|
+
value,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const root = manifests.get("package/package.json");
|
|
293
|
+
if (root === undefined)
|
|
294
|
+
return "npm artifact has no unambiguous package/package.json";
|
|
295
|
+
if (packageName !== undefined
|
|
296
|
+
&& (!Schema.is(Schema.String)(root.value.name)
|
|
297
|
+
|| root.value.name !== packageName)) {
|
|
298
|
+
return "npm package manifest name does not match the reviewed package";
|
|
299
|
+
}
|
|
300
|
+
if (version !== undefined
|
|
301
|
+
&& (!Schema.is(Schema.String)(root.value.version)
|
|
302
|
+
|| root.value.version !== version)) {
|
|
303
|
+
return "npm package manifest version does not match the reviewed version";
|
|
304
|
+
}
|
|
305
|
+
const bundled = bundledDependencyNames(root);
|
|
306
|
+
if (!bundled.ok)
|
|
307
|
+
return bundled.message;
|
|
308
|
+
const bundledSet = new Set(bundled.value);
|
|
309
|
+
const rootDependencies = dependencyObject(root.value.dependencies, "dependencies");
|
|
310
|
+
const rootOptionalDependencies = dependencyObject(root.value.optionalDependencies, "optionalDependencies");
|
|
311
|
+
if (!rootDependencies.ok)
|
|
312
|
+
return rootDependencies.message;
|
|
313
|
+
if (!rootOptionalDependencies.ok)
|
|
314
|
+
return rootOptionalDependencies.message;
|
|
315
|
+
if (root.value.peerDependencies !== undefined
|
|
316
|
+
|| root.value.peerDependenciesMeta !== undefined) {
|
|
317
|
+
return "npm package manifest declares peer dependency resolution metadata";
|
|
318
|
+
}
|
|
319
|
+
if (root.value.packageManager !== undefined
|
|
320
|
+
|| root.value.devEngines !== undefined
|
|
321
|
+
|| root.value.workspaces !== undefined) {
|
|
322
|
+
return "npm package manifest declares install-time package manager indirection";
|
|
323
|
+
}
|
|
324
|
+
const rootDependencyNames = new Set([
|
|
325
|
+
...Object.keys(rootDependencies.value),
|
|
326
|
+
...Object.keys(rootOptionalDependencies.value),
|
|
327
|
+
]);
|
|
328
|
+
for (const name of rootDependencyNames) {
|
|
329
|
+
if (!bundledSet.has(name)) {
|
|
330
|
+
return `npm package dependency ${name} is not declared as bundled`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const name of bundledSet) {
|
|
334
|
+
if (embeddedPackageManifest(manifests, root.directory, name) === undefined) {
|
|
335
|
+
return `npm bundled dependency ${name} is not embedded in the artifact`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (rootDependencyNames.size === 0 && bundledSet.size > 0) {
|
|
339
|
+
return "npm artifact has bundled dependencies without declared dependencies";
|
|
340
|
+
}
|
|
341
|
+
const referencedManifestPaths = new Set([root.path]);
|
|
342
|
+
for (const manifest of manifests.values()) {
|
|
343
|
+
if (manifest.path !== root.path
|
|
344
|
+
&& !manifest.path.includes("/node_modules/")) {
|
|
345
|
+
return "npm artifact contains a package manifest outside node_modules";
|
|
346
|
+
}
|
|
347
|
+
const dependencies = dependencyObject(manifest.value.dependencies, "dependencies");
|
|
348
|
+
const optionalDependencies = dependencyObject(manifest.value.optionalDependencies, "optionalDependencies");
|
|
349
|
+
if (!dependencies.ok)
|
|
350
|
+
return dependencies.message;
|
|
351
|
+
if (!optionalDependencies.ok)
|
|
352
|
+
return optionalDependencies.message;
|
|
353
|
+
if (manifest.value.peerDependencies !== undefined
|
|
354
|
+
|| manifest.value.peerDependenciesMeta !== undefined) {
|
|
355
|
+
return "npm embedded package declares peer dependency resolution metadata";
|
|
356
|
+
}
|
|
357
|
+
if (manifest.value.packageManager !== undefined
|
|
358
|
+
|| manifest.value.devEngines !== undefined
|
|
359
|
+
|| manifest.value.workspaces !== undefined) {
|
|
360
|
+
return "npm embedded package declares install-time package manager indirection";
|
|
361
|
+
}
|
|
362
|
+
for (const [field, values] of [
|
|
363
|
+
["dependencies", dependencies.value],
|
|
364
|
+
["optionalDependencies", optionalDependencies.value],
|
|
365
|
+
]) {
|
|
366
|
+
for (const [name, specification] of Object.entries(values)) {
|
|
367
|
+
const specificationError = dependencySpecificationError(field, name, specification);
|
|
368
|
+
if (specificationError !== undefined)
|
|
369
|
+
return specificationError;
|
|
370
|
+
const embedded = embeddedPackageManifest(manifests, manifest.directory, name);
|
|
371
|
+
if (embedded === undefined) {
|
|
372
|
+
return `npm embedded package dependency ${name} is not fully embedded`;
|
|
373
|
+
}
|
|
374
|
+
referencedManifestPaths.add(embedded.path);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const manifest of manifests.values()) {
|
|
379
|
+
if (!referencedManifestPaths.has(manifest.path)) {
|
|
380
|
+
return `npm artifact contains an unreferenced embedded package ${manifest.path}`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
};
|
|
385
|
+
const npmTarballPath = (packageName, version) => {
|
|
386
|
+
const packagePart = packageName.startsWith("@")
|
|
387
|
+
? packageName.slice(packageName.indexOf("/") + 1)
|
|
388
|
+
: packageName;
|
|
389
|
+
return `/${packageName}/-/${packagePart}-${version}.tgz`;
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* A reviewed artifact is a single, exact npm registry URL. This check is
|
|
393
|
+
* intentionally stricter than URL parsing: equivalent spellings must not
|
|
394
|
+
* create multiple cache identities or permit an origin/path substitution.
|
|
395
|
+
*/
|
|
396
|
+
export const validateNpmArtifactSource = (source, packageName, version) => {
|
|
397
|
+
const expected = `https://registry.npmjs.org${npmTarballPath(packageName, version)}`;
|
|
398
|
+
if (source !== expected) {
|
|
399
|
+
return "source is not the canonical npm registry tarball for the declared package and version";
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const url = new URL(source);
|
|
403
|
+
if (url.protocol !== "https:"
|
|
404
|
+
|| url.origin !== "https://registry.npmjs.org"
|
|
405
|
+
|| url.username.length > 0
|
|
406
|
+
|| url.password.length > 0
|
|
407
|
+
|| url.search.length > 0
|
|
408
|
+
|| url.hash.length > 0
|
|
409
|
+
|| url.port.length > 0
|
|
410
|
+
|| decodeURIComponent(url.pathname) !== npmTarballPath(packageName, version)) {
|
|
411
|
+
return "source is not a credential-free canonical HTTPS npm registry tarball";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return "source is not a valid canonical HTTPS npm registry tarball";
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
};
|
|
419
|
+
const validateInput = (input) => validateNpmArtifactSource(input.source, input.packageName, input.version)
|
|
420
|
+
?? (expectedIntegrity(input.integrity) === undefined
|
|
421
|
+
? "artifact integrity must be a supported sha256 or sha512 SRI value"
|
|
422
|
+
: undefined);
|
|
423
|
+
const filesystemMessage = (cause) => cause instanceof Error
|
|
424
|
+
? cause.message.replace(/\s+/gu, " ").slice(0, 1024)
|
|
425
|
+
: "filesystem operation failed";
|
|
426
|
+
const cacheName = (integrity) => `${integrity.replaceAll("/", "_").replaceAll("+", "-")}.tgz`;
|
|
427
|
+
const cachePathFor = (input) => join(input.cacheDirectory, cacheName(input.integrity));
|
|
428
|
+
const cacheLockPathFor = (path) => `${path}.lock`;
|
|
429
|
+
const ensureCacheDirectory = async (directory) => {
|
|
430
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
431
|
+
if (process.platform !== "win32")
|
|
432
|
+
await chmod(directory, 0o700);
|
|
433
|
+
};
|
|
434
|
+
const readVerifiedCache = async (path, integrity, maximumBytes) => {
|
|
435
|
+
try {
|
|
436
|
+
const details = await lstat(path);
|
|
437
|
+
if (!details.isFile()) {
|
|
438
|
+
await rm(path, { force: true });
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
const bytes = await readFile(path);
|
|
442
|
+
if (bytes.byteLength > maximumBytes || !verifyNpmArtifactBytes(bytes, integrity)) {
|
|
443
|
+
await rm(path, { force: true });
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return bytes;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const writeVerifiedCache = async (path, chunks, integrity, maximumBytes, signal) => {
|
|
453
|
+
const temporary = `${path}.${randomUUID()}.part`;
|
|
454
|
+
const expected = expectedIntegrity(integrity);
|
|
455
|
+
if (expected === undefined)
|
|
456
|
+
throw new Error("unsupported artifact integrity");
|
|
457
|
+
let handle;
|
|
458
|
+
let bytes = 0;
|
|
459
|
+
const hash = createHash(expected.algorithm);
|
|
460
|
+
try {
|
|
461
|
+
handle = await open(temporary, "wx", 0o600);
|
|
462
|
+
for await (const chunk of chunks) {
|
|
463
|
+
if (signal?.aborted === true)
|
|
464
|
+
throw new Error("artifact download interrupted");
|
|
465
|
+
const value = Buffer.from(chunk);
|
|
466
|
+
bytes += value.byteLength;
|
|
467
|
+
if (bytes > maximumBytes) {
|
|
468
|
+
throw new Error("artifact response exceeds the size limit");
|
|
469
|
+
}
|
|
470
|
+
hash.update(value);
|
|
471
|
+
await handle.write(value);
|
|
472
|
+
}
|
|
473
|
+
if (signal?.aborted === true)
|
|
474
|
+
throw new Error("artifact download interrupted");
|
|
475
|
+
if (!hash.digest().equals(expected.digest)) {
|
|
476
|
+
throw new Error("artifact integrity mismatch");
|
|
477
|
+
}
|
|
478
|
+
await handle.sync();
|
|
479
|
+
await handle.close();
|
|
480
|
+
handle = undefined;
|
|
481
|
+
await rename(temporary, path);
|
|
482
|
+
return bytes;
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
if (handle !== undefined)
|
|
486
|
+
await handle.close().catch(() => undefined);
|
|
487
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const acquireCacheLock = async (path, timeoutMilliseconds) => open(cacheLockPathFor(path), "wx", 0o600).catch(async (cause) => {
|
|
491
|
+
if (cause.code !== "EEXIST")
|
|
492
|
+
throw cause;
|
|
493
|
+
const details = await lstat(cacheLockPathFor(path)).catch(() => {
|
|
494
|
+
throw cause;
|
|
495
|
+
});
|
|
496
|
+
if (Date.now() - details.mtimeMs <= timeoutMilliseconds)
|
|
497
|
+
throw cause;
|
|
498
|
+
await rm(cacheLockPathFor(path), { force: true });
|
|
499
|
+
return await open(cacheLockPathFor(path), "wx", 0o600);
|
|
500
|
+
});
|
|
501
|
+
const releaseCacheLock = async (path, handle) => {
|
|
502
|
+
await handle.close().catch(() => undefined);
|
|
503
|
+
await rm(cacheLockPathFor(path), { force: true }).catch(() => undefined);
|
|
504
|
+
};
|
|
505
|
+
const incomingMessageRequest = (source, options) => new Promise((resolveResponse, rejectResponse) => {
|
|
506
|
+
let settled = false;
|
|
507
|
+
const request = httpsRequest(source, {
|
|
508
|
+
method: "GET",
|
|
509
|
+
headers: {
|
|
510
|
+
accept: "application/octet-stream",
|
|
511
|
+
"accept-encoding": "identity",
|
|
512
|
+
},
|
|
513
|
+
}, (response) => {
|
|
514
|
+
settled = true;
|
|
515
|
+
resolveResponse({
|
|
516
|
+
statusCode: response.statusCode ?? 500,
|
|
517
|
+
headers: response.headers,
|
|
518
|
+
// SAFETY: IncomingMessage is Node's async iterable readable response;
|
|
519
|
+
// each yielded Buffer is a Uint8Array consumed by the bounded writer.
|
|
520
|
+
body: response,
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
const fail = (cause) => {
|
|
524
|
+
if (settled)
|
|
525
|
+
return;
|
|
526
|
+
settled = true;
|
|
527
|
+
rejectResponse(cause instanceof Error ? cause : new Error(String(cause)));
|
|
528
|
+
};
|
|
529
|
+
const abort = () => {
|
|
530
|
+
request.destroy(new Error("artifact download interrupted"));
|
|
531
|
+
};
|
|
532
|
+
if (options.signal?.aborted === true) {
|
|
533
|
+
fail(new Error("artifact download interrupted"));
|
|
534
|
+
request.destroy();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
538
|
+
request.setTimeout(options.timeoutMilliseconds, () => {
|
|
539
|
+
request.destroy(new Error("artifact request timed out"));
|
|
540
|
+
});
|
|
541
|
+
request.once("error", fail);
|
|
542
|
+
request.once("close", () => options.signal?.removeEventListener("abort", abort));
|
|
543
|
+
request.end();
|
|
544
|
+
});
|
|
545
|
+
const downloadWithRequest = (request, input) => Effect.gen(function* () {
|
|
546
|
+
const invalid = validateInput(input);
|
|
547
|
+
if (invalid !== undefined) {
|
|
548
|
+
return yield* new NpmArtifactError({
|
|
549
|
+
operation: "validate npm artifact",
|
|
550
|
+
message: invalid,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const maximumBytes = input.maximumBytes ?? defaultMaximumBytes;
|
|
554
|
+
const timeoutMilliseconds = input.timeoutMilliseconds ?? defaultTimeoutMilliseconds;
|
|
555
|
+
if (!Number.isSafeInteger(maximumBytes)
|
|
556
|
+
|| maximumBytes <= 0
|
|
557
|
+
|| !Number.isSafeInteger(timeoutMilliseconds)
|
|
558
|
+
|| timeoutMilliseconds <= 0) {
|
|
559
|
+
return yield* new NpmArtifactError({
|
|
560
|
+
operation: "validate npm artifact limits",
|
|
561
|
+
message: "artifact size and timeout limits must be positive safe integers",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const path = cachePathFor(input);
|
|
565
|
+
yield* Effect.tryPromise({
|
|
566
|
+
try: () => ensureCacheDirectory(input.cacheDirectory),
|
|
567
|
+
catch: (cause) => new NpmArtifactError({
|
|
568
|
+
operation: "create npm artifact cache",
|
|
569
|
+
message: filesystemMessage(cause),
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
const cached = yield* Effect.tryPromise({
|
|
573
|
+
try: () => readVerifiedCache(path, input.integrity, maximumBytes),
|
|
574
|
+
catch: (cause) => new NpmArtifactError({
|
|
575
|
+
operation: "read npm artifact cache",
|
|
576
|
+
message: filesystemMessage(cause),
|
|
577
|
+
}),
|
|
578
|
+
});
|
|
579
|
+
if (cached !== undefined) {
|
|
580
|
+
return {
|
|
581
|
+
path,
|
|
582
|
+
bytes: cached.byteLength,
|
|
583
|
+
integrity: input.integrity,
|
|
584
|
+
source: input.source,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const lockAttempt = yield* Effect.tryPromise({
|
|
588
|
+
try: () => acquireCacheLock(path, timeoutMilliseconds),
|
|
589
|
+
catch: (cause) => new NpmArtifactError({
|
|
590
|
+
operation: "lock npm artifact cache",
|
|
591
|
+
message: filesystemMessage(cause),
|
|
592
|
+
}),
|
|
593
|
+
}).pipe(Effect.match({
|
|
594
|
+
onFailure: (error) => ({ error }),
|
|
595
|
+
onSuccess: (handle) => ({ handle }),
|
|
596
|
+
}));
|
|
597
|
+
if ("error" in lockAttempt) {
|
|
598
|
+
const concurrent = yield* Effect.promise(() => readVerifiedCache(path, input.integrity, maximumBytes).catch(() => undefined));
|
|
599
|
+
if (concurrent !== undefined) {
|
|
600
|
+
return {
|
|
601
|
+
path,
|
|
602
|
+
bytes: concurrent.byteLength,
|
|
603
|
+
integrity: input.integrity,
|
|
604
|
+
source: input.source,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
return yield* new NpmArtifactError({
|
|
608
|
+
operation: "lock npm artifact cache",
|
|
609
|
+
message: "artifact cache is being written concurrently",
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const lock = lockAttempt.handle;
|
|
613
|
+
const lockedDownload = Effect.gen(function* () {
|
|
614
|
+
const lockedCached = yield* Effect.tryPromise({
|
|
615
|
+
try: () => readVerifiedCache(path, input.integrity, maximumBytes),
|
|
616
|
+
catch: (cause) => new NpmArtifactError({
|
|
617
|
+
operation: "read npm artifact cache",
|
|
618
|
+
message: filesystemMessage(cause),
|
|
619
|
+
}),
|
|
620
|
+
});
|
|
621
|
+
if (lockedCached !== undefined) {
|
|
622
|
+
return {
|
|
623
|
+
path,
|
|
624
|
+
bytes: lockedCached.byteLength,
|
|
625
|
+
integrity: input.integrity,
|
|
626
|
+
source: input.source,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const response = yield* Effect.tryPromise({
|
|
630
|
+
try: () => request(input.source, {
|
|
631
|
+
timeoutMilliseconds,
|
|
632
|
+
signal: input.signal,
|
|
633
|
+
}),
|
|
634
|
+
catch: (cause) => new NpmArtifactError({
|
|
635
|
+
operation: "request npm artifact",
|
|
636
|
+
message: cause instanceof Error
|
|
637
|
+
? cause.message.replace(/\s+/gu, " ").slice(0, 1024)
|
|
638
|
+
: "artifact request failed",
|
|
639
|
+
}),
|
|
640
|
+
});
|
|
641
|
+
if (response.statusCode !== 200) {
|
|
642
|
+
return yield* new NpmArtifactError({
|
|
643
|
+
operation: "request npm artifact",
|
|
644
|
+
message: response.statusCode >= 300 && response.statusCode < 400
|
|
645
|
+
? "artifact redirects are not followed"
|
|
646
|
+
: `artifact request returned status ${response.statusCode}`,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
const written = yield* Effect.tryPromise({
|
|
650
|
+
try: () => writeVerifiedCache(path, response.body, input.integrity, maximumBytes, input.signal),
|
|
651
|
+
catch: (cause) => new NpmArtifactError({
|
|
652
|
+
operation: "verify and cache npm artifact",
|
|
653
|
+
message: cause instanceof Error
|
|
654
|
+
? cause.message.replace(/\s+/gu, " ").slice(0, 1024)
|
|
655
|
+
: "artifact could not be verified and cached",
|
|
656
|
+
}),
|
|
657
|
+
});
|
|
658
|
+
return {
|
|
659
|
+
path,
|
|
660
|
+
bytes: written,
|
|
661
|
+
integrity: input.integrity,
|
|
662
|
+
source: input.source,
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
return yield* lockedDownload.pipe(Effect.ensuring(Effect.promise(() => releaseCacheLock(path, lock))));
|
|
666
|
+
});
|
|
667
|
+
export const makeNpmArtifactTransport = (request = incomingMessageRequest) => ({
|
|
668
|
+
download: (input) => downloadWithRequest(request, input),
|
|
669
|
+
});
|
|
670
|
+
export const defaultNpmArtifactTransport = makeNpmArtifactTransport();
|