@preventive/upstream 1.0.0-alpha.1 → 1.0.0-alpha.11

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/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))
@@ -72,5 +92,19 @@ export const assertPackageVersion = assertion('an exact semver version', isExact
72
92
  export const assertGhsa = assertion('a GHSA id', isGhsa)
73
93
  export const assertCrateName = assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u))
74
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)
75
109
  // As Cargo.lock and soldeer.lock write a checksum.
76
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,18 +1,58 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { constants } from 'node:fs'
3
3
  import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
4
- import { dirname, join, resolve } from 'node:path'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
6
+ import { promisify } from 'node:util'
7
+ import { brotliCompress, brotliDecompress, constants as zlib } from 'node:zlib'
5
8
 
6
- import { assertDirectoryPath } from './args.js'
9
+ import { assertCacheName, assertDirectoryPath, isRepo } from './args.js'
7
10
  import { MAX_BYTES, decode } from './http.js'
8
11
 
9
- const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/zips'])
12
+ const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees', 'github/advisories'])
10
13
  const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
14
+ // Records filed brotli'd, at quality 9, as `<name>.json.br`: a listing
15
+ // carries each advisory's full text, Markdown that compresses well.
16
+ const BROTLI_DIRS = new Set(['github/advisories'])
17
+ const BROTLI = { params: { [zlib.BROTLI_PARAM_QUALITY]: 9 } }
18
+ const compress = promisify(brotliCompress)
19
+ const decompress = promisify(brotliDecompress)
20
+
21
+ // An environment variable may hold anything: only an absolute path is one.
22
+ const absolute = (path) => (typeof path === 'string' && isAbsolute(path) ? path : undefined)
23
+ const within = (base, ...names) => (base === undefined ? undefined : join(base, ...names))
24
+
25
+ // os.homedir() takes $HOME as it is, relative or empty, and throws where
26
+ // neither it nor the user's passwd entry has one.
27
+ function home() {
28
+ try {
29
+ return absolute(homedir())
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ // Only Linux and the BSDs follow the XDG Base Directory spec, so macOS and
36
+ // Windows take no XDG_CACHE_HOME, as Go's os.UserCacheDir and Rust's dirs
37
+ // take none.
38
+ export function cacheDirFor(name) {
39
+ assertCacheName('cacheDirFor', 'name', name)
40
+ if (process.platform === 'darwin') return within(home(), 'Library', 'Caches', name)
41
+ if (process.platform === 'win32') return within(absolute(process.env.LOCALAPPDATA) ?? within(home(), 'AppData', 'Local'), name, 'Cache')
42
+ return within(absolute(process.env.XDG_CACHE_HOME) ?? within(home(), '.cache'), name)
43
+ }
44
+
45
+ export const defaultCacheDir = cacheDirFor('PreventiveMeasures')
11
46
 
12
47
  let root
13
48
  let tmpSeq = 0
14
49
 
15
- export function setCacheDir(dir) {
50
+ export function setCacheDir(dir = defaultCacheDir) {
51
+ if (dir === false) {
52
+ root = undefined
53
+ return
54
+ }
55
+ assert.ok(dir !== undefined, 'setCacheDir: there is no default cache directory, as no absolute home directory is known: give one')
16
56
  assertDirectoryPath('setCacheDir', 'dir', dir)
17
57
  root = resolve(dir)
18
58
  }
@@ -24,12 +64,18 @@ export function setCacheDir(dir) {
24
64
  // would still meet, so a capital is written `!` and the letter, and a `!`
25
65
  // as `!!`. A Windows device name (`con.json` is the console) has its first
26
66
  // letter escaped, which encodeURIComponent never does.
27
- function cachePath(dir, key) {
67
+ function cachePath(dir, key, base = root) {
28
68
  assert.ok(DIRS.has(dir) && key && typeof key === 'string', `Unexpected cache entry: ${dir}`)
29
69
  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()}`))
70
+ 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()}`))
31
71
  }
32
72
 
73
+ // Caches of ours, read whether set or not for what is checked whatever its
74
+ // source: the default one, and stasis's, which it sets with setCacheDir.
75
+ const OURS = [defaultCacheDir, cacheDirFor('stasis')].filter((base) => base !== undefined)
76
+
77
+ export const ourCachePaths = (dir, key) => OURS.map((base) => cachePath(dir, key, base))
78
+
33
79
  // Only a regular file, checked on the file as opened, so one swapped in
34
80
  // after a check can't pass: a FIFO or a device would block or never end.
35
81
  // Read to the size it had when opened, and no further.
@@ -57,10 +103,12 @@ export async function readCache(dir, key) {
57
103
  return path && await readRegularFile(path)
58
104
  }
59
105
 
60
- export async function readCacheJSON(dir, key) {
106
+ // With `brotli`, the file is decompressed first, to no more than a file
107
+ // read; one that is not brotli, or runs past that, is a miss.
108
+ export async function readCacheJSON(dir, key, { brotli = false } = {}) {
61
109
  const bytes = await readCache(dir, key)
62
110
  try {
63
- return bytes && JSON.parse(decode(bytes, key))
111
+ return bytes && JSON.parse(decode(brotli ? await decompress(bytes, { maxOutputLength: MAX_BYTES }) : bytes, key))
64
112
  } catch {
65
113
  return null
66
114
  }
@@ -81,15 +129,46 @@ export async function writeCache(dir, key, data) {
81
129
  }
82
130
  }
83
131
 
84
- export async function writeCacheJSON(dir, key, value) {
85
- return await writeCache(dir, key, JSON.stringify(value))
132
+ export async function writeCacheJSON(dir, key, value, { brotli = false } = {}) {
133
+ const json = JSON.stringify(value)
134
+ return await writeCache(dir, key, brotli ? await compress(json, BROTLI) : json)
86
135
  }
87
136
 
88
- export async function readRecord(dir, name) {
89
- const record = await readCacheJSON(dir, `${name}.json`)
137
+ // A record is kept for `ttl`, in the cache set, or with `store`, in the
138
+ // caller's store instead, under `<dir>/<name>`, as it is: only the files
139
+ // are compressed.
140
+ const recordFile = (dir, name) => (BROTLI_DIRS.has(dir) ? `${name}.json.br` : `${name}.json`)
141
+
142
+ export async function readRecord(dir, name, { ttl = RECORD_TTL_MS, store } = {}) {
143
+ assert.ok(DIRS.has(dir), `Unexpected cache entry: ${dir}`)
144
+ const record = store ? await store.read(`${dir}/${name}`) : await readCacheJSON(dir, recordFile(dir, name), { brotli: BROTLI_DIRS.has(dir) })
90
145
  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.
146
+ const fresh = age >= 0 && age <= ttl // An entry from the future is not fresh forever.
92
147
  return fresh && record.name === name ? record : null
93
148
  }
94
149
 
95
- export const writeRecord = (dir, name, value) => writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, ...value })
150
+ export async function writeRecord(dir, name, value, { store } = {}) {
151
+ assert.ok(DIRS.has(dir), `Unexpected cache entry: ${dir}`)
152
+ const record = { at: Date.now(), name, ...value }
153
+ if (!store) return await writeCacheJSON(dir, recordFile(dir, name), record, { brotli: BROTLI_DIRS.has(dir) })
154
+ await store.write(`${dir}/${name}`, record)
155
+ return true
156
+ }
157
+
158
+ // Name → the GitHub repo cached for it, for those of `names` that have one.
159
+ export async function readRepos(dir, names) {
160
+ const repos = new Map()
161
+ for (const name of names) {
162
+ const entry = await readRecord(dir, name)
163
+ if (isRepo(entry?.github)) repos.set(name, entry.github)
164
+ }
165
+ return repos
166
+ }
167
+
168
+ // An answer into `repos`, and into the cache where it found a repo.
169
+ export async function addRepos(dir, repos, answer) {
170
+ for (const [name, github] of answer) {
171
+ repos.set(name, github)
172
+ if (github) await writeRecord(dir, name, { github })
173
+ }
174
+ }
@@ -3,16 +3,16 @@ import { readdir } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
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'
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
9
 
10
10
  const DIR = 'cargo/crates' // No expiry: crates.io never takes a version twice.
11
11
 
12
12
  // The sparse index, what cargo reads: a file per crate, by its lowercased
13
13
  // name under 1/, 2/, 3/<first letter>/ or <1st-2nd>/<3rd-4th>/, with a
14
14
  // JSON line per version, `cksum` the .crate's sha256.
15
- async function getChecksum(name, version) {
15
+ async function getChecksum(method, name, version) {
16
16
  const lower = name.toLowerCase()
17
17
  const prefix = lower.length < 3 ? [String(lower.length)] : lower.length === 3 ? ['3', lower[0]] : [lower.slice(0, 2), lower.slice(2, 4)]
18
18
  const text = await request(buildUrl(CRATES_INDEX, [...prefix, lower]), { as: 'text' })
@@ -20,13 +20,13 @@ async function getChecksum(name, version) {
20
20
  try {
21
21
  return JSON.parse(line)
22
22
  } catch {
23
- assert.fail(`getCrate: the index for ${name} has a line that is not JSON: ${printable(line.slice(0, 200))}`)
23
+ assert.fail(`${method}: the index for ${name} has a line that is not JSON: ${printable(line.slice(0, 200))}`)
24
24
  }
25
25
  }
26
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)
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
30
  return entry.cksum
31
31
  }
32
32
 
@@ -37,11 +37,17 @@ async function localPaths(name, version) {
37
37
  return (await readdir(root).catch(() => [])).map((registry) => join(root, registry, `${name}-${version}.crate`))
38
38
  }
39
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
+
40
47
  export async function getCrate(name, version, checksum) {
41
- assertCrateName('getCrate', 'name', name)
42
- assertCrateVersion('getCrate', 'version', version)
48
+ assertCrate('getCrate', name, version)
43
49
  if (checksum !== undefined) assertSha256('getCrate', 'checksum', checksum)
44
- const expected = checksum ?? await getChecksum(name, version)
50
+ const expected = checksum ?? await getChecksum('getCrate', name, version)
45
51
  const locate = () => buildUrl(CRATES_STATIC, ['crates', name, `${name}-${encodeSegment(version)}.crate`])
46
52
  return await verifiedDownload({ method: 'getCrate', dir: DIR, what: `${name}@${version}`, ext: 'crate', algorithm: 'sha256', expected, local: await localPaths(name, version), locate })
47
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 CHANGED
@@ -1,38 +1,42 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { createHash } from 'node:crypto'
3
3
 
4
- import { readCache, readRegularFile, writeCache } from './cache.js'
4
+ import { ourCachePaths, readCache, readRegularFile, writeCache } from './cache.js'
5
5
  import { request } from './http.js'
6
+ import { gitTreeOfTarball } from './tree.js'
6
7
 
7
8
  // A hash as each registry writes it: npm's integrity, one sha512 in base64
8
- // after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex.
9
+ // after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex; a
10
+ // git tree id for a tarball of that tree.
9
11
  const DIGESTS = {
10
12
  sha512: (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
11
13
  sha256: (bytes) => createHash('sha256').update(bytes).digest('hex'),
14
+ tree: gitTreeOfTarball,
12
15
  }
13
16
 
14
17
  // Bytes whose `algorithm` hash is `expected`, which the registry or the
15
18
  // caller gives, never a disk; without it nothing is read. From `local`,
16
- // other tools' caches (read, never written; a mismatch is passed over),
17
- // else ours (a mismatch throws), else what `locate` answers, checked and
18
- // then cached.
19
- export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], locate }) {
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 }) {
20
24
  assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
21
- const digest = DIGESTS[algorithm]
22
- for (const path of local) {
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) {
23
28
  const bytes = await readRegularFile(path)
24
- if (bytes && digest(bytes) === expected) return bytes
29
+ if (bytes && await digest(bytes) === expected) return bytes
25
30
  }
26
- const check = (bytes, from) => {
27
- const actual = digest(bytes)
31
+ const check = async (bytes, from) => {
32
+ const actual = await digest(bytes)
28
33
  assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
29
34
  return bytes
30
35
  }
31
- const key = `${what}.${ext}`
32
36
  const cached = await readCache(dir, key)
33
- if (cached) return check(cached, 'the cache')
37
+ if (cached) return await check(cached, 'the cache')
34
38
  const url = await locate()
35
- const bytes = check(await request(url, { as: 'bytes' }), url)
39
+ const bytes = await check(await request(url, { ...options, as: 'bytes' }), url)
36
40
  await writeCache(dir, key, bytes)
37
41
  return bytes
38
42
  }
package/src/git.js CHANGED
@@ -54,5 +54,5 @@ export async function findGitCheckout(dir) {
54
54
  const directory = relative(dirs.root, start).split(sep).join('/')
55
55
  const github = await originGitHub(dirs.commonDir)
56
56
  const commit = await headCommit(dirs.gitDir, dirs.commonDir)
57
- return { ...(github && { github }), ...(directory && { directory }), ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
57
+ return { ...(github && { github }), directory, ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
58
58
  }