@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.
@@ -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
+ }
package/src/npm/repos.js CHANGED
@@ -1,20 +1,30 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
3
+ import { assertNames, assertPackageName, assertion, isRepo, optional } from '../args.js'
4
4
  import { readRecord, writeRecord } from '../cache.js'
5
- import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
5
+ import { isNotFound, recover } from '../http.js'
6
6
  import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
7
7
  import { pool } from '../pool.js'
8
+ import { getDocument } from './registry.js'
8
9
 
9
10
  const DIR = 'npm/repos'
10
11
  const CONCURRENCY = 8
12
+ // Stamped on each entry, and raised when getRepo would answer differently:
13
+ // 2 is `repository` taking precedence over `bugs`; 3 the shorthand dropping
14
+ // a `.git`, URLs read past whitespace, and `repository.directory`'s `\` read
15
+ // as `/`; 4 a declared root answered as `''`, no directory declared left
16
+ // out (unknown) rather than read as the root, and a declared directory's
17
+ // empty and `.` parts dropped.
18
+ const VERSION = 4
19
+
20
+ // A slug a lookup gives: getRepo answers no repo ending in `.git`, the
21
+ // suffix it drops (withoutDotGit; `.GIT` it keeps, and so does this).
22
+ const isLookedUpRepo = (github) => isRepo(github) && !github.endsWith('.git')
23
+ // A write takes no slug a read would discard: whatever it writes reads back.
24
+ const assertLookedUpRepo = assertion('"owner/name" with no trailing ".git"', isLookedUpRepo)
11
25
 
12
26
  // `latest`, not the full packument, which is megabytes of version history.
13
- async function fetchRepo(method, name) {
14
- const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
15
- assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
16
- return getRepo(json)
17
- }
27
+ const fetchRepo = async (method, name) => getRepo(await getDocument(method, name, 'latest'))
18
28
 
19
29
  export async function getGitHub(name) {
20
30
  assertPackageName('getGitHub', 'name', name)
@@ -23,29 +33,29 @@ export async function getGitHub(name) {
23
33
  return link
24
34
  }
25
35
 
26
- // An entry without `directory` predates the field: a miss, not a package
27
- // at the repo root.
36
+ // An entry under another VERSION was resolved by other rules, and may
37
+ // name a repo getRepo no longer would (a stale tracker's, or a shorthand's
38
+ // with its `.git`, or the root where a package declared no directory): a
39
+ // miss, as is one naming a repo or a directory no lookup gives. One without
40
+ // `directory` is a package whose place in its repo is unknown.
28
41
  export async function readPackageRepoCache(name) {
29
42
  assertPackageName('readPackageRepoCache', 'name', name)
30
43
  const entry = await readRecord(DIR, name)
31
- if (!isRepo(entry?.github) || !isRepoDirectory(entry.directory)) return null
32
- return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
44
+ if (entry?.v !== VERSION || !isLookedUpRepo(entry.github) || (entry.directory !== undefined && !isRepoDirectory(entry.directory))) return null
45
+ return { github: entry.github, ...(entry.directory !== undefined && { directory: entry.directory }) }
33
46
  }
34
47
 
35
48
  // Only resolved repos are cached: a 404, a rate limit and a package with
36
49
  // no repo link fail alike.
37
- export async function writePackageRepoCache(name, github, directory = '') {
50
+ export async function writePackageRepoCache(name, github, directory) {
38
51
  assertPackageName('writePackageRepoCache', 'name', name)
39
- assertRepo('writePackageRepoCache', 'github', github)
40
- assertRepoDirectory('writePackageRepoCache', 'directory', directory)
41
- return await writeRecord(DIR, name, { github, directory })
52
+ assertLookedUpRepo('writePackageRepoCache', 'github', github)
53
+ optional(assertRepoDirectory)('writePackageRepoCache', 'directory', directory)
54
+ return await writeRecord(DIR, name, { v: VERSION, github, ...(directory !== undefined && { directory }) })
42
55
  }
43
56
 
44
57
  export async function resolvePackageRepos(packageNames, options = {}) {
45
- assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
46
- assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
47
- const names = [...new Set(packageNames)]
48
- for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
58
+ const names = assertNames('resolvePackageRepos', 'packageNames', packageNames, options, assertPackageName)
49
59
  const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
50
60
  return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
51
61
  }
@@ -58,5 +68,5 @@ export async function lookUpPackageRepo(name) {
58
68
  const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch(recover(isNotFound, {}))
59
69
  if (!github) return null
60
70
  await writePackageRepoCache(name, github, directory)
61
- return { github, ...(directory && { directory }) }
71
+ return { github, ...(directory !== undefined && { directory }) }
62
72
  }
@@ -3,9 +3,10 @@ import { Buffer } from 'node:buffer'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
6
- import { assertArgs, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
6
+ import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
7
7
  import { verifiedDownload } from '../download.js'
8
- import { NPM_REGISTRY, buildUrl, request } from '../http.js'
8
+ import { NPM_REGISTRY, buildUrl } from '../http.js'
9
+ import { getDocument, withNpmToken } from './registry.js'
9
10
 
10
11
  const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
11
12
  // One sha512 and nothing else, as the registry writes it: a sha1, a second
@@ -13,11 +14,6 @@ const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twic
13
14
  // last character before `==` two bits, so only A, Q, g or w is canonical.
14
15
  const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
15
16
 
16
- function assertPackage(method, name, version) {
17
- assertPackageName(method, 'name', name)
18
- assertPackageVersion(method, 'version', version)
19
- }
20
-
21
17
  // `{ tarball, integrity }` and nothing else, the tarball exactly the
22
18
  // registry's own URL for that version. Each field is read once, and only
23
19
  // as the object's own, into a copy that is checked and used from then on:
@@ -34,8 +30,8 @@ function checkedDist(method, name, version, dist) {
34
30
  }
35
31
 
36
32
  async function getDist(method, name, version) {
37
- const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), version]), { as: 'json' })
38
- assert.ok(json?.name === name && json.version === version, `${method}: the registry answered for ${show(json?.name)}@${show(json?.version)}, not ${name}@${version}`)
33
+ const json = await getDocument(method, name, version)
34
+ assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
39
35
  return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
40
36
  }
41
37
 
@@ -75,5 +71,5 @@ export async function verifyDist(name, version, dist) {
75
71
  export async function getTarball(name, version, dist) {
76
72
  assertPackage('getTarball', name, version)
77
73
  const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
78
- return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball })
74
+ return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), ours: true, locate: () => tarball, options: withNpmToken(name, tarball) })
79
75
  }
package/src/package.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
3
  import { assertion, isRepo, isRepoPath, sameName } from './args.js'
4
- import { githubRepoOfUrl } from './remote.js'
4
+ import { githubRepoOfUrl, withoutDotGit } from './remote.js'
5
5
 
6
6
  const bugsRegex = /^(?i:https?:\/\/github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+)\/issues\/?$/u
7
7
  // npm's `owner/name` shorthand means GitHub. No dots in the owner, so a
@@ -11,9 +11,12 @@ const shorthandRegex = /^(?:github:)?(?<repo>[\w-]+\/[\w.-]+)$/u
11
11
  // The ref is one segment: a branch with a `/` reads as part of the directory.
12
12
  const homepageRegex = /^(?:https?:\/\/)?(?:www\.)?github\.com(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?(?:\/(?:tree\/[^/]+(?:\/(?<directory>.*))?)?)?$/iu
13
13
  const str = (value) => (typeof value === 'string' ? value : '')
14
- const urlOf = (field) => str(field?.url ?? field)
14
+ // Read past the whitespace around it, as `homepage` is.
15
+ const urlOf = (field) => str(field?.url ?? field).trim()
15
16
  const homepageUrl = (homepage) => str(homepage).trim().split(/[?#]/u)[0] // npm appends `#readme`.
16
- const repoIn = (regex, text) => regex.exec(text)?.groups.repo
17
+ // Every trailing `.git` dropped, as the URLs' are: no answer ends in one
18
+ // (the npm repo cache refuses one).
19
+ const repoIn = (regex, text) => withoutDotGit(regex.exec(text)?.groups.repo)
17
20
  // A URL's path spells a tree path percent-encoded; one that does not decode names none.
18
21
  function decodePath(path = '') {
19
22
  try {
@@ -33,6 +36,19 @@ function repoSubdirectory(value) {
33
36
  return isRepoPath(path) ? path : undefined // No empty, `.`, `..` or `.git` part: a path inside the repo.
34
37
  }
35
38
 
39
+ // `repository.directory` is a path its author may write with Windows' `\`.
40
+ // A tree path is `/`-separated: a `\` is a name's own only in a homepage's
41
+ // (`%5C`), which is the path itself. Its empty and `.` parts are dropped
42
+ // (`./a//b/.` is `a/b`), and one of nothing else (`./`, `/`, `/.`, `.`, `''`)
43
+ // declares the repo's root, `''`; one with a `..` part names none, whatever
44
+ // it would come to. A package.json that declares none says nothing of where
45
+ // in the repo it sits.
46
+ function declaredDirectory(value) {
47
+ if (typeof value !== 'string') return undefined
48
+ const parts = value.replaceAll('\\', '/').split('/').filter((part) => part !== '' && part !== '.')
49
+ return parts.length === 0 ? '' : repoSubdirectory(parts.join('/'))
50
+ }
51
+
36
52
  export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
37
53
  export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
38
54
 
@@ -41,9 +57,12 @@ export function getRepo(pkg) {
41
57
  const { bugs, repository } = pkg
42
58
  const declared = repositoryRepo(urlOf(repository))
43
59
  const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
44
- const github = [repoIn(bugsRegex, urlOf(bugs)), declared, homepage?.repo].find(isRepo)
60
+ const homepageRepo = withoutDotGit(homepage?.repo)
61
+ // `repository` first: it is the field a publisher maintains, where a
62
+ // `bugs` tracker can be left pointing at a former owner or misspelt.
63
+ const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
45
64
  if (github === undefined) return {}
46
- const directory = (sameName(declared, github) && repoSubdirectory(repository?.directory))
47
- || (sameName(homepage?.repo, github) && repoSubdirectory(decodePath(homepage.directory)))
48
- return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
65
+ const directory = (sameName(declared, github) ? declaredDirectory(repository?.directory) : undefined)
66
+ ?? (sameName(homepageRepo, github) ? repoSubdirectory(decodePath(homepage.directory)) : undefined)
67
+ return { github, ...(directory !== undefined && { directory }), url: `https://github.com/${github}` }
49
68
  }
package/src/remote.js CHANGED
@@ -7,9 +7,16 @@ import { isRepo } from './args.js'
7
7
  const gitUrlRegex = /^(?i:(?:git\+)?(?:https?|git|ssh)):\/\/(?:[\w.~%!$&'()*+,;=:-]*@)?(?i:github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?\/?$/u
8
8
  const scpRegex = /^(?i:git\+ssh:\/\/)?git@(?i:github\.com):(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?$/u
9
9
 
10
+ // A repo with every trailing `.git` dropped, so none ends in one: in a loop,
11
+ // as a regex would backtrack over a long run of them in a crafted URL.
12
+ export function withoutDotGit(repo) {
13
+ while (repo?.endsWith('.git')) repo = repo.slice(0, -4)
14
+ return repo
15
+ }
16
+
10
17
  export function githubRepoOfUrl(url) {
11
18
  if (typeof url !== 'string') return undefined
12
19
  const onGitHub = URL.parse(url.replace(/^git\+/iu, ''))?.hostname.toLowerCase() === 'github.com'
13
- const repo = scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined)
20
+ const repo = withoutDotGit(scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined))
14
21
  return isRepo(repo) ? repo : undefined
15
22
  }
@@ -0,0 +1,8 @@
1
+ 'use strict'
2
+
3
+ // The semver peer dependency by a plain require(), which a bundler follows
4
+ // and bundles: CommonJS for that alone. In an ESM bundle that leaves semver
5
+ // out, this require() is the bundler's stub, which throws.
6
+ module.exports = function requirePeer() {
7
+ return require('semver')
8
+ }
package/src/semver.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { createRequire } from 'node:module'
3
3
  import { basename, dirname, resolve } from 'node:path'
4
+ import requirePeer from './semver-peer.cjs'
4
5
 
5
6
  // npm's own semver, borrowed from the npm next to node rather than added
6
- // as a dependency: the same implementation `npm audit` uses.
7
+ // as a dependency: the same implementation `npm audit` uses. Where there is
8
+ // no npm, as in a bundled serverless function, the `semver` package itself,
9
+ // an optional peer dependency.
7
10
 
8
11
  // Plain releases that semver.valid answers unchanged, so it need not be
9
12
  // loaded for them: no leading zeros, and at most 15 digits a part, under
@@ -11,26 +14,37 @@ import { basename, dirname, resolve } from 'node:path'
11
14
  // to semver.
12
15
  const PLAIN_RELEASE = /^(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})$/u
13
16
 
14
- let found
17
+ // What load() returns if it is semver, or null if it is not or throws.
18
+ function attempt(load) {
19
+ try {
20
+ const lib = load()
21
+ if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
22
+ } catch {}
23
+ return null
24
+ }
15
25
 
16
26
  function find() {
17
27
  const argv0 = process.argv[0]
18
28
  // Another host executable's `../lib` is not npm's.
19
- if (!['node', 'node.exe'].includes(basename(argv0 ?? ''))) return null
20
- const require = createRequire(import.meta.url)
21
- // POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
22
- for (const prefix of ['../lib', '.']) {
23
- try {
24
- const lib = require(resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver'))
25
- if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
26
- } catch {}
29
+ if (['node', 'node.exe'].includes(basename(argv0 ?? ''))) {
30
+ // POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
31
+ for (const prefix of ['../lib', '.']) {
32
+ // Required from itself, which a bundle of any format leaves alone.
33
+ const path = resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver')
34
+ const lib = attempt(() => createRequire(path)(path))
35
+ if (lib) return lib
36
+ }
27
37
  }
28
- return null
38
+ // The peer, by the plain require() a bundler follows; or, where an ESM
39
+ // bundle leaves it out, by node's own require() from beside the bundle.
40
+ return attempt(requirePeer) ?? attempt(() => createRequire(import.meta.url)('semver'))
29
41
  }
30
42
 
43
+ let found
44
+
31
45
  function semver() {
32
46
  if (found === undefined) found = find()
33
- assert.ok(found, 'semver: no npm beside node to borrow it from')
47
+ assert.ok(found, 'semver: no npm beside node to borrow it from, and the semver peer dependency is not installed')
34
48
  return found
35
49
  }
36
50
 
@@ -40,3 +54,5 @@ export const intersects = (...args) => semver().intersects(...args)
40
54
  export const compareVersions = (...args) => semver().compare(...args)
41
55
  export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
42
56
  export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version
57
+ export const clean = (...args) => semver().clean(...args)
58
+ export const major = (...args) => semver().major(...args)
package/src/soldeer.js CHANGED
@@ -1,16 +1,10 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertSha256, assertion, matches, show } from './args.js'
3
+ import { assertSha256, assertSoldeerPackage, show } from './args.js'
4
4
  import { verifiedDownload } from './download.js'
5
5
  import { SOLDEER_API, SOLDEER_REVISIONS, buildUrl, request } from './http.js'
6
6
 
7
7
  const DIR = 'soldeer/zips' // No expiry: the registry refuses a version it already has.
8
- // Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
9
- // `-`, starting with one of them or `@`, ending with a letter or digit.
10
- const assertName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
11
- // Soldeer takes any version but an empty one. Its registry has semver
12
- // (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
13
- const assertVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
14
8
 
15
9
  // Where the registry keeps the version's zip; it says nothing of what the
16
10
  // zip hashes to.
@@ -26,8 +20,7 @@ async function zipUrl(name, version) {
26
20
  // The registry has no hash to check a zip against, so the caller brings
27
21
  // one, as soldeer.lock records it.
28
22
  export async function getZip(name, version, checksum) {
29
- assertName('getZip', 'name', name)
30
- assertVersion('getZip', 'version', version)
23
+ assertSoldeerPackage('getZip', name, version)
31
24
  assertSha256('getZip', 'checksum', checksum)
32
25
  return await verifiedDownload({ method: 'getZip', dir: DIR, what: `${name}@${version}`, ext: 'zip', algorithm: 'sha256', expected: checksum, locate: () => zipUrl(name, version) })
33
26
  }