@packall/core 0.0.1
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/dist/Archive.d.ts +32 -0
- package/dist/Archive.d.ts.map +1 -0
- package/dist/Bundle.d.ts +123 -0
- package/dist/Bundle.d.ts.map +1 -0
- package/dist/DependencyRange.d.ts +26 -0
- package/dist/DependencyRange.d.ts.map +1 -0
- package/dist/Download.d.ts +35 -0
- package/dist/Download.d.ts.map +1 -0
- package/dist/Errors.d.ts +124 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/InputFile.d.ts +46 -0
- package/dist/InputFile.d.ts.map +1 -0
- package/dist/Integrity.d.ts +48 -0
- package/dist/Integrity.d.ts.map +1 -0
- package/dist/Layout.d.ts +53 -0
- package/dist/Layout.d.ts.map +1 -0
- package/dist/Manifest.d.ts +82 -0
- package/dist/Manifest.d.ts.map +1 -0
- package/dist/Options.d.ts +100 -0
- package/dist/Options.d.ts.map +1 -0
- package/dist/Platform.d.ts +69 -0
- package/dist/Platform.d.ts.map +1 -0
- package/dist/Progress.d.ts +107 -0
- package/dist/Progress.d.ts.map +1 -0
- package/dist/Registry.d.ts +106 -0
- package/dist/Registry.d.ts.map +1 -0
- package/dist/Resolve.d.ts +105 -0
- package/dist/Resolve.d.ts.map +1 -0
- package/dist/Spec.d.ts +62 -0
- package/dist/Spec.d.ts.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1766 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/Archive.ts +92 -0
- package/src/Bundle.ts +570 -0
- package/src/DependencyRange.ts +115 -0
- package/src/Download.ts +132 -0
- package/src/Errors.ts +216 -0
- package/src/InputFile.ts +229 -0
- package/src/Integrity.ts +122 -0
- package/src/Layout.ts +103 -0
- package/src/Manifest.ts +133 -0
- package/src/Options.ts +123 -0
- package/src/Platform.ts +182 -0
- package/src/Progress.ts +106 -0
- package/src/Registry.ts +121 -0
- package/src/Resolve.ts +528 -0
- package/src/Spec.ts +186 -0
- package/src/index.ts +44 -0
package/src/Download.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching tarballs into the staging tree.
|
|
3
|
+
*
|
|
4
|
+
* Downloads run concurrently, every one is checksum-verified before it is
|
|
5
|
+
* written, and the destination is always the scoped temp directory — nothing
|
|
6
|
+
* lands next to the user's own files.
|
|
7
|
+
*/
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as FileSystem from "effect/FileSystem"
|
|
10
|
+
import * as Path from "effect/Path"
|
|
11
|
+
import type { PlatformError } from "effect/PlatformError"
|
|
12
|
+
import type { BundlerError } from "./Errors.js"
|
|
13
|
+
import { IntegrityError, OutputError } from "./Errors.js"
|
|
14
|
+
import * as Integrity from "./Integrity.js"
|
|
15
|
+
import { packagePath } from "./Layout.js"
|
|
16
|
+
import * as Progress from "./Progress.js"
|
|
17
|
+
import { Registry } from "./Registry.js"
|
|
18
|
+
import type { ResolvedPackage } from "./Resolve.js"
|
|
19
|
+
|
|
20
|
+
/** What a completed download run produced. */
|
|
21
|
+
export interface DownloadReport {
|
|
22
|
+
/** `name@version` -> bytes written. */
|
|
23
|
+
readonly sizes: ReadonlyMap<string, number>
|
|
24
|
+
/** Packages the registry advertised no usable checksum for. */
|
|
25
|
+
readonly unverified: ReadonlyArray<string>
|
|
26
|
+
readonly totalBytes: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Downloads every package into `destDir`, laid out the way a registry serves
|
|
31
|
+
* them.
|
|
32
|
+
*
|
|
33
|
+
* `verifyIntegrity` defaulting to on is deliberate: a corrupt tarball that
|
|
34
|
+
* makes it into a corporate registry is far more expensive than a failed run.
|
|
35
|
+
*/
|
|
36
|
+
export const downloadAll = (
|
|
37
|
+
packages: ReadonlyArray<ResolvedPackage>,
|
|
38
|
+
destDir: string,
|
|
39
|
+
options: { readonly concurrency: number; readonly verifyIntegrity: boolean }
|
|
40
|
+
): Effect.Effect<
|
|
41
|
+
DownloadReport,
|
|
42
|
+
BundlerError | PlatformError,
|
|
43
|
+
Registry | Progress.Progress | FileSystem.FileSystem | Path.Path
|
|
44
|
+
> =>
|
|
45
|
+
Effect.gen(function*() {
|
|
46
|
+
const fs = yield* FileSystem.FileSystem
|
|
47
|
+
const path = yield* Path.Path
|
|
48
|
+
const registry = yield* Registry
|
|
49
|
+
|
|
50
|
+
yield* Progress.emit({ _tag: "PhaseStarted", phase: "download", total: packages.length })
|
|
51
|
+
|
|
52
|
+
let completed = 0
|
|
53
|
+
const sizes = new Map<string, number>()
|
|
54
|
+
const unverified: Array<string> = []
|
|
55
|
+
|
|
56
|
+
const results = yield* Effect.forEach(
|
|
57
|
+
packages,
|
|
58
|
+
(pkg) =>
|
|
59
|
+
Effect.gen(function*() {
|
|
60
|
+
const key = `${pkg.name}@${pkg.version}`
|
|
61
|
+
|
|
62
|
+
yield* Progress.emit({
|
|
63
|
+
_tag: "DownloadStarted",
|
|
64
|
+
name: pkg.name,
|
|
65
|
+
version: pkg.version
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const bytes = yield* registry.download(pkg.manifest)
|
|
69
|
+
|
|
70
|
+
if (options.verifyIntegrity) {
|
|
71
|
+
const result = Integrity.verify(bytes, pkg.manifest.dist)
|
|
72
|
+
if (result._tag === "Mismatch") {
|
|
73
|
+
return yield* Effect.fail(
|
|
74
|
+
new IntegrityError(pkg.name, pkg.version, result.expected, result.actual)
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
if (result._tag === "Unverifiable") {
|
|
78
|
+
yield* Progress.emit({
|
|
79
|
+
_tag: "Warning",
|
|
80
|
+
message: `${key} could not be verified (${result.reason})`
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const verifiable =
|
|
86
|
+
(pkg.manifest.dist.integrity?.length ?? 0) > 0 ||
|
|
87
|
+
(pkg.manifest.dist.shasum?.length ?? 0) > 0
|
|
88
|
+
|
|
89
|
+
const relative = packagePath(pkg.name, pkg.version)
|
|
90
|
+
const target = path.join(destDir, ...relative.split("/"))
|
|
91
|
+
|
|
92
|
+
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(
|
|
93
|
+
Effect.mapError(
|
|
94
|
+
(cause) =>
|
|
95
|
+
new OutputError(path.dirname(target), "could not create directory", { cause })
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
yield* fs.writeFile(target, bytes).pipe(
|
|
99
|
+
Effect.mapError((cause) => new OutputError(target, "could not write tarball", { cause }))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// Counting here rather than inside the concurrent body would be
|
|
103
|
+
// wrong; each fiber increments as it finishes, which is what makes
|
|
104
|
+
// the progress counter monotonic.
|
|
105
|
+
completed += 1
|
|
106
|
+
yield* Progress.emit({
|
|
107
|
+
_tag: "DownloadCompleted",
|
|
108
|
+
name: pkg.name,
|
|
109
|
+
version: pkg.version,
|
|
110
|
+
bytes: bytes.byteLength,
|
|
111
|
+
completedCount: completed,
|
|
112
|
+
totalCount: packages.length
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return { key, bytes: bytes.byteLength, verifiable } as const
|
|
116
|
+
}),
|
|
117
|
+
{ concurrency: options.concurrency }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
for (const result of results) {
|
|
121
|
+
sizes.set(result.key, result.bytes)
|
|
122
|
+
if (!result.verifiable) unverified.push(result.key)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
yield* Progress.emit({ _tag: "PhaseCompleted", phase: "download" })
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
sizes,
|
|
129
|
+
unverified,
|
|
130
|
+
totalBytes: results.reduce((sum, r) => sum + r.bytes, 0)
|
|
131
|
+
}
|
|
132
|
+
})
|
package/src/Errors.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure types for the bundler.
|
|
3
|
+
*
|
|
4
|
+
* These are deliberately plain classes carrying a literal `_tag` rather than
|
|
5
|
+
* `Data.TaggedError`. They work with `Effect.catchTag` / `Effect.catchTags`
|
|
6
|
+
* exactly the same way, and they keep the public error surface independent of
|
|
7
|
+
* any single Effect release — useful while Effect v4 is still in beta.
|
|
8
|
+
*
|
|
9
|
+
* Every error carries enough context to be actionable without a stack trace,
|
|
10
|
+
* because the primary consumer is somebody staring at a terminal on a
|
|
11
|
+
* locked-down network trying to work out why a download did not happen.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Discriminant union of every failure the bundler can produce. */
|
|
15
|
+
export type BundlerError =
|
|
16
|
+
| InvalidSpecError
|
|
17
|
+
| InvalidInputFileError
|
|
18
|
+
| RegistryUnreachableError
|
|
19
|
+
| PackageNotFoundError
|
|
20
|
+
| VersionNotFoundError
|
|
21
|
+
| NoMatchingVersionsError
|
|
22
|
+
| RegistryResponseError
|
|
23
|
+
| AuthenticationError
|
|
24
|
+
| IntegrityError
|
|
25
|
+
| ArchiveError
|
|
26
|
+
| OutputError
|
|
27
|
+
|
|
28
|
+
abstract class BundlerErrorBase extends Error {
|
|
29
|
+
abstract readonly _tag: string
|
|
30
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
31
|
+
super(message, options as ErrorOptions)
|
|
32
|
+
this.name = new.target.name
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A package spec on the command line or in an input file could not be parsed. */
|
|
37
|
+
export class InvalidSpecError extends BundlerErrorBase {
|
|
38
|
+
readonly _tag = "InvalidSpecError" as const
|
|
39
|
+
readonly spec: string
|
|
40
|
+
readonly reason: string
|
|
41
|
+
constructor(spec: string, reason: string) {
|
|
42
|
+
super(`Invalid package spec ${JSON.stringify(spec)}: ${reason}`)
|
|
43
|
+
this.spec = spec
|
|
44
|
+
this.reason = reason
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `--file` pointed at something that is neither a usable package.json nor a spec list. */
|
|
49
|
+
export class InvalidInputFileError extends BundlerErrorBase {
|
|
50
|
+
readonly _tag = "InvalidInputFileError" as const
|
|
51
|
+
readonly path: string
|
|
52
|
+
readonly reason: string
|
|
53
|
+
constructor(path: string, reason: string, options?: { cause?: unknown }) {
|
|
54
|
+
super(`Cannot read specs from ${path}: ${reason}`, options)
|
|
55
|
+
this.path = path
|
|
56
|
+
this.reason = reason
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The registry could not be reached at all — DNS failure, refused connection,
|
|
62
|
+
* proxy blackhole, or a preflight that timed out.
|
|
63
|
+
*
|
|
64
|
+
* This is the error that fixes "hangs forever with no network": we surface it
|
|
65
|
+
* quickly and say which host we could not reach.
|
|
66
|
+
*/
|
|
67
|
+
export class RegistryUnreachableError extends BundlerErrorBase {
|
|
68
|
+
readonly _tag = "RegistryUnreachableError" as const
|
|
69
|
+
readonly registry: string
|
|
70
|
+
readonly timeoutMillis: number | undefined
|
|
71
|
+
constructor(
|
|
72
|
+
registry: string,
|
|
73
|
+
detail: string,
|
|
74
|
+
options?: { cause?: unknown; timeoutMillis?: number }
|
|
75
|
+
) {
|
|
76
|
+
super(
|
|
77
|
+
`Cannot reach registry ${registry}: ${detail}.\n` +
|
|
78
|
+
` Check your network connection, VPN, and the proxy settings in your .npmrc.`,
|
|
79
|
+
options
|
|
80
|
+
)
|
|
81
|
+
this.registry = registry
|
|
82
|
+
this.timeoutMillis = options?.timeoutMillis
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The registry answered, but has never heard of this package. */
|
|
87
|
+
export class PackageNotFoundError extends BundlerErrorBase {
|
|
88
|
+
readonly _tag = "PackageNotFoundError" as const
|
|
89
|
+
readonly packageName: string
|
|
90
|
+
readonly registry: string
|
|
91
|
+
constructor(packageName: string, registry: string) {
|
|
92
|
+
super(`Package "${packageName}" was not found on ${registry}`)
|
|
93
|
+
this.packageName = packageName
|
|
94
|
+
this.registry = registry
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The package exists but the exact version requested does not. */
|
|
99
|
+
export class VersionNotFoundError extends BundlerErrorBase {
|
|
100
|
+
readonly _tag = "VersionNotFoundError" as const
|
|
101
|
+
readonly packageName: string
|
|
102
|
+
readonly version: string
|
|
103
|
+
readonly available: ReadonlyArray<string>
|
|
104
|
+
constructor(packageName: string, version: string, available: ReadonlyArray<string>) {
|
|
105
|
+
const tail = available.slice(-5).join(", ")
|
|
106
|
+
super(
|
|
107
|
+
`${packageName}@${version} does not exist.` +
|
|
108
|
+
(tail.length > 0 ? ` Most recent published versions: ${tail}` : "")
|
|
109
|
+
)
|
|
110
|
+
this.packageName = packageName
|
|
111
|
+
this.version = version
|
|
112
|
+
this.available = available
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A range (or dist-tag) matched nothing that is actually published. */
|
|
117
|
+
export class NoMatchingVersionsError extends BundlerErrorBase {
|
|
118
|
+
readonly _tag = "NoMatchingVersionsError" as const
|
|
119
|
+
readonly packageName: string
|
|
120
|
+
readonly selector: string
|
|
121
|
+
readonly available: ReadonlyArray<string>
|
|
122
|
+
constructor(packageName: string, selector: string, available: ReadonlyArray<string>) {
|
|
123
|
+
const tail = available.slice(-5).join(", ")
|
|
124
|
+
super(
|
|
125
|
+
`No published version of ${packageName} satisfies "${selector}".` +
|
|
126
|
+
(tail.length > 0 ? ` Most recent published versions: ${tail}` : "")
|
|
127
|
+
)
|
|
128
|
+
this.packageName = packageName
|
|
129
|
+
this.selector = selector
|
|
130
|
+
this.available = available
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The registry responded, but with something we cannot use. */
|
|
135
|
+
export class RegistryResponseError extends BundlerErrorBase {
|
|
136
|
+
readonly _tag = "RegistryResponseError" as const
|
|
137
|
+
readonly url: string
|
|
138
|
+
readonly status: number | undefined
|
|
139
|
+
constructor(url: string, detail: string, options?: { cause?: unknown; status?: number }) {
|
|
140
|
+
super(`Unexpected response from ${url}: ${detail}`, options)
|
|
141
|
+
this.url = url
|
|
142
|
+
this.status = options?.status
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 401/403 — almost always a missing or stale token in `.npmrc`. */
|
|
147
|
+
export class AuthenticationError extends BundlerErrorBase {
|
|
148
|
+
readonly _tag = "AuthenticationError" as const
|
|
149
|
+
readonly registry: string
|
|
150
|
+
readonly status: number
|
|
151
|
+
constructor(registry: string, status: number, packageName?: string) {
|
|
152
|
+
super(
|
|
153
|
+
`Registry ${registry} rejected the request with HTTP ${status}` +
|
|
154
|
+
(packageName ? ` while fetching "${packageName}"` : "") +
|
|
155
|
+
`.\n Add credentials to your .npmrc, e.g.\n` +
|
|
156
|
+
` //${safeHost(registry)}/:_authToken=\${NPM_TOKEN}`
|
|
157
|
+
)
|
|
158
|
+
this.registry = registry
|
|
159
|
+
this.status = status
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A downloaded tarball did not match the checksum the registry advertised.
|
|
165
|
+
*
|
|
166
|
+
* Never soft-fail this: a bundle is a supply-chain artifact and a corrupt or
|
|
167
|
+
* substituted tarball is exactly what integrity checking exists to catch.
|
|
168
|
+
*/
|
|
169
|
+
export class IntegrityError extends BundlerErrorBase {
|
|
170
|
+
readonly _tag = "IntegrityError" as const
|
|
171
|
+
readonly packageName: string
|
|
172
|
+
readonly version: string
|
|
173
|
+
readonly expected: string
|
|
174
|
+
readonly actual: string
|
|
175
|
+
constructor(packageName: string, version: string, expected: string, actual: string) {
|
|
176
|
+
super(
|
|
177
|
+
`Integrity check failed for ${packageName}@${version}.\n` +
|
|
178
|
+
` expected: ${expected}\n` +
|
|
179
|
+
` actual: ${actual}\n` +
|
|
180
|
+
` The download was discarded. This is either corruption in transit or a tampered artifact.`
|
|
181
|
+
)
|
|
182
|
+
this.packageName = packageName
|
|
183
|
+
this.version = version
|
|
184
|
+
this.expected = expected
|
|
185
|
+
this.actual = actual
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Something went wrong writing the `.tgz`. */
|
|
190
|
+
export class ArchiveError extends BundlerErrorBase {
|
|
191
|
+
readonly _tag = "ArchiveError" as const
|
|
192
|
+
readonly path: string
|
|
193
|
+
constructor(path: string, detail: string, options?: { cause?: unknown }) {
|
|
194
|
+
super(`Failed to create archive ${path}: ${detail}`, options)
|
|
195
|
+
this.path = path
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Something went wrong preparing or writing to the output directory. */
|
|
200
|
+
export class OutputError extends BundlerErrorBase {
|
|
201
|
+
readonly _tag = "OutputError" as const
|
|
202
|
+
readonly path: string
|
|
203
|
+
constructor(path: string, detail: string, options?: { cause?: unknown }) {
|
|
204
|
+
super(`Output error at ${path}: ${detail}`, options)
|
|
205
|
+
this.path = path
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const safeHost = (registry: string): string => {
|
|
210
|
+
try {
|
|
211
|
+
const url = new URL(registry)
|
|
212
|
+
return url.host + url.pathname.replace(/\/+$/, "")
|
|
213
|
+
} catch {
|
|
214
|
+
return registry
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/InputFile.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--file` handling.
|
|
3
|
+
*
|
|
4
|
+
* One flag, two accepted shapes, detected from the content rather than the
|
|
5
|
+
* extension so that `--file deps.json` and `--file my-packages` both do the
|
|
6
|
+
* obvious thing:
|
|
7
|
+
*
|
|
8
|
+
* - a **package.json** — bundle everything it depends on
|
|
9
|
+
* - a **newline-delimited list** — one spec per line, `#` and `//` comments
|
|
10
|
+
* allowed, blank lines ignored
|
|
11
|
+
*/
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import * as FileSystem from "effect/FileSystem"
|
|
14
|
+
import { parseDependencyTarget } from "./DependencyRange.js"
|
|
15
|
+
import { InvalidInputFileError } from "./Errors.js"
|
|
16
|
+
import type { InvalidSpecError } from "./Errors.js"
|
|
17
|
+
import type { PackageSpec } from "./Spec.js"
|
|
18
|
+
import { parseSpecs } from "./Spec.js"
|
|
19
|
+
|
|
20
|
+
/** Which of the two shapes a file turned out to be. */
|
|
21
|
+
export type InputFileKind = "package.json" | "list"
|
|
22
|
+
|
|
23
|
+
export interface InputFileResult {
|
|
24
|
+
readonly kind: InputFileKind
|
|
25
|
+
readonly specs: ReadonlyArray<PackageSpec>
|
|
26
|
+
/** Entries that were recognised but cannot be bundled from a registry. */
|
|
27
|
+
readonly warnings: ReadonlyArray<string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Which dependency blocks of a package.json become root specs. */
|
|
31
|
+
export interface InputFileOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Include `devDependencies`.
|
|
34
|
+
*
|
|
35
|
+
* On by default: if you are pointing this tool at a package.json, you are
|
|
36
|
+
* almost certainly trying to make that project installable behind the
|
|
37
|
+
* firewall, and a project without its dev tooling does not build. `--prod`
|
|
38
|
+
* turns it off.
|
|
39
|
+
*/
|
|
40
|
+
readonly includeDev: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const defaultInputFileOptions: InputFileOptions = { includeDev: true }
|
|
44
|
+
|
|
45
|
+
/** Reads and parses an input file. */
|
|
46
|
+
export const readInputFile = (
|
|
47
|
+
filePath: string,
|
|
48
|
+
options: InputFileOptions = defaultInputFileOptions
|
|
49
|
+
): Effect.Effect<InputFileResult, InvalidInputFileError, FileSystem.FileSystem> =>
|
|
50
|
+
Effect.gen(function*() {
|
|
51
|
+
const fs = yield* FileSystem.FileSystem
|
|
52
|
+
|
|
53
|
+
const exists = yield* fs.exists(filePath).pipe(
|
|
54
|
+
Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))
|
|
55
|
+
)
|
|
56
|
+
if (!exists) {
|
|
57
|
+
return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const content = yield* fs.readFileString(filePath).pipe(
|
|
61
|
+
Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return yield* Effect.try({
|
|
65
|
+
try: () => parseInputFile(filePath, content, options),
|
|
66
|
+
catch: (error) =>
|
|
67
|
+
error instanceof InvalidInputFileError
|
|
68
|
+
? error
|
|
69
|
+
: new InvalidInputFileError(filePath, describe(error), { cause: error })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses file content that has already been read.
|
|
75
|
+
*
|
|
76
|
+
* Split out from the IO so the whole of this logic is testable with plain
|
|
77
|
+
* strings and no file system at all.
|
|
78
|
+
*/
|
|
79
|
+
export const parseInputFile = (
|
|
80
|
+
filePath: string,
|
|
81
|
+
content: string,
|
|
82
|
+
options: InputFileOptions = defaultInputFileOptions
|
|
83
|
+
): InputFileResult => {
|
|
84
|
+
const trimmed = content.trim()
|
|
85
|
+
if (trimmed.length === 0) {
|
|
86
|
+
throw new InvalidInputFileError(filePath, "file is empty")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return looksLikeJson(trimmed)
|
|
90
|
+
? fromPackageJson(filePath, trimmed, options)
|
|
91
|
+
: fromList(filePath, content)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const looksLikeJson = (trimmed: string): boolean => trimmed.startsWith("{")
|
|
95
|
+
|
|
96
|
+
/* -------------------------------------------------------------------------- */
|
|
97
|
+
/* package.json */
|
|
98
|
+
/* -------------------------------------------------------------------------- */
|
|
99
|
+
|
|
100
|
+
interface PackageJsonShape {
|
|
101
|
+
readonly dependencies?: Record<string, string>
|
|
102
|
+
readonly devDependencies?: Record<string, string>
|
|
103
|
+
readonly optionalDependencies?: Record<string, string>
|
|
104
|
+
readonly peerDependencies?: Record<string, string>
|
|
105
|
+
readonly peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fromPackageJson = (
|
|
109
|
+
filePath: string,
|
|
110
|
+
content: string,
|
|
111
|
+
options: InputFileOptions
|
|
112
|
+
): InputFileResult => {
|
|
113
|
+
let parsed: PackageJsonShape
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(content) as PackageJsonShape
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new InvalidInputFileError(filePath, `not valid JSON — ${describe(error)}`, {
|
|
118
|
+
cause: error
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const specs: Array<PackageSpec> = []
|
|
123
|
+
const warnings: Array<string> = []
|
|
124
|
+
const seen = new Set<string>()
|
|
125
|
+
|
|
126
|
+
const collect = (
|
|
127
|
+
entries: Record<string, string> | undefined,
|
|
128
|
+
label: string,
|
|
129
|
+
skip?: (name: string) => boolean
|
|
130
|
+
): void => {
|
|
131
|
+
if (entries === undefined) return
|
|
132
|
+
for (const [name, raw] of Object.entries(entries)) {
|
|
133
|
+
if (skip?.(name)) continue
|
|
134
|
+
const target = parseDependencyTarget(name, raw)
|
|
135
|
+
if (target._tag === "Unsupported") {
|
|
136
|
+
warnings.push(
|
|
137
|
+
`${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
const key = `${target.name}|${JSON.stringify(target.selector)}`
|
|
142
|
+
if (seen.has(key)) continue
|
|
143
|
+
seen.add(key)
|
|
144
|
+
specs.push({
|
|
145
|
+
name: target.name,
|
|
146
|
+
selector: target.selector,
|
|
147
|
+
raw: `${target.name}@${raw}`
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
collect(parsed.dependencies, "dependencies")
|
|
153
|
+
collect(parsed.optionalDependencies, "optionalDependencies")
|
|
154
|
+
if (options.includeDev) {
|
|
155
|
+
collect(parsed.devDependencies, "devDependencies")
|
|
156
|
+
}
|
|
157
|
+
const meta = parsed.peerDependenciesMeta ?? {}
|
|
158
|
+
collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true)
|
|
159
|
+
|
|
160
|
+
if (specs.length === 0) {
|
|
161
|
+
throw new InvalidInputFileError(
|
|
162
|
+
filePath,
|
|
163
|
+
options.includeDev
|
|
164
|
+
? "package.json declares no dependencies to bundle"
|
|
165
|
+
: "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)"
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { kind: "package.json", specs, warnings }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* -------------------------------------------------------------------------- */
|
|
173
|
+
/* Spec list */
|
|
174
|
+
/* -------------------------------------------------------------------------- */
|
|
175
|
+
|
|
176
|
+
const fromList = (filePath: string, content: string): InputFileResult => {
|
|
177
|
+
const lines: Array<string> = []
|
|
178
|
+
const rawLines = content.split(/\r?\n/)
|
|
179
|
+
|
|
180
|
+
for (const line of rawLines) {
|
|
181
|
+
const withoutComment = stripComment(line).trim()
|
|
182
|
+
if (withoutComment.length === 0) continue
|
|
183
|
+
lines.push(withoutComment)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (lines.length === 0) {
|
|
187
|
+
throw new InvalidInputFileError(filePath, "no package specs found (only blank lines and comments)")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { specs, errors } = parseSpecs(lines)
|
|
191
|
+
if (errors.length > 0) {
|
|
192
|
+
throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { kind: "list", specs, warnings: [] }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Strips `#` and `//` comments.
|
|
200
|
+
*
|
|
201
|
+
* `//` only counts when it is not part of a URL, so a future line containing
|
|
202
|
+
* `https://…` does not get silently truncated.
|
|
203
|
+
*/
|
|
204
|
+
const stripComment = (line: string): string => {
|
|
205
|
+
const hash = line.indexOf("#")
|
|
206
|
+
let result = hash === -1 ? line : line.slice(0, hash)
|
|
207
|
+
const slashes = result.indexOf("//")
|
|
208
|
+
if (slashes > 0 && result[slashes - 1] !== ":") {
|
|
209
|
+
result = result.slice(0, slashes)
|
|
210
|
+
} else if (slashes === 0) {
|
|
211
|
+
result = ""
|
|
212
|
+
}
|
|
213
|
+
return result
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const formatSpecErrors = (
|
|
217
|
+
errors: ReadonlyArray<InvalidSpecError>,
|
|
218
|
+
rawLines: ReadonlyArray<string>
|
|
219
|
+
): string => {
|
|
220
|
+
const details = errors.map((error) => {
|
|
221
|
+
const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec)
|
|
222
|
+
const where = index === -1 ? "" : ` (line ${index + 1})`
|
|
223
|
+
return ` ${error.spec}${where}: ${error.reason}`
|
|
224
|
+
})
|
|
225
|
+
return `${errors.length} invalid spec(s):\n${details.join("\n")}`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const describe = (error: unknown): string =>
|
|
229
|
+
error instanceof Error ? error.message : String(error)
|
package/src/Integrity.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checksum verification for downloaded tarballs.
|
|
3
|
+
*
|
|
4
|
+
* A bundle is a supply-chain artifact that gets imported wholesale into a
|
|
5
|
+
* corporate registry, so "the bytes I got are the bytes the registry promised"
|
|
6
|
+
* is the one property worth being strict about. A failed check discards the
|
|
7
|
+
* download rather than warning and carrying on.
|
|
8
|
+
*
|
|
9
|
+
* Implemented directly against `node:crypto` rather than pulling in `ssri`,
|
|
10
|
+
* because the subset of Subresource Integrity that npm actually publishes is
|
|
11
|
+
* small and entirely mechanical.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, timingSafeEqual } from "node:crypto"
|
|
14
|
+
|
|
15
|
+
/** One parsed SRI hash. */
|
|
16
|
+
export interface IntegrityHash {
|
|
17
|
+
readonly algorithm: string
|
|
18
|
+
/** Base64 digest, exactly as written in the SRI string. */
|
|
19
|
+
readonly digest: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SUPPORTED = new Set(["sha512", "sha384", "sha256", "sha1"])
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses an SRI string such as `sha512-abc...==`.
|
|
26
|
+
*
|
|
27
|
+
* npm permits several space-separated hashes; we keep every one we can verify
|
|
28
|
+
* and ignore algorithms Node does not implement.
|
|
29
|
+
*/
|
|
30
|
+
export const parseIntegrity = (integrity: string): ReadonlyArray<IntegrityHash> => {
|
|
31
|
+
const hashes: Array<IntegrityHash> = []
|
|
32
|
+
for (const token of integrity.trim().split(/\s+/)) {
|
|
33
|
+
if (token.length === 0) continue
|
|
34
|
+
const dash = token.indexOf("-")
|
|
35
|
+
if (dash <= 0) continue
|
|
36
|
+
const algorithm = token.slice(0, dash).toLowerCase()
|
|
37
|
+
const digest = token.slice(dash + 1)
|
|
38
|
+
if (!SUPPORTED.has(algorithm) || digest.length === 0) continue
|
|
39
|
+
hashes.push({ algorithm, digest })
|
|
40
|
+
}
|
|
41
|
+
return hashes
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Computes the base64 digest of some bytes under one algorithm. */
|
|
45
|
+
export const digestOf = (data: Uint8Array, algorithm: string): string =>
|
|
46
|
+
createHash(algorithm).update(data).digest("base64")
|
|
47
|
+
|
|
48
|
+
/** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
|
|
49
|
+
export const hexDigestOf = (data: Uint8Array, algorithm: string): string =>
|
|
50
|
+
createHash(algorithm).update(data).digest("hex")
|
|
51
|
+
|
|
52
|
+
/** Result of checking a download. */
|
|
53
|
+
export type VerificationResult =
|
|
54
|
+
/** Verified against at least one checksum. */
|
|
55
|
+
| { readonly _tag: "Verified"; readonly using: string }
|
|
56
|
+
/** The registry advertised no checksum we could use. */
|
|
57
|
+
| { readonly _tag: "Unverifiable"; readonly reason: string }
|
|
58
|
+
/** A checksum was present and did not match. */
|
|
59
|
+
| { readonly _tag: "Mismatch"; readonly expected: string; readonly actual: string }
|
|
60
|
+
|
|
61
|
+
/** Constant-time comparison of two digest strings of equal length. */
|
|
62
|
+
const digestsEqual = (a: string, b: string): boolean => {
|
|
63
|
+
const left = Buffer.from(a)
|
|
64
|
+
const right = Buffer.from(b)
|
|
65
|
+
// `timingSafeEqual` throws on unequal lengths, which is itself a mismatch.
|
|
66
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checks bytes against whichever checksums the registry supplied.
|
|
71
|
+
*
|
|
72
|
+
* SRI is preferred; `shasum` is the fallback for versions published before
|
|
73
|
+
* integrity strings existed. If the registry gave us neither, that is reported
|
|
74
|
+
* as `Unverifiable` rather than silently treated as a pass — the caller decides
|
|
75
|
+
* whether to tolerate it.
|
|
76
|
+
*/
|
|
77
|
+
export const verify = (
|
|
78
|
+
data: Uint8Array,
|
|
79
|
+
dist: { readonly integrity?: string | undefined; readonly shasum?: string | undefined }
|
|
80
|
+
): VerificationResult => {
|
|
81
|
+
if (dist.integrity !== undefined && dist.integrity.length > 0) {
|
|
82
|
+
const hashes = parseIntegrity(dist.integrity)
|
|
83
|
+
if (hashes.length === 0) {
|
|
84
|
+
return { _tag: "Unverifiable", reason: `no supported algorithm in "${dist.integrity}"` }
|
|
85
|
+
}
|
|
86
|
+
// Strongest first, so a mismatch is reported against the best hash present.
|
|
87
|
+
const ordered = [...hashes].sort(
|
|
88
|
+
(a, b) => strength(b.algorithm) - strength(a.algorithm)
|
|
89
|
+
)
|
|
90
|
+
const best = ordered[0]!
|
|
91
|
+
const actual = digestOf(data, best.algorithm)
|
|
92
|
+
return digestsEqual(actual, best.digest)
|
|
93
|
+
? { _tag: "Verified", using: best.algorithm }
|
|
94
|
+
: {
|
|
95
|
+
_tag: "Mismatch",
|
|
96
|
+
expected: `${best.algorithm}-${best.digest}`,
|
|
97
|
+
actual: `${best.algorithm}-${actual}`
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dist.shasum !== undefined && dist.shasum.length > 0) {
|
|
102
|
+
const actual = hexDigestOf(data, "sha1")
|
|
103
|
+
return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase())
|
|
104
|
+
? { _tag: "Verified", using: "sha1" }
|
|
105
|
+
: { _tag: "Mismatch", expected: `sha1-${dist.shasum}`, actual: `sha1-${actual}` }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { _tag: "Unverifiable", reason: "registry advertised no integrity or shasum" }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const strength = (algorithm: string): number => {
|
|
112
|
+
switch (algorithm) {
|
|
113
|
+
case "sha512":
|
|
114
|
+
return 4
|
|
115
|
+
case "sha384":
|
|
116
|
+
return 3
|
|
117
|
+
case "sha256":
|
|
118
|
+
return 2
|
|
119
|
+
default:
|
|
120
|
+
return 1
|
|
121
|
+
}
|
|
122
|
+
}
|