@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.
- package/LICENSE-APACHE +202 -0
- package/advisories.d.ts +47 -16
- package/cargo.d.ts +36 -0
- package/cargo.js +3 -0
- package/github.d.ts +67 -13
- package/npm.d.ts +40 -13
- package/npm.js +1 -1
- package/package.d.ts +3 -3
- package/package.json +41 -4
- package/semver.d.ts +12 -2
- package/semver.js +1 -1
- package/soldeer.d.ts +17 -0
- package/soldeer.js +2 -0
- package/src/advisories/common.js +27 -5
- package/src/advisories/github.js +38 -20
- package/src/advisories/index.js +7 -7
- package/src/advisories/npm.js +20 -6
- package/src/advisories/osv.js +39 -21
- package/src/advisories/repos.js +18 -42
- package/src/args.js +40 -2
- package/src/attributes.js +81 -0
- package/src/cache.js +103 -9
- package/src/cargo/crate.js +53 -0
- package/src/cargo/repos.js +52 -0
- package/src/download.js +42 -0
- package/src/github/read.js +113 -14
- package/src/github/write.js +6 -9
- package/src/http.js +29 -14
- package/src/npm/registry.js +19 -0
- package/src/npm/repos.js +27 -25
- package/src/npm/tarball.js +13 -53
- package/src/package.js +17 -6
- package/src/pool.js +2 -0
- package/src/remote.js +8 -1
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +29 -12
- package/src/soldeer.js +26 -0
- package/src/tree.js +237 -0
- package/src/weak-edges.js +87 -0
- package/weak-edges.d.ts +36 -0
- package/weak-edges.js +1 -0
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/src/advisories/repos.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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 =
|
|
22
|
-
|
|
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((
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/download.js
ADDED
|
@@ -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
|
+
}
|