@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.
@@ -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 { dirname, join, resolve } from 'node:path'
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 root === undefined ? null : join(root, dir, name.replace(/^(?=(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))./u, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`))
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 { 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
  }
@@ -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 { decode, encodeSegment } from '../http.js'
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 = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
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
- // The one request that follows a redirect, to codeload.github.com. A full
70
- // sha only, so the bytes are that commit's, not wherever a ref points now.
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
- return await call(headers, repoApi(options.repo, ['tarball', options.sha]), { as: 'bytes', redirect: 'follow' })
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 list = await call(headers, repoApi(options.repo, ['security-advisories'], { state: 'published', per_page: PER_PAGE }))
118
- assert.ok(Array.isArray(list), `listRepoAdvisories: expected an array for ${options.repo}`)
119
- assert.ok(list.length < PER_PAGE, `listRepoAdvisories: ${options.repo} has ${PER_PAGE} or more published advisories, more than a page`)
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 request(url, options) {
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
- const bytes = await readBody(res, LIMITS[options.as].bytes)
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
+ }