@preventive/upstream 1.0.0-alpha.0
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 +21 -0
- package/advisories.d.ts +82 -0
- package/advisories.js +2 -0
- package/git.d.ts +21 -0
- package/git.js +1 -0
- package/github/write.d.ts +42 -0
- package/github/write.js +2 -0
- package/github.d.ts +68 -0
- package/github.js +2 -0
- package/npm.d.ts +89 -0
- package/npm.js +4 -0
- package/package.d.ts +18 -0
- package/package.js +1 -0
- package/package.json +95 -0
- package/semver.d.ts +11 -0
- package/semver.js +1 -0
- package/src/advisories/common.js +34 -0
- package/src/advisories/github.js +90 -0
- package/src/advisories/index.js +50 -0
- package/src/advisories/npm.js +58 -0
- package/src/advisories/osv.js +108 -0
- package/src/advisories/repos.js +72 -0
- package/src/args.js +72 -0
- package/src/cache.js +57 -0
- package/src/git.js +58 -0
- package/src/github/client.js +31 -0
- package/src/github/read.js +124 -0
- package/src/github/write.js +112 -0
- package/src/http.js +112 -0
- package/src/npm/repos.js +68 -0
- package/src/npm/tarball.js +115 -0
- package/src/package.js +49 -0
- package/src/pool.js +12 -0
- package/src/remote.js +15 -0
- package/src/semver.js +41 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertArgs, assertBoolean, assertLine, assertLogin, assertPath, assertRef, assertRepo, assertRepoName, assertSha, assertText,
|
|
6
|
+
optional, printable,
|
|
7
|
+
} from '../args.js'
|
|
8
|
+
import { HttpError, decode, readBody, send } from '../http.js'
|
|
9
|
+
import { api, bindMethods, call, clientHeaders, repoApi } from './client.js'
|
|
10
|
+
import { getRepoHead, readMethods } from './read.js'
|
|
11
|
+
|
|
12
|
+
const CREATE_COMMIT_MUTATION = `mutation($input: CreateCommitOnBranchInput!) {
|
|
13
|
+
createCommitOnBranch(input: $input) {
|
|
14
|
+
commit { oid url }
|
|
15
|
+
}
|
|
16
|
+
}`
|
|
17
|
+
|
|
18
|
+
function toBase64(value, what) {
|
|
19
|
+
// Buffer.from would put U+FFFD in for a lone surrogate, and commit other bytes than asked.
|
|
20
|
+
assert.ok((typeof value === 'string' && value.isWellFormed()) || value instanceof Uint8Array, `createCommit: ${what} must be a well-formed string or a Uint8Array, got ${typeof value === 'string' ? 'a malformed string' : typeof value}`)
|
|
21
|
+
return Buffer.from(value).toString('base64')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function commitMessage(message) {
|
|
25
|
+
if (typeof message === 'string') {
|
|
26
|
+
assertLine('createCommit', 'message', message)
|
|
27
|
+
return { headline: message }
|
|
28
|
+
}
|
|
29
|
+
assertArgs('createCommit', message, { headline: assertLine, body: optional(assertText) }, 'message')
|
|
30
|
+
return { headline: message.headline, ...(message.body && { body: message.body }) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fileChanges(additions = [], deletions = []) {
|
|
34
|
+
assert.ok(Array.isArray(additions), 'createCommit: additions must be an array')
|
|
35
|
+
assert.ok(Array.isArray(deletions), 'createCommit: deletions must be an array')
|
|
36
|
+
const seen = new Set()
|
|
37
|
+
const once = (path, what) => {
|
|
38
|
+
assertPath('createCommit', what, path)
|
|
39
|
+
assert.ok(!seen.has(path), `createCommit: ${what} names ${JSON.stringify(path)} a second time`)
|
|
40
|
+
seen.add(path)
|
|
41
|
+
return path
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
additions: additions.map((addition, i) => {
|
|
45
|
+
assertArgs('createCommit', addition, { path: null, contents: null }, `additions[${i}]`)
|
|
46
|
+
return { path: once(addition.path, `additions[${i}].path`), contents: toBase64(addition.contents, `additions[${i}].contents`) }
|
|
47
|
+
}),
|
|
48
|
+
deletions: deletions.map((deletion, i) => {
|
|
49
|
+
if (typeof deletion === 'string') return { path: once(deletion, `deletions[${i}]`) }
|
|
50
|
+
assertArgs('createCommit', deletion, { path: null }, `deletions[${i}]`)
|
|
51
|
+
return { path: once(deletion.path, `deletions[${i}].path`) }
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertHead(method, what, head) {
|
|
57
|
+
const colon = typeof head === 'string' ? head.indexOf(':') : -1
|
|
58
|
+
if (colon === -1) return assertRef(method, what, head)
|
|
59
|
+
assertLogin(method, `${what} owner`, head.slice(0, colon))
|
|
60
|
+
assertRef(method, `${what} branch`, head.slice(colon + 1))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Exported to test the error paths without a fetch mock.
|
|
64
|
+
export function parseGraphQLResponse(status, text) {
|
|
65
|
+
if (status < 200 || status >= 300) {
|
|
66
|
+
throw new HttpError(status, `GitHub GraphQL ${status}: ${printable(text.slice(0, 4096)) || '(empty body)'}`)
|
|
67
|
+
}
|
|
68
|
+
let json
|
|
69
|
+
try { json = JSON.parse(text) } catch (err) {
|
|
70
|
+
throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${err.message}): ${printable(text.slice(0, 200))}`, { cause: err })
|
|
71
|
+
}
|
|
72
|
+
if (json.errors) throw new Error(`GitHub GraphQL: ${printable(JSON.stringify(json.errors))}`)
|
|
73
|
+
return json.data
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A signed commit; the branch must already exist.
|
|
77
|
+
async function createCommitOnBranch(headers, input) {
|
|
78
|
+
const res = await send(api(['graphql']), { method: 'POST', headers, body: { query: CREATE_COMMIT_MUTATION, variables: { input } }, as: 'json' })
|
|
79
|
+
return parseGraphQLResponse(res.status, decode(await readBody(res, 1024 * 1024), 'GitHub GraphQL')).createCommitOnBranch.commit
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function forkRepo(headers, options) {
|
|
83
|
+
assertArgs('forkRepo', options, { repo: assertRepo, name: optional(assertRepoName), organization: optional(assertLogin), defaultBranchOnly: optional(assertBoolean) })
|
|
84
|
+
const { repo, name, organization, defaultBranchOnly } = options
|
|
85
|
+
const body = { ...(name && { name }), ...(organization && { organization }), ...(defaultBranchOnly && { default_branch_only: true }) }
|
|
86
|
+
return await call(headers, repoApi(repo, ['forks']), { method: 'POST', body })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function createBranch(headers, options) {
|
|
90
|
+
assertArgs('createBranch', options, { repo: assertRepo, branch: assertRef, oid: optional(assertSha) })
|
|
91
|
+
const { repo, branch } = options
|
|
92
|
+
const sha = options.oid ?? (await getRepoHead(headers, { repo })).oid
|
|
93
|
+
return await call(headers, repoApi(repo, ['git', 'refs']), { method: 'POST', body: { ref: `refs/heads/${branch}`, sha } })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function createCommit(headers, options) {
|
|
97
|
+
assertArgs('createCommit', options, { repo: assertRepo, branch: assertRef, message: null, additions: null, deletions: null, expectedHeadOid: optional(assertSha) })
|
|
98
|
+
const { repo, branch } = options
|
|
99
|
+
const changes = fileChanges(options.additions, options.deletions)
|
|
100
|
+
const message = commitMessage(options.message)
|
|
101
|
+
const expectedHeadOid = options.expectedHeadOid ?? (await getRepoHead(headers, { repo, branch })).oid
|
|
102
|
+
return await createCommitOnBranch(headers, { branch: { repositoryNameWithOwner: repo, branchName: branch }, message, fileChanges: changes, expectedHeadOid })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function createPR(headers, options) {
|
|
106
|
+
assertArgs('createPR', options, { repo: assertRepo, title: assertLine, body: optional(assertText), head: assertHead, base: assertRef, draft: optional(assertBoolean) })
|
|
107
|
+
const { repo, title, body, head, base, draft } = options
|
|
108
|
+
const payload = { title, head, base, ...(body && { body }), ...(draft && { draft: true }) }
|
|
109
|
+
return await call(headers, repoApi(repo, ['pulls']), { method: 'POST', body: payload })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const createWriteClient = (options) => bindMethods(clientHeaders('createWriteClient', options, false), { ...readMethods, forkRepo, createBranch, createCommit, createPR })
|
package/src/http.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { matches, printable } from './args.js'
|
|
4
|
+
|
|
5
|
+
// Every request goes through here, to a URL from buildUrl, which has to
|
|
6
|
+
// come back out of `new URL()` unchanged: no segment can then be read as
|
|
7
|
+
// `..` (`%2e%2e` included), a query or a fragment.
|
|
8
|
+
|
|
9
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
10
|
+
export const GITHUB_API = 'https://api.github.com'
|
|
11
|
+
export const OSV_API = 'https://api.osv.dev'
|
|
12
|
+
export const CRATES_API = 'https://crates.io'
|
|
13
|
+
export const PACKAGIST_REPO = 'https://repo.packagist.org'
|
|
14
|
+
export const MAX_BYTES = 512 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO])
|
|
17
|
+
const isSegment = matches(/^(?!\.\.?$)(?:[\w.~@-]|%[\dA-F]{2})+$/u)
|
|
18
|
+
const isQueryKey = matches(/^[a-z_]+(?:\[\])?$/u)
|
|
19
|
+
const isQueryValue = (value) => (typeof value === 'string' && value !== '') || (Number.isSafeInteger(value) && value >= 0)
|
|
20
|
+
// Only a `key[]` repeats, and it always does.
|
|
21
|
+
const isQuery = ([key, value]) => isQueryKey(key) && (key.endsWith('[]') ? Array.isArray(value) && value.length > 0 && value.every(isQueryValue) : isQueryValue(value))
|
|
22
|
+
// A line break in a value would start a header of its own.
|
|
23
|
+
const isHeader = ([name, value]) => /^[A-Za-z][\w-]*$/u.test(name) && matches(/^[ -~]*$/u)(value)
|
|
24
|
+
const LIMITS = {
|
|
25
|
+
json: { bytes: 64 * 1024 * 1024, ms: 30_000 },
|
|
26
|
+
text: { bytes: 128 * 1024 * 1024, ms: 30_000 },
|
|
27
|
+
bytes: { bytes: MAX_BYTES, ms: 300_000 },
|
|
28
|
+
}
|
|
29
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
30
|
+
|
|
31
|
+
export class HttpError extends Error {
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = 'HttpError'
|
|
35
|
+
this.status = status
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function decode(bytes, from) {
|
|
40
|
+
try {
|
|
41
|
+
return decoder.decode(bytes)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`Malformed UTF-8 from ${from}`, { cause: err })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// encodeURIComponent leaves `!'()*` as they are.
|
|
48
|
+
export function encodeSegment(value) {
|
|
49
|
+
assert.equal(typeof value, 'string')
|
|
50
|
+
return encodeURIComponent(value).replace(/[!'()*]/gu, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildUrl(origin, segments, query = {}) {
|
|
54
|
+
assert.ok(ORIGINS.has(origin), `Unexpected origin: ${origin}`)
|
|
55
|
+
assert.ok(segments.length > 0 && segments.every(isSegment), `Unexpected URL path segment in ${JSON.stringify(segments)}`)
|
|
56
|
+
assert.ok(Object.entries(query).every(isQuery), `Unexpected query parameter in ${JSON.stringify(query)}`)
|
|
57
|
+
const search = new URLSearchParams(Object.entries(query).flatMap(([key, value]) => [value].flat().map((item) => [key, item]))).toString()
|
|
58
|
+
const href = `${origin}/${segments.join('/')}${search && `?${search}`}`
|
|
59
|
+
assert.equal(new URL(href).href, href, `URL changed in parsing: ${href}`)
|
|
60
|
+
return href
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Redirects aren't followed unless asked: an answer about another repo is
|
|
64
|
+
// worse than an error.
|
|
65
|
+
export async function send(url, { method = 'GET', headers = {}, body, redirect = 'manual', as } = {}) {
|
|
66
|
+
const parsed = typeof url === 'string' ? URL.parse(url) : null
|
|
67
|
+
assert.ok(parsed?.href === url && ORIGINS.has(parsed.origin) && !parsed.username && !parsed.password && !parsed.hash, `Unexpected URL: ${printable(url)}`)
|
|
68
|
+
assert.ok(['GET', 'POST'].includes(method), `Unexpected method: ${method}`)
|
|
69
|
+
assert.ok(['manual', 'follow'].includes(redirect), `Unexpected redirect mode: ${redirect}`)
|
|
70
|
+
assert.ok(Object.hasOwn(LIMITS, as), `Unexpected response type: ${as}`)
|
|
71
|
+
assert.ok(Object.entries(headers).every(isHeader), 'Unexpected header')
|
|
72
|
+
const json = body === undefined ? {} : { headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
|
73
|
+
return await fetch(url, { method, headers, redirect, signal: AbortSignal.timeout(LIMITS[as].ms), ...json })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function readBody(res, limit, { truncate = false } = {}) {
|
|
77
|
+
const declared = Number(res.headers.get('content-length'))
|
|
78
|
+
if (!truncate && declared > limit) {
|
|
79
|
+
await res.body?.cancel()
|
|
80
|
+
throw new Error(`Response too large: ${declared} bytes, over ${limit}`)
|
|
81
|
+
}
|
|
82
|
+
const chunks = []
|
|
83
|
+
let size = 0
|
|
84
|
+
for await (const chunk of res.body ?? []) {
|
|
85
|
+
chunks.push(chunk)
|
|
86
|
+
size += chunk.byteLength
|
|
87
|
+
if (size <= limit) continue
|
|
88
|
+
if (!truncate) throw new Error(`Response too large: over ${limit} bytes`)
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
return new Uint8Array(await new Blob(chunks).arrayBuffer(), 0, Math.min(size, limit))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Read as the caller says, not by content type, which proxies drop or
|
|
95
|
+
// rewrite.
|
|
96
|
+
export async function request(url, options) {
|
|
97
|
+
const res = await send(url, options)
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
// A stream decode holds back a character cut at the limit rather than refusing it.
|
|
100
|
+
const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
|
|
101
|
+
throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
|
|
102
|
+
}
|
|
103
|
+
const bytes = await readBody(res, LIMITS[options.as].bytes)
|
|
104
|
+
if (options.as === 'bytes') return bytes
|
|
105
|
+
const text = decode(bytes, url)
|
|
106
|
+
if (options.as === 'text') return text
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(text)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause: err })
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/npm/repos.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
|
|
4
|
+
import { readCacheJSON, writeCacheJSON } from '../cache.js'
|
|
5
|
+
import { HttpError, NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
6
|
+
import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
|
|
7
|
+
import { pool } from '../pool.js'
|
|
8
|
+
|
|
9
|
+
const DIR = 'npm/repos'
|
|
10
|
+
const TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
11
|
+
const CONCURRENCY = 8
|
|
12
|
+
|
|
13
|
+
// `latest`, not the full packument, which is megabytes of version history.
|
|
14
|
+
async function fetchRepo(method, name) {
|
|
15
|
+
const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
|
|
16
|
+
assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
|
|
17
|
+
return getRepo(json)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getGitHub(name) {
|
|
21
|
+
assertPackageName('getGitHub', 'name', name)
|
|
22
|
+
const link = await fetchRepo('getGitHub', name)
|
|
23
|
+
assert.ok(link.github, `getGitHub: no GitHub repo for ${name}`)
|
|
24
|
+
return link
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// An entry without `directory` predates the field: a miss, not a package
|
|
28
|
+
// at the repo root.
|
|
29
|
+
export async function readPackageRepoCache(name) {
|
|
30
|
+
assertPackageName('readPackageRepoCache', 'name', name)
|
|
31
|
+
const entry = await readCacheJSON(DIR, `${name}.json`)
|
|
32
|
+
const age = typeof entry?.at === 'number' ? Date.now() - entry.at : Number.NaN
|
|
33
|
+
const fresh = age >= 0 && age <= TTL_MS // An entry from the future is not fresh forever.
|
|
34
|
+
if (!fresh || entry.name !== name || !isRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
|
|
35
|
+
return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Only resolved repos are cached: a 404, a rate limit and a package with
|
|
39
|
+
// no repo link fail alike.
|
|
40
|
+
export async function writePackageRepoCache(name, github, directory = '') {
|
|
41
|
+
assertPackageName('writePackageRepoCache', 'name', name)
|
|
42
|
+
assertRepo('writePackageRepoCache', 'github', github)
|
|
43
|
+
assertRepoDirectory('writePackageRepoCache', 'directory', directory)
|
|
44
|
+
return await writeCacheJSON(DIR, `${name}.json`, { at: Date.now(), name, github, directory })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function resolvePackageRepos(packageNames, options = {}) {
|
|
48
|
+
assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
|
|
49
|
+
assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
|
|
50
|
+
const names = [...new Set(packageNames)]
|
|
51
|
+
for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
|
|
52
|
+
const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
|
|
53
|
+
return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Through the cache. Null for a package the registry does not have, or
|
|
57
|
+
// one naming no GitHub repo; any other failure throws.
|
|
58
|
+
export async function lookUpPackageRepo(name) {
|
|
59
|
+
const stored = await readPackageRepoCache(name)
|
|
60
|
+
if (stored) return stored
|
|
61
|
+
const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch((err) => {
|
|
62
|
+
if (err instanceof HttpError && err.status === 404) return {}
|
|
63
|
+
throw err
|
|
64
|
+
})
|
|
65
|
+
if (!github) return null
|
|
66
|
+
await writePackageRepoCache(name, github, directory)
|
|
67
|
+
return { github, ...(directory && { directory }) }
|
|
68
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { assertArgs, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
|
|
9
|
+
import { readCache, writeCache } from '../cache.js'
|
|
10
|
+
import { MAX_BYTES, NPM_REGISTRY, buildUrl, request } from '../http.js'
|
|
11
|
+
|
|
12
|
+
const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
|
|
13
|
+
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
14
|
+
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
15
|
+
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
16
|
+
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
17
|
+
const sha512 = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
|
|
18
|
+
|
|
19
|
+
function assertPackage(method, name, version) {
|
|
20
|
+
assertPackageName(method, 'name', name)
|
|
21
|
+
assertPackageVersion(method, 'version', version)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tarballUrl(name, version) {
|
|
25
|
+
const segments = name.split('/')
|
|
26
|
+
return buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
30
|
+
// registry's own URL for that version. Each field is read once, and only
|
|
31
|
+
// as the object's own, into a copy that is checked and used from then on:
|
|
32
|
+
// the caller's object can change while a request is out, and a field on
|
|
33
|
+
// its prototype is not one it has.
|
|
34
|
+
function checkedDist(method, name, version, dist) {
|
|
35
|
+
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
36
|
+
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
37
|
+
assertIntegrity(method, 'dist.integrity', integrity)
|
|
38
|
+
const expected = tarballUrl(name, version)
|
|
39
|
+
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
40
|
+
return { tarball, integrity }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function getDist(method, name, version) {
|
|
44
|
+
const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), version]), { as: 'json' })
|
|
45
|
+
assert.ok(json?.name === name && json.version === version, `${method}: the registry answered for ${show(json?.name)}@${show(json?.version)}, not ${name}@${version}`)
|
|
46
|
+
return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertBytes(bytes, integrity, what) {
|
|
50
|
+
const actual = sha512(bytes)
|
|
51
|
+
assert.ok(actual === integrity, `getTarball: integrity mismatch for ${what}: expected ${integrity}, got ${actual}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Where npm keeps its cache, short of an .npmrc moving it: npm_config_cache,
|
|
55
|
+
// as npm sets it for what it runs, else npm's default. On Windows, npm 4
|
|
56
|
+
// and before defaulted to %APPDATA%, later ones to %LOCALAPPDATA%.
|
|
57
|
+
function npmCacheDirs() {
|
|
58
|
+
const configured = process.env.npm_config_cache || process.env.NPM_CONFIG_CACHE
|
|
59
|
+
if (configured) return [resolve(configured.replace(/^~(?=[/\\])/u, homedir()))]
|
|
60
|
+
if (process.platform !== 'win32') return [join(homedir(), '.npm')]
|
|
61
|
+
return [process.env.LOCALAPPDATA, process.env.APPDATA].map((dir) => join(dir || homedir(), 'npm-cache'))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Only a regular file: a FIFO or a device would block or never end.
|
|
65
|
+
async function readRegularFile(path) {
|
|
66
|
+
const stats = await stat(path).catch(() => null)
|
|
67
|
+
if (!stats?.isFile() || stats.size > MAX_BYTES) return null
|
|
68
|
+
return await readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// npm 5+ (cacache) files a tarball by its sha512, npm 4 and before as
|
|
72
|
+
// <name>/<version>/package.tgz; ~/.audit as <org>:<name>-<version>.tgz.
|
|
73
|
+
async function readLocalCaches(name, version, integrity) {
|
|
74
|
+
const hex = Buffer.from(integrity.slice('sha512-'.length), 'base64').toString('hex')
|
|
75
|
+
const npm = npmCacheDirs().flatMap((root) => [join(root, '_cacache/content-v2/sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)), join(root, name, version, 'package.tgz')])
|
|
76
|
+
const audit = join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)
|
|
77
|
+
for (const path of [...npm, audit]) {
|
|
78
|
+
const bytes = await readRegularFile(path)
|
|
79
|
+
if (bytes && sha512(bytes) === integrity) return bytes
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function getMeta(name, version) {
|
|
85
|
+
assertPackage('getMeta', name, version)
|
|
86
|
+
return { name, version, dist: await getDist('getMeta', name, version) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function verifyDist(name, version, dist) {
|
|
90
|
+
assertPackage('verifyDist', name, version)
|
|
91
|
+
const given = checkedDist('verifyDist', name, version, dist)
|
|
92
|
+
const { integrity } = await getDist('verifyDist', name, version)
|
|
93
|
+
assert.ok(given.integrity === integrity, `verifyDist: ${name}@${version} is ${integrity} on the registry, not ${given.integrity}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Without `dist`, the version document is read every time, cache or not:
|
|
97
|
+
// bytes from a cache are checked against the registry's integrity, never
|
|
98
|
+
// against anything a cache itself holds. A mismatch in ours throws rather
|
|
99
|
+
// than fetching over it.
|
|
100
|
+
export async function getTarball(name, version, dist) {
|
|
101
|
+
assertPackage('getTarball', name, version)
|
|
102
|
+
dist = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
|
|
103
|
+
const local = await readLocalCaches(name, version, dist.integrity)
|
|
104
|
+
if (local) return local
|
|
105
|
+
const key = `${name}@${version}.tgz`
|
|
106
|
+
const cached = await readCache(DIR, key)
|
|
107
|
+
if (cached) {
|
|
108
|
+
assertBytes(cached, dist.integrity, `${name}@${version} from the cache`)
|
|
109
|
+
return cached
|
|
110
|
+
}
|
|
111
|
+
const bytes = await request(dist.tarball, { as: 'bytes' })
|
|
112
|
+
assertBytes(bytes, dist.integrity, `${name}@${version} from ${dist.tarball}`)
|
|
113
|
+
await writeCache(DIR, key, bytes)
|
|
114
|
+
return bytes
|
|
115
|
+
}
|
package/src/package.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { assertion, isRepo, isRepoPath, sameName } from './args.js'
|
|
4
|
+
import { githubRepoOfUrl } from './remote.js'
|
|
5
|
+
|
|
6
|
+
const bugsRegex = /^(?i:https?:\/\/github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+)\/issues\/?$/u
|
|
7
|
+
// npm's `owner/name` shorthand means GitHub. No dots in the owner, so a
|
|
8
|
+
// domain (`srvx.h3.dev/srvx`) isn't read as one; another forge's prefix
|
|
9
|
+
// (`gitlab:`) can't match.
|
|
10
|
+
const shorthandRegex = /^(?:github:)?(?<repo>[\w-]+\/[\w.-]+)$/u
|
|
11
|
+
// The ref is one segment: a branch with a `/` reads as part of the directory.
|
|
12
|
+
const homepageRegex = /^(?:https?:\/\/)?(?:www\.)?github\.com(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?(?:\/(?:tree\/[^/]+(?:\/(?<directory>.*))?)?)?$/iu
|
|
13
|
+
const str = (value) => (typeof value === 'string' ? value : '')
|
|
14
|
+
const urlOf = (field) => str(field?.url ?? field)
|
|
15
|
+
const homepageUrl = (homepage) => str(homepage).trim().split(/[?#]/u)[0] // npm appends `#readme`.
|
|
16
|
+
const repoIn = (regex, text) => regex.exec(text)?.groups.repo
|
|
17
|
+
// A URL's path spells a tree path percent-encoded; one that does not decode names none.
|
|
18
|
+
function decodePath(path = '') {
|
|
19
|
+
try {
|
|
20
|
+
return decodeURIComponent(path)
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// npm lets a repository name a commit-ish after `#`; it is not part of the repo.
|
|
25
|
+
function repositoryRepo(url) {
|
|
26
|
+
const repo = url.replace(/#.*$/su, '')
|
|
27
|
+
return repoIn(shorthandRegex, repo) ?? githubRepoOfUrl(repo)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Spaces are kept: git allows them at either end of a name.
|
|
31
|
+
function repoSubdirectory(value) {
|
|
32
|
+
const path = str(value).replace(/^(?:\.\/|\/)+/u, '').replace(/\/+$/u, '')
|
|
33
|
+
return isRepoPath(path) ? path : undefined // No empty, `.`, `..` or `.git` part: a path inside the repo.
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
|
|
37
|
+
export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
|
|
38
|
+
|
|
39
|
+
export function getRepo(pkg) {
|
|
40
|
+
assert.ok(pkg && typeof pkg === 'object' && !Array.isArray(pkg), 'getRepo: expected a package.json object')
|
|
41
|
+
const { bugs, repository } = pkg
|
|
42
|
+
const declared = repositoryRepo(urlOf(repository))
|
|
43
|
+
const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
|
|
44
|
+
const github = [repoIn(bugsRegex, urlOf(bugs)), declared, homepage?.repo].find(isRepo)
|
|
45
|
+
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}` }
|
|
49
|
+
}
|
package/src/pool.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// `fn` over `items`, at most `limit` at a time; results in `items` order.
|
|
2
|
+
export async function pool(items, limit, fn) {
|
|
3
|
+
const results = []
|
|
4
|
+
let next = 0
|
|
5
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
6
|
+
while (next < items.length) {
|
|
7
|
+
const i = next++
|
|
8
|
+
results[i] = await fn(items[i])
|
|
9
|
+
}
|
|
10
|
+
}))
|
|
11
|
+
return results
|
|
12
|
+
}
|
package/src/remote.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isRepo } from './args.js'
|
|
2
|
+
|
|
3
|
+
// `user:token@` is skipped, never returned. It is held to the characters
|
|
4
|
+
// RFC 3986 allows there — a `#`, `?` or `\` before the `@` ends the
|
|
5
|
+
// authority early, so `https://evil.example#@github.com/a/b` goes to
|
|
6
|
+
// evil.example — and URL parsing has to agree the host is github.com.
|
|
7
|
+
const gitUrlRegex = /^(?i:(?:git\+)?(?:https?|git|ssh)):\/\/(?:[\w.~%!$&'()*+,;=:-]*@)?(?i:github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?\/?$/u
|
|
8
|
+
const scpRegex = /^(?i:git\+ssh:\/\/)?git@(?i:github\.com):(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?$/u
|
|
9
|
+
|
|
10
|
+
export function githubRepoOfUrl(url) {
|
|
11
|
+
if (typeof url !== 'string') return undefined
|
|
12
|
+
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)
|
|
14
|
+
return isRepo(repo) ? repo : undefined
|
|
15
|
+
}
|
package/src/semver.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// 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
|
+
|
|
8
|
+
// Plain releases that semver.valid answers unchanged, so it need not be
|
|
9
|
+
// loaded for them: no leading zeros, and at most 15 digits a part, under
|
|
10
|
+
// Number.MAX_SAFE_INTEGER. Anything else, or any call with options, goes
|
|
11
|
+
// to semver.
|
|
12
|
+
const PLAIN_RELEASE = /^(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})$/u
|
|
13
|
+
|
|
14
|
+
let found
|
|
15
|
+
|
|
16
|
+
function find() {
|
|
17
|
+
const argv0 = process.argv[0]
|
|
18
|
+
// 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', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function semver() {
|
|
32
|
+
if (found === undefined) found = find()
|
|
33
|
+
assert.ok(found, 'semver: no npm beside node to borrow it from')
|
|
34
|
+
return found
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const satisfies = (...args) => semver().satisfies(...args)
|
|
38
|
+
export const validRange = (...args) => semver().validRange(...args)
|
|
39
|
+
export const compareVersions = (...args) => semver().compare(...args)
|
|
40
|
+
export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
|
|
41
|
+
export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version
|