@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.
- package/LICENSE-APACHE +202 -0
- package/advisories.d.ts +47 -16
- package/cargo.d.ts +18 -1
- package/cargo.js +2 -1
- package/github.d.ts +67 -13
- package/npm.d.ts +40 -14
- package/npm.js +1 -1
- package/package.d.ts +3 -3
- package/package.json +26 -4
- package/semver.d.ts +8 -2
- package/semver.js +1 -1
- package/src/advisories/common.js +27 -5
- package/src/advisories/github.js +36 -16
- package/src/advisories/index.js +7 -7
- package/src/advisories/npm.js +19 -5
- package/src/advisories/osv.js +34 -15
- package/src/advisories/repos.js +17 -35
- package/src/args.js +36 -2
- package/src/attributes.js +81 -0
- package/src/cache.js +62 -6
- package/src/{cargo.js → cargo/crate.js} +17 -11
- package/src/cargo/repos.js +52 -0
- package/src/download.js +18 -14
- package/src/github/read.js +113 -14
- package/src/http.js +16 -11
- package/src/npm/registry.js +19 -0
- package/src/npm/repos.js +24 -16
- package/src/npm/tarball.js +6 -10
- package/src/package.js +17 -6
- package/src/remote.js +8 -1
- package/src/semver-peer.cjs +8 -0
- package/src/semver.js +28 -12
- package/src/soldeer.js +2 -9
- package/src/tree.js +237 -0
- package/src/weak-edges.js +87 -0
- package/weak-edges.d.ts +36 -0
- package/weak-edges.js +1 -0
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/src/npm/repos.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { assertNames, assertPackageName, assertion, isRepo } from '../args.js'
|
|
4
4
|
import { readRecord, writeRecord } from '../cache.js'
|
|
5
|
-
import {
|
|
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 `/`.
|
|
16
|
+
const VERSION = 3
|
|
17
|
+
|
|
18
|
+
// A slug a lookup gives: getRepo answers no repo ending in `.git`, the
|
|
19
|
+
// suffix it drops (withoutDotGit; `.GIT` it keeps, and so does this).
|
|
20
|
+
const isLookedUpRepo = (github) => isRepo(github) && !github.endsWith('.git')
|
|
21
|
+
// A write takes no slug a read would discard: whatever it writes reads back.
|
|
22
|
+
const assertLookedUpRepo = assertion('"owner/name" with no trailing ".git"', isLookedUpRepo)
|
|
11
23
|
|
|
12
24
|
// `latest`, not the full packument, which is megabytes of version history.
|
|
13
|
-
async
|
|
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
|
-
}
|
|
25
|
+
const fetchRepo = async (method, name) => getRepo(await getDocument(method, name, 'latest'))
|
|
18
26
|
|
|
19
27
|
export async function getGitHub(name) {
|
|
20
28
|
assertPackageName('getGitHub', 'name', name)
|
|
@@ -23,12 +31,15 @@ export async function getGitHub(name) {
|
|
|
23
31
|
return link
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
// An entry
|
|
27
|
-
//
|
|
34
|
+
// An entry under another VERSION was resolved by other rules, and may
|
|
35
|
+
// name a repo getRepo no longer would (a stale tracker's, or a shorthand's
|
|
36
|
+
// with its `.git`): a miss, as is one naming a repo or a directory no
|
|
37
|
+
// lookup gives, or without `directory`, which would read as a package at
|
|
38
|
+
// the repo root.
|
|
28
39
|
export async function readPackageRepoCache(name) {
|
|
29
40
|
assertPackageName('readPackageRepoCache', 'name', name)
|
|
30
41
|
const entry = await readRecord(DIR, name)
|
|
31
|
-
if (!
|
|
42
|
+
if (entry?.v !== VERSION || !isLookedUpRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
|
|
32
43
|
return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -36,16 +47,13 @@ export async function readPackageRepoCache(name) {
|
|
|
36
47
|
// no repo link fail alike.
|
|
37
48
|
export async function writePackageRepoCache(name, github, directory = '') {
|
|
38
49
|
assertPackageName('writePackageRepoCache', 'name', name)
|
|
39
|
-
|
|
50
|
+
assertLookedUpRepo('writePackageRepoCache', 'github', github)
|
|
40
51
|
assertRepoDirectory('writePackageRepoCache', 'directory', directory)
|
|
41
|
-
return await writeRecord(DIR, name, { github, directory })
|
|
52
|
+
return await writeRecord(DIR, name, { v: VERSION, github, directory })
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export async function resolvePackageRepos(packageNames, options = {}) {
|
|
45
|
-
|
|
46
|
-
assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
|
|
47
|
-
const names = [...new Set(packageNames)]
|
|
48
|
-
for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
|
|
56
|
+
const names = assertNames('resolvePackageRepos', 'packageNames', packageNames, options, assertPackageName)
|
|
49
57
|
const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
|
|
50
58
|
return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
|
|
51
59
|
}
|
package/src/npm/tarball.js
CHANGED
|
@@ -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,
|
|
6
|
+
import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
|
|
7
7
|
import { verifiedDownload } from '../download.js'
|
|
8
|
-
import { NPM_REGISTRY, buildUrl
|
|
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
|
|
38
|
-
assert.ok(json
|
|
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
|
-
|
|
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,11 @@ 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.
|
|
42
|
+
const declaredDirectory = (value) => repoSubdirectory(str(value).replaceAll('\\', '/'))
|
|
43
|
+
|
|
36
44
|
export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
|
|
37
45
|
export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
|
|
38
46
|
|
|
@@ -41,9 +49,12 @@ export function getRepo(pkg) {
|
|
|
41
49
|
const { bugs, repository } = pkg
|
|
42
50
|
const declared = repositoryRepo(urlOf(repository))
|
|
43
51
|
const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
|
|
44
|
-
const
|
|
52
|
+
const homepageRepo = withoutDotGit(homepage?.repo)
|
|
53
|
+
// `repository` first: it is the field a publisher maintains, where a
|
|
54
|
+
// `bugs` tracker can be left pointing at a former owner or misspelt.
|
|
55
|
+
const github = [declared, repoIn(bugsRegex, urlOf(bugs)), homepageRepo].find(isRepo)
|
|
45
56
|
if (github === undefined) return {}
|
|
46
|
-
const directory = (sameName(declared, github) &&
|
|
47
|
-
|| (sameName(
|
|
57
|
+
const directory = (sameName(declared, github) && declaredDirectory(repository?.directory))
|
|
58
|
+
|| (sameName(homepageRepo, github) && repoSubdirectory(decodePath(homepage.directory)))
|
|
48
59
|
return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
|
|
49
60
|
}
|
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
|
-
|
|
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 (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const lib =
|
|
25
|
-
if (
|
|
26
|
-
}
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/src/tree.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { gunzipSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { isSha1, matches } from './args.js'
|
|
6
|
+
import { withAttributes, writtenWithCrlf } from './attributes.js'
|
|
7
|
+
|
|
8
|
+
const BLOCK = 512
|
|
9
|
+
const MAX_UNPACKED_BYTES = 2 ** 30
|
|
10
|
+
// Only what git writes is read: anything else, a pax `size` or a mode of
|
|
11
|
+
// 0000, would be extracted as it is not hashed. Modes are as its umask,
|
|
12
|
+
// 002 or 022, leaves them: a file 0664 or 0644, 0775 or 0755 executable;
|
|
13
|
+
// a directory 0775 or 0755; a symlink 0777.
|
|
14
|
+
const PAX_KEYS = new Set(['path', 'linkpath'])
|
|
15
|
+
const MODES = { 0: [0o664, 0o644, 0o775, 0o755], 2: [0o777], 5: [0o775, 0o755] }
|
|
16
|
+
// The top directory is not hashed, so it is held to GitHub's name for it,
|
|
17
|
+
// owner-repo-id: another, `..\..`, `C:` or a Windows device such as
|
|
18
|
+
// `con.txt`, could take an extractor elsewhere.
|
|
19
|
+
const isTop = matches(/^(?!(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[\da-z][\w.-]*$/iu)
|
|
20
|
+
|
|
21
|
+
// Names are kept as latin1 strings, a char per byte, so they sort and hash
|
|
22
|
+
// as the bytes git has.
|
|
23
|
+
const field = (header, start, end) => {
|
|
24
|
+
const bytes = header.subarray(start, end)
|
|
25
|
+
const nul = bytes.indexOf(0)
|
|
26
|
+
return Buffer.from(nul === -1 ? bytes : bytes.subarray(0, nul)).toString('latin1')
|
|
27
|
+
}
|
|
28
|
+
const octal = (header, start, end) => {
|
|
29
|
+
const text = field(header, start, end).trim()
|
|
30
|
+
return /^[0-7]{1,12}$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
|
|
31
|
+
}
|
|
32
|
+
export const objectId = (type, content) => createHash('sha1').update(`${type} ${content.length}\0`).update(content).digest()
|
|
33
|
+
const checksum = (header) => header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0)
|
|
34
|
+
const isZero = (bytes) => bytes.every((byte) => byte === 0)
|
|
35
|
+
|
|
36
|
+
// `<length> <key>=<value>\n`, the length counting the whole record. A NUL
|
|
37
|
+
// would end a path for an extractor, and a name in a tree for git.
|
|
38
|
+
function paxRecords(body) {
|
|
39
|
+
const text = Buffer.from(body).toString('latin1')
|
|
40
|
+
const records = new Map()
|
|
41
|
+
for (let at = 0; at < text.length;) {
|
|
42
|
+
const space = text.indexOf(' ', at)
|
|
43
|
+
const length = text.slice(at, space)
|
|
44
|
+
const end = at + Number(length)
|
|
45
|
+
if (!/^[1-9]\d*$/u.test(length) || end > text.length || text[end - 1] !== '\n') return null
|
|
46
|
+
const record = text.slice(space + 1, end - 1)
|
|
47
|
+
const equals = record.indexOf('=')
|
|
48
|
+
if (equals < 1 || record.includes('\0')) return null
|
|
49
|
+
records.set(record.slice(0, equals), record.slice(equals + 1))
|
|
50
|
+
at = end
|
|
51
|
+
}
|
|
52
|
+
return records
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Git keeps no empty directory: in a tarball, one is a submodule. Each as
|
|
56
|
+
// [the directory holding it, its name, its path].
|
|
57
|
+
function emptyDirs(dir, prefix = '') {
|
|
58
|
+
return [...dir].flatMap(([name, entry]) => {
|
|
59
|
+
if (!(entry instanceof Map)) return []
|
|
60
|
+
const path = `${prefix}${name}`
|
|
61
|
+
return entry.size === 0 ? [[dir, name, path]] : emptyDirs(entry, `${path}/`)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Git sorts a subtree as if its name ended in `/`.
|
|
66
|
+
function treeId(dir) {
|
|
67
|
+
const entries = [...dir].map(([name, entry]) => (entry instanceof Map ? { mode: '40000', name, id: treeId(entry) } : { ...entry, name }))
|
|
68
|
+
const keyed = entries.map((entry) => [entry.mode === '40000' ? `${entry.name}/` : entry.name, entry])
|
|
69
|
+
keyed.sort(([a], [b]) => (a < b ? -1 : Number(a > b)))
|
|
70
|
+
return objectId('tree', Buffer.concat(keyed.flatMap(([, { mode, name, id }]) => [Buffer.from(`${mode} ${name}\0`, 'latin1'), id])))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
|
|
74
|
+
const isName = matches(/^[^\0/]+$/u)
|
|
75
|
+
const isEntry = (entry) => isName(entry?.path) && isSha1(entry.sha)
|
|
76
|
+
const nameOf = (path) => Buffer.from(path).toString('latin1')
|
|
77
|
+
const subtree = (sha) => ({ mode: '40000', id: Buffer.from(sha, 'hex') })
|
|
78
|
+
|
|
79
|
+
// A subtree with nothing in it but subtrees like it, down to the empty
|
|
80
|
+
// tree: its listing, read as subtrees alone, hashes to its id.
|
|
81
|
+
async function holdsNothing(sha, listed) {
|
|
82
|
+
if (sha === EMPTY_TREE) return true
|
|
83
|
+
const entries = await listed(sha)
|
|
84
|
+
if (treeId(new Map(entries.map((entry) => [entry.name, subtree(entry.sha)]))).toString('hex') !== sha) return false
|
|
85
|
+
for (const entry of entries) if (!await holdsNothing(entry.sha, listed)) return false
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CHUNK = 64 * 1024
|
|
90
|
+
|
|
91
|
+
// The id of the blob that `eol=crlf`, a CR written before each LF with
|
|
92
|
+
// none, writes out as these bytes: theirs with each LF's CR taken out,
|
|
93
|
+
// hashed a chunk at a time, the bytes only read. Null where no blob writes
|
|
94
|
+
// them so: an LF with no CR before it, a CR before that CR, or no LF at
|
|
95
|
+
// all. Most files are passed over at their first LF, a binary one with
|
|
96
|
+
// none at once.
|
|
97
|
+
function crlfBlobId(written) {
|
|
98
|
+
const first = written.indexOf(0x0a)
|
|
99
|
+
if (first < 1 || written[first - 1] !== 0x0d) return null
|
|
100
|
+
let lfs = 0
|
|
101
|
+
for (let i = first; i < written.length; i++) {
|
|
102
|
+
if (written[i] !== 0x0a) continue
|
|
103
|
+
if (written[i - 1] !== 0x0d || written[i - 2] === 0x0d) return null
|
|
104
|
+
lfs++
|
|
105
|
+
}
|
|
106
|
+
const hash = createHash('sha1').update(`blob ${written.length - lfs}\0`)
|
|
107
|
+
const chunk = Buffer.allocUnsafe(CHUNK)
|
|
108
|
+
let at = 0
|
|
109
|
+
for (let i = 0; i < written.length; i++) {
|
|
110
|
+
if (written[i] === 0x0d && written[i + 1] === 0x0a) continue
|
|
111
|
+
chunk[at++] = written[i]
|
|
112
|
+
if (at === CHUNK) {
|
|
113
|
+
hash.update(chunk)
|
|
114
|
+
at = 0
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return hash.update(chunk.subarray(0, at)).digest()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Where a directory's id is not the one listed, its listing names what
|
|
121
|
+
// `git archive` wrote otherwise, as a checkout writes it. A subtree with no
|
|
122
|
+
// file in it, which only plumbing makes, it leaves out: one is put back
|
|
123
|
+
// only once its own listings show it holds nothing. A file marked
|
|
124
|
+
// `eol=crlf` it writes with CRLF where git has LF: one is hashed as the
|
|
125
|
+
// blob listed, its bytes left as written, where that blob writes them and
|
|
126
|
+
// the tree's .gitattributes have git write it so.
|
|
127
|
+
async function mend(dir, sha, listed, base = '', above = []) {
|
|
128
|
+
const own = dir.get('.gitattributes')
|
|
129
|
+
const attributes = withAttributes(above, base, own && (own.body ?? null))
|
|
130
|
+
for (const entry of await listed(sha)) {
|
|
131
|
+
const here = dir.get(entry.name)
|
|
132
|
+
if (entry.type === 'tree') {
|
|
133
|
+
if (here instanceof Map && treeId(here).toString('hex') !== entry.sha) await mend(here, entry.sha, listed, `${base}${entry.name}/`, attributes)
|
|
134
|
+
else if (here === undefined && await holdsNothing(entry.sha, listed)) dir.set(entry.name, subtree(entry.sha))
|
|
135
|
+
} else if (entry.type === 'blob' && here?.body && here.id.toString('hex') !== entry.sha
|
|
136
|
+
&& crlfBlobId(here.body)?.toString('hex') === entry.sha && writtenWithCrlf(attributes, `${base}${entry.name}`, here.body)) {
|
|
137
|
+
dir.set(entry.name, { ...here, id: Buffer.from(entry.sha, 'hex') })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The id of the git tree a gzipped tarball holds, as `git archive` writes
|
|
143
|
+
// one, under a single top directory: a file's mode is its exec bit, a
|
|
144
|
+
// symlink's blob its target. What a tarball cannot show comes from `list`,
|
|
145
|
+
// which answers a listing of a tree by its id, as GitHub's trees API does
|
|
146
|
+
// ({ type, path, sha } entries), walked from `expected`: a submodule's
|
|
147
|
+
// commit for an empty directory, and, where the id comes out otherwise, the
|
|
148
|
+
// subtrees with nothing in them it leaves out and the blobs of the files it
|
|
149
|
+
// wrote with CRLF for `eol=crlf`. The id is `expected` only if those are
|
|
150
|
+
// right. Where there is no such tree, a reason, never an id.
|
|
151
|
+
export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
|
|
152
|
+
let bytes
|
|
153
|
+
try {
|
|
154
|
+
bytes = gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES })
|
|
155
|
+
} catch {
|
|
156
|
+
return 'no tree: not gzip, or larger than 1 GiB unpacked'
|
|
157
|
+
}
|
|
158
|
+
const root = new Map()
|
|
159
|
+
let top
|
|
160
|
+
let pax = null
|
|
161
|
+
for (let at = 0; ;) {
|
|
162
|
+
const header = bytes.subarray(at, at + BLOCK)
|
|
163
|
+
if (header.length < BLOCK) return 'no tree: the tarball is cut short'
|
|
164
|
+
if (isZero(header)) {
|
|
165
|
+
if (!isZero(bytes.subarray(at))) return 'no tree: data after the end of the tarball'
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
if (Buffer.from(header.subarray(257, 265)).toString('latin1') !== 'ustar\u000000') return 'no tree: a header that is not POSIX ustar'
|
|
169
|
+
if (octal(header, 148, 156) !== checksum(header)) return 'no tree: a header that fails its checksum'
|
|
170
|
+
const size = octal(header, 124, 136)
|
|
171
|
+
const body = bytes.subarray(at + BLOCK, at + BLOCK + size)
|
|
172
|
+
if (body.length !== size) return 'no tree: the tarball is cut short'
|
|
173
|
+
at += BLOCK + Math.ceil(size / BLOCK) * BLOCK
|
|
174
|
+
const type = String.fromCodePoint(header[156])
|
|
175
|
+
if (type === 'x') {
|
|
176
|
+
const records = paxRecords(body)
|
|
177
|
+
if (records === null) return 'no tree: a malformed pax header'
|
|
178
|
+
const other = [...records.keys()].find((key) => !PAX_KEYS.has(key))
|
|
179
|
+
if (other !== undefined) return `no tree: a pax record git does not write, ${JSON.stringify(other)}`
|
|
180
|
+
if (pax !== null) return 'no tree: two pax headers for one entry'
|
|
181
|
+
pax = records
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const prefix = field(header, 345, 500)
|
|
185
|
+
const path = pax?.get('path') ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100))
|
|
186
|
+
const target = pax?.get('linkpath') ?? field(header, 157, 257)
|
|
187
|
+
pax = null
|
|
188
|
+
if (!Object.hasOwn(MODES, type)) return `no tree: an entry of type ${JSON.stringify(type)}`
|
|
189
|
+
const mode = octal(header, 100, 108)
|
|
190
|
+
const asGitWrites = MODES[type].includes(mode) && (type === '0' || size === 0) && path.endsWith('/') === (type === '5')
|
|
191
|
+
&& octal(header, 108, 116) === 0 && octal(header, 116, 124) === 0 && field(header, 265, 297) === 'root' && field(header, 297, 329) === 'root'
|
|
192
|
+
if (!asGitWrites) return `no tree: a header git does not write, ${JSON.stringify(path)}`
|
|
193
|
+
const [first, ...parts] = path.replace(/\/$/u, '').split('/')
|
|
194
|
+
top ??= first
|
|
195
|
+
const name = type === '5' ? null : parts.pop()
|
|
196
|
+
if (!isTop(first) || first !== top || name === undefined || [...parts, name].some((part) => ['', '.', '..'].includes(part))) return `no tree: an entry outside one top directory, ${JSON.stringify(path)}`
|
|
197
|
+
let dir = root
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
if (!dir.has(part)) dir.set(part, new Map())
|
|
200
|
+
dir = dir.get(part)
|
|
201
|
+
if (!(dir instanceof Map)) return `no tree: a file where a directory is, ${JSON.stringify(path)}`
|
|
202
|
+
}
|
|
203
|
+
if (name === null) continue
|
|
204
|
+
if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
|
|
205
|
+
dir.set(name, type === '0' ? { mode: mode & 0o100 ? '100755' : '100644', id: objectId('blob', body), body } : { mode: '120000', id: objectId('blob', Buffer.from(target, 'latin1')) })
|
|
206
|
+
}
|
|
207
|
+
if (top === undefined) return 'no tree: an empty tarball'
|
|
208
|
+
const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
|
|
209
|
+
.filter(isEntry)
|
|
210
|
+
.map((entry) => ({ ...entry, name: nameOf(entry.path) }))
|
|
211
|
+
for (const [dir, name, path] of emptyDirs(root)) {
|
|
212
|
+
let sha = expected
|
|
213
|
+
const parts = path.split('/')
|
|
214
|
+
for (const [i, part] of parts.entries()) {
|
|
215
|
+
const type = i === parts.length - 1 ? 'commit' : 'tree'
|
|
216
|
+
sha = (await listed(sha)).find((entry) => entry.type === type && entry.name === part)?.sha
|
|
217
|
+
}
|
|
218
|
+
if (!isSha1(sha)) return `no tree: an empty directory, ${JSON.stringify(path)}, and no submodule there`
|
|
219
|
+
dir.set(name, { mode: '160000', id: Buffer.from(sha, 'hex') })
|
|
220
|
+
}
|
|
221
|
+
const id = treeId(root).toString('hex')
|
|
222
|
+
if (id === expected) return id
|
|
223
|
+
await mend(root, expected, listed)
|
|
224
|
+
return treeId(root).toString('hex')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// GitHub lists a subtree's mode as `040000`, which git writes `40000`.
|
|
228
|
+
const LISTED = new Set(['100644 blob', '100755 blob', '120000 blob', '040000 tree', '160000 commit'])
|
|
229
|
+
|
|
230
|
+
// The id of the tree a listing such as GitHub's names, its entries
|
|
231
|
+
// { path, mode, type, sha }, or null where one is not an entry a tree can
|
|
232
|
+
// hold, or a name is there twice.
|
|
233
|
+
export function gitTreeOfListing(entries) {
|
|
234
|
+
if (!entries.every((entry) => isEntry(entry) && LISTED.has(`${entry.mode} ${entry.type}`))) return null
|
|
235
|
+
const dir = new Map(entries.map(({ path, mode, sha }) => [nameOf(path), { mode: mode.replace(/^0/u, ''), id: Buffer.from(sha, 'hex') }]))
|
|
236
|
+
return dir.size === entries.length ? treeId(dir).toString('hex') : null
|
|
237
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Package-relative coordinates keep this inventory independent of installation
|
|
2
|
+
// roots, pnpm layouts, display-prefix stripping, and graph package grouping.
|
|
3
|
+
const babelConfig = { package: '@babel/core', path: /^lib\/config\//u }
|
|
4
|
+
const babelPlugins = { package: '@babel/core', path: /^lib\/config\/files\/(?:module-types|plugins)\.js$/u }
|
|
5
|
+
|
|
6
|
+
export const weakEdges = Object.freeze([
|
|
7
|
+
{
|
|
8
|
+
id: 'react-native-codegen-discovery',
|
|
9
|
+
reason: 'Codegen discovers package metadata and React Native configuration.',
|
|
10
|
+
from: { package: 'react-native', path: /^scripts\/codegen\/generate-artifacts-executor\.js$/u },
|
|
11
|
+
to: { path: /(?:^|\/)(?:package\.json|react-native\.config\.js)$/u },
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'react-native-release-version',
|
|
15
|
+
reason: 'The CLI release checker reads the installed React Native version.',
|
|
16
|
+
from: { package: '@react-native-community/cli-tools', path: /^build\/releaseChecker\/index\.js$/u },
|
|
17
|
+
to: { package: 'react-native', path: /^package\.json$/u },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'react-native-platform-config',
|
|
21
|
+
reason: 'React Native configuration loads platform CLIs supplied by the host project.',
|
|
22
|
+
from: { package: 'react-native', path: /^react-native\.config\.js$/u },
|
|
23
|
+
to: { package: /^@react-native-community\/cli-platform-(?:android|ios)$/u, path: /^build\/index\.js$/u },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'babel-project-config',
|
|
27
|
+
reason: 'Babel discovers project configuration.',
|
|
28
|
+
from: babelConfig,
|
|
29
|
+
to: { path: /(?:^|\/)babel\.config\.js$/u },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'babel-typescript-preset',
|
|
33
|
+
reason: 'Babel configuration loads the TypeScript preset.',
|
|
34
|
+
from: babelConfig,
|
|
35
|
+
to: { package: '@babel/preset-typescript', path: /./u },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'babel-react-native-preset',
|
|
39
|
+
reason: 'Babel config loaders load the configured React Native preset.',
|
|
40
|
+
from: babelPlugins,
|
|
41
|
+
to: { package: '@react-native/babel-preset', path: /^(?:src\/)?index\.js$/u },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'babel-reanimated-plugin',
|
|
45
|
+
reason: 'Babel config loaders load the configured Reanimated plugin, including scoped copies.',
|
|
46
|
+
from: babelPlugins,
|
|
47
|
+
to: { package: /^(?:@[^/]+\/)?react-native-reanimated$/u, path: /^plugin\/index\.js$/u },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'babel-transform-plugin',
|
|
51
|
+
reason: 'Babel config loaders load configured transform plugins.',
|
|
52
|
+
from: babelPlugins,
|
|
53
|
+
to: { package: /^@babel\/plugin-transform-[^/]+$/u, path: /^lib\/index\.js$/u },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'cosmiconfig-config',
|
|
57
|
+
reason: 'Cosmiconfig loads discovered configuration, including dependency configuration.',
|
|
58
|
+
from: { package: 'cosmiconfig', path: /^dist\/loaders\.js$/u },
|
|
59
|
+
to: { path: /\.config\.js$/u },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'import-fresh-own-config',
|
|
63
|
+
reason: 'import-fresh loads host-project configuration.',
|
|
64
|
+
from: { package: 'import-fresh', path: /./u },
|
|
65
|
+
to: { path: /\.config\.js$/u },
|
|
66
|
+
ownSourceOnly: true,
|
|
67
|
+
},
|
|
68
|
+
].map(rule => Object.freeze({ ...rule, from: Object.freeze(rule.from), to: Object.freeze(rule.to) })))
|
|
69
|
+
|
|
70
|
+
// Do not guess ownership from a partial or shortened path. A nested dependency
|
|
71
|
+
// must be passed as its own package, not as node_modules/... inside its parent.
|
|
72
|
+
function normalizedPath(path) {
|
|
73
|
+
return typeof path === 'string' && !path.includes('\\') && !path.includes(':')
|
|
74
|
+
&& path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part !== 'node_modules')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function matches(selector, file) {
|
|
78
|
+
const pkg = selector.package
|
|
79
|
+
if (pkg !== undefined && file.ownSource === true) return false
|
|
80
|
+
return (pkg === undefined || (typeof pkg === 'string' ? pkg === file.package : pkg.test(file.package ?? '')))
|
|
81
|
+
&& selector.path.test(file.path)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getWeakEdge(from, to) {
|
|
85
|
+
if (from?.ownSource === true || !normalizedPath(from?.path) || !normalizedPath(to?.path)) return undefined
|
|
86
|
+
return weakEdges.find(rule => (!rule.ownSourceOnly || to.ownSource === true) && matches(rule.from, from) && matches(rule.to, to))
|
|
87
|
+
}
|