-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpin.ts
More file actions
271 lines (260 loc) · 9.03 KB
/
pin.ts
File metadata and controls
271 lines (260 loc) · 9.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
/**
* @file `resolvePipPackagePin()` — the Python mirror of
* `resolveNpmPackagePin()` (dlx/lockfile). Resolves a pip spec and its full
* dependency closure WITHOUT installing into the interpreter, then returns
* everything needed to pin a reproducible, hash-verified install:
*
* - the resolved top-level name + version,
* - the top-level artifact's hashes (sha512 SRI + sha256 hex), and
* - a fully-hashed `requirements.txt` body (`name==version --hash=sha256:<hex>`
* for every artifact in the closure) ready to feed back to
* `downloadPipPackage` / `pip install --require-hashes`. Engine: `pip
* download --dest <scratch> <spec>` downloads the spec + its resolved
* closure as wheels/sdists into a scratch dir (no install, no venv), each
* file is hashed, then the scratch dir is torn down. This is pip's own
* recipe for producing hashed requirements — `pip-tools` is NOT required.
* Contrast `resolveNpmPackagePin` (dlx/lockfile): same contract, npm engine
* (Arborist lockfile-only + pacote), emits a `package-lock.json`. The pip
* side emits a hashed `requirements.txt` because that — not a lockfile — is
* what `pip install --require-hashes` consumes. NOTE on the soak window:
* `resolveNpmPackagePin` applies a min-release-age cutoff via Arborist's
* `before` date. pip has no native release-age gate, so this generator does
* NOT enforce one — callers that need a soak must vet the resolved versions
* out of band. The spec itself remains the primary pin: `==<version>` (PyPI
* is immutable per version) or `@<full-sha>` (git is content-addressed).
*/
// oxlint-disable-next-line socket/prefer-async-spawn -- pip download streams progress; the lib promise wrapper rejects on nonzero and hides output.
import { spawn } from '../../process/spawn/child'
import os from 'node:os'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { WIN32 } from '../../constants/platform'
import { safeDelete, safeMkdir } from '../../fs/safe'
import { computeHashes } from '../../integrity'
import type { ComputedHashes } from '../../integrity'
export interface ResolvePipPackagePinOptions {
/**
* Absolute path to the Python interpreter used to run `pip download`,
* typically from `resolvePython()`. The interpreter is NOT modified.
*/
readonly pythonBin: string
/**
* Directory `pip download` resolves the closure into. Defaults to a unique
* scratch dir under the OS temp dir, removed before returning.
*/
readonly scratchDir?: string | undefined
/**
* Pip spec to pin: `<pkg>==<version>` (PyPI exact pin) or
* `git+https://<url>@<sha>` (git-SHA pin).
*/
readonly spec: string
}
export interface PipArtifactPin {
/**
* Sha256 hex of the artifact, the `--hash=sha256:<hex>` value pip expects.
*/
readonly checksum: string
/**
* Downloaded artifact filename, e.g. `is_odd-3.0.1-py3-none-any.whl`.
*/
readonly file: string
/**
* Distribution name parsed from the filename, e.g. `is-odd`.
*/
readonly name: string
/**
* Distribution version parsed from the filename, e.g. `3.0.1`.
*/
readonly version: string
}
export interface PipPackagePin {
/**
* Per-artifact pins for the full resolved closure (top-level + transitive).
*/
readonly artifacts: readonly PipArtifactPin[]
/**
* Hashes of the top-level artifact (sha512 SRI + sha256 hex). The Python
* analog of `NpmPackagePin.hash`.
*/
readonly hash: ComputedHashes
/**
* Resolved top-level distribution name.
*/
readonly name: string
/**
* Fully-hashed `requirements.txt` content, ready to write to disk and feed to
* `pip install --require-hashes -r <file>`. The Python analog of
* `NpmPackagePin.lockfile`.
*/
readonly requirements: string
/**
* Resolved top-level distribution version.
*/
readonly version: string
}
/**
* Thrown when `pip download` produces no artifacts or a filename can't be
* parsed into a name + version.
*/
export class PipPackagePinError extends Error {
constructor(
message: string,
options?: { cause?: unknown | undefined } | undefined,
) {
super(message, options)
this.name = 'PipPackagePinError'
}
}
/**
* Normalize a PEP 503 distribution name: lowercase, runs of `_ . -` collapse to
* a single `-`. Wheel filenames use `_`; requirements/PyPI use `-`.
*/
export function normalizeDistName(name: string): string {
return name.toLowerCase().replace(/[-_.]+/g, '-')
}
/**
* Parse `<name>-<version>` out of a wheel (`name-ver-...whl`) or sdist
* (`name-ver.tar.gz` / `name-ver.zip`) filename. Returns undefined when the
* shape isn't recognized.
*/
export function parseArtifactFilename(
file: string,
): { name: string; version: string } | undefined {
// Wheel: name-version-pythontag-abi-platform.whl — name + version are the
// first two `-`-delimited fields.
if (file.endsWith('.whl')) {
const parts = file.slice(0, -'.whl'.length).split('-')
if (parts.length < 2) {
return undefined
}
return { name: normalizeDistName(parts[0]!), version: parts[1]! }
}
// sdist: name-version.<ext>. Strip the extension, then split on the LAST `-`
// (names may contain `-`, versions start with a digit).
const ext = ['.tar.gz', '.tar.bz2', '.zip', '.tgz'].find(e =>
file.endsWith(e),
)
if (!ext) {
return undefined
}
const stem = file.slice(0, -ext.length)
const dashIdx = stem.lastIndexOf('-')
if (dashIdx <= 0) {
return undefined
}
return {
name: normalizeDistName(stem.slice(0, dashIdx)),
version: stem.slice(dashIdx + 1),
}
}
/**
* Generate a vendorable, hash-pinned closure for a pip spec without installing
* it. Mirrors `resolveNpmPackagePin`. Throws `PipPackagePinError` on an empty
* download or an unparseable artifact filename.
*/
export async function resolvePipPackagePin(
options: ResolvePipPackagePinOptions,
): Promise<PipPackagePin> {
const { pythonBin, spec } = options
if (typeof spec !== 'string' || spec.length === 0) {
throw new PipPackagePinError('resolvePipPackagePin requires a package spec')
}
const scratch =
options.scratchDir ??
path.join(os.tmpdir(), `socket-lib-pip-pin-${process.pid}-${Date.now()}`)
await safeMkdir(scratch, { recursive: true })
try {
await spawn(
pythonBin,
[
'-m',
'pip',
'download',
'--no-input',
'--quiet',
'--dest',
scratch,
spec,
],
{ shell: WIN32, stdio: 'inherit' },
)
const files = (await fs.readdir(scratch)).filter(
f =>
f.endsWith('.whl') ||
f.endsWith('.tar.gz') ||
f.endsWith('.tar.bz2') ||
f.endsWith('.zip') ||
f.endsWith('.tgz'),
)
if (!files.length) {
throw new PipPackagePinError(
`resolvePipPackagePin: pip download ${spec} produced no artifacts in ${scratch}`,
)
}
const artifacts: PipArtifactPin[] = []
const targetName = normalizeDistName(specDistName(spec))
let top: { hash: ComputedHashes; name: string; version: string } | undefined
for (const file of files.toSorted()) {
// eslint-disable-next-line no-await-in-loop -- bounded by closure size.
const bytes = await fs.readFile(path.join(scratch, file))
const hash = computeHashes(bytes)
const parsed = parseArtifactFilename(file)
if (!parsed) {
throw new PipPackagePinError(
`resolvePipPackagePin: could not parse name/version from artifact ${file}`,
)
}
artifacts.push({
checksum: hash.checksum,
file,
name: parsed.name,
version: parsed.version,
})
if (!top && parsed.name === targetName) {
top = { hash, name: parsed.name, version: parsed.version }
}
}
// Fall back to the first artifact when the spec name (e.g. a git URL)
// doesn't match any filename.
if (!top) {
const first = artifacts[0]!
const bytes = await fs.readFile(path.join(scratch, first.file))
top = {
hash: computeHashes(bytes),
name: first.name,
version: first.version,
}
}
const requirements =
artifacts
.map(a => `${a.name}==${a.version} --hash=sha256:${a.checksum}`)
.join('\n') + '\n'
return {
artifacts,
hash: top.hash,
name: top.name,
requirements,
version: top.version,
}
} finally {
// Swallow cleanup failures so a scratch-dir-delete error doesn't mask the
// real exception from the try-block.
try {
await safeDelete(scratch, { force: true })
} catch {}
}
}
/**
* Best-effort distribution name from a pip spec for matching the top-level
* artifact: strips a `==`/`>=`/etc. version and a `git+...#egg=<name>`
* fragment. Falls back to the raw spec when neither is present.
*/
export function specDistName(spec: string): string {
const eggIdx = spec.indexOf('#egg=')
if (eggIdx !== -1) {
return spec.slice(eggIdx + '#egg='.length)
}
const match = /^([A-Za-z0-9._-]+)\s*(?:@|[=<>!~]=?)/.exec(spec)
return match ? match[1]! : spec
}