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

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.
@@ -1,54 +1,24 @@
1
1
  import assert from 'node:assert/strict'
2
- import { setTimeout as sleep } from 'node:timers/promises'
3
2
 
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'
3
+ import { show } from '../args.js'
4
+ import { addRepos, readRepos } from '../cache.js'
5
+ import { lookUpCrateRepos } from '../cargo/repos.js'
6
+ import { PACKAGIST_REPO, SOLDEER_API, buildUrl, isNotFound, recover, request } from '../http.js'
7
7
  import { lookUpPackageRepo } from '../npm/repos.js'
8
8
  import { pool } from '../pool.js'
9
9
  import { githubRepoOfUrl } from '../remote.js'
10
10
 
11
- const TTL_MS = 30 * 24 * 60 * 60 * 1000
12
- const CRATES_PER_REQUEST = 100
13
- const CRATES_PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
14
- const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
15
11
  const PACKAGES_AT_ONCE = 8
16
12
 
17
13
  // Name → its GitHub repo, or null where the registry has no such package
18
14
  // or it names no GitHub repo; any other failure throws. Only a repo found
19
15
  // is cached, for a month.
20
16
  async function cachedRepos(dir, names, fetchMissing) {
21
- const repos = new Map()
22
- 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)
26
- }
27
- const missing = names.filter((name) => !repos.has(name))
28
- for (const [name, github] of await fetchMissing(missing)) {
29
- repos.set(name, github)
30
- if (github) await writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, github })
31
- }
17
+ const repos = await readRepos(dir, names)
18
+ await addRepos(dir, repos, await fetchMissing(names.filter((name) => !repos.has(name))))
32
19
  return repos
33
20
  }
34
21
 
35
- // A crate crates.io leaves out of its answer is one it does not have.
36
- async function fetchCrates(names) {
37
- const found = new Map(names.map((name) => [name, null]))
38
- for (let i = 0; i < names.length; i += CRATES_PER_REQUEST) {
39
- if (i > 0) await sleep(CRATES_PACE_MS)
40
- const chunk = names.slice(i, i + CRATES_PER_REQUEST)
41
- const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': chunk, per_page: CRATES_PER_REQUEST })
42
- const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
43
- assert.ok(Array.isArray(answer?.crates), 'advisories: expected a list of crates from crates.io')
44
- for (const crate of answer.crates) {
45
- assert.ok(chunk.includes(crate?.id), `advisories: crates.io answered for ${show(crate?.id)}, which was not asked`)
46
- found.set(crate.id, githubRepoOfUrl(crate.repository) ?? null)
47
- }
48
- }
49
- return found
50
- }
51
-
52
22
  async function fetchEach(names, fetch) {
53
23
  const found = await pool(names, PACKAGES_AT_ONCE, fetch)
54
24
  return new Map(names.map((name, i) => [name, found[i]]))
@@ -57,16 +27,22 @@ async function fetchEach(names, fetch) {
57
27
  // The first version in Packagist's file is its latest, in full.
58
28
  async function fetchComposerRepo(name) {
59
29
  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
- })
30
+ const answer = await request(buildUrl(PACKAGIST_REPO, ['p2', vendor, `${pkg}.json`]), { as: 'json' }).catch(recover(isNotFound, null))
64
31
  if (answer === null) return null
65
- const versions = Object.hasOwn(answer?.packages ?? {}, name) ? answer.packages[name] : undefined
32
+ const versions = Object.hasOwn(answer.packages ?? {}, name) ? answer.packages[name] : undefined
66
33
  assert.ok(Array.isArray(versions), `advisories: Packagist answered without ${name}`)
67
34
  return githubRepoOfUrl(versions[0]?.source?.url) ?? null
68
35
  }
69
36
 
70
- export const crateRepos = (names) => cachedRepos('cargo/repos', names, fetchCrates)
37
+ // Soldeer answers a project it does not have with an empty list.
38
+ async function fetchSoldeerRepo(name) {
39
+ const answer = await request(buildUrl(SOLDEER_API, ['api', 'v1', 'project'], { project_name: name }), { as: 'json' })
40
+ assert.ok(Array.isArray(answer?.data), `advisories: expected a list of projects from Soldeer for ${name}`)
41
+ for (const project of answer.data) assert.ok(project?.name === name, `advisories: Soldeer answered for ${show(project?.name)}, not ${name}`)
42
+ return githubRepoOfUrl(answer.data[0]?.github_url) ?? null
43
+ }
44
+
45
+ export const crateRepos = (names) => lookUpCrateRepos('advisories', names)
71
46
  export const composerRepos = (names) => cachedRepos('composer/repos', names, (missing) => fetchEach(missing, fetchComposerRepo))
72
47
  export const npmRepos = (names) => fetchEach(names, async (name) => (await lookUpPackageRepo(name))?.github ?? null)
48
+ export const soldeerRepos = (names) => cachedRepos('soldeer/repos', names, (missing) => fetchEach(missing, fetchSoldeerRepo))
package/src/args.js CHANGED
@@ -15,7 +15,9 @@ const isLogin = matches(/^(?=.{1,39}$)[a-z\d](?:-?[a-z\d])*$/iu)
15
15
  const isRepoName = matches(/^(?!\.\.?$)[\w.-]{1,100}$/u)
16
16
  // `.git` is never in a repo's tree, and no commit should write into it.
17
17
  export const isRepoPath = (value) => isString(value, 4096) && value.split('/').every((part) => !['', '.', '..', '.git'].includes(part.toLowerCase()))
18
- const BAD_REF = /^$|^@$|^-|[ ~^:?*[\\]|\.\.|@\{|^\/|\/$|\/\/|(?:^|\/)\.|\.lock(?:\/|$)|\.$/u // git check-ref-format, a rule per alternative
18
+ // git check-ref-format, a rule per alternative. A tag's name may be `@` or
19
+ // start with `-`; a branch's may not.
20
+ const BAD_REF = /^$|[ ~^:?*[\\]|\.\.|@\{|^\/|\/$|\/\/|(?:^|\/)\.|\.lock(?:\/|$)|\.$/u
19
21
  const isToken = matches(/^[!-~]+$/u)
20
22
  // npm's rules for existing names: capitals allowed (JSONStream), the
21
23
  // legacy `~'!()*` not.
@@ -37,8 +39,10 @@ export function isRepo(value) {
37
39
  }
38
40
 
39
41
  export const isStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string')
42
+ export const isIterable = (value) => typeof value?.[Symbol.iterator] === 'function' && typeof value !== 'string'
40
43
  export const sameName = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase()
41
- export const isRefName = (value) => isString(value, 255) && !BAD_REF.test(value)
44
+ export const isTagName = (value) => isString(value, 255) && !BAD_REF.test(value)
45
+ export const isRefName = (value) => isTagName(value) && !/^(?:@$|-)/u.test(value)
42
46
  export const isSha = matches(/^(?:[\da-f]{40}|[\da-f]{64})$/u)
43
47
  export const isGhsa = matches(/^GHSA(?:-[\da-hj-km-np-tv-z]{4}){3}$/u)
44
48
  export const assertion = (must, predicate) => (method, what, value) => assert.ok(predicate(value), `${method}: ${what} must be ${must}, got ${show(value)}`)
@@ -52,17 +56,33 @@ export function assertArgs(method, options, spec, name) {
52
56
  for (const [key, check] of Object.entries(spec)) check?.(method, label(key), options[key])
53
57
  }
54
58
 
59
+ // The names a resolve*Repos call is given, each checked and given once,
60
+ // and its options.
61
+ export function assertNames(method, what, names, options, assertName) {
62
+ assert.ok(isIterable(names), `${method}: ${what} must be an iterable of names`)
63
+ assertArgs(method, options, { cachedOnly: optional(assertBoolean) })
64
+ const unique = [...new Set(names)]
65
+ for (const name of unique) assertName(method, 'name', name)
66
+ return unique
67
+ }
68
+
55
69
  export const assertBoolean = assertion('a boolean', (value) => typeof value === 'boolean')
56
70
  export const assertNumber = assertion('a positive integer', (value) => Number.isSafeInteger(value) && value > 0)
57
71
  export const assertLine = assertion('a non-empty single line', (value) => isString(value, 1024) && value.trim() !== '')
58
72
  // 65,536 is GitHub's own limit on a body.
59
73
  export const assertText = assertion('text with no control characters', (value) => isString(value, 65_536, '\t\n\r'))
60
74
  export const assertDirectoryPath = assertion('a directory path', (value) => isString(value, 4096) && value !== '')
75
+ // A short ASCII name, which no filesystem refuses, reads as another or takes
76
+ // for a device: none of those is six characters of these alone.
77
+ export const assertCacheName = assertion('6 to 32 of a-z, A-Z, 0-9, _ and -', matches(/^[a-zA-Z0-9_-]{6,32}$/u))
61
78
  export const assertLogin = assertion('a GitHub login', isLogin)
62
79
  export const assertRepoName = assertion('a repository name', isRepoName)
63
80
  export const assertRepo = assertion('"owner/name"', isRepo)
64
81
  export const assertRef = assertion('a branch or tag name', isRefName)
82
+ export const assertTagName = assertion('a tag name', isTagName)
65
83
  export const assertSha = assertion('a full commit sha', isSha)
84
+ export const isSha1 = matches(/^[\da-f]{40}$/u)
85
+ export const assertTreeId = assertion('a full tree id', isSha1)
66
86
  export const assertPath = assertion('a path inside a repository', isRepoPath)
67
87
  export const assertToken = assertion('a token', isToken)
68
88
  export const assertTokenOrNull = assertion('a token, or null for anonymous access', (value) => value === null || isToken(value))
@@ -70,3 +90,21 @@ export const assertUserAgent = assertion('a printable user agent', matches(/^[ -
70
90
  export const assertPackageName = assertion('an npm package name', isPackageName)
71
91
  export const assertPackageVersion = assertion('an exact semver version', isExactVersion)
72
92
  export const assertGhsa = assertion('a GHSA id', isGhsa)
93
+ export const assertCrateName = assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u))
94
+ 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))
95
+ // Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
96
+ // `-`, starting with one of them or `@`, ending with a letter or digit.
97
+ export const assertSoldeerName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
98
+ // Soldeer takes any version but an empty one. Its registry has semver
99
+ // (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
100
+ export const assertSoldeerVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
101
+ // A package's name and version, as a registry's getters take them.
102
+ const assertNameVersion = (assertName, assertVersion) => (method, name, version) => {
103
+ assertName(method, 'name', name)
104
+ assertVersion(method, 'version', version)
105
+ }
106
+ export const assertCrate = assertNameVersion(assertCrateName, assertCrateVersion)
107
+ export const assertPackage = assertNameVersion(assertPackageName, assertPackageVersion)
108
+ export const assertSoldeerPackage = assertNameVersion(assertSoldeerName, assertSoldeerVersion)
109
+ // As Cargo.lock and soldeer.lock write a checksum.
110
+ export const assertSha256 = assertion('a sha256 in lowercase hex', matches(/^[\da-f]{64}$/u))
@@ -0,0 +1,81 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ // Whether git writes a file out with CRLF, from the .gitattributes in its
4
+ // tree alone, as `git archive` and a checkout with no config read them. Only
5
+ // `*`, `*.ext`, name and path rules are read: any other rule that sets an
6
+ // attribute line endings depend on refuses its file, and all below it.
7
+
8
+ const RELEVANT = new Set(['text', 'crlf', 'eol', 'binary', 'ident', 'filter', 'working-tree-encoding'])
9
+ const BINARY = [['diff', false], ['merge', false], ['text', false]]
10
+ const GLOB = /[*?[\\]/u
11
+ const PRINTING = new Set([0x08, 0x09, 0x0c, 0x1b]) // BS, HT, FF, ESC
12
+
13
+ function matcher(pattern) {
14
+ const name = (path) => path.slice(path.lastIndexOf('/') + 1)
15
+ if (pattern.endsWith('/')) return () => false // a directory
16
+ if (!GLOB.test(pattern)) return pattern.includes('/') ? (path) => path === pattern.replace(/^\//u, '') : (path) => name(path) === pattern
17
+ const end = pattern.slice(1)
18
+ return pattern[0] === '*' && !GLOB.test(end) && !end.includes('/') ? (path) => name(path).endsWith(end) : null
19
+ }
20
+
21
+ // Read to the first NUL, as git reads one from a tree. A line with a name
22
+ // git takes for none, or a negative pattern, git leaves out.
23
+ function parseAttributes(bytes) {
24
+ const nul = bytes.indexOf(0)
25
+ const rules = []
26
+ for (const line of Buffer.from(bytes.buffer, bytes.byteOffset, nul === -1 ? bytes.length : nul).toString('latin1').split('\n')) {
27
+ const [pattern, ...tokens] = line.split(/[ \t\r]+/u).filter(Boolean)
28
+ if (pattern?.startsWith('"')) return null
29
+ if (pattern === undefined || pattern.startsWith('#') || pattern.startsWith('!')) continue
30
+ const states = tokens.map((token) => /^([-!]?)([\w.][\w.-]*)(?:=(.*))?$/u.exec(token))
31
+ if (states.includes(null) || !states.some(([, , name]) => RELEVANT.has(name))) continue
32
+ const match = matcher(pattern)
33
+ if (!match) return null
34
+ rules.push({ match, states: states.map(([, sign, name, value = true]) => [name, sign === '-' ? false : sign === '!' ? null : value]) })
35
+ }
36
+ return rules
37
+ }
38
+
39
+ // `base` is the directory's path and a `/`; `bytes` is undefined where it
40
+ // has no .gitattributes, null where that is no file.
41
+ export function withAttributes(above, base, bytes) {
42
+ if (above === null || bytes === null) return null
43
+ const rules = bytes === undefined ? [] : parseAttributes(bytes)
44
+ return rules && [...above, { base, rules }]
45
+ }
46
+
47
+ // convert.c's text test for `text=auto`, on the blob these bytes are written from.
48
+ function isAutoText(written) {
49
+ let printable = 0
50
+ let other = written.at(-1) === 0x1a ? -1 : 0
51
+ for (let i = 0; i < written.length; i++) {
52
+ const c = written[i]
53
+ if (c === 0x0d && written[i + 1] !== 0x0a) return false
54
+ if (c === 0x0d || c === 0x0a) continue
55
+ if (c === 0) return false
56
+ if (c >= 0x20 ? c !== 0x7f : PRINTING.has(c)) printable++
57
+ else other++
58
+ }
59
+ return printable >> 7 >= other
60
+ }
61
+
62
+ // attr.c's order: the deepest file and its last line first. Anything else
63
+ // rewriting the file (ident, filter, working-tree-encoding) refuses it.
64
+ export function writtenWithCrlf(files, path, written) {
65
+ if (files === null) return false
66
+ const states = new Map()
67
+ const fill = (list) => {
68
+ for (const [name, value] of list.toReversed()) {
69
+ if (states.has(name)) continue
70
+ states.set(name, value)
71
+ if (name === 'binary' && value === true) fill(BINARY)
72
+ }
73
+ }
74
+ for (const { base, rules } of files.toReversed()) {
75
+ for (const { match, states: list } of rules.toReversed()) if (match(path.slice(base.length))) fill(list)
76
+ }
77
+ if (states.get('ident') === true || typeof states.get('filter') === 'string' || states.get('working-tree-encoding')) return false
78
+ const action = [states.get('text'), states.get('crlf')].find((value) => typeof value === 'boolean' || value === 'input' || value === 'auto')
79
+ if (action === false || states.get('eol') !== 'crlf') return false
80
+ return action !== 'auto' || isAutoText(written)
81
+ }
package/src/cache.js CHANGED
@@ -1,16 +1,50 @@
1
1
  import assert from 'node:assert/strict'
2
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
- import { dirname, join, resolve } from 'node:path'
2
+ import { constants } from 'node:fs'
3
+ import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
4
6
 
5
- import { assertDirectoryPath } from './args.js'
6
- import { decode } from './http.js'
7
+ import { assertCacheName, assertDirectoryPath, isRepo } from './args.js'
8
+ import { MAX_BYTES, decode } from './http.js'
7
9
 
8
- const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'composer/repos'])
10
+ const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees'])
11
+ const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
12
+
13
+ // An environment variable may hold anything: only an absolute path is one.
14
+ const absolute = (path) => (typeof path === 'string' && isAbsolute(path) ? path : undefined)
15
+ const within = (base, ...names) => (base === undefined ? undefined : join(base, ...names))
16
+
17
+ // os.homedir() takes $HOME as it is, relative or empty, and throws where
18
+ // neither it nor the user's passwd entry has one.
19
+ function home() {
20
+ try {
21
+ return absolute(homedir())
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ // Only Linux and the BSDs follow the XDG Base Directory spec, so macOS and
28
+ // Windows take no XDG_CACHE_HOME, as Go's os.UserCacheDir and Rust's dirs
29
+ // take none.
30
+ export function cacheDirFor(name) {
31
+ assertCacheName('cacheDirFor', 'name', name)
32
+ if (process.platform === 'darwin') return within(home(), 'Library', 'Caches', name)
33
+ if (process.platform === 'win32') return within(absolute(process.env.LOCALAPPDATA) ?? within(home(), 'AppData', 'Local'), name, 'Cache')
34
+ return within(absolute(process.env.XDG_CACHE_HOME) ?? within(home(), '.cache'), name)
35
+ }
36
+
37
+ export const defaultCacheDir = cacheDirFor('PreventiveMeasures')
9
38
 
10
39
  let root
11
40
  let tmpSeq = 0
12
41
 
13
- export function setCacheDir(dir) {
42
+ export function setCacheDir(dir = defaultCacheDir) {
43
+ if (dir === false) {
44
+ root = undefined
45
+ return
46
+ }
47
+ assert.ok(dir !== undefined, 'setCacheDir: there is no default cache directory, as no absolute home directory is known: give one')
14
48
  assertDirectoryPath('setCacheDir', 'dir', dir)
15
49
  root = resolve(dir)
16
50
  }
@@ -18,14 +52,47 @@ export function setCacheDir(dir) {
18
52
  // URI-encoded, so a `/` or `..` in a key stays inside one file name, then
19
53
  // `@` kept and `/` written `+`, as pnpm names its store: `@babel+core@7.29.7`.
20
54
  // encodeURIComponent writes neither `@` nor `+` bare, so no two keys meet.
21
- function cachePath(dir, key) {
55
+ // Where case is not told apart (macOS, Windows), JSONStream and jsonstream
56
+ // would still meet, so a capital is written `!` and the letter, and a `!`
57
+ // as `!!`. A Windows device name (`con.json` is the console) has its first
58
+ // letter escaped, which encodeURIComponent never does.
59
+ function cachePath(dir, key, base = root) {
22
60
  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', '+'))
61
+ const name = encodeURIComponent(key.replace(/[!A-Z]/gu, (char) => `!${char.toLowerCase()}`)).replaceAll('%40', '@').replaceAll('%2F', '+')
62
+ return base === undefined ? null : join(base, dir, name.replace(/^(?=(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))./u, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`))
63
+ }
64
+
65
+ // Caches of ours, read whether set or not for what is checked whatever its
66
+ // source: the default one, and stasis's, which it sets with setCacheDir.
67
+ const OURS = [defaultCacheDir, cacheDirFor('stasis')].filter((base) => base !== undefined)
68
+
69
+ export const ourCachePaths = (dir, key) => OURS.map((base) => cachePath(dir, key, base))
70
+
71
+ // Only a regular file, checked on the file as opened, so one swapped in
72
+ // after a check can't pass: a FIFO or a device would block or never end.
73
+ // Read to the size it had when opened, and no further.
74
+ export async function readRegularFile(path) {
75
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)).catch(() => null)
76
+ try {
77
+ const stats = await handle?.stat()
78
+ if (!stats?.isFile() || stats.size > MAX_BYTES) return null
79
+ const bytes = new Uint8Array(stats.size)
80
+ for (let at = 0; at < bytes.length;) {
81
+ const { bytesRead } = await handle.read(bytes, at, bytes.length - at, at)
82
+ if (bytesRead === 0) return null
83
+ at += bytesRead
84
+ }
85
+ return bytes
86
+ } catch {
87
+ return null
88
+ } finally {
89
+ await handle?.close()
90
+ }
24
91
  }
25
92
 
26
93
  export async function readCache(dir, key) {
27
94
  const path = cachePath(dir, key)
28
- return path && await readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
95
+ return path && await readRegularFile(path)
29
96
  }
30
97
 
31
98
  export async function readCacheJSON(dir, key) {
@@ -55,3 +122,30 @@ export async function writeCache(dir, key, data) {
55
122
  export async function writeCacheJSON(dir, key, value) {
56
123
  return await writeCache(dir, key, JSON.stringify(value))
57
124
  }
125
+
126
+ export async function readRecord(dir, name) {
127
+ const record = await readCacheJSON(dir, `${name}.json`)
128
+ const age = typeof record?.at === 'number' ? Date.now() - record.at : Number.NaN
129
+ const fresh = age >= 0 && age <= RECORD_TTL_MS // An entry from the future is not fresh forever.
130
+ return fresh && record.name === name ? record : null
131
+ }
132
+
133
+ export const writeRecord = (dir, name, value) => writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, ...value })
134
+
135
+ // Name → the GitHub repo cached for it, for those of `names` that have one.
136
+ export async function readRepos(dir, names) {
137
+ const repos = new Map()
138
+ for (const name of names) {
139
+ const entry = await readRecord(dir, name)
140
+ if (isRepo(entry?.github)) repos.set(name, entry.github)
141
+ }
142
+ return repos
143
+ }
144
+
145
+ // An answer into `repos`, and into the cache where it found a repo.
146
+ export async function addRepos(dir, repos, answer) {
147
+ for (const [name, github] of answer) {
148
+ repos.set(name, github)
149
+ if (github) await writeRecord(dir, name, { github })
150
+ }
151
+ }
@@ -0,0 +1,53 @@
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 { assertCrate, 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(method, 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(`${method}: 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, `${method}: the index has no ${name}@${version}`)
28
+ assert.ok(entry.name === name, `${method}: the index answered for ${show(entry.name)}, not ${name}`)
29
+ assertSha256(method, '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 verifyChecksum(name, version, checksum) {
41
+ assertCrate('verifyChecksum', name, version)
42
+ assertSha256('verifyChecksum', 'checksum', checksum)
43
+ const cksum = await getChecksum('verifyChecksum', name, version)
44
+ assert.ok(cksum === checksum, `verifyChecksum: ${name}@${version} is ${cksum} on crates.io, not ${checksum}`)
45
+ }
46
+
47
+ export async function getCrate(name, version, checksum) {
48
+ assertCrate('getCrate', name, version)
49
+ if (checksum !== undefined) assertSha256('getCrate', 'checksum', checksum)
50
+ const expected = checksum ?? await getChecksum('getCrate', name, version)
51
+ const locate = () => buildUrl(CRATES_STATIC, ['crates', name, `${name}-${encodeSegment(version)}.crate`])
52
+ return await verifiedDownload({ method: 'getCrate', dir: DIR, what: `${name}@${version}`, ext: 'crate', algorithm: 'sha256', expected, local: await localPaths(name, version), locate })
53
+ }
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+
4
+ import { assertCrateName, assertNames, show } from '../args.js'
5
+ import { addRepos, readRepos } from '../cache.js'
6
+ import { CRATES_API, buildUrl, request } from '../http.js'
7
+ import { chunks } from '../pool.js'
8
+ import { githubRepoOfUrl } from '../remote.js'
9
+
10
+ const DIR = 'cargo/repos'
11
+ const PER_REQUEST = 100
12
+ const PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
13
+ const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
14
+
15
+ // One request for up to a hundred crates: each name → the GitHub repo its
16
+ // `repository` names, or null where it names none. A crate crates.io
17
+ // leaves out of its answer is one it does not have, null too.
18
+ async function askCrates(method, names) {
19
+ const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': names, per_page: PER_REQUEST })
20
+ const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
21
+ assert.ok(Array.isArray(answer?.crates), `${method}: expected a list of crates from crates.io`)
22
+ const found = new Map(names.map((name) => [name, null]))
23
+ for (const crate of answer.crates) {
24
+ assert.ok(names.includes(crate?.id), `${method}: crates.io answered for ${show(crate?.id)}, which was not asked`)
25
+ found.set(crate.id, githubRepoOfUrl(crate.repository) ?? null)
26
+ }
27
+ return found
28
+ }
29
+
30
+ // Name → its GitHub repo, or null where crates.io has no such crate or it
31
+ // names no GitHub repo, through the cache: only a repo found is cached,
32
+ // for a month. A failed request throws, or with `soft` leaves its names
33
+ // out; `cachedOnly` asks nothing.
34
+ export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false } = {}) {
35
+ const repos = await readRepos(DIR, names)
36
+ if (cachedOnly) return repos
37
+ for (const [i, chunk] of chunks(names.filter((name) => !repos.has(name)), PER_REQUEST).entries()) {
38
+ if (i > 0) await sleep(PACE_MS)
39
+ const answer = await askCrates(method, chunk).catch((error) => {
40
+ if (soft) return new Map()
41
+ throw error
42
+ })
43
+ await addRepos(DIR, repos, answer)
44
+ }
45
+ return repos
46
+ }
47
+
48
+ export async function resolveCrateRepos(crateNames, options = {}) {
49
+ const names = assertNames('resolveCrateRepos', 'crateNames', crateNames, options, assertCrateName)
50
+ const repos = await lookUpCrateRepos('resolveCrateRepos', names, { soft: true, cachedOnly: options.cachedOnly })
51
+ return new Map(names.flatMap((name) => (repos.get(name) ? [[name, { github: repos.get(name) }]] : [])))
52
+ }
@@ -0,0 +1,42 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createHash } from 'node:crypto'
3
+
4
+ import { ourCachePaths, 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, and with `ours` our default cache and stasis's,
20
+ // whether set or not (read, never written; a mismatch is passed over),
21
+ // else the cache set (a mismatch throws), else what `locate` answers,
22
+ // checked and then cached.
23
+ export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], ours = false, locate, options = {}, list }) {
24
+ assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
25
+ const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list })
26
+ const key = `${what}.${ext}`
27
+ for (const path of ours ? [...local, ...ourCachePaths(dir, key)] : local) {
28
+ const bytes = await readRegularFile(path)
29
+ if (bytes && await digest(bytes) === expected) return bytes
30
+ }
31
+ const check = async (bytes, from) => {
32
+ const actual = await digest(bytes)
33
+ assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
34
+ return bytes
35
+ }
36
+ const cached = await readCache(dir, key)
37
+ if (cached) return await check(cached, 'the cache')
38
+ const url = await locate()
39
+ const bytes = await check(await request(url, { ...options, as: 'bytes' }), url)
40
+ await writeCache(dir, key, bytes)
41
+ return bytes
42
+ }