@preventive/upstream 1.0.0-alpha.0 → 1.0.0-alpha.2

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 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
@@ -0,0 +1,2 @@
1
+ export { getCrate } from './src/cargo.js'
2
+ export { HttpError } from './src/http.js'
package/github.d.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  // Hand-written against github.js; a change to either belongs with the other.
2
2
  //
3
- // Every method checks its arguments hard before any request is built:
4
- // an options object holding only the keys listed here, a repo that is
3
+ // Every method checks its arguments hard before any request is built: an
4
+ // options object holding only the keys listed here, a repo that is
5
5
  // `owner/name` by GitHub's rules, branches and tags that git would take,
6
- // full commit shas, paths with no empty, `.` or `..` component, titles
7
- // and headlines on one line. A bad one is a rejection naming the method;
8
- // nothing is sent. Every URL is built from checked, encoded segments and
9
- // has to come back out of URL parsing unchanged. Redirects are refused,
10
- // except the tarball's, so an answer is about the repo asked for. A
11
- // response is read up to a size limit (64 MiB of JSON, 128 MiB of a file,
12
- // 512 MiB of a tarball) and within a timeout (30 seconds, 5 minutes for a
13
- // tarball), and what it says goes into an error message escaped.
6
+ // full commit shas and tree ids, paths with no empty, `.` or `..`
7
+ // component, titles and headlines on one line. A bad one is a rejection
8
+ // naming the method; nothing is sent. Every URL is built from checked,
9
+ // encoded segments and has to come back out of URL parsing unchanged.
10
+ // Redirects are refused, except the tarballs', so an answer is about the
11
+ // repo asked for. A response is read up to a size limit (64 MiB of JSON,
12
+ // 128 MiB of a file, 512 MiB of a tarball) and within a timeout (30
13
+ // seconds, 5 minutes for a tarball), and what it says goes into an error
14
+ // message escaped.
14
15
 
15
16
  // `owner/name`.
16
17
  export type RepoName = string
@@ -54,8 +55,25 @@ export interface Client {
54
55
  getRepoFile(options: { repo: RepoName; path: string; ref?: string }): Promise<string>
55
56
  // The head of `branch`, or of the default branch without one.
56
57
  getRepoHead(options: { repo: RepoName; branch?: string }): Promise<{ branch: string; oid: string }>
57
- // The repo's gzipped tarball at the full commit `sha`, whole, in memory.
58
+ // The repo's gzipped tarball at the full commit `sha`, whole, in memory:
59
+ // getRepoTreeTarball's for the tree GitHub names for that commit, asked
60
+ // on every call, cached or not. So its top directory is named for the
61
+ // tree, and files marked `export-subst` are as committed, not rewritten.
58
62
  getRepoTarball(options: { repo: RepoName; sha: string }): Promise<Uint8Array>
63
+ // The gzipped tarball of a tree, the repo's own or any subdirectory's, by
64
+ // its id, whole, in memory: GitHub's, its files under one top directory.
65
+ // Its files are hashed back into git's tree and the id must be `tree`,
66
+ // whether downloaded or read from setCacheDir's cache (npm.js), where it
67
+ // is kept by the id alone, for good; a cached copy that does not match
68
+ // throws. A tarball shows a submodule as an empty directory, and leaves
69
+ // out a subtree with no file in it: the submodule's commit and the
70
+ // subtrees come from GitHub's listings of the trees, asked only then, and
71
+ // the id must still come out `tree`: the bytes are the tree's own, the
72
+ // same for every repo. So GitHub's tarball is refused for a tree with
73
+ // files marked `export-ignore`, which it leaves out, and from a repo set
74
+ // to include Git LFS objects in archives, which replace the pointers git
75
+ // has; that repo still gets a tree already cached, pointers and all.
76
+ getRepoTreeTarball(options: { repo: RepoName; tree: string }): Promise<Uint8Array>
59
77
  // The repository's published security advisories, as GitHub's
60
78
  // repository advisory objects. One page of 100: GitHub pages this list
61
79
  // by cursor, so a repository with a full page is refused.
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 don't rely on this, since they
17
- // are checked against the registry's integrity on every call.
16
+ // restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
17
+ // (soldeer.js) and tree tarballs (github.js) don't rely on this, since
18
+ // they are checked against their 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.0",
4
- "description": "Minimal npm registry and GitHub clients, and a git checkout reader: the calls we use, nothing more",
3
+ "version": "1.0.0-alpha.2",
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,9 @@
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/tree.js",
85
+ "src/soldeer.js"
70
86
  ],
71
87
  "engines": {
72
88
  "node": ">=24.14.0"
package/semver.d.ts CHANGED
@@ -4,8 +4,17 @@
4
4
  // as it is. Without it, every call throws, except valid() on a plain
5
5
  // MAJOR.MINOR.PATCH with no options.
6
6
  export function satisfies(version: string, range: string, options?: { includePrerelease?: boolean; loose?: boolean }): boolean
7
- export function compareVersions(a: string, b: string): -1 | 0 | 1
7
+ export function compareVersions(a: string, b: string, options?: { loose?: boolean }): -1 | 0 | 1
8
8
  // The version as semver spells it, or null: `v1.2.3` answers `1.2.3`.
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
16
+ // The version as semver cleans it, spaces and a leading `=` or `v` dropped,
17
+ // or null: ` =v1.2.3 ` answers `1.2.3`.
18
+ export function clean(version: string, options?: { loose?: boolean } | boolean): string | null
19
+ // The version's major, as semver reads it; throws for one that is not a version.
20
+ export function major(version: string, options?: { loose?: boolean } | boolean): number
package/semver.js CHANGED
@@ -1 +1 @@
1
- export { compareVersions, isExactVersion, satisfies, valid } from './src/semver.js'
1
+ export { clean, compareVersions, intersects, isExactVersion, major, 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
@@ -0,0 +1,2 @@
1
+ export { HttpError } from './src/http.js'
2
+ export { getZip } from './src/soldeer.js'
@@ -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((err) => {
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
@@ -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 (let i = 0; i < names.length; i += NAMES_PER_REQUEST) {
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')
@@ -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: assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u)),
22
- assertVersion: 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)),
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 (let i = 0; i < list.length; i += QUERIES_PER_REQUEST) {
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')
@@ -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 { readCacheJSON, writeCacheJSON } from '../cache.js'
6
- import { CRATES_API, HttpError, PACKAGIST_REPO, buildUrl, request } from '../http.js'
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 readCacheJSON(dir, `${name}.json`)
24
- const age = typeof entry?.at === 'number' ? Date.now() - entry.at : Number.NaN
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 writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, github })
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 (let i = 0; i < names.length; i += CRATES_PER_REQUEST) {
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((err) => {
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?.packages ?? {}, name) ? answer.packages[name] : undefined
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
@@ -63,6 +63,8 @@ export const assertRepoName = assertion('a repository name', isRepoName)
63
63
  export const assertRepo = assertion('"owner/name"', isRepo)
64
64
  export const assertRef = assertion('a branch or tag name', isRefName)
65
65
  export const assertSha = assertion('a full commit sha', isSha)
66
+ export const isSha1 = matches(/^[\da-f]{40}$/u)
67
+ export const assertTreeId = assertion('a full tree id', isSha1)
66
68
  export const assertPath = assertion('a path inside a repository', isRepoPath)
67
69
  export const assertToken = assertion('a token', isToken)
68
70
  export const assertTokenOrNull = assertion('a token, or null for anonymous access', (value) => value === null || isToken(value))
@@ -70,3 +72,7 @@ export const assertUserAgent = assertion('a printable user agent', matches(/^[ -
70
72
  export const assertPackageName = assertion('an npm package name', isPackageName)
71
73
  export const assertPackageVersion = assertion('an exact semver version', isExactVersion)
72
74
  export const assertGhsa = assertion('a GHSA id', isGhsa)
75
+ export const assertCrateName = assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u))
76
+ 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))
77
+ // As Cargo.lock and soldeer.lock write a checksum.
78
+ 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 { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
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', 'github/trees'])
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
- return root === undefined ? null : join(root, dir, encodeURIComponent(key).replaceAll('%40', '@').replaceAll('%2F', '+'))
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 readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
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
+ }
@@ -0,0 +1,41 @@
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
+ import { gitTreeOfTarball } from './tree.js'
7
+
8
+ // A hash as each registry writes it: npm's integrity, one sha512 in base64
9
+ // after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex; a
10
+ // git tree id for a tarball of that tree.
11
+ const DIGESTS = {
12
+ sha512: (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
13
+ sha256: (bytes) => createHash('sha256').update(bytes).digest('hex'),
14
+ tree: gitTreeOfTarball,
15
+ }
16
+
17
+ // Bytes whose `algorithm` hash is `expected`, which the registry or the
18
+ // caller gives, never a disk; without it nothing is read. From `local`,
19
+ // other tools' caches (read, never written; a mismatch is passed over),
20
+ // else ours (a mismatch throws), else what `locate` answers, checked and
21
+ // then cached.
22
+ export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], locate, options = {}, list }) {
23
+ assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
24
+ const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list })
25
+ for (const path of local) {
26
+ const bytes = await readRegularFile(path)
27
+ if (bytes && await digest(bytes) === expected) return bytes
28
+ }
29
+ const check = async (bytes, from) => {
30
+ const actual = await digest(bytes)
31
+ assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
32
+ return bytes
33
+ }
34
+ const key = `${what}.${ext}`
35
+ const cached = await readCache(dir, key)
36
+ if (cached) return await check(cached, 'the cache')
37
+ const url = await locate()
38
+ const bytes = await check(await request(url, { ...options, as: 'bytes' }), url)
39
+ await writeCache(dir, key, bytes)
40
+ return bytes
41
+ }
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict'
2
2
  import { Buffer } from 'node:buffer'
3
3
  import { createHash } from 'node:crypto'
4
4
 
5
- import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, isSha, optional, sameName, show } from '../args.js'
5
+ import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTreeId, isSha, isSha1, optional, sameName, show } from '../args.js'
6
+ import { verifiedDownload } from '../download.js'
6
7
  import { decode, encodeSegment } from '../http.js'
7
8
  import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
8
9
 
@@ -66,11 +67,36 @@ async function getRepoFile(headers, options) {
66
67
  return decode(bytes, url)
67
68
  }
68
69
 
69
- // The one request that follows a redirect, to codeload.github.com. A full
70
- // sha only, so the bytes are that commit's, not wherever a ref points now.
70
+ // Follows the redirect to codeload.github.com. A tree id names its content,
71
+ // so the bytes are held to it, downloaded or cached, and cached by it alone,
72
+ // for good. What a tarball cannot show, a submodule's commit or a subtree
73
+ // with nothing in it, comes from GitHub's listings of the trees, which the
74
+ // id checks as well: a directory at a time, as a recursive listing of a
75
+ // large tree is cut short.
76
+ async function treeTarball(method, headers, repo, tree) {
77
+ const listings = new Map()
78
+ const list = (sha) => {
79
+ if (!listings.has(sha)) listings.set(sha, call(headers, repoApi(repo, ['git', 'trees', sha])).then((listing) => (Array.isArray(listing?.tree) ? listing.tree : [])))
80
+ return listings.get(sha)
81
+ }
82
+ const locate = () => repoApi(repo, ['tarball', tree])
83
+ return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list })
84
+ }
85
+
86
+ // A full sha only, so the bytes are that commit's, not wherever a ref points
87
+ // now. Its tree's tarball, not its own, in which `git archive` rewrites the
88
+ // files marked `export-subst`.
71
89
  async function getRepoTarball(headers, options) {
72
90
  assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
73
- return await call(headers, repoApi(options.repo, ['tarball', options.sha]), { as: 'bytes', redirect: 'follow' })
91
+ const { repo, sha } = options
92
+ const commit = await call(headers, repoApi(repo, ['git', 'commits', sha]))
93
+ assert.ok(commit?.sha === sha && isSha1(commit.tree?.sha), `getRepoTarball: GitHub names no tree for ${repo}@${sha}`)
94
+ return await treeTarball('getRepoTarball', headers, repo, commit.tree.sha)
95
+ }
96
+
97
+ async function getRepoTreeTarball(headers, options) {
98
+ assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId })
99
+ return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
74
100
  }
75
101
 
76
102
  async function getPullRequest(headers, options) {
@@ -120,5 +146,5 @@ async function listRepoAdvisories(headers, options) {
120
146
  return list
121
147
  }
122
148
 
123
- export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
149
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getRepoTreeTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
124
150
  export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
@@ -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 (err) {
70
- throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${err.message}): ${printable(text.slice(0, 200))}`, { cause: err })
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
- return await createCommitOnBranch(headers, { branch: { repositoryNameWithOwner: repo, branchName: branch }, message, fileChanges: changes, expectedHeadOid })
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 (err) {
43
- throw new Error(`Malformed UTF-8 from ${from}`, { cause: err })
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 (err) {
110
- throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause: err })
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 { readCacheJSON, writeCacheJSON } from '../cache.js'
5
- import { HttpError, NPM_REGISTRY, buildUrl, request } from '../http.js'
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 readCacheJSON(DIR, `${name}.json`)
32
- const age = typeof entry?.at === 'number' ? Date.now() - entry.at : Number.NaN
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 writeCacheJSON(DIR, `${name}.json`, { at: Date.now(), name, github, directory })
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((err) => {
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 }) }
@@ -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 { readCache, writeCache } from '../cache.js'
10
- import { MAX_BYTES, NPM_REGISTRY, buildUrl, request } from '../http.js'
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 expected = tarballUrl(name, version)
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
- async function readLocalCaches(name, version, integrity) {
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
- const audit = join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)
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. A mismatch in ours throws rather
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
- dist = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
103
- const local = await readLocalCaches(name, version, dist.integrity)
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
@@ -10,3 +10,5 @@ export async function pool(items, limit, fn) {
10
10
  }))
11
11
  return results
12
12
  }
13
+
14
+ export const chunks = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size))
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,9 @@ 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
43
+ export const clean = (...args) => semver().clean(...args)
44
+ export const major = (...args) => semver().major(...args)
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
+ }
package/src/tree.js ADDED
@@ -0,0 +1,180 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { gunzipSync } from 'node:zlib'
4
+
5
+ import { isSha1, matches } from './args.js'
6
+
7
+ const BLOCK = 512
8
+ const MAX_UNPACKED_BYTES = 2 ** 30
9
+ // Only what git writes is read: anything else, a pax `size` or a mode of
10
+ // 0000, would be extracted as it is not hashed. Modes are as its umask,
11
+ // 002 or 022, leaves them: a file 0664 or 0644, 0775 or 0755 executable;
12
+ // a directory 0775 or 0755; a symlink 0777.
13
+ const PAX_KEYS = new Set(['path', 'linkpath'])
14
+ const MODES = { 0: [0o664, 0o644, 0o775, 0o755], 2: [0o777], 5: [0o775, 0o755] }
15
+ // The top directory is not hashed, so it is held to GitHub's name for it,
16
+ // owner-repo-id: another, `..\..`, `C:` or a Windows device such as
17
+ // `con.txt`, could take an extractor elsewhere.
18
+ const isTop = matches(/^(?!(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[\da-z][\w.-]*$/iu)
19
+
20
+ // Names are kept as latin1 strings, a char per byte, so they sort and hash
21
+ // as the bytes git has.
22
+ const field = (header, start, end) => {
23
+ const bytes = header.subarray(start, end)
24
+ const nul = bytes.indexOf(0)
25
+ return Buffer.from(nul === -1 ? bytes : bytes.subarray(0, nul)).toString('latin1')
26
+ }
27
+ const octal = (header, start, end) => {
28
+ const text = field(header, start, end).trim()
29
+ return /^[0-7]{1,12}$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
30
+ }
31
+ const objectId = (type, content) => createHash('sha1').update(`${type} ${content.length}\0`).update(content).digest()
32
+ const checksum = (header) => header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0)
33
+ const isZero = (bytes) => bytes.every((byte) => byte === 0)
34
+
35
+ // `<length> <key>=<value>\n`, the length counting the whole record. A NUL
36
+ // would end a path for an extractor, and a name in a tree for git.
37
+ function paxRecords(body) {
38
+ const text = Buffer.from(body).toString('latin1')
39
+ const records = new Map()
40
+ for (let at = 0; at < text.length;) {
41
+ const space = text.indexOf(' ', at)
42
+ const length = text.slice(at, space)
43
+ const end = at + Number(length)
44
+ if (!/^[1-9]\d*$/u.test(length) || end > text.length || text[end - 1] !== '\n') return null
45
+ const record = text.slice(space + 1, end - 1)
46
+ const equals = record.indexOf('=')
47
+ if (equals < 1 || record.includes('\0')) return null
48
+ records.set(record.slice(0, equals), record.slice(equals + 1))
49
+ at = end
50
+ }
51
+ return records
52
+ }
53
+
54
+ // Git keeps no empty directory: in a tarball, one is a submodule. Each as
55
+ // [the directory holding it, its name, its path].
56
+ function emptyDirs(dir, prefix = '') {
57
+ return [...dir].flatMap(([name, entry]) => {
58
+ if (!(entry instanceof Map)) return []
59
+ const path = `${prefix}${name}`
60
+ return entry.size === 0 ? [[dir, name, path]] : emptyDirs(entry, `${path}/`)
61
+ })
62
+ }
63
+
64
+ // Git sorts a subtree as if its name ended in `/`.
65
+ function treeId(dir) {
66
+ const entries = [...dir].map(([name, entry]) => (entry instanceof Map ? { mode: '40000', name, id: treeId(entry) } : { ...entry, name }))
67
+ const key = ({ mode, name }) => (mode === '40000' ? `${name}/` : name)
68
+ entries.sort((a, b) => (key(a) < key(b) ? -1 : Number(key(a) > key(b))))
69
+ return objectId('tree', Buffer.concat(entries.flatMap(({ mode, name, id }) => [Buffer.from(`${mode} ${name}\0`, 'latin1'), id])))
70
+ }
71
+
72
+ const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
73
+ const isName = matches(/^[^\0/]+$/u)
74
+ const subtree = (sha) => ({ mode: '40000', id: Buffer.from(sha, 'hex') })
75
+
76
+ // A subtree with nothing in it but subtrees like it, down to the empty
77
+ // tree: its listing, read as subtrees alone, hashes to its id.
78
+ async function holdsNothing(sha, listed) {
79
+ if (sha === EMPTY_TREE) return true
80
+ const entries = await listed(sha)
81
+ if (treeId(new Map(entries.map((entry) => [entry.name, subtree(entry.sha)]))).toString('hex') !== sha) return false
82
+ for (const entry of entries) if (!await holdsNothing(entry.sha, listed)) return false
83
+ return true
84
+ }
85
+
86
+ // `git archive` leaves out a subtree with no file in it, which only
87
+ // plumbing makes. Where a directory's id is not the one listed, its listing
88
+ // names any, and one is put back only once its own listings show it holds
89
+ // nothing.
90
+ async function putBackEmptyTrees(dir, sha, listed) {
91
+ if (treeId(dir).toString('hex') === sha) return
92
+ for (const entry of await listed(sha)) {
93
+ if (entry.type !== 'tree') continue
94
+ const here = dir.get(entry.name)
95
+ if (here instanceof Map) await putBackEmptyTrees(here, entry.sha, listed)
96
+ else if (here === undefined && await holdsNothing(entry.sha, listed)) dir.set(entry.name, subtree(entry.sha))
97
+ }
98
+ }
99
+
100
+ // The id of the git tree a gzipped tarball holds, as `git archive` writes
101
+ // one, under a single top directory: a file's mode is its exec bit, a
102
+ // symlink's blob its target. What a tarball cannot show comes from `list`,
103
+ // which answers a listing of a tree by its id, as GitHub's trees API does
104
+ // ({ type, path, sha } entries), walked from `expected`: a submodule's
105
+ // commit for an empty directory, and, where the id comes out otherwise, the
106
+ // subtrees with nothing in them it leaves out. The id is `expected` only if
107
+ // those are right. Where there is no such tree, a reason, never an id.
108
+ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
109
+ let bytes
110
+ try {
111
+ bytes = gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES })
112
+ } catch {
113
+ return 'no tree: not gzip, or larger than 1 GiB unpacked'
114
+ }
115
+ const root = new Map()
116
+ let top
117
+ let pax = null
118
+ for (let at = 0; ;) {
119
+ const header = bytes.subarray(at, at + BLOCK)
120
+ if (header.length < BLOCK) return 'no tree: the tarball is cut short'
121
+ if (isZero(header)) {
122
+ if (!isZero(bytes.subarray(at))) return 'no tree: data after the end of the tarball'
123
+ break
124
+ }
125
+ if (Buffer.from(header.subarray(257, 265)).toString('latin1') !== 'ustar\u000000') return 'no tree: a header that is not POSIX ustar'
126
+ if (octal(header, 148, 156) !== checksum(header)) return 'no tree: a header that fails its checksum'
127
+ const size = octal(header, 124, 136)
128
+ const body = bytes.subarray(at + BLOCK, at + BLOCK + size)
129
+ if (body.length !== size) return 'no tree: the tarball is cut short'
130
+ at += BLOCK + Math.ceil(size / BLOCK) * BLOCK
131
+ const type = String.fromCodePoint(header[156])
132
+ if (type === 'x') {
133
+ const records = paxRecords(body)
134
+ if (records === null) return 'no tree: a malformed pax header'
135
+ const other = [...records.keys()].find((key) => !PAX_KEYS.has(key))
136
+ if (other !== undefined) return `no tree: a pax record git does not write, ${JSON.stringify(other)}`
137
+ if (pax !== null) return 'no tree: two pax headers for one entry'
138
+ pax = records
139
+ continue
140
+ }
141
+ const prefix = field(header, 345, 500)
142
+ const path = pax?.get('path') ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100))
143
+ const target = pax?.get('linkpath') ?? field(header, 157, 257)
144
+ pax = null
145
+ if (!Object.hasOwn(MODES, type)) return `no tree: an entry of type ${JSON.stringify(type)}`
146
+ const mode = octal(header, 100, 108)
147
+ const asGitWrites = MODES[type].includes(mode) && (type === '0' || size === 0) && path.endsWith('/') === (type === '5')
148
+ && octal(header, 108, 116) === 0 && octal(header, 116, 124) === 0 && field(header, 265, 297) === 'root' && field(header, 297, 329) === 'root'
149
+ if (!asGitWrites) return `no tree: a header git does not write, ${JSON.stringify(path)}`
150
+ const [first, ...parts] = path.replace(/\/$/u, '').split('/')
151
+ top ??= first
152
+ const name = type === '5' ? null : parts.pop()
153
+ if (!isTop(first) || first !== top || name === undefined || [...parts, name].some((part) => ['', '.', '..'].includes(part))) return `no tree: an entry outside one top directory, ${JSON.stringify(path)}`
154
+ let dir = root
155
+ for (const part of parts) {
156
+ if (!dir.has(part)) dir.set(part, new Map())
157
+ dir = dir.get(part)
158
+ if (!(dir instanceof Map)) return `no tree: a file where a directory is, ${JSON.stringify(path)}`
159
+ }
160
+ if (name === null) continue
161
+ if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
162
+ dir.set(name, type === '0' ? { mode: mode & 0o100 ? '100755' : '100644', id: objectId('blob', body) } : { mode: '120000', id: objectId('blob', Buffer.from(target, 'latin1')) })
163
+ }
164
+ if (top === undefined) return 'no tree: an empty tarball'
165
+ const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
166
+ .filter((entry) => isName(entry?.path) && isSha1(entry.sha))
167
+ .map((entry) => ({ ...entry, name: Buffer.from(entry.path).toString('latin1') }))
168
+ for (const [dir, name, path] of emptyDirs(root)) {
169
+ let sha = expected
170
+ const parts = path.split('/')
171
+ for (const [i, part] of parts.entries()) {
172
+ const type = i === parts.length - 1 ? 'commit' : 'tree'
173
+ sha = (await listed(sha)).find((entry) => entry.type === type && entry.name === part)?.sha
174
+ }
175
+ if (!isSha1(sha)) return `no tree: an empty directory, ${JSON.stringify(path)}, and no submodule there`
176
+ dir.set(name, { mode: '160000', id: Buffer.from(sha, 'hex') })
177
+ }
178
+ await putBackEmptyTrees(root, expected, listed)
179
+ return treeId(root).toString('hex')
180
+ }