@preventive/upstream 1.0.0-alpha.0 → 1.0.0-alpha.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/cargo.d.ts +19 -0
- package/cargo.js +2 -0
- package/npm.d.ts +3 -2
- package/package.json +18 -3
- package/semver.d.ts +4 -0
- package/semver.js +1 -1
- package/soldeer.d.ts +17 -0
- package/soldeer.js +2 -0
- package/src/advisories/github.js +2 -4
- package/src/advisories/npm.js +2 -2
- package/src/advisories/osv.js +5 -6
- package/src/advisories/repos.js +9 -15
- package/src/args.js +4 -0
- package/src/cache.js +43 -5
- package/src/cargo.js +47 -0
- package/src/download.js +38 -0
- package/src/github/write.js +6 -9
- package/src/http.js +15 -5
- package/src/npm/repos.js +6 -12
- package/src/npm/tarball.js +9 -45
- package/src/pool.js +2 -0
- package/src/semver.js +2 -1
- package/src/soldeer.js +33 -0
package/cargo.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Hand-written against cargo.js; a change to either belongs with the other.
|
|
2
|
+
//
|
|
3
|
+
// The name is asserted to be a crate name, the version an exact semver
|
|
4
|
+
// version, and a checksum 64 lowercase hex digits, before any request or
|
|
5
|
+
// cache path. The cache is setCacheDir's (npm.js).
|
|
6
|
+
|
|
7
|
+
export { HttpError } from './npm.js'
|
|
8
|
+
|
|
9
|
+
// A published version's .crate, whole, in memory. `checksum` is its
|
|
10
|
+
// sha256 as Cargo.lock records it; without one, crates.io's index is read
|
|
11
|
+
// for it on every call, cached crate or not. Either way the bytes are
|
|
12
|
+
// checked against it, whether they were downloaded from static.crates.io
|
|
13
|
+
// (before they are cached) or read from a cache.
|
|
14
|
+
//
|
|
15
|
+
// Cargo's own cache is read first, and never written: each registry's
|
|
16
|
+
// `<name>-<version>.crate` under $CARGO_HOME/registry/cache, else under
|
|
17
|
+
// `~/.cargo`. A file there that does not match is passed over. One in
|
|
18
|
+
// setCacheDir's cache that does not match throws.
|
|
19
|
+
export function getCrate(name: string, version: string, checksum?: string): Promise<Uint8Array>
|
package/cargo.js
ADDED
package/npm.d.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// The directory is trusted: a package's repo is answered from it as it
|
|
14
14
|
// was written, with no request. Point it only at storage that nothing
|
|
15
15
|
// less trusted than the caller can write, never a cache shared with or
|
|
16
|
-
// restored from lower-trust jobs. Tarballs
|
|
17
|
-
//
|
|
16
|
+
// restored from lower-trust jobs. Tarballs, crates (cargo.js) and zips
|
|
17
|
+
// (soldeer.js) don't rely on this, since they are checked against their
|
|
18
|
+
// integrity on every call.
|
|
18
19
|
export function setCacheDir(dir: string): void
|
|
19
20
|
|
|
20
21
|
// A failed request: `status` is the HTTP status the registry answered
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/upstream",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "Minimal npm
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
|
+
"description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./advisories.js": {
|
|
8
8
|
"types": "./advisories.d.ts",
|
|
9
9
|
"default": "./advisories.js"
|
|
10
10
|
},
|
|
11
|
+
"./cargo.js": {
|
|
12
|
+
"types": "./cargo.d.ts",
|
|
13
|
+
"default": "./cargo.js"
|
|
14
|
+
},
|
|
11
15
|
"./github.js": {
|
|
12
16
|
"types": "./github.d.ts",
|
|
13
17
|
"default": "./github.js"
|
|
@@ -31,11 +35,17 @@
|
|
|
31
35
|
"./semver.js": {
|
|
32
36
|
"types": "./semver.d.ts",
|
|
33
37
|
"default": "./semver.js"
|
|
38
|
+
},
|
|
39
|
+
"./soldeer.js": {
|
|
40
|
+
"types": "./soldeer.d.ts",
|
|
41
|
+
"default": "./soldeer.js"
|
|
34
42
|
}
|
|
35
43
|
},
|
|
36
44
|
"files": [
|
|
37
45
|
"advisories.d.ts",
|
|
38
46
|
"advisories.js",
|
|
47
|
+
"cargo.d.ts",
|
|
48
|
+
"cargo.js",
|
|
39
49
|
"github.d.ts",
|
|
40
50
|
"github.js",
|
|
41
51
|
"git.d.ts",
|
|
@@ -48,6 +58,8 @@
|
|
|
48
58
|
"package.js",
|
|
49
59
|
"semver.d.ts",
|
|
50
60
|
"semver.js",
|
|
61
|
+
"soldeer.d.ts",
|
|
62
|
+
"soldeer.js",
|
|
51
63
|
"src/advisories/common.js",
|
|
52
64
|
"src/advisories/github.js",
|
|
53
65
|
"src/advisories/index.js",
|
|
@@ -56,6 +68,8 @@
|
|
|
56
68
|
"src/advisories/repos.js",
|
|
57
69
|
"src/args.js",
|
|
58
70
|
"src/cache.js",
|
|
71
|
+
"src/cargo.js",
|
|
72
|
+
"src/download.js",
|
|
59
73
|
"src/git.js",
|
|
60
74
|
"src/github/client.js",
|
|
61
75
|
"src/github/read.js",
|
|
@@ -66,7 +80,8 @@
|
|
|
66
80
|
"src/package.js",
|
|
67
81
|
"src/pool.js",
|
|
68
82
|
"src/remote.js",
|
|
69
|
-
"src/semver.js"
|
|
83
|
+
"src/semver.js",
|
|
84
|
+
"src/soldeer.js"
|
|
70
85
|
],
|
|
71
86
|
"engines": {
|
|
72
87
|
"node": ">=24.14.0"
|
package/semver.d.ts
CHANGED
|
@@ -9,3 +9,7 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1
|
|
|
9
9
|
export function valid(version: unknown, options?: { loose?: boolean }): string | null
|
|
10
10
|
// A string that valid() answers unchanged.
|
|
11
11
|
export function isExactVersion(version: unknown): version is string
|
|
12
|
+
// The range as semver normalizes it, or null where it is not one.
|
|
13
|
+
export function validRange(range: unknown, options?: { loose?: boolean; includePrerelease?: boolean }): string | null
|
|
14
|
+
// Whether some version is in both ranges; throws for one that is not a range.
|
|
15
|
+
export function intersects(a: string, b: string, options?: { loose?: boolean; includePrerelease?: boolean }): boolean
|
package/semver.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { compareVersions, isExactVersion, satisfies, valid } from './src/semver.js'
|
|
1
|
+
export { compareVersions, intersects, isExactVersion, satisfies, valid, validRange } from './src/semver.js'
|
package/soldeer.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Hand-written against soldeer.js; a change to either belongs with the
|
|
2
|
+
// other.
|
|
3
|
+
//
|
|
4
|
+
// The name is asserted to be one Soldeer takes, the version a string of
|
|
5
|
+
// letters, digits, `.`, `_`, `+` and `-`, and the checksum 64 lowercase hex
|
|
6
|
+
// digits, before any request or cache path. The cache is setCacheDir's
|
|
7
|
+
// (npm.js).
|
|
8
|
+
|
|
9
|
+
export { HttpError } from './npm.js'
|
|
10
|
+
|
|
11
|
+
// A version's zip from Soldeer's registry, whole, in memory. The registry
|
|
12
|
+
// says where the zip is but not what it hashes to, so `checksum` is
|
|
13
|
+
// required: the zip's sha256, as soldeer.lock records it. The bytes are
|
|
14
|
+
// checked against it, whether they were downloaded (before they are
|
|
15
|
+
// cached) or read from setCacheDir's cache, where one that does not match
|
|
16
|
+
// throws. A cached zip that matches is answered with no request at all.
|
|
17
|
+
export function getZip(name: string, version: string, checksum: string): Promise<Uint8Array>
|
package/soldeer.js
ADDED
package/src/advisories/github.js
CHANGED
|
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
|
|
3
3
|
import { assertRepo, assertion, isGhsa, isRefName, isStrings } from '../args.js'
|
|
4
4
|
import { isGone } from '../github/client.js'
|
|
5
|
+
import { recover } from '../http.js'
|
|
5
6
|
import { pool } from '../pool.js'
|
|
6
7
|
import { valid } from '../semver.js'
|
|
7
8
|
import { covered, inRange, isText, metrics } from './common.js'
|
|
@@ -14,10 +15,7 @@ const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' &&
|
|
|
14
15
|
|
|
15
16
|
export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
|
|
16
17
|
|
|
17
|
-
const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).catch((
|
|
18
|
-
if (isGone(err)) return []
|
|
19
|
-
throw err
|
|
20
|
-
})
|
|
18
|
+
const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).catch(recover(isGone, []))
|
|
21
19
|
|
|
22
20
|
// GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
|
|
23
21
|
// Maintainers write these unreviewed: one without a range, or with one
|
package/src/advisories/npm.js
CHANGED
|
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
|
|
3
3
|
import { assertPackageName, assertPackageVersion, isGhsa, isStrings, show } from '../args.js'
|
|
4
4
|
import { NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
5
|
+
import { chunks } from '../pool.js'
|
|
5
6
|
import { compareVersions } from '../semver.js'
|
|
6
7
|
import { covered, isText, metrics } from './common.js'
|
|
7
8
|
import { withRepositories } from './github.js'
|
|
@@ -34,8 +35,7 @@ function fromRegistry(name, row, asked) {
|
|
|
34
35
|
async function registryAdvisories(asked) {
|
|
35
36
|
const names = [...asked.keys()]
|
|
36
37
|
const advisories = []
|
|
37
|
-
for (
|
|
38
|
-
const chunk = names.slice(i, i + NAMES_PER_REQUEST)
|
|
38
|
+
for (const chunk of chunks(names, NAMES_PER_REQUEST)) {
|
|
39
39
|
const body = Object.fromEntries(chunk.map((name) => [name, asked.get(name)]))
|
|
40
40
|
const answer = await request(BULK_URL, { method: 'POST', body, as: 'json' })
|
|
41
41
|
assert.ok(answer && typeof answer === 'object' && !Array.isArray(answer), 'advisories: expected an object from the registry')
|
package/src/advisories/osv.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import { assertion, isGhsa, isStrings, matches, show } from '../args.js'
|
|
3
|
+
import { assertCrateName, assertCrateVersion, assertion, isGhsa, isStrings, matches, show } from '../args.js'
|
|
4
4
|
import { OSV_API, buildUrl, request } from '../http.js'
|
|
5
|
-
import { pool } from '../pool.js'
|
|
5
|
+
import { chunks, pool } from '../pool.js'
|
|
6
6
|
import { isExactVersion } from '../semver.js'
|
|
7
7
|
import { inRange, isText, metrics, order } from './common.js'
|
|
8
8
|
import { withRepositories } from './github.js'
|
|
@@ -18,8 +18,8 @@ export const CARGO = {
|
|
|
18
18
|
osv: 'crates.io',
|
|
19
19
|
github: 'rust',
|
|
20
20
|
lookUp: crateRepos,
|
|
21
|
-
assertName:
|
|
22
|
-
assertVersion:
|
|
21
|
+
assertName: assertCrateName,
|
|
22
|
+
assertVersion: assertCrateVersion,
|
|
23
23
|
keep: (id) => id.startsWith('RUSTSEC-'), // What `cargo audit` reads; the GHSA records mirror it.
|
|
24
24
|
advisories: (asked, options) => osvAdvisories(CARGO, asked, options),
|
|
25
25
|
}
|
|
@@ -77,8 +77,7 @@ async function osvAdvisories(ecosystem, asked, options) {
|
|
|
77
77
|
const { osv, github, lookUp, covers, keep = () => true } = ecosystem
|
|
78
78
|
const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
|
|
79
79
|
const hits = new Map() // id → name → versions, in `list` order
|
|
80
|
-
for (
|
|
81
|
-
const chunk = list.slice(i, i + QUERIES_PER_REQUEST)
|
|
80
|
+
for (const chunk of chunks(list, QUERIES_PER_REQUEST)) {
|
|
82
81
|
const body = { queries: chunk.map(({ name, version }) => ({ package: { name, ecosystem: osv }, version })) }
|
|
83
82
|
const answer = await request(QUERY_URL, { method: 'POST', body, as: 'json' })
|
|
84
83
|
assert.ok(Array.isArray(answer?.results) && answer.results.length === chunk.length, 'advisories: expected one OSV result per query')
|
package/src/advisories/repos.js
CHANGED
|
@@ -2,13 +2,12 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
3
|
|
|
4
4
|
import { isRepo, show } from '../args.js'
|
|
5
|
-
import {
|
|
6
|
-
import { CRATES_API,
|
|
5
|
+
import { readRecord, writeRecord } from '../cache.js'
|
|
6
|
+
import { CRATES_API, PACKAGIST_REPO, buildUrl, isNotFound, recover, request } from '../http.js'
|
|
7
7
|
import { lookUpPackageRepo } from '../npm/repos.js'
|
|
8
|
-
import { pool } from '../pool.js'
|
|
8
|
+
import { chunks, pool } from '../pool.js'
|
|
9
9
|
import { githubRepoOfUrl } from '../remote.js'
|
|
10
10
|
|
|
11
|
-
const TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
12
11
|
const CRATES_PER_REQUEST = 100
|
|
13
12
|
const CRATES_PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
|
|
14
13
|
const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
|
|
@@ -20,14 +19,13 @@ const PACKAGES_AT_ONCE = 8
|
|
|
20
19
|
async function cachedRepos(dir, names, fetchMissing) {
|
|
21
20
|
const repos = new Map()
|
|
22
21
|
for (const name of names) {
|
|
23
|
-
const entry = await
|
|
24
|
-
|
|
25
|
-
if (age >= 0 && age <= TTL_MS && entry.name === name && isRepo(entry.github)) repos.set(name, entry.github)
|
|
22
|
+
const entry = await readRecord(dir, name)
|
|
23
|
+
if (isRepo(entry?.github)) repos.set(name, entry.github)
|
|
26
24
|
}
|
|
27
25
|
const missing = names.filter((name) => !repos.has(name))
|
|
28
26
|
for (const [name, github] of await fetchMissing(missing)) {
|
|
29
27
|
repos.set(name, github)
|
|
30
|
-
if (github) await
|
|
28
|
+
if (github) await writeRecord(dir, name, { github })
|
|
31
29
|
}
|
|
32
30
|
return repos
|
|
33
31
|
}
|
|
@@ -35,9 +33,8 @@ async function cachedRepos(dir, names, fetchMissing) {
|
|
|
35
33
|
// A crate crates.io leaves out of its answer is one it does not have.
|
|
36
34
|
async function fetchCrates(names) {
|
|
37
35
|
const found = new Map(names.map((name) => [name, null]))
|
|
38
|
-
for (
|
|
36
|
+
for (const [i, chunk] of chunks(names, CRATES_PER_REQUEST).entries()) {
|
|
39
37
|
if (i > 0) await sleep(CRATES_PACE_MS)
|
|
40
|
-
const chunk = names.slice(i, i + CRATES_PER_REQUEST)
|
|
41
38
|
const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': chunk, per_page: CRATES_PER_REQUEST })
|
|
42
39
|
const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
|
|
43
40
|
assert.ok(Array.isArray(answer?.crates), 'advisories: expected a list of crates from crates.io')
|
|
@@ -57,12 +54,9 @@ async function fetchEach(names, fetch) {
|
|
|
57
54
|
// The first version in Packagist's file is its latest, in full.
|
|
58
55
|
async function fetchComposerRepo(name) {
|
|
59
56
|
const [vendor, pkg] = name.split('/')
|
|
60
|
-
const answer = await request(buildUrl(PACKAGIST_REPO, ['p2', vendor, `${pkg}.json`]), { as: 'json' }).catch((
|
|
61
|
-
if (err instanceof HttpError && err.status === 404) return null
|
|
62
|
-
throw err
|
|
63
|
-
})
|
|
57
|
+
const answer = await request(buildUrl(PACKAGIST_REPO, ['p2', vendor, `${pkg}.json`]), { as: 'json' }).catch(recover(isNotFound, null))
|
|
64
58
|
if (answer === null) return null
|
|
65
|
-
const versions = Object.hasOwn(answer
|
|
59
|
+
const versions = Object.hasOwn(answer.packages ?? {}, name) ? answer.packages[name] : undefined
|
|
66
60
|
assert.ok(Array.isArray(versions), `advisories: Packagist answered without ${name}`)
|
|
67
61
|
return githubRepoOfUrl(versions[0]?.source?.url) ?? null
|
|
68
62
|
}
|
package/src/args.js
CHANGED
|
@@ -70,3 +70,7 @@ export const assertUserAgent = assertion('a printable user agent', matches(/^[ -
|
|
|
70
70
|
export const assertPackageName = assertion('an npm package name', isPackageName)
|
|
71
71
|
export const assertPackageVersion = assertion('an exact semver version', isExactVersion)
|
|
72
72
|
export const assertGhsa = assertion('a GHSA id', isGhsa)
|
|
73
|
+
export const assertCrateName = assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u))
|
|
74
|
+
export const assertCrateVersion = assertion('a semver version', matches(/^(?=.{5,256}$)(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u))
|
|
75
|
+
// As Cargo.lock and soldeer.lock write a checksum.
|
|
76
|
+
export const assertSha256 = assertion('a sha256 in lowercase hex', matches(/^[\da-f]{64}$/u))
|
package/src/cache.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { dirname, join, resolve } from 'node:path'
|
|
4
5
|
|
|
5
6
|
import { assertDirectoryPath } from './args.js'
|
|
6
|
-
import { decode } from './http.js'
|
|
7
|
+
import { MAX_BYTES, decode } from './http.js'
|
|
7
8
|
|
|
8
|
-
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'composer/repos'])
|
|
9
|
+
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/zips'])
|
|
10
|
+
const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
9
11
|
|
|
10
12
|
let root
|
|
11
13
|
let tmpSeq = 0
|
|
@@ -18,14 +20,41 @@ export function setCacheDir(dir) {
|
|
|
18
20
|
// URI-encoded, so a `/` or `..` in a key stays inside one file name, then
|
|
19
21
|
// `@` kept and `/` written `+`, as pnpm names its store: `@babel+core@7.29.7`.
|
|
20
22
|
// encodeURIComponent writes neither `@` nor `+` bare, so no two keys meet.
|
|
23
|
+
// Where case is not told apart (macOS, Windows), JSONStream and jsonstream
|
|
24
|
+
// would still meet, so a capital is written `!` and the letter, and a `!`
|
|
25
|
+
// as `!!`. A Windows device name (`con.json` is the console) has its first
|
|
26
|
+
// letter escaped, which encodeURIComponent never does.
|
|
21
27
|
function cachePath(dir, key) {
|
|
22
28
|
assert.ok(DIRS.has(dir) && key && typeof key === 'string', `Unexpected cache entry: ${dir}`)
|
|
23
|
-
|
|
29
|
+
const name = encodeURIComponent(key.replace(/[!A-Z]/gu, (char) => `!${char.toLowerCase()}`)).replaceAll('%40', '@').replaceAll('%2F', '+')
|
|
30
|
+
return root === undefined ? null : join(root, dir, name.replace(/^(?=(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))./u, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Only a regular file, checked on the file as opened, so one swapped in
|
|
34
|
+
// after a check can't pass: a FIFO or a device would block or never end.
|
|
35
|
+
// Read to the size it had when opened, and no further.
|
|
36
|
+
export async function readRegularFile(path) {
|
|
37
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)).catch(() => null)
|
|
38
|
+
try {
|
|
39
|
+
const stats = await handle?.stat()
|
|
40
|
+
if (!stats?.isFile() || stats.size > MAX_BYTES) return null
|
|
41
|
+
const bytes = new Uint8Array(stats.size)
|
|
42
|
+
for (let at = 0; at < bytes.length;) {
|
|
43
|
+
const { bytesRead } = await handle.read(bytes, at, bytes.length - at, at)
|
|
44
|
+
if (bytesRead === 0) return null
|
|
45
|
+
at += bytesRead
|
|
46
|
+
}
|
|
47
|
+
return bytes
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
} finally {
|
|
51
|
+
await handle?.close()
|
|
52
|
+
}
|
|
24
53
|
}
|
|
25
54
|
|
|
26
55
|
export async function readCache(dir, key) {
|
|
27
56
|
const path = cachePath(dir, key)
|
|
28
|
-
return path && await
|
|
57
|
+
return path && await readRegularFile(path)
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
export async function readCacheJSON(dir, key) {
|
|
@@ -55,3 +84,12 @@ export async function writeCache(dir, key, data) {
|
|
|
55
84
|
export async function writeCacheJSON(dir, key, value) {
|
|
56
85
|
return await writeCache(dir, key, JSON.stringify(value))
|
|
57
86
|
}
|
|
87
|
+
|
|
88
|
+
export async function readRecord(dir, name) {
|
|
89
|
+
const record = await readCacheJSON(dir, `${name}.json`)
|
|
90
|
+
const age = typeof record?.at === 'number' ? Date.now() - record.at : Number.NaN
|
|
91
|
+
const fresh = age >= 0 && age <= RECORD_TTL_MS // An entry from the future is not fresh forever.
|
|
92
|
+
return fresh && record.name === name ? record : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const writeRecord = (dir, name, value) => writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, ...value })
|
package/src/cargo.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readdir } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { assertCrateName, assertCrateVersion, assertSha256, printable, show } from './args.js'
|
|
7
|
+
import { verifiedDownload } from './download.js'
|
|
8
|
+
import { CRATES_INDEX, CRATES_STATIC, buildUrl, encodeSegment, request } from './http.js'
|
|
9
|
+
|
|
10
|
+
const DIR = 'cargo/crates' // No expiry: crates.io never takes a version twice.
|
|
11
|
+
|
|
12
|
+
// The sparse index, what cargo reads: a file per crate, by its lowercased
|
|
13
|
+
// name under 1/, 2/, 3/<first letter>/ or <1st-2nd>/<3rd-4th>/, with a
|
|
14
|
+
// JSON line per version, `cksum` the .crate's sha256.
|
|
15
|
+
async function getChecksum(name, version) {
|
|
16
|
+
const lower = name.toLowerCase()
|
|
17
|
+
const prefix = lower.length < 3 ? [String(lower.length)] : lower.length === 3 ? ['3', lower[0]] : [lower.slice(0, 2), lower.slice(2, 4)]
|
|
18
|
+
const text = await request(buildUrl(CRATES_INDEX, [...prefix, lower]), { as: 'text' })
|
|
19
|
+
const parse = (line) => {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(line)
|
|
22
|
+
} catch {
|
|
23
|
+
assert.fail(`getCrate: the index for ${name} has a line that is not JSON: ${printable(line.slice(0, 200))}`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const entry = text.split('\n').filter(Boolean).map(parse).find((candidate) => candidate?.vers === version)
|
|
27
|
+
assert.ok(entry, `getCrate: the index has no ${name}@${version}`)
|
|
28
|
+
assert.ok(entry.name === name, `getCrate: the index answered for ${show(entry.name)}, not ${name}`)
|
|
29
|
+
assertSha256('getCrate', 'the index cksum', entry.cksum)
|
|
30
|
+
return entry.cksum
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Cargo's own downloads, a directory per registry under
|
|
34
|
+
// $CARGO_HOME/registry/cache, each holding <name>-<version>.crate.
|
|
35
|
+
async function localPaths(name, version) {
|
|
36
|
+
const root = join(resolve(process.env.CARGO_HOME || join(homedir(), '.cargo')), 'registry', 'cache')
|
|
37
|
+
return (await readdir(root).catch(() => [])).map((registry) => join(root, registry, `${name}-${version}.crate`))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getCrate(name, version, checksum) {
|
|
41
|
+
assertCrateName('getCrate', 'name', name)
|
|
42
|
+
assertCrateVersion('getCrate', 'version', version)
|
|
43
|
+
if (checksum !== undefined) assertSha256('getCrate', 'checksum', checksum)
|
|
44
|
+
const expected = checksum ?? await getChecksum(name, version)
|
|
45
|
+
const locate = () => buildUrl(CRATES_STATIC, ['crates', name, `${name}-${encodeSegment(version)}.crate`])
|
|
46
|
+
return await verifiedDownload({ method: 'getCrate', dir: DIR, what: `${name}@${version}`, ext: 'crate', algorithm: 'sha256', expected, local: await localPaths(name, version), locate })
|
|
47
|
+
}
|
package/src/download.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { readCache, readRegularFile, writeCache } from './cache.js'
|
|
5
|
+
import { request } from './http.js'
|
|
6
|
+
|
|
7
|
+
// A hash as each registry writes it: npm's integrity, one sha512 in base64
|
|
8
|
+
// after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex.
|
|
9
|
+
const DIGESTS = {
|
|
10
|
+
sha512: (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
|
|
11
|
+
sha256: (bytes) => createHash('sha256').update(bytes).digest('hex'),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Bytes whose `algorithm` hash is `expected`, which the registry or the
|
|
15
|
+
// caller gives, never a disk; without it nothing is read. From `local`,
|
|
16
|
+
// other tools' caches (read, never written; a mismatch is passed over),
|
|
17
|
+
// else ours (a mismatch throws), else what `locate` answers, checked and
|
|
18
|
+
// then cached.
|
|
19
|
+
export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], locate }) {
|
|
20
|
+
assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
|
|
21
|
+
const digest = DIGESTS[algorithm]
|
|
22
|
+
for (const path of local) {
|
|
23
|
+
const bytes = await readRegularFile(path)
|
|
24
|
+
if (bytes && digest(bytes) === expected) return bytes
|
|
25
|
+
}
|
|
26
|
+
const check = (bytes, from) => {
|
|
27
|
+
const actual = digest(bytes)
|
|
28
|
+
assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
|
|
29
|
+
return bytes
|
|
30
|
+
}
|
|
31
|
+
const key = `${what}.${ext}`
|
|
32
|
+
const cached = await readCache(dir, key)
|
|
33
|
+
if (cached) return check(cached, 'the cache')
|
|
34
|
+
const url = await locate()
|
|
35
|
+
const bytes = check(await request(url, { as: 'bytes' }), url)
|
|
36
|
+
await writeCache(dir, key, bytes)
|
|
37
|
+
return bytes
|
|
38
|
+
}
|
package/src/github/write.js
CHANGED
|
@@ -66,19 +66,13 @@ export function parseGraphQLResponse(status, text) {
|
|
|
66
66
|
throw new HttpError(status, `GitHub GraphQL ${status}: ${printable(text.slice(0, 4096)) || '(empty body)'}`)
|
|
67
67
|
}
|
|
68
68
|
let json
|
|
69
|
-
try { json = JSON.parse(text) } catch (
|
|
70
|
-
throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${
|
|
69
|
+
try { json = JSON.parse(text) } catch (cause) {
|
|
70
|
+
throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${cause.message}): ${printable(text.slice(0, 200))}`, { cause })
|
|
71
71
|
}
|
|
72
72
|
if (json.errors) throw new Error(`GitHub GraphQL: ${printable(JSON.stringify(json.errors))}`)
|
|
73
73
|
return json.data
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// A signed commit; the branch must already exist.
|
|
77
|
-
async function createCommitOnBranch(headers, input) {
|
|
78
|
-
const res = await send(api(['graphql']), { method: 'POST', headers, body: { query: CREATE_COMMIT_MUTATION, variables: { input } }, as: 'json' })
|
|
79
|
-
return parseGraphQLResponse(res.status, decode(await readBody(res, 1024 * 1024), 'GitHub GraphQL')).createCommitOnBranch.commit
|
|
80
|
-
}
|
|
81
|
-
|
|
82
76
|
async function forkRepo(headers, options) {
|
|
83
77
|
assertArgs('forkRepo', options, { repo: assertRepo, name: optional(assertRepoName), organization: optional(assertLogin), defaultBranchOnly: optional(assertBoolean) })
|
|
84
78
|
const { repo, name, organization, defaultBranchOnly } = options
|
|
@@ -93,13 +87,16 @@ async function createBranch(headers, options) {
|
|
|
93
87
|
return await call(headers, repoApi(repo, ['git', 'refs']), { method: 'POST', body: { ref: `refs/heads/${branch}`, sha } })
|
|
94
88
|
}
|
|
95
89
|
|
|
90
|
+
// A signed commit; the branch must already exist.
|
|
96
91
|
async function createCommit(headers, options) {
|
|
97
92
|
assertArgs('createCommit', options, { repo: assertRepo, branch: assertRef, message: null, additions: null, deletions: null, expectedHeadOid: optional(assertSha) })
|
|
98
93
|
const { repo, branch } = options
|
|
99
94
|
const changes = fileChanges(options.additions, options.deletions)
|
|
100
95
|
const message = commitMessage(options.message)
|
|
101
96
|
const expectedHeadOid = options.expectedHeadOid ?? (await getRepoHead(headers, { repo, branch })).oid
|
|
102
|
-
|
|
97
|
+
const input = { branch: { repositoryNameWithOwner: repo, branchName: branch }, message, fileChanges: changes, expectedHeadOid }
|
|
98
|
+
const res = await send(api(['graphql']), { method: 'POST', headers, body: { query: CREATE_COMMIT_MUTATION, variables: { input } }, as: 'json' })
|
|
99
|
+
return parseGraphQLResponse(res.status, decode(await readBody(res, 1024 * 1024), 'GitHub GraphQL')).createCommitOnBranch.commit
|
|
103
100
|
}
|
|
104
101
|
|
|
105
102
|
async function createPR(headers, options) {
|
package/src/http.js
CHANGED
|
@@ -11,9 +11,13 @@ export const GITHUB_API = 'https://api.github.com'
|
|
|
11
11
|
export const OSV_API = 'https://api.osv.dev'
|
|
12
12
|
export const CRATES_API = 'https://crates.io'
|
|
13
13
|
export const PACKAGIST_REPO = 'https://repo.packagist.org'
|
|
14
|
+
export const CRATES_INDEX = 'https://index.crates.io'
|
|
15
|
+
export const CRATES_STATIC = 'https://static.crates.io'
|
|
16
|
+
export const SOLDEER_API = 'https://api.soldeer.xyz'
|
|
17
|
+
export const SOLDEER_REVISIONS = 'https://soldeer-revisions.s3.amazonaws.com'
|
|
14
18
|
export const MAX_BYTES = 512 * 1024 * 1024
|
|
15
19
|
|
|
16
|
-
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO])
|
|
20
|
+
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO, CRATES_INDEX, CRATES_STATIC, SOLDEER_API, SOLDEER_REVISIONS])
|
|
17
21
|
const isSegment = matches(/^(?!\.\.?$)(?:[\w.~@-]|%[\dA-F]{2})+$/u)
|
|
18
22
|
const isQueryKey = matches(/^[a-z_]+(?:\[\])?$/u)
|
|
19
23
|
const isQueryValue = (value) => (typeof value === 'string' && value !== '') || (Number.isSafeInteger(value) && value >= 0)
|
|
@@ -36,11 +40,17 @@ export class HttpError extends Error {
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
export const isNotFound = (err) => err instanceof HttpError && err.status === 404
|
|
44
|
+
export const recover = (expected, value) => (err) => {
|
|
45
|
+
if (!expected(err)) throw err
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
export function decode(bytes, from) {
|
|
40
50
|
try {
|
|
41
51
|
return decoder.decode(bytes)
|
|
42
|
-
} catch (
|
|
43
|
-
throw new Error(`Malformed UTF-8 from ${from}`, { cause
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
throw new Error(`Malformed UTF-8 from ${from}`, { cause })
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -106,7 +116,7 @@ export async function request(url, options) {
|
|
|
106
116
|
if (options.as === 'text') return text
|
|
107
117
|
try {
|
|
108
118
|
return JSON.parse(text)
|
|
109
|
-
} catch (
|
|
110
|
-
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause })
|
|
111
121
|
}
|
|
112
122
|
}
|
package/src/npm/repos.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
3
|
import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { readRecord, writeRecord } from '../cache.js'
|
|
5
|
+
import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
|
|
6
6
|
import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
|
|
7
7
|
import { pool } from '../pool.js'
|
|
8
8
|
|
|
9
9
|
const DIR = 'npm/repos'
|
|
10
|
-
const TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
11
10
|
const CONCURRENCY = 8
|
|
12
11
|
|
|
13
12
|
// `latest`, not the full packument, which is megabytes of version history.
|
|
@@ -28,10 +27,8 @@ export async function getGitHub(name) {
|
|
|
28
27
|
// at the repo root.
|
|
29
28
|
export async function readPackageRepoCache(name) {
|
|
30
29
|
assertPackageName('readPackageRepoCache', 'name', name)
|
|
31
|
-
const entry = await
|
|
32
|
-
|
|
33
|
-
const fresh = age >= 0 && age <= TTL_MS // An entry from the future is not fresh forever.
|
|
34
|
-
if (!fresh || entry.name !== name || !isRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
|
|
30
|
+
const entry = await readRecord(DIR, name)
|
|
31
|
+
if (!isRepo(entry?.github) || !isRepoDirectory(entry.directory)) return null
|
|
35
32
|
return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
|
|
36
33
|
}
|
|
37
34
|
|
|
@@ -41,7 +38,7 @@ export async function writePackageRepoCache(name, github, directory = '') {
|
|
|
41
38
|
assertPackageName('writePackageRepoCache', 'name', name)
|
|
42
39
|
assertRepo('writePackageRepoCache', 'github', github)
|
|
43
40
|
assertRepoDirectory('writePackageRepoCache', 'directory', directory)
|
|
44
|
-
return await
|
|
41
|
+
return await writeRecord(DIR, name, { github, directory })
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
export async function resolvePackageRepos(packageNames, options = {}) {
|
|
@@ -58,10 +55,7 @@ export async function resolvePackageRepos(packageNames, options = {}) {
|
|
|
58
55
|
export async function lookUpPackageRepo(name) {
|
|
59
56
|
const stored = await readPackageRepoCache(name)
|
|
60
57
|
if (stored) return stored
|
|
61
|
-
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch((
|
|
62
|
-
if (err instanceof HttpError && err.status === 404) return {}
|
|
63
|
-
throw err
|
|
64
|
-
})
|
|
58
|
+
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
|
|
65
59
|
if (!github) return null
|
|
66
60
|
await writePackageRepoCache(name, github, directory)
|
|
67
61
|
return { github, ...(directory && { directory }) }
|
package/src/npm/tarball.js
CHANGED
|
@@ -1,31 +1,23 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
5
3
|
import { homedir } from 'node:os'
|
|
6
4
|
import { join, resolve } from 'node:path'
|
|
7
5
|
|
|
8
6
|
import { assertArgs, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { verifiedDownload } from '../download.js'
|
|
8
|
+
import { NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
11
9
|
|
|
12
10
|
const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
|
|
13
11
|
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
14
12
|
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
15
13
|
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
16
14
|
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
17
|
-
const sha512 = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
|
|
18
15
|
|
|
19
16
|
function assertPackage(method, name, version) {
|
|
20
17
|
assertPackageName(method, 'name', name)
|
|
21
18
|
assertPackageVersion(method, 'version', version)
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
function tarballUrl(name, version) {
|
|
25
|
-
const segments = name.split('/')
|
|
26
|
-
return buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
30
22
|
// registry's own URL for that version. Each field is read once, and only
|
|
31
23
|
// as the object's own, into a copy that is checked and used from then on:
|
|
@@ -35,7 +27,8 @@ function checkedDist(method, name, version, dist) {
|
|
|
35
27
|
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
36
28
|
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
37
29
|
assertIntegrity(method, 'dist.integrity', integrity)
|
|
38
|
-
const
|
|
30
|
+
const segments = name.split('/')
|
|
31
|
+
const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
39
32
|
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
40
33
|
return { tarball, integrity }
|
|
41
34
|
}
|
|
@@ -46,11 +39,6 @@ async function getDist(method, name, version) {
|
|
|
46
39
|
return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
function assertBytes(bytes, integrity, what) {
|
|
50
|
-
const actual = sha512(bytes)
|
|
51
|
-
assert.ok(actual === integrity, `getTarball: integrity mismatch for ${what}: expected ${integrity}, got ${actual}`)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
42
|
// Where npm keeps its cache, short of an .npmrc moving it: npm_config_cache,
|
|
55
43
|
// as npm sets it for what it runs, else npm's default. On Windows, npm 4
|
|
56
44
|
// and before defaulted to %APPDATA%, later ones to %LOCALAPPDATA%.
|
|
@@ -61,24 +49,12 @@ function npmCacheDirs() {
|
|
|
61
49
|
return [process.env.LOCALAPPDATA, process.env.APPDATA].map((dir) => join(dir || homedir(), 'npm-cache'))
|
|
62
50
|
}
|
|
63
51
|
|
|
64
|
-
// Only a regular file: a FIFO or a device would block or never end.
|
|
65
|
-
async function readRegularFile(path) {
|
|
66
|
-
const stats = await stat(path).catch(() => null)
|
|
67
|
-
if (!stats?.isFile() || stats.size > MAX_BYTES) return null
|
|
68
|
-
return await readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
52
|
// npm 5+ (cacache) files a tarball by its sha512, npm 4 and before as
|
|
72
53
|
// <name>/<version>/package.tgz; ~/.audit as <org>:<name>-<version>.tgz.
|
|
73
|
-
|
|
54
|
+
function localPaths(name, version, integrity) {
|
|
74
55
|
const hex = Buffer.from(integrity.slice('sha512-'.length), 'base64').toString('hex')
|
|
75
56
|
const npm = npmCacheDirs().flatMap((root) => [join(root, '_cacache/content-v2/sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)), join(root, name, version, 'package.tgz')])
|
|
76
|
-
|
|
77
|
-
for (const path of [...npm, audit]) {
|
|
78
|
-
const bytes = await readRegularFile(path)
|
|
79
|
-
if (bytes && sha512(bytes) === integrity) return bytes
|
|
80
|
-
}
|
|
81
|
-
return null
|
|
57
|
+
return [...npm, join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)]
|
|
82
58
|
}
|
|
83
59
|
|
|
84
60
|
export async function getMeta(name, version) {
|
|
@@ -95,21 +71,9 @@ export async function verifyDist(name, version, dist) {
|
|
|
95
71
|
|
|
96
72
|
// Without `dist`, the version document is read every time, cache or not:
|
|
97
73
|
// bytes from a cache are checked against the registry's integrity, never
|
|
98
|
-
// against anything a cache itself holds.
|
|
99
|
-
// than fetching over it.
|
|
74
|
+
// against anything a cache itself holds.
|
|
100
75
|
export async function getTarball(name, version, dist) {
|
|
101
76
|
assertPackage('getTarball', name, version)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (local) return local
|
|
105
|
-
const key = `${name}@${version}.tgz`
|
|
106
|
-
const cached = await readCache(DIR, key)
|
|
107
|
-
if (cached) {
|
|
108
|
-
assertBytes(cached, dist.integrity, `${name}@${version} from the cache`)
|
|
109
|
-
return cached
|
|
110
|
-
}
|
|
111
|
-
const bytes = await request(dist.tarball, { as: 'bytes' })
|
|
112
|
-
assertBytes(bytes, dist.integrity, `${name}@${version} from ${dist.tarball}`)
|
|
113
|
-
await writeCache(DIR, key, bytes)
|
|
114
|
-
return bytes
|
|
77
|
+
const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
|
|
78
|
+
return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball })
|
|
115
79
|
}
|
package/src/pool.js
CHANGED
package/src/semver.js
CHANGED
|
@@ -22,7 +22,7 @@ function find() {
|
|
|
22
22
|
for (const prefix of ['../lib', '.']) {
|
|
23
23
|
try {
|
|
24
24
|
const lib = require(resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver'))
|
|
25
|
-
if (['compare', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
|
|
25
|
+
if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
|
|
26
26
|
} catch {}
|
|
27
27
|
}
|
|
28
28
|
return null
|
|
@@ -36,6 +36,7 @@ function semver() {
|
|
|
36
36
|
|
|
37
37
|
export const satisfies = (...args) => semver().satisfies(...args)
|
|
38
38
|
export const validRange = (...args) => semver().validRange(...args)
|
|
39
|
+
export const intersects = (...args) => semver().intersects(...args)
|
|
39
40
|
export const compareVersions = (...args) => semver().compare(...args)
|
|
40
41
|
export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
|
|
41
42
|
export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version
|
package/src/soldeer.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { assertSha256, assertion, matches, show } from './args.js'
|
|
4
|
+
import { verifiedDownload } from './download.js'
|
|
5
|
+
import { SOLDEER_API, SOLDEER_REVISIONS, buildUrl, request } from './http.js'
|
|
6
|
+
|
|
7
|
+
const DIR = 'soldeer/zips' // No expiry: the registry refuses a version it already has.
|
|
8
|
+
// Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
|
|
9
|
+
// `-`, starting with one of them or `@`, ending with a letter or digit.
|
|
10
|
+
const assertName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
|
|
11
|
+
// Soldeer takes any version but an empty one. Its registry has semver
|
|
12
|
+
// (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
|
|
13
|
+
const assertVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
|
|
14
|
+
|
|
15
|
+
// Where the registry keeps the version's zip; it says nothing of what the
|
|
16
|
+
// zip hashes to.
|
|
17
|
+
async function zipUrl(name, version) {
|
|
18
|
+
const json = await request(buildUrl(SOLDEER_API, ['api', 'v1', 'revision-cli'], { project_name: name, revision: version }), { as: 'json' })
|
|
19
|
+
const revision = json?.data?.[0]
|
|
20
|
+
assert.ok(revision?.version === version, `getZip: the registry answered for ${show(revision?.version)}, not ${name}@${version}`)
|
|
21
|
+
const prefix = `${SOLDEER_REVISIONS}/${name}/`
|
|
22
|
+
assert.ok(typeof revision.url === 'string' && revision.url.startsWith(prefix), `getZip: the registry keeps ${name}@${version} outside ${prefix}`)
|
|
23
|
+
return revision.url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The registry has no hash to check a zip against, so the caller brings
|
|
27
|
+
// one, as soldeer.lock records it.
|
|
28
|
+
export async function getZip(name, version, checksum) {
|
|
29
|
+
assertName('getZip', 'name', name)
|
|
30
|
+
assertVersion('getZip', 'version', version)
|
|
31
|
+
assertSha256('getZip', 'checksum', checksum)
|
|
32
|
+
return await verifiedDownload({ method: 'getZip', dir: DIR, what: `${name}@${version}`, ext: 'zip', algorithm: 'sha256', expected: checksum, locate: () => zipUrl(name, version) })
|
|
33
|
+
}
|