@preventive/upstream 1.0.0-alpha.1 → 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 +18 -1
- package/cargo.js +2 -1
- package/github.d.ts +67 -13
- package/npm.d.ts +40 -14
- package/npm.js +1 -1
- package/package.d.ts +3 -3
- package/package.json +26 -4
- package/semver.d.ts +8 -2
- package/semver.js +1 -1
- package/src/advisories/common.js +27 -5
- package/src/advisories/github.js +36 -16
- package/src/advisories/index.js +7 -7
- package/src/advisories/npm.js +19 -5
- package/src/advisories/osv.js +34 -15
- package/src/advisories/repos.js +17 -35
- package/src/args.js +36 -2
- package/src/attributes.js +81 -0
- package/src/cache.js +62 -6
- package/src/{cargo.js → cargo/crate.js} +17 -11
- package/src/cargo/repos.js +52 -0
- package/src/download.js +18 -14
- package/src/github/read.js +113 -14
- package/src/http.js +16 -11
- package/src/npm/registry.js +19 -0
- package/src/npm/repos.js +24 -16
- package/src/npm/tarball.js +6 -10
- package/src/package.js +17 -6
- package/src/remote.js +8 -1
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +28 -12
- package/src/soldeer.js +2 -9
- 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
|
@@ -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,50 @@
|
|
|
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 {
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
5
6
|
|
|
6
|
-
import { assertDirectoryPath } from './args.js'
|
|
7
|
+
import { assertCacheName, assertDirectoryPath, isRepo } from './args.js'
|
|
7
8
|
import { MAX_BYTES, decode } from './http.js'
|
|
8
9
|
|
|
9
|
-
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/zips'])
|
|
10
|
+
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees'])
|
|
10
11
|
const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
11
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')
|
|
38
|
+
|
|
12
39
|
let root
|
|
13
40
|
let tmpSeq = 0
|
|
14
41
|
|
|
15
|
-
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')
|
|
16
48
|
assertDirectoryPath('setCacheDir', 'dir', dir)
|
|
17
49
|
root = resolve(dir)
|
|
18
50
|
}
|
|
@@ -24,12 +56,18 @@ export function setCacheDir(dir) {
|
|
|
24
56
|
// would still meet, so a capital is written `!` and the letter, and a `!`
|
|
25
57
|
// as `!!`. A Windows device name (`con.json` is the console) has its first
|
|
26
58
|
// letter escaped, which encodeURIComponent never does.
|
|
27
|
-
function cachePath(dir, key) {
|
|
59
|
+
function cachePath(dir, key, base = root) {
|
|
28
60
|
assert.ok(DIRS.has(dir) && key && typeof key === 'string', `Unexpected cache entry: ${dir}`)
|
|
29
61
|
const name = encodeURIComponent(key.replace(/[!A-Z]/gu, (char) => `!${char.toLowerCase()}`)).replaceAll('%40', '@').replaceAll('%2F', '+')
|
|
30
|
-
return
|
|
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()}`))
|
|
31
63
|
}
|
|
32
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
|
+
|
|
33
71
|
// Only a regular file, checked on the file as opened, so one swapped in
|
|
34
72
|
// after a check can't pass: a FIFO or a device would block or never end.
|
|
35
73
|
// Read to the size it had when opened, and no further.
|
|
@@ -93,3 +131,21 @@ export async function readRecord(dir, name) {
|
|
|
93
131
|
}
|
|
94
132
|
|
|
95
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
|
+
}
|
|
@@ -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 {
|
|
7
|
-
import { verifiedDownload } from '
|
|
8
|
-
import { CRATES_INDEX, CRATES_STATIC, buildUrl, encodeSegment, request } from '
|
|
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(
|
|
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,
|
|
28
|
-
assert.ok(entry.name === name,
|
|
29
|
-
assertSha256(
|
|
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
|
-
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
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
|
-
|
|
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/github/read.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
3
|
|
|
5
|
-
import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, isSha, optional, sameName, show } from '../args.js'
|
|
6
|
-
import {
|
|
4
|
+
import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
|
|
5
|
+
import { verifiedDownload } from '../download.js'
|
|
6
|
+
import { decode, encodeSegment, requestWithHeaders } from '../http.js'
|
|
7
|
+
import { gitTreeOfListing, objectId } from '../tree.js'
|
|
7
8
|
import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
|
|
8
9
|
|
|
9
10
|
const PER_PAGE = 100
|
|
@@ -20,6 +21,24 @@ async function* pages(method, headers, pageUrl, maxPages = MAX_PAGES) {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
// A list GitHub pages by a cursor instead: the `after` of each page's
|
|
25
|
+
// Link header `next` link, the one thing read off the link, so the next
|
|
26
|
+
// page is asked of the list `pageUrl` names, wherever the link points. A
|
|
27
|
+
// next link proves a page more.
|
|
28
|
+
async function* cursorPages(method, headers, pageUrl, maxPages = MAX_PAGES) {
|
|
29
|
+
const paging = { per_page: PER_PAGE }
|
|
30
|
+
for (let page = 1; ; page++) {
|
|
31
|
+
const answer = await requestWithHeaders(pageUrl(paging), { as: 'json', headers })
|
|
32
|
+
assert.ok(Array.isArray(answer.body), `${method}: expected an array for page ${page}`)
|
|
33
|
+
yield answer.body
|
|
34
|
+
const next = /<([^>]*)>\s*;\s*rel="next"/u.exec(answer.headers.get('link') ?? '')?.[1]
|
|
35
|
+
if (next === undefined) return
|
|
36
|
+
paging.after = URL.parse(next)?.searchParams.get('after')
|
|
37
|
+
assert.ok(paging.after, `${method}: page ${page} links the next with no cursor`)
|
|
38
|
+
assert.ok(page < maxPages, `${method}: more than ${maxPages} pages`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
const paginate = async (...args) => (await Array.fromAsync(pages(...args))).flat()
|
|
24
43
|
const getCurrentUser = (headers) => call(headers, api(['user']))
|
|
25
44
|
|
|
@@ -46,6 +65,32 @@ export async function getRepoHead(headers, options) {
|
|
|
46
65
|
return { branch: ref, oid: data.object.sha }
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
async function getRepoTag(headers, options) {
|
|
69
|
+
assertArgs('getRepoTag', options, { repo: assertRepo, tag: assertTagName })
|
|
70
|
+
const { repo, tag } = options
|
|
71
|
+
const ref = await call(headers, repoApi(repo, ['git', 'ref', 'tags', encodeSegment(tag)]))
|
|
72
|
+
assert.ok(ref?.ref === `refs/tags/${tag}`, `getRepoTag: GitHub answered for ${show(ref?.ref)}, not ${show(`refs/tags/${tag}`)}`)
|
|
73
|
+
let { object } = ref
|
|
74
|
+
for (let hops = 0; object?.type === 'tag' && isSha(object.sha) && hops < 8; hops++) {
|
|
75
|
+
const annotated = await call(headers, repoApi(repo, ['git', 'tags', object.sha]))
|
|
76
|
+
assert.ok(annotated?.sha === object.sha, `getRepoTag: GitHub answered for another tag object than ${object.sha}`)
|
|
77
|
+
object = annotated.object
|
|
78
|
+
}
|
|
79
|
+
assert.ok(object?.type === 'commit' && isSha(object.sha), `getRepoTag: ${repo} has no commit for tag ${show(tag)}`)
|
|
80
|
+
return { tag, oid: object.sha }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// GitHub lists each tag with the commit it names, an annotated one's too.
|
|
84
|
+
async function listRepoTags(headers, options) {
|
|
85
|
+
assertArgs('listRepoTags', options, { repo: assertRepo, maxPages: optional(assertNumber) })
|
|
86
|
+
const { repo, maxPages } = options
|
|
87
|
+
const tags = await paginate('listRepoTags', headers, (paging) => repoApi(repo, ['tags'], paging), maxPages)
|
|
88
|
+
return tags.map((entry) => {
|
|
89
|
+
assert.ok(isTagName(entry?.name) && isSha(entry.commit?.sha), `listRepoTags: GitHub listed ${show(entry?.name)} in ${repo}, which is no tag name with a commit`)
|
|
90
|
+
return { tag: entry.name, oid: entry.commit.sha }
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
49
94
|
// GitHub's object for the path first: a directory, symlink or submodule is
|
|
50
95
|
// refused rather than read as a file's text. Up to 1 MB the object holds
|
|
51
96
|
// the content; past it, `encoding: none`, and the blob it names is read,
|
|
@@ -61,16 +106,73 @@ async function getRepoFile(headers, options) {
|
|
|
61
106
|
const bytes = file.encoding === 'none'
|
|
62
107
|
? await call({ ...headers, Accept: 'application/vnd.github.raw' }, repoApi(repo, ['git', 'blobs', file.sha]), { as: 'bytes' })
|
|
63
108
|
: Buffer.from(file.content, 'base64')
|
|
64
|
-
const blob =
|
|
109
|
+
const blob = objectId('blob', bytes).toString('hex')
|
|
65
110
|
assert.ok(bytes.length === file.size && blob === file.sha, `getRepoFile: ${show(path)} came back as blob ${blob}, not ${file.sha}`)
|
|
66
111
|
return decode(bytes, url)
|
|
67
112
|
}
|
|
68
113
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
114
|
+
// Follows the redirect to codeload.github.com. A tree id names its content,
|
|
115
|
+
// so the bytes are held to it, downloaded or cached, and cached by it alone,
|
|
116
|
+
// for good. What a tarball cannot show, a submodule's commit, a subtree
|
|
117
|
+
// with nothing in it or the LF blob of a file marked `eol=crlf`, comes
|
|
118
|
+
// from GitHub's listings of the trees, which the id checks as well: a
|
|
119
|
+
// directory at a time, as a recursive listing of a large tree is cut short.
|
|
120
|
+
async function treeTarball(method, headers, repo, tree) {
|
|
121
|
+
const listings = new Map()
|
|
122
|
+
const list = (sha) => {
|
|
123
|
+
if (!listings.has(sha)) listings.set(sha, call(headers, repoApi(repo, ['git', 'trees', sha])).then((listing) => (Array.isArray(listing?.tree) ? listing.tree : [])))
|
|
124
|
+
return listings.get(sha)
|
|
125
|
+
}
|
|
126
|
+
const locate = () => repoApi(repo, ['tarball', tree])
|
|
127
|
+
return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function commitTree(method, headers, repo, sha) {
|
|
131
|
+
const commit = await call(headers, repoApi(repo, ['git', 'commits', sha]))
|
|
132
|
+
assert.ok(commit?.sha === sha && isSha1(commit.tree?.sha), `${method}: GitHub names no tree for ${repo}@${sha}`)
|
|
133
|
+
return commit.tree.sha
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A full sha only, so the bytes are that commit's, not wherever a ref points
|
|
137
|
+
// now. Its tree's tarball, not its own, in which `git archive` rewrites the
|
|
138
|
+
// files marked `export-subst`.
|
|
71
139
|
async function getRepoTarball(headers, options) {
|
|
72
140
|
assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
|
|
73
|
-
|
|
141
|
+
const { repo, sha } = options
|
|
142
|
+
const tree = await commitTree('getRepoTarball', headers, repo, sha)
|
|
143
|
+
return await treeTarball('getRepoTarball', headers, repo, tree)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function getRepoTreeTarball(headers, options) {
|
|
147
|
+
assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId })
|
|
148
|
+
return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Not recursive: GitHub cuts short a recursive listing of a large tree.
|
|
152
|
+
async function listTree(method, headers, repo, tree) {
|
|
153
|
+
const entries = (await call(headers, repoApi(repo, ['git', 'trees', tree])))?.tree
|
|
154
|
+
assert.ok(Array.isArray(entries) && gitTreeOfListing(entries) === tree, `${method}: GitHub's listing of tree ${tree} in ${repo} is not that tree`)
|
|
155
|
+
return entries.map(({ path, mode, type, sha }) => ({ path, mode, type, sha }))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function treeAt(method, headers, options) {
|
|
159
|
+
assertArgs(method, options, { repo: assertRepo, sha: assertSha, directory: optional(assertPath), path: optional(assertPath) })
|
|
160
|
+
assert.ok(options.directory === undefined || options.path === undefined, `${method}: directory and path are one option, pass directory alone`)
|
|
161
|
+
const { repo, sha, directory = options.path } = options
|
|
162
|
+
let tree = await commitTree(method, headers, repo, sha)
|
|
163
|
+
for (const name of directory?.split('/') ?? []) {
|
|
164
|
+
const entry = (await listTree(method, headers, repo, tree)).find((candidate) => candidate.path === name)
|
|
165
|
+
assert.ok(entry?.type === 'tree', `${method}: ${repo}@${sha} has no directory at ${show(directory)}`)
|
|
166
|
+
tree = entry.sha
|
|
167
|
+
}
|
|
168
|
+
return tree
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const getRepoTreeId = (headers, options) => treeAt('getRepoTreeId', headers, options)
|
|
172
|
+
|
|
173
|
+
async function listRepoDir(headers, options) {
|
|
174
|
+
const tree = await treeAt('listRepoDir', headers, options)
|
|
175
|
+
return await listTree('listRepoDir', headers, options.repo, tree)
|
|
74
176
|
}
|
|
75
177
|
|
|
76
178
|
async function getPullRequest(headers, options) {
|
|
@@ -110,15 +212,12 @@ async function getAdvisory(headers, options) {
|
|
|
110
212
|
return advisory
|
|
111
213
|
}
|
|
112
214
|
|
|
113
|
-
// One page: GitHub pages this list by a cursor in the Link header, which
|
|
114
|
-
// `call` does not read, so a full page is refused rather than cut short.
|
|
115
215
|
async function listRepoAdvisories(headers, options) {
|
|
116
216
|
assertArgs('listRepoAdvisories', options, { repo: assertRepo })
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return list
|
|
217
|
+
const { repo } = options
|
|
218
|
+
const pageUrl = (paging) => repoApi(repo, ['security-advisories'], { state: 'published', ...paging })
|
|
219
|
+
return (await Array.fromAsync(cursorPages('listRepoAdvisories', headers, pageUrl))).flat()
|
|
121
220
|
}
|
|
122
221
|
|
|
123
|
-
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
222
|
+
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
124
223
|
export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
|
package/src/http.js
CHANGED
|
@@ -101,22 +101,27 @@ export async function readBody(res, limit, { truncate = false } = {}) {
|
|
|
101
101
|
return new Uint8Array(await new Blob(chunks).arrayBuffer(), 0, Math.min(size, limit))
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
function parse(bytes, url, as) {
|
|
105
|
+
if (as === 'bytes') return bytes
|
|
106
|
+
const text = decode(bytes, url)
|
|
107
|
+
if (as === 'text') return text
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(text)
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
104
115
|
// Read as the caller says, not by content type, which proxies drop or
|
|
105
|
-
// rewrite.
|
|
106
|
-
export async function
|
|
116
|
+
// rewrite; answered with the response's headers.
|
|
117
|
+
export async function requestWithHeaders(url, options) {
|
|
107
118
|
const res = await send(url, options)
|
|
108
119
|
if (!res.ok) {
|
|
109
120
|
// A stream decode holds back a character cut at the limit rather than refusing it.
|
|
110
121
|
const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
|
|
111
122
|
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
|
|
112
123
|
}
|
|
113
|
-
|
|
114
|
-
if (options.as === 'bytes') return bytes
|
|
115
|
-
const text = decode(bytes, url)
|
|
116
|
-
if (options.as === 'text') return text
|
|
117
|
-
try {
|
|
118
|
-
return JSON.parse(text)
|
|
119
|
-
} catch (cause) {
|
|
120
|
-
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause })
|
|
121
|
-
}
|
|
124
|
+
return { body: parse(await readBody(res, LIMITS[options.as].bytes), url, options.as), headers: res.headers }
|
|
122
125
|
}
|
|
126
|
+
|
|
127
|
+
export const request = async (url, options) => (await requestWithHeaders(url, options)).body
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { show } from '../args.js'
|
|
4
|
+
import { NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
5
|
+
|
|
6
|
+
// Private packages are always scoped.
|
|
7
|
+
export function withNpmToken(name, url, options = {}) {
|
|
8
|
+
const method = options.method ?? 'GET'
|
|
9
|
+
assert.ok(method === 'GET' && `${new URL(url)}` === url && url.startsWith(`${NPM_REGISTRY}/${name}/`), `Unexpected request for ${name}: ${method} ${url}`)
|
|
10
|
+
const token = process.env.NPM_TOKEN
|
|
11
|
+
return token && name.startsWith('@') ? { ...options, method, headers: { ...options.headers, Authorization: `Bearer ${token}` } } : options
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getDocument(method, name, spec) {
|
|
15
|
+
const url = buildUrl(NPM_REGISTRY, [...name.split('/'), spec])
|
|
16
|
+
const json = await request(url, withNpmToken(name, url, { as: 'json' }))
|
|
17
|
+
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
18
|
+
return json
|
|
19
|
+
}
|