@ontrails/regrade 0.2.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.
@@ -0,0 +1,375 @@
1
+ import {
2
+ ConflictError,
3
+ InternalError,
4
+ NotFoundError,
5
+ Result,
6
+ ValidationError,
7
+ } from '@ontrails/core';
8
+ import type { Result as TrailsResult } from '@ontrails/core';
9
+ import { createHash } from 'node:crypto';
10
+ import { once } from 'node:events';
11
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
+ import { basename, isAbsolute, join } from 'node:path';
13
+ import { extract, Parser } from 'tar';
14
+ import type { ReadEntry } from 'tar';
15
+
16
+ import type {
17
+ RegradePackageSourceError,
18
+ RegradePackageSourceExpectation,
19
+ } from './package-source.js';
20
+
21
+ const errorCause = (error: unknown): Error =>
22
+ error instanceof Error ? error : new Error(String(error));
23
+
24
+ const sha256 = (bytes: Uint8Array | string): string =>
25
+ createHash('sha256').update(bytes).digest('hex');
26
+
27
+ export interface PackageSourceArtifactAcquirer {
28
+ readonly acquirePublished: (params: {
29
+ readonly destination: string;
30
+ readonly name: string;
31
+ readonly root: string;
32
+ readonly version: string;
33
+ }) => Promise<TrailsResult<string, RegradePackageSourceError>>;
34
+ }
35
+
36
+ const runProcess = async (
37
+ command: readonly string[],
38
+ cwd?: string
39
+ ): Promise<TrailsResult<string, InternalError>> => {
40
+ try {
41
+ const proc = Bun.spawn(command as string[], {
42
+ ...(cwd === undefined ? {} : { cwd }),
43
+ stderr: 'pipe',
44
+ stdout: 'pipe',
45
+ });
46
+ const [exitCode, stdout, stderr] = await Promise.all([
47
+ proc.exited,
48
+ new Response(proc.stdout).text(),
49
+ new Response(proc.stderr).text(),
50
+ ]);
51
+ if (exitCode !== 0) {
52
+ return Result.err(
53
+ new InternalError(`Package artifact command failed: ${command[0]}`, {
54
+ context: { command, exitCode, stderr: stderr.trim() },
55
+ })
56
+ );
57
+ }
58
+ return Result.ok(stdout);
59
+ } catch (error) {
60
+ return Result.err(
61
+ new InternalError(`Package artifact command failed: ${command[0]}`, {
62
+ cause: errorCause(error),
63
+ context: { command },
64
+ })
65
+ );
66
+ }
67
+ };
68
+
69
+ export const defaultArtifactAcquirer: PackageSourceArtifactAcquirer = {
70
+ acquirePublished: async ({ destination, name, root, version }) => {
71
+ const packed = await runProcess(
72
+ [
73
+ 'npm',
74
+ 'pack',
75
+ '--ignore-scripts',
76
+ '--json',
77
+ '--pack-destination',
78
+ destination,
79
+ `${name}@${version}`,
80
+ ],
81
+ root
82
+ );
83
+ if (packed.isErr()) {
84
+ const stderr = packed.error.context?.['stderr'];
85
+ return typeof stderr === 'string' &&
86
+ /(?:E404|ETARGET|no matching version found|not found)/iu.test(stderr)
87
+ ? Result.err(
88
+ new NotFoundError(
89
+ `Published package ${name}@${version} was not found.`,
90
+ {
91
+ cause: packed.error,
92
+ }
93
+ )
94
+ )
95
+ : packed;
96
+ }
97
+ try {
98
+ const parsed = JSON.parse(packed.value) as readonly {
99
+ filename?: string;
100
+ }[];
101
+ const filename = parsed[0]?.filename;
102
+ if (filename === undefined || filename !== basename(filename)) {
103
+ return Result.err(
104
+ new InternalError(
105
+ `npm pack returned no safe artifact name for ${name}@${version}.`
106
+ )
107
+ );
108
+ }
109
+ return Result.ok(join(destination, filename));
110
+ } catch (error) {
111
+ return Result.err(
112
+ new InternalError(
113
+ `npm pack returned invalid output for ${name}@${version}.`,
114
+ {
115
+ cause: errorCause(error),
116
+ }
117
+ )
118
+ );
119
+ }
120
+ },
121
+ };
122
+
123
+ const windowsDeviceNamePattern =
124
+ /^(?:aux|con|nul|prn|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\.|$)/iu;
125
+
126
+ const archiveSegmentIsPortable = (segment: string): boolean =>
127
+ !/[<>:"\\|?*]/u.test(segment) &&
128
+ ![...segment].some(
129
+ (character) => (character.codePointAt(0) ?? Number.POSITIVE_INFINITY) <= 31
130
+ ) &&
131
+ !/[ .]$/u.test(segment) &&
132
+ !windowsDeviceNamePattern.test(segment);
133
+
134
+ const archivePathIsSafe = (path: string): boolean =>
135
+ path.startsWith('package/') &&
136
+ !path.includes('\\') &&
137
+ !path.includes('\0') &&
138
+ !isAbsolute(path) &&
139
+ path
140
+ .replace(/\/$/u, '')
141
+ .split('/')
142
+ .every(
143
+ (segment) =>
144
+ segment !== '.' &&
145
+ segment !== '..' &&
146
+ segment !== '' &&
147
+ archiveSegmentIsPortable(segment)
148
+ );
149
+
150
+ const archiveRawHeaderPathIsPortable = (path: string): boolean =>
151
+ !path.includes('\\') && !path.includes('\0');
152
+
153
+ // Default Unicode mappings make archive collisions visible without depending
154
+ // on the host filesystem's case behavior or locale.
155
+ const portableCaseCollisionKey = (value: string): string =>
156
+ value.normalize('NFC').toLowerCase().toUpperCase().normalize('NFC');
157
+
158
+ const archivePathsArePortable = (paths: readonly string[]): boolean => {
159
+ const exactPaths = new Set<string>();
160
+ const foldedPrefixes = new Map<string, string>();
161
+ for (const path of paths) {
162
+ const normalizedPath = path.replace(/\/$/u, '').normalize('NFC');
163
+ if (exactPaths.has(normalizedPath)) {
164
+ return false;
165
+ }
166
+ exactPaths.add(normalizedPath);
167
+ const segments = normalizedPath.split('/');
168
+ for (let index = 0; index < segments.length; index += 1) {
169
+ const exactPrefix = segments.slice(0, index + 1).join('/');
170
+ const foldedPrefix = portableCaseCollisionKey(exactPrefix);
171
+ const prior = foldedPrefixes.get(foldedPrefix);
172
+ if (prior !== undefined && prior !== exactPrefix) {
173
+ return false;
174
+ }
175
+ foldedPrefixes.set(foldedPrefix, exactPrefix);
176
+ }
177
+ }
178
+ return true;
179
+ };
180
+
181
+ interface ArchiveEntry {
182
+ readonly path: string;
183
+ readonly rawHeaderPath: string | undefined;
184
+ readonly type: string;
185
+ }
186
+
187
+ const readArchiveEntries = async (
188
+ tarballPath: string
189
+ ): Promise<TrailsResult<readonly ArchiveEntry[], ValidationError>> => {
190
+ const entries: ArchiveEntry[] = [];
191
+ try {
192
+ const recordEntry = (entry: ReadEntry): void => {
193
+ entries.push({
194
+ path: entry.path,
195
+ rawHeaderPath: entry.header.path,
196
+ type: entry.type,
197
+ });
198
+ entry.resume();
199
+ };
200
+ // Parser exposes entries that the higher-level list command omits, so an
201
+ // unsupported member type cannot disappear from the admission check.
202
+ const parser = new Parser({ onReadEntry: recordEntry, strict: true });
203
+ parser.on('ignoredEntry', recordEntry);
204
+ const artifact = await readFile(tarballPath);
205
+ const finished = once(parser, 'end');
206
+ parser.end(artifact);
207
+ await finished;
208
+ return Result.ok(entries);
209
+ } catch (error) {
210
+ return Result.err(
211
+ new ValidationError('Selected package artifact is malformed.', {
212
+ cause: errorCause(error),
213
+ context: { tarballPath },
214
+ })
215
+ );
216
+ }
217
+ };
218
+
219
+ export const extractPackageArtifact = async (
220
+ tarballPath: string,
221
+ destination: string
222
+ ): Promise<TrailsResult<string, RegradePackageSourceError>> => {
223
+ const archiveEntries = await readArchiveEntries(tarballPath);
224
+ if (archiveEntries.isErr()) {
225
+ return archiveEntries;
226
+ }
227
+ const entries = archiveEntries.value.map((entry) => entry.path);
228
+ if (
229
+ entries.length === 0 ||
230
+ archiveEntries.value.some(
231
+ (entry) =>
232
+ !archivePathIsSafe(entry.path) ||
233
+ (entry.rawHeaderPath !== undefined &&
234
+ !archiveRawHeaderPathIsPortable(entry.rawHeaderPath))
235
+ ) ||
236
+ !archivePathsArePortable(entries)
237
+ ) {
238
+ return Result.err(
239
+ new ValidationError(
240
+ 'Package artifact contains an unsafe or filesystem-equivalent archive path.',
241
+ { context: { tarballPath } }
242
+ )
243
+ );
244
+ }
245
+ if (
246
+ archiveEntries.value.some(
247
+ (entry) =>
248
+ entry.type !== 'File' &&
249
+ entry.type !== 'OldFile' &&
250
+ entry.type !== 'Directory'
251
+ )
252
+ ) {
253
+ return Result.err(
254
+ new ValidationError(
255
+ 'Package artifact may contain only regular files and directories.',
256
+ { context: { tarballPath } }
257
+ )
258
+ );
259
+ }
260
+ await mkdir(destination, { recursive: true });
261
+ try {
262
+ await extract({
263
+ cwd: destination,
264
+ file: tarballPath,
265
+ noChmod: true,
266
+ preserveOwner: false,
267
+ strict: true,
268
+ });
269
+ return Result.ok(join(destination, 'package'));
270
+ } catch (error) {
271
+ return Result.err(
272
+ new ValidationError('Selected package artifact is malformed.', {
273
+ cause: errorCause(error),
274
+ context: { tarballPath },
275
+ })
276
+ );
277
+ }
278
+ };
279
+
280
+ export interface SelectedArtifactSnapshot {
281
+ readonly artifactSha256: string;
282
+ readonly originalPath: string;
283
+ readonly snapshotPath: string;
284
+ }
285
+
286
+ export const revalidateSelectedArtifact = async (
287
+ artifact: SelectedArtifactSnapshot
288
+ ): Promise<TrailsResult<void, ConflictError | InternalError>> => {
289
+ try {
290
+ const currentArtifactSha256 = sha256(await readFile(artifact.originalPath));
291
+ return currentArtifactSha256 === artifact.artifactSha256
292
+ ? Result.ok()
293
+ : Result.err(
294
+ new ConflictError('Selected package artifact changed during proof.', {
295
+ context: {
296
+ actual: currentArtifactSha256,
297
+ expected: artifact.artifactSha256,
298
+ },
299
+ })
300
+ );
301
+ } catch (error) {
302
+ return Result.err(
303
+ new InternalError('Selected package artifact could not be revalidated.', {
304
+ cause: errorCause(error),
305
+ context: { path: artifact.originalPath },
306
+ })
307
+ );
308
+ }
309
+ };
310
+
311
+ export const snapshotSelectedArtifact = async (params: {
312
+ readonly acquirer: PackageSourceArtifactAcquirer;
313
+ readonly expected: RegradePackageSourceExpectation;
314
+ readonly expectedTarballPath: string | undefined;
315
+ readonly root: string;
316
+ readonly tempRoot: string;
317
+ }): Promise<
318
+ TrailsResult<SelectedArtifactSnapshot, RegradePackageSourceError>
319
+ > => {
320
+ let selected: TrailsResult<string, RegradePackageSourceError>;
321
+ if (params.expected.kind === 'published') {
322
+ selected = await params.acquirer.acquirePublished({
323
+ destination: params.tempRoot,
324
+ name: params.expected.name,
325
+ root: params.root,
326
+ version: params.expected.version,
327
+ });
328
+ } else if (params.expectedTarballPath === undefined) {
329
+ selected = Result.err(
330
+ new InternalError('Tarball package-source path was not resolved.')
331
+ );
332
+ } else {
333
+ selected = Result.ok(params.expectedTarballPath);
334
+ }
335
+ if (selected.isErr()) {
336
+ return selected;
337
+ }
338
+ let tarballBytes: Uint8Array;
339
+ try {
340
+ tarballBytes = await readFile(selected.value);
341
+ } catch (error) {
342
+ return Result.err(
343
+ new NotFoundError('Selected package artifact was not found.', {
344
+ cause: errorCause(error),
345
+ context: { path: selected.value },
346
+ })
347
+ );
348
+ }
349
+ const artifactSha256 = sha256(tarballBytes);
350
+ if (
351
+ params.expected.kind === 'tarball' &&
352
+ artifactSha256 !== params.expected.sha256
353
+ ) {
354
+ return Result.err(
355
+ new ConflictError('Selected package artifact hash does not match.', {
356
+ context: { actual: artifactSha256, expected: params.expected.sha256 },
357
+ })
358
+ );
359
+ }
360
+ const snapshotPath = join(params.tempRoot, 'selected-package.tgz');
361
+ try {
362
+ await writeFile(snapshotPath, tarballBytes, { flag: 'wx', mode: 0o600 });
363
+ } catch (error) {
364
+ return Result.err(
365
+ new InternalError('Selected package artifact could not be snapshotted.', {
366
+ cause: errorCause(error),
367
+ })
368
+ );
369
+ }
370
+ return Result.ok({
371
+ artifactSha256,
372
+ originalPath: selected.value,
373
+ snapshotPath,
374
+ });
375
+ };
@@ -0,0 +1,195 @@
1
+ import {
2
+ ConflictError,
3
+ InternalError,
4
+ Result,
5
+ ValidationError,
6
+ } from '@ontrails/core';
7
+ import type { Result as TrailsResult } from '@ontrails/core';
8
+ import { createHash } from 'node:crypto';
9
+ import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
10
+ import { isAbsolute, join, relative, sep } from 'node:path';
11
+
12
+ const errorCause = (error: unknown): Error =>
13
+ error instanceof Error ? error : new Error(String(error));
14
+
15
+ interface FileDigestEntry {
16
+ readonly bytes: Uint8Array;
17
+ readonly path: string;
18
+ }
19
+
20
+ const compareCodeUnits = (left: string, right: string): number => {
21
+ if (left < right) {
22
+ return -1;
23
+ }
24
+ if (left > right) {
25
+ return 1;
26
+ }
27
+ return 0;
28
+ };
29
+
30
+ export const collectRegularFiles = async (
31
+ root: string,
32
+ current = root
33
+ ): Promise<
34
+ TrailsResult<readonly FileDigestEntry[], ValidationError | InternalError>
35
+ > => {
36
+ let entries;
37
+ try {
38
+ entries = await readdir(current, { withFileTypes: true });
39
+ } catch (error) {
40
+ return Result.err(
41
+ new InternalError('Package source files could not be enumerated.', {
42
+ cause: errorCause(error),
43
+ context: { current, root },
44
+ })
45
+ );
46
+ }
47
+ const files: FileDigestEntry[] = [];
48
+ for (const entry of entries.toSorted((left, right) =>
49
+ compareCodeUnits(left.name, right.name)
50
+ )) {
51
+ const absolutePath = join(current, entry.name);
52
+ if (entry.isSymbolicLink()) {
53
+ return Result.err(
54
+ new ValidationError('Package source contains a symbolic link.', {
55
+ context: { path: relative(root, absolutePath) },
56
+ })
57
+ );
58
+ }
59
+ if (entry.isDirectory()) {
60
+ const nested = await collectRegularFiles(root, absolutePath);
61
+ if (nested.isErr()) {
62
+ return nested;
63
+ }
64
+ files.push(...nested.value);
65
+ continue;
66
+ }
67
+ if (!entry.isFile()) {
68
+ return Result.err(
69
+ new ValidationError('Package source contains a non-regular file.', {
70
+ context: { path: relative(root, absolutePath) },
71
+ })
72
+ );
73
+ }
74
+ try {
75
+ files.push({
76
+ bytes: await readFile(absolutePath),
77
+ path: relative(root, absolutePath).split(sep).join('/'),
78
+ });
79
+ } catch (error) {
80
+ return Result.err(
81
+ new InternalError('Package source file could not be read.', {
82
+ cause: errorCause(error),
83
+ context: { path: relative(root, absolutePath) },
84
+ })
85
+ );
86
+ }
87
+ }
88
+ return Result.ok(files);
89
+ };
90
+
91
+ const contentDigest = (files: readonly FileDigestEntry[]): string => {
92
+ const hash = createHash('sha256');
93
+ for (const file of files) {
94
+ hash.update(String(Buffer.byteLength(file.path)));
95
+ hash.update('\0');
96
+ hash.update(file.path);
97
+ hash.update('\0');
98
+ hash.update(String(file.bytes.byteLength));
99
+ hash.update('\0');
100
+ hash.update(file.bytes);
101
+ }
102
+ return hash.digest('hex');
103
+ };
104
+
105
+ export const compareInstalledFiles = async (
106
+ artifactFiles: readonly FileDigestEntry[],
107
+ installedRoot: string
108
+ ): Promise<
109
+ TrailsResult<string, ConflictError | InternalError | ValidationError>
110
+ > => {
111
+ for (const artifactFile of artifactFiles) {
112
+ const installedPath = join(installedRoot, artifactFile.path);
113
+ try {
114
+ const installedRealPath = await realpath(installedPath);
115
+ const installedRelative = relative(
116
+ await realpath(installedRoot),
117
+ installedRealPath
118
+ );
119
+ if (
120
+ installedRelative === '..' ||
121
+ installedRelative.startsWith(`..${sep}`) ||
122
+ isAbsolute(installedRelative)
123
+ ) {
124
+ return Result.err(
125
+ new ConflictError(
126
+ 'Installed package file resolves outside its package root.',
127
+ {
128
+ context: { path: artifactFile.path },
129
+ }
130
+ )
131
+ );
132
+ }
133
+ const status = await lstat(installedPath);
134
+ if (!status.isFile() || status.isSymbolicLink()) {
135
+ return Result.err(
136
+ new ConflictError('Installed package file is not a regular file.', {
137
+ context: { path: artifactFile.path },
138
+ })
139
+ );
140
+ }
141
+ const installedBytes = await readFile(installedPath);
142
+ if (
143
+ !Buffer.from(installedBytes).equals(Buffer.from(artifactFile.bytes))
144
+ ) {
145
+ return Result.err(
146
+ new ConflictError(
147
+ 'Installed package bytes do not match the selected artifact.',
148
+ {
149
+ context: { path: artifactFile.path },
150
+ }
151
+ )
152
+ );
153
+ }
154
+ } catch (error) {
155
+ return Result.err(
156
+ new ConflictError(
157
+ 'Installed package is missing a shipped artifact file.',
158
+ {
159
+ cause: errorCause(error),
160
+ context: { path: artifactFile.path },
161
+ }
162
+ )
163
+ );
164
+ }
165
+ }
166
+ const installedFiles = await collectRegularFiles(installedRoot);
167
+ if (installedFiles.isErr()) {
168
+ return installedFiles;
169
+ }
170
+ const artifactPaths = new Set(artifactFiles.map((file) => file.path));
171
+ const installedPaths = new Set(installedFiles.value.map((file) => file.path));
172
+ const missingInstalledFile = artifactFiles.find(
173
+ (file) => !installedPaths.has(file.path)
174
+ );
175
+ if (missingInstalledFile !== undefined) {
176
+ return Result.err(
177
+ new ConflictError(
178
+ 'Installed package is missing a shipped artifact file.',
179
+ { context: { path: missingInstalledFile.path } }
180
+ )
181
+ );
182
+ }
183
+ const extraInstalledFile = installedFiles.value.find(
184
+ (file) => !artifactPaths.has(file.path)
185
+ );
186
+ if (extraInstalledFile !== undefined) {
187
+ return Result.err(
188
+ new ConflictError(
189
+ 'Installed package contains a regular file not shipped by the selected artifact.',
190
+ { context: { path: extraInstalledFile.path } }
191
+ )
192
+ );
193
+ }
194
+ return Result.ok(contentDigest(artifactFiles));
195
+ };