@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.
@@ -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
- 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,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)), homepage?.repo].find(isRepo)
63
+ const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
47
64
  if (github === undefined) return {}
48
- const directory = (sameName(declared, github) && repoSubdirectory(repository?.directory))
49
- || (sameName(homepage?.repo, github) && repoSubdirectory(decodePath(homepage.directory)))
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 — a `#`, `?` or `\` before the `@` ends the
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 — and URL parsing has to agree the host is github.com.
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
  }