@preventive/upstream 1.0.0-alpha.9 → 1.0.1
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/advisories.d.ts +21 -10
- package/cargo.d.ts +0 -2
- package/git.d.ts +2 -4
- package/github/write.d.ts +1 -2
- package/github.d.ts +63 -9
- package/npm.d.ts +120 -25
- package/npm.js +1 -1
- package/package.d.ts +5 -4
- package/package.json +4 -1
- package/semver.d.ts +0 -2
- package/soldeer.d.ts +0 -3
- package/src/advisories/github.js +87 -26
- package/src/advisories/index.js +4 -3
- package/src/advisories/osv.js +3 -3
- package/src/advisories/repos.js +21 -10
- package/src/args.js +1 -1
- package/src/cache.js +77 -24
- package/src/cargo/repos.js +8 -8
- package/src/download.js +8 -7
- package/src/git.js +1 -1
- package/src/github/read.js +91 -6
- package/src/http.js +4 -2
- package/src/lockfiles.js +113 -0
- package/src/npm/cacache.js +44 -0
- package/src/npm/registry.js +10 -4
- package/src/npm/repos.js +25 -13
- package/src/npm/tarball.js +99 -37
- package/src/npm/versions.js +97 -0
- package/src/package.js +24 -7
- package/src/remote.js +10 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { assertArgs, assertion, matches, show } from '../args.js'
|
|
4
|
+
import { readJSON, writeJSON } from '../cache.js'
|
|
5
|
+
import { NPM_REGISTRY, buildUrl, isNotFound, recover } from '../http.js'
|
|
6
|
+
import { getDocument } from './registry.js'
|
|
7
|
+
|
|
8
|
+
const DIR = 'npm/versions' // No expiry: the registry never takes a version twice, nor changes its dist.
|
|
9
|
+
// One sha512 and nothing else, as the registry writes it: a sha1, a second
|
|
10
|
+
// hash or an option is refused rather than trusted. 64 bytes leave the
|
|
11
|
+
// last character before `==` two bits, so only A, Q, g or w is canonical.
|
|
12
|
+
const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
|
|
13
|
+
|
|
14
|
+
// `{ tarball, integrity }` and nothing else, the tarball exactly the
|
|
15
|
+
// registry's own URL for that version. Each field is read once, and only
|
|
16
|
+
// as the object's own, into a copy that is checked and used from then on:
|
|
17
|
+
// the caller's object can change while a request is out, and a field on
|
|
18
|
+
// its prototype is not one it has.
|
|
19
|
+
export function checkedDist(method, name, version, dist) {
|
|
20
|
+
assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
|
|
21
|
+
const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
|
|
22
|
+
assertIntegrity(method, 'dist.integrity', integrity)
|
|
23
|
+
const segments = name.split('/')
|
|
24
|
+
const expected = buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
|
|
25
|
+
assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
|
|
26
|
+
return { tarball, integrity }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const distOf = (method, name, version, json) => checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
|
|
30
|
+
|
|
31
|
+
// Whether a version document is one to keep, or to take as kept: for that
|
|
32
|
+
// name and version, with a dist that passes.
|
|
33
|
+
function isKeepable(method, name, version, json) {
|
|
34
|
+
if (json?.name !== name || json.version !== version) return false
|
|
35
|
+
try {
|
|
36
|
+
distOf(method, name, version, json)
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A version's document as `cache` keeps it (readJSON), or none.
|
|
44
|
+
async function keptDocument(method, name, version, cache) {
|
|
45
|
+
const json = await readJSON(DIR, `${name}@${version}`, cache)
|
|
46
|
+
return isKeepable(method, name, version, json) ? json : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The registry's document, kept whole in `cache` once it is keepable; one
|
|
50
|
+
// whose dist is refused is answered all the same.
|
|
51
|
+
async function keepDocument(method, name, version, json, cache) {
|
|
52
|
+
assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
|
|
53
|
+
if (isKeepable(method, name, version, json)) await writeJSON(DIR, `${name}@${version}`, json, cache)
|
|
54
|
+
return json
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fetchDocument = async (method, name, version, cache) => await keepDocument(method, name, version, await getDocument(method, name, version), cache)
|
|
58
|
+
|
|
59
|
+
// A version's document, kept or else fetched, for what is not an
|
|
60
|
+
// integrity: a package's repo (advisories.js), as trusted as setCacheDir's
|
|
61
|
+
// repo records are. Null where the registry does not have that version:
|
|
62
|
+
// its 404 alone, never a failure of the cache's.
|
|
63
|
+
export async function getVersionDocument(method, name, version, cache) {
|
|
64
|
+
const kept = await keptDocument(method, name, version, cache)
|
|
65
|
+
if (kept) return kept
|
|
66
|
+
const json = await getDocument(method, name, version).catch(recover(isNotFound, null))
|
|
67
|
+
return json && await keepDocument(method, name, version, json, cache)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The dist of a version document a cache answers, where it is that
|
|
71
|
+
// version's, its dist passes, and its tarball and integrity are `given`'s
|
|
72
|
+
// (checkedDist): a cache never supplies a dist, only confirms one.
|
|
73
|
+
export function matchingDist(method, name, version, json, given) {
|
|
74
|
+
if (!isKeepable(method, name, version, json)) return undefined
|
|
75
|
+
const dist = distOf(method, name, version, json)
|
|
76
|
+
return dist.integrity === given.integrity && dist.tarball === given.tarball ? dist : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A version's document, and its dist checked. A kept document is read only
|
|
80
|
+
// for a caller that already has a dist, `given`, and answers with no
|
|
81
|
+
// request where it matches (matchingDist). Else, or without one, the
|
|
82
|
+
// registry is asked, and its document, kept in place of any other, throws
|
|
83
|
+
// where it does not.
|
|
84
|
+
export async function getVersion(method, name, version, given, cache) {
|
|
85
|
+
if (given !== undefined) {
|
|
86
|
+
const kept = await readJSON(DIR, `${name}@${version}`, cache)
|
|
87
|
+
const dist = matchingDist(method, name, version, kept, given)
|
|
88
|
+
if (dist) return { dist, json: kept }
|
|
89
|
+
}
|
|
90
|
+
const json = await fetchDocument(method, name, version, cache)
|
|
91
|
+
const dist = distOf(method, name, version, json)
|
|
92
|
+
if (given !== undefined) {
|
|
93
|
+
assert.ok(dist.integrity === given.integrity, `${method}: ${name}@${version} is ${dist.integrity} on the registry, not ${given.integrity}`)
|
|
94
|
+
assert.ok(dist.tarball === given.tarball, `${method}: ${name}@${version} is at ${dist.tarball} on the registry, not ${given.tarball}`)
|
|
95
|
+
}
|
|
96
|
+
return { dist, json }
|
|
97
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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,11 +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
|
|
60
|
+
const homepageRepo = withoutDotGit(homepage?.repo)
|
|
44
61
|
// `repository` first: it is the field a publisher maintains, where a
|
|
45
62
|
// `bugs` tracker can be left pointing at a former owner or misspelt.
|
|
46
|
-
const github = [declared, repoIn(bugsRegex, urlOf(bugs)),
|
|
63
|
+
const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
|
|
47
64
|
if (github === undefined) return {}
|
|
48
|
-
const directory = (sameName(declared, github)
|
|
49
|
-
|
|
50
|
-
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}` }
|
|
51
68
|
}
|
package/src/remote.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { isRepo } from './args.js'
|
|
2
2
|
|
|
3
3
|
// `user:token@` is skipped, never returned. It is held to the characters
|
|
4
|
-
// RFC 3986 allows there
|
|
4
|
+
// RFC 3986 allows there -- a `#`, `?` or `\` before the `@` ends the
|
|
5
5
|
// authority early, so `https://evil.example#@github.com/a/b` goes to
|
|
6
|
-
// evil.example
|
|
6
|
+
// evil.example -- and URL parsing has to agree the host is github.com.
|
|
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
|
}
|