@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,369 @@
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 { readFile, realpath } from 'node:fs/promises';
10
+ import {
11
+ dirname,
12
+ isAbsolute,
13
+ join,
14
+ posix,
15
+ relative,
16
+ resolve,
17
+ sep,
18
+ } from 'node:path';
19
+ import { z } from 'zod';
20
+
21
+ import type {
22
+ RegradePackageSourceError,
23
+ RegradePackageSourceExpectation,
24
+ } from './package-source.js';
25
+
26
+ export interface PackageManifest {
27
+ readonly dependencies?: Readonly<Record<string, string>>;
28
+ readonly devDependencies?: Readonly<Record<string, string>>;
29
+ readonly name?: string;
30
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
31
+ readonly peerDependencies?: Readonly<Record<string, string>>;
32
+ readonly version?: string;
33
+ }
34
+
35
+ export const exactVersionPattern =
36
+ /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
37
+ const packageNamePattern = /^@ontrails\/[a-z0-9][a-z0-9._-]*$/u;
38
+ const sha256Pattern = /^[a-f0-9]{64}$/u;
39
+
40
+ export const regradePackageSourceExpectationSchema = z.discriminatedUnion(
41
+ 'kind',
42
+ [
43
+ z
44
+ .object({
45
+ kind: z.literal('published'),
46
+ name: z.string().regex(packageNamePattern),
47
+ version: z.string().regex(exactVersionPattern),
48
+ })
49
+ .strict(),
50
+ z
51
+ .object({
52
+ kind: z.literal('tarball'),
53
+ name: z.string().regex(packageNamePattern),
54
+ path: z.string().min(1),
55
+ sha256: z.string().regex(sha256Pattern),
56
+ })
57
+ .strict(),
58
+ ]
59
+ );
60
+
61
+ const isNormalizedTarballPath = (value: string): boolean => {
62
+ if (value.length === 0) {
63
+ return false;
64
+ }
65
+ const normalized = posix.normalize(value);
66
+ return normalized === value || `./${normalized}` === value;
67
+ };
68
+
69
+ const errorCause = (error: unknown): Error =>
70
+ error instanceof Error ? error : new Error(String(error));
71
+
72
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
73
+ typeof value === 'object' && value !== null && !Array.isArray(value);
74
+
75
+ const dependencyMapIsValid = (value: unknown): boolean =>
76
+ value === undefined ||
77
+ (isRecord(value) &&
78
+ Object.values(value).every((entry) => typeof entry === 'string'));
79
+
80
+ export const parsePackageManifest = (
81
+ bytes: string,
82
+ label: string
83
+ ): TrailsResult<PackageManifest, ValidationError> => {
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(bytes) as unknown;
87
+ } catch (error) {
88
+ return Result.err(
89
+ new ValidationError(`${label} package manifest is malformed.`, {
90
+ cause: errorCause(error),
91
+ })
92
+ );
93
+ }
94
+ if (
95
+ !isRecord(parsed) ||
96
+ (parsed['name'] !== undefined && typeof parsed['name'] !== 'string') ||
97
+ (parsed['version'] !== undefined &&
98
+ typeof parsed['version'] !== 'string') ||
99
+ !dependencyMapIsValid(parsed['dependencies']) ||
100
+ !dependencyMapIsValid(parsed['devDependencies']) ||
101
+ !dependencyMapIsValid(parsed['optionalDependencies']) ||
102
+ !dependencyMapIsValid(parsed['peerDependencies'])
103
+ ) {
104
+ return Result.err(
105
+ new ValidationError(`${label} package manifest has an invalid shape.`)
106
+ );
107
+ }
108
+ return Result.ok(parsed as PackageManifest);
109
+ };
110
+
111
+ const validateExpectation = (
112
+ expectation: RegradePackageSourceExpectation
113
+ ): TrailsResult<void, ValidationError> => {
114
+ if (!packageNamePattern.test(expectation.name)) {
115
+ return Result.err(
116
+ new ValidationError(
117
+ 'Regrade package-source proof requires one @ontrails package name.',
118
+ { context: { name: expectation.name } }
119
+ )
120
+ );
121
+ }
122
+ if (
123
+ expectation.kind === 'published' &&
124
+ !exactVersionPattern.test(expectation.version)
125
+ ) {
126
+ return Result.err(
127
+ new ValidationError(
128
+ 'Published Regrade package-source proof requires an exact version.',
129
+ { context: { version: expectation.version } }
130
+ )
131
+ );
132
+ }
133
+ if (
134
+ expectation.kind === 'tarball' &&
135
+ !sha256Pattern.test(expectation.sha256)
136
+ ) {
137
+ return Result.err(
138
+ new ValidationError(
139
+ 'Tarball Regrade package-source proof requires a lowercase SHA-256 digest.',
140
+ { context: { sha256: expectation.sha256 } }
141
+ )
142
+ );
143
+ }
144
+ if (
145
+ expectation.kind === 'tarball' &&
146
+ !isNormalizedTarballPath(expectation.path)
147
+ ) {
148
+ return Result.err(
149
+ new ValidationError(
150
+ 'Tarball Regrade package-source proof requires a normalized path.',
151
+ { context: { path: expectation.path } }
152
+ )
153
+ );
154
+ }
155
+ return Result.ok();
156
+ };
157
+
158
+ const directSpecifier = (
159
+ manifest: PackageManifest,
160
+ name: string
161
+ ): TrailsResult<string | undefined, ConflictError> => {
162
+ const declarations = [
163
+ manifest.dependencies?.[name],
164
+ manifest.optionalDependencies?.[name],
165
+ manifest.peerDependencies?.[name],
166
+ manifest.devDependencies?.[name],
167
+ ].filter((specifier): specifier is string => specifier !== undefined);
168
+ if (new Set(declarations).size > 1) {
169
+ return Result.err(
170
+ new ConflictError(
171
+ `Downstream manifest declares conflicting sources for "${name}".`,
172
+ { context: { declarations, name } }
173
+ )
174
+ );
175
+ }
176
+ return Result.ok(declarations[0]);
177
+ };
178
+
179
+ const normalizedTarballDeclaration = (
180
+ root: string,
181
+ specifier: string
182
+ ): string | undefined => {
183
+ if (!specifier.startsWith('file:')) {
184
+ return undefined;
185
+ }
186
+ const locator = specifier.slice('file:'.length);
187
+ if (!isNormalizedTarballPath(locator)) {
188
+ return undefined;
189
+ }
190
+ return resolve(root, locator);
191
+ };
192
+
193
+ const findPackageRoot = async (
194
+ entryPath: string | undefined,
195
+ expectedName: string,
196
+ root: string
197
+ ): Promise<string | undefined> => {
198
+ const resolvedEntryPath =
199
+ entryPath === undefined ? undefined : await realpath(entryPath);
200
+ let searchRoot = root;
201
+ while (true) {
202
+ const candidate = join(searchRoot, 'node_modules', expectedName);
203
+ try {
204
+ const resolvedCandidate = await realpath(candidate);
205
+ const entryRelative =
206
+ resolvedEntryPath === undefined
207
+ ? ''
208
+ : relative(resolvedCandidate, resolvedEntryPath);
209
+ if (
210
+ entryRelative !== '..' &&
211
+ !entryRelative.startsWith(`..${sep}`) &&
212
+ !isAbsolute(entryRelative)
213
+ ) {
214
+ return candidate;
215
+ }
216
+ } catch {
217
+ // Keep walking: dependency installations can be hoisted above the root.
218
+ }
219
+ const parent = dirname(searchRoot);
220
+ if (parent === searchRoot) {
221
+ break;
222
+ }
223
+ searchRoot = parent;
224
+ }
225
+ return undefined;
226
+ };
227
+
228
+ const resolveInstalledPackage = async (
229
+ root: string,
230
+ name: string
231
+ ): Promise<
232
+ TrailsResult<string, NotFoundError | InternalError | ValidationError>
233
+ > => {
234
+ let entryPath: string;
235
+ try {
236
+ entryPath = Bun.resolveSync(name, root);
237
+ } catch (error) {
238
+ try {
239
+ const packageRoot = await findPackageRoot(undefined, name, root);
240
+ if (packageRoot !== undefined) {
241
+ return Result.ok(packageRoot);
242
+ }
243
+ } catch {
244
+ // The normal missing-package error below owns absent direct installs.
245
+ }
246
+ return Result.err(
247
+ new NotFoundError(`Installed package "${name}" was not found.`, {
248
+ cause: errorCause(error),
249
+ context: { name, root },
250
+ })
251
+ );
252
+ }
253
+ try {
254
+ const packageRoot = await findPackageRoot(entryPath, name, root);
255
+ return packageRoot === undefined
256
+ ? Result.err(
257
+ new NotFoundError(
258
+ `Installed package root for "${name}" was not found.`,
259
+ {
260
+ context: { entryPath, name, root },
261
+ }
262
+ )
263
+ )
264
+ : Result.ok(packageRoot);
265
+ } catch (error) {
266
+ return Result.err(
267
+ new InternalError(`Installed package "${name}" could not be inspected.`, {
268
+ cause: errorCause(error),
269
+ context: { name, root },
270
+ })
271
+ );
272
+ }
273
+ };
274
+
275
+ export interface PreparedPackageSource {
276
+ readonly declaredSpecifier: string;
277
+ readonly expectedTarballPath: string | undefined;
278
+ readonly installedRoot: string;
279
+ readonly root: string;
280
+ }
281
+
282
+ export const preparePackageSource = async (params: {
283
+ readonly root: string;
284
+ readonly expected: RegradePackageSourceExpectation;
285
+ }): Promise<TrailsResult<PreparedPackageSource, RegradePackageSourceError>> => {
286
+ const expectation = validateExpectation(params.expected);
287
+ if (expectation.isErr()) {
288
+ return expectation;
289
+ }
290
+ const selectedRoot = resolve(params.root);
291
+ let root: string;
292
+ try {
293
+ root = await realpath(selectedRoot);
294
+ } catch (error) {
295
+ return Result.err(
296
+ new NotFoundError('Downstream package root was not found.', {
297
+ cause: errorCause(error),
298
+ context: { root: selectedRoot },
299
+ })
300
+ );
301
+ }
302
+ let rootManifestBytes: string;
303
+ try {
304
+ rootManifestBytes = await readFile(join(root, 'package.json'), 'utf8');
305
+ } catch (error) {
306
+ return Result.err(
307
+ new NotFoundError('Downstream package manifest was not found.', {
308
+ cause: errorCause(error),
309
+ context: { root },
310
+ })
311
+ );
312
+ }
313
+ const parsedRootManifest = parsePackageManifest(
314
+ rootManifestBytes,
315
+ 'Downstream'
316
+ );
317
+ if (parsedRootManifest.isErr()) {
318
+ return parsedRootManifest;
319
+ }
320
+ const declaredSpecifier = directSpecifier(
321
+ parsedRootManifest.value,
322
+ params.expected.name
323
+ );
324
+ if (declaredSpecifier.isErr()) {
325
+ return declaredSpecifier;
326
+ }
327
+ if (declaredSpecifier.value === undefined) {
328
+ return Result.err(
329
+ new NotFoundError(
330
+ `Downstream manifest does not directly declare "${params.expected.name}".`,
331
+ { context: { name: params.expected.name, root } }
332
+ )
333
+ );
334
+ }
335
+ const expectedTarballPath =
336
+ params.expected.kind === 'tarball'
337
+ ? resolve(root, params.expected.path)
338
+ : undefined;
339
+ const declarationMatches =
340
+ params.expected.kind === 'published'
341
+ ? declaredSpecifier.value === params.expected.version
342
+ : normalizedTarballDeclaration(root, declaredSpecifier.value) ===
343
+ expectedTarballPath;
344
+ if (!declarationMatches) {
345
+ return Result.err(
346
+ new ConflictError(
347
+ `Downstream declaration for "${params.expected.name}" does not match the selected source.`,
348
+ {
349
+ context: {
350
+ actual: declaredSpecifier.value,
351
+ expected: params.expected,
352
+ },
353
+ }
354
+ )
355
+ );
356
+ }
357
+ const installedRoot = await resolveInstalledPackage(
358
+ root,
359
+ params.expected.name
360
+ );
361
+ return installedRoot.isErr()
362
+ ? installedRoot
363
+ : Result.ok({
364
+ declaredSpecifier: declaredSpecifier.value,
365
+ expectedTarballPath,
366
+ installedRoot: installedRoot.value,
367
+ root,
368
+ });
369
+ };
@@ -0,0 +1,237 @@
1
+ import {
2
+ ConflictError,
3
+ InternalError,
4
+ Result,
5
+ ValidationError,
6
+ } from '@ontrails/core';
7
+ import type { Result as TrailsResult, NotFoundError } from '@ontrails/core';
8
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import {
13
+ defaultArtifactAcquirer,
14
+ extractPackageArtifact,
15
+ revalidateSelectedArtifact,
16
+ snapshotSelectedArtifact,
17
+ } from './package-source-artifact.js';
18
+ import type { PackageSourceArtifactAcquirer } from './package-source-artifact.js';
19
+ import {
20
+ collectRegularFiles,
21
+ compareInstalledFiles,
22
+ } from './package-source-files.js';
23
+ import {
24
+ exactVersionPattern,
25
+ parsePackageManifest,
26
+ preparePackageSource,
27
+ } from './package-source-manifest.js';
28
+ import type { PackageManifest } from './package-source-manifest.js';
29
+
30
+ const errorCause = (error: unknown): Error =>
31
+ error instanceof Error ? error : new Error(String(error));
32
+
33
+ export type RegradePackageSourceExpectation =
34
+ | {
35
+ readonly kind: 'published';
36
+ readonly name: string;
37
+ readonly version: string;
38
+ }
39
+ | {
40
+ readonly kind: 'tarball';
41
+ readonly name: string;
42
+ readonly path: string;
43
+ readonly sha256: string;
44
+ };
45
+
46
+ export interface RegradePackageSourceEvidence {
47
+ readonly kind: RegradePackageSourceExpectation['kind'];
48
+ readonly name: string;
49
+ readonly version: string;
50
+ readonly declaredSpecifier: string;
51
+ readonly resolvedPackagePath: string;
52
+ readonly artifactSha256: string;
53
+ readonly contentSha256: string;
54
+ }
55
+
56
+ export type RegradePackageSourceError =
57
+ | ConflictError
58
+ | InternalError
59
+ | NotFoundError
60
+ | ValidationError;
61
+
62
+ /**
63
+ * Test seam for deterministic published-artifact fixtures.
64
+ *
65
+ * @internal
66
+ */
67
+ export const verifyDownstreamPackageSourceWithAcquirer = async (
68
+ params: {
69
+ readonly root: string;
70
+ readonly expected: RegradePackageSourceExpectation;
71
+ },
72
+ acquirer: PackageSourceArtifactAcquirer
73
+ ): Promise<
74
+ TrailsResult<RegradePackageSourceEvidence, RegradePackageSourceError>
75
+ > => {
76
+ const prepared = await preparePackageSource(params);
77
+ if (prepared.isErr()) {
78
+ return prepared;
79
+ }
80
+ let tempRoot: string;
81
+ try {
82
+ tempRoot = await mkdtemp(join(tmpdir(), 'trails-regrade-package-source-'));
83
+ } catch (error) {
84
+ return Result.err(
85
+ new InternalError(
86
+ 'Package source proof workspace could not be created.',
87
+ {
88
+ cause: errorCause(error),
89
+ }
90
+ )
91
+ );
92
+ }
93
+ try {
94
+ const artifact = await snapshotSelectedArtifact({
95
+ acquirer,
96
+ expected: params.expected,
97
+ expectedTarballPath: prepared.value.expectedTarballPath,
98
+ root: prepared.value.root,
99
+ tempRoot,
100
+ });
101
+ if (artifact.isErr()) {
102
+ return artifact;
103
+ }
104
+ const extracted = await extractPackageArtifact(
105
+ artifact.value.snapshotPath,
106
+ join(tempRoot, 'extracted')
107
+ );
108
+ if (extracted.isErr()) {
109
+ return extracted;
110
+ }
111
+ const revalidated = await revalidateSelectedArtifact(artifact.value);
112
+ if (revalidated.isErr()) {
113
+ return revalidated;
114
+ }
115
+ let artifactManifest: PackageManifest;
116
+ let installedManifest: PackageManifest;
117
+ try {
118
+ const [artifactManifestBytes, installedManifestBytes] = await Promise.all(
119
+ [
120
+ readFile(join(extracted.value, 'package.json'), 'utf8'),
121
+ readFile(join(prepared.value.installedRoot, 'package.json'), 'utf8'),
122
+ ]
123
+ );
124
+ const artifactParsed = parsePackageManifest(
125
+ artifactManifestBytes,
126
+ 'Selected artifact'
127
+ );
128
+ if (artifactParsed.isErr()) {
129
+ return artifactParsed;
130
+ }
131
+ const installedParsed = parsePackageManifest(
132
+ installedManifestBytes,
133
+ 'Installed'
134
+ );
135
+ if (installedParsed.isErr()) {
136
+ return installedParsed;
137
+ }
138
+ artifactManifest = artifactParsed.value;
139
+ installedManifest = installedParsed.value;
140
+ } catch (error) {
141
+ return Result.err(
142
+ new ValidationError(
143
+ 'Package source manifest is malformed or missing.',
144
+ {
145
+ cause: errorCause(error),
146
+ }
147
+ )
148
+ );
149
+ }
150
+ const selectedVersion =
151
+ params.expected.kind === 'published'
152
+ ? params.expected.version
153
+ : artifactManifest.version;
154
+ if (
155
+ artifactManifest.name !== params.expected.name ||
156
+ artifactManifest.version !== selectedVersion ||
157
+ installedManifest.name !== params.expected.name ||
158
+ installedManifest.version !== selectedVersion
159
+ ) {
160
+ return Result.err(
161
+ new ConflictError('Package source name or version does not match.', {
162
+ context: {
163
+ artifact: {
164
+ name: artifactManifest.name,
165
+ version: artifactManifest.version,
166
+ },
167
+ installed: {
168
+ name: installedManifest.name,
169
+ version: installedManifest.version,
170
+ },
171
+ selected: { name: params.expected.name, version: selectedVersion },
172
+ },
173
+ })
174
+ );
175
+ }
176
+ if (
177
+ selectedVersion === undefined ||
178
+ !exactVersionPattern.test(selectedVersion)
179
+ ) {
180
+ return Result.err(
181
+ new ValidationError('Selected package artifact has no exact version.')
182
+ );
183
+ }
184
+ const artifactFiles = await collectRegularFiles(extracted.value);
185
+ if (artifactFiles.isErr()) {
186
+ return artifactFiles;
187
+ }
188
+ const matched = await compareInstalledFiles(
189
+ artifactFiles.value,
190
+ prepared.value.installedRoot
191
+ );
192
+ if (matched.isErr()) {
193
+ return matched;
194
+ }
195
+ return Result.ok({
196
+ artifactSha256: artifact.value.artifactSha256,
197
+ contentSha256: matched.value,
198
+ declaredSpecifier: prepared.value.declaredSpecifier,
199
+ kind: params.expected.kind,
200
+ name: params.expected.name,
201
+ resolvedPackagePath: prepared.value.installedRoot,
202
+ version: selectedVersion,
203
+ });
204
+ } catch (error) {
205
+ return Result.err(
206
+ new InternalError('Package source proof failed unexpectedly.', {
207
+ cause: errorCause(error),
208
+ })
209
+ );
210
+ } finally {
211
+ try {
212
+ await rm(tempRoot, { force: true, recursive: true });
213
+ } catch {
214
+ // Cleanup must not replace the proof result or its specific error.
215
+ }
216
+ }
217
+ };
218
+
219
+ /**
220
+ * Prove that one installed downstream package matches an explicitly selected
221
+ * published or local tarball artifact, without changing the target project.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * const proof = await verifyDownstreamPackageSource({
226
+ * expected: { kind: 'published', name: '@ontrails/core', version: '0.2.0' },
227
+ * root: process.cwd(),
228
+ * });
229
+ * if (proof.isErr()) throw proof.error;
230
+ * ```
231
+ */
232
+ export const verifyDownstreamPackageSource = async (params: {
233
+ readonly root: string;
234
+ readonly expected: RegradePackageSourceExpectation;
235
+ }): Promise<
236
+ TrailsResult<RegradePackageSourceEvidence, RegradePackageSourceError>
237
+ > => verifyDownstreamPackageSourceWithAcquirer(params, defaultArtifactAcquirer);