@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/src/npm/repos.js CHANGED
@@ -1,20 +1,28 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
3
+ import { assertNames, assertPackageName, assertion, isRepo } from '../args.js'
4
4
  import { readRecord, writeRecord } from '../cache.js'
5
- import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
5
+ import { isNotFound, recover } from '../http.js'
6
6
  import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
7
7
  import { pool } from '../pool.js'
8
+ import { getDocument } from './registry.js'
8
9
 
9
10
  const DIR = 'npm/repos'
10
11
  const CONCURRENCY = 8
12
+ // Stamped on each entry, and raised when getRepo would answer differently:
13
+ // 2 is `repository` taking precedence over `bugs`; 3 the shorthand dropping
14
+ // a `.git`, URLs read past whitespace, and `repository.directory`'s `\` read
15
+ // as `/`.
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 function fetchRepo(method, name) {
14
- const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
15
- assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
16
- return getRepo(json)
17
- }
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 without `directory` predates the field: a miss, not a package
27
- // at the repo root.
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 (!isRepo(entry?.github) || !isRepoDirectory(entry.directory)) return null
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
- assertRepo('writePackageRepoCache', 'github', github)
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
- assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
46
- assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
47
- const names = [...new Set(packageNames)]
48
- for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
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
  }
@@ -3,9 +3,10 @@ import { Buffer } from 'node:buffer'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
6
- import { assertArgs, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
6
+ import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
7
7
  import { verifiedDownload } from '../download.js'
8
- import { NPM_REGISTRY, buildUrl, request } from '../http.js'
8
+ import { NPM_REGISTRY, buildUrl } from '../http.js'
9
+ import { getDocument, withNpmToken } from './registry.js'
9
10
 
10
11
  const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
11
12
  // One sha512 and nothing else, as the registry writes it: a sha1, a second
@@ -13,11 +14,6 @@ const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twic
13
14
  // last character before `==` two bits, so only A, Q, g or w is canonical.
14
15
  const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
15
16
 
16
- function assertPackage(method, name, version) {
17
- assertPackageName(method, 'name', name)
18
- assertPackageVersion(method, 'version', version)
19
- }
20
-
21
17
  // `{ tarball, integrity }` and nothing else, the tarball exactly the
22
18
  // registry's own URL for that version. Each field is read once, and only
23
19
  // as the object's own, into a copy that is checked and used from then on:
@@ -34,8 +30,8 @@ function checkedDist(method, name, version, dist) {
34
30
  }
35
31
 
36
32
  async function getDist(method, name, version) {
37
- const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), version]), { as: 'json' })
38
- assert.ok(json?.name === name && json.version === version, `${method}: the registry answered for ${show(json?.name)}@${show(json?.version)}, not ${name}@${version}`)
33
+ const json = await getDocument(method, name, version)
34
+ assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
39
35
  return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
40
36
  }
41
37
 
@@ -75,5 +71,5 @@ export async function verifyDist(name, version, dist) {
75
71
  export async function getTarball(name, version, dist) {
76
72
  assertPackage('getTarball', name, version)
77
73
  const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
78
- return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball })
74
+ return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), ours: true, locate: () => tarball, options: withNpmToken(name, tarball) })
79
75
  }
package/src/package.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
3
  import { assertion, isRepo, isRepoPath, sameName } from './args.js'
4
- import { githubRepoOfUrl } from './remote.js'
4
+ import { githubRepoOfUrl, withoutDotGit } from './remote.js'
5
5
 
6
6
  const bugsRegex = /^(?i:https?:\/\/github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+)\/issues\/?$/u
7
7
  // npm's `owner/name` shorthand means GitHub. No dots in the owner, so a
@@ -11,9 +11,12 @@ const shorthandRegex = /^(?:github:)?(?<repo>[\w-]+\/[\w.-]+)$/u
11
11
  // The ref is one segment: a branch with a `/` reads as part of the directory.
12
12
  const homepageRegex = /^(?:https?:\/\/)?(?:www\.)?github\.com(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?(?:\/(?:tree\/[^/]+(?:\/(?<directory>.*))?)?)?$/iu
13
13
  const str = (value) => (typeof value === 'string' ? value : '')
14
- const urlOf = (field) => str(field?.url ?? field)
14
+ // Read past the whitespace around it, as `homepage` is.
15
+ const urlOf = (field) => str(field?.url ?? field).trim()
15
16
  const homepageUrl = (homepage) => str(homepage).trim().split(/[?#]/u)[0] // npm appends `#readme`.
16
- const repoIn = (regex, text) => regex.exec(text)?.groups.repo
17
+ // Every trailing `.git` dropped, as the URLs' are: no answer ends in one
18
+ // (the npm repo cache refuses one).
19
+ const repoIn = (regex, text) => withoutDotGit(regex.exec(text)?.groups.repo)
17
20
  // A URL's path spells a tree path percent-encoded; one that does not decode names none.
18
21
  function decodePath(path = '') {
19
22
  try {
@@ -33,6 +36,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 github = [repoIn(bugsRegex, urlOf(bugs)), declared, homepage?.repo].find(isRepo)
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) && repoSubdirectory(repository?.directory))
47
- || (sameName(homepage?.repo, github) && repoSubdirectory(decodePath(homepage.directory)))
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
- let found
17
+ // What load() returns if it is semver, or null if it is not or throws.
18
+ function attempt(load) {
19
+ try {
20
+ const lib = load()
21
+ if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
22
+ } catch {}
23
+ return null
24
+ }
15
25
 
16
26
  function find() {
17
27
  const argv0 = process.argv[0]
18
28
  // Another host executable's `../lib` is not npm's.
19
- if (!['node', 'node.exe'].includes(basename(argv0 ?? ''))) return null
20
- const require = createRequire(import.meta.url)
21
- // POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
22
- for (const prefix of ['../lib', '.']) {
23
- try {
24
- const lib = require(resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver'))
25
- if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
26
- } catch {}
29
+ if (['node', 'node.exe'].includes(basename(argv0 ?? ''))) {
30
+ // POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
31
+ for (const prefix of ['../lib', '.']) {
32
+ // Required from itself, which a bundle of any format leaves alone.
33
+ const path = resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver')
34
+ const lib = attempt(() => createRequire(path)(path))
35
+ if (lib) return lib
36
+ }
27
37
  }
28
- return null
38
+ // The peer, by the plain require() a bundler follows; or, where an ESM
39
+ // bundle leaves it out, by node's own require() from beside the bundle.
40
+ return attempt(requirePeer) ?? attempt(() => createRequire(import.meta.url)('semver'))
29
41
  }
30
42
 
43
+ let found
44
+
31
45
  function semver() {
32
46
  if (found === undefined) found = find()
33
- assert.ok(found, 'semver: no npm beside node to borrow it from')
47
+ assert.ok(found, 'semver: no npm beside node to borrow it from, and the semver peer dependency is not installed')
34
48
  return found
35
49
  }
36
50
 
@@ -40,3 +54,5 @@ export const intersects = (...args) => semver().intersects(...args)
40
54
  export const compareVersions = (...args) => semver().compare(...args)
41
55
  export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
42
56
  export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version
57
+ export const clean = (...args) => semver().clean(...args)
58
+ export const major = (...args) => semver().major(...args)
package/src/soldeer.js CHANGED
@@ -1,16 +1,10 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertSha256, assertion, matches, show } from './args.js'
3
+ import { assertSha256, assertSoldeerPackage, show } from './args.js'
4
4
  import { verifiedDownload } from './download.js'
5
5
  import { SOLDEER_API, SOLDEER_REVISIONS, buildUrl, request } from './http.js'
6
6
 
7
7
  const DIR = 'soldeer/zips' // No expiry: the registry refuses a version it already has.
8
- // Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
9
- // `-`, starting with one of them or `@`, ending with a letter or digit.
10
- const assertName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
11
- // Soldeer takes any version but an empty one. Its registry has semver
12
- // (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
13
- const assertVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
14
8
 
15
9
  // Where the registry keeps the version's zip; it says nothing of what the
16
10
  // zip hashes to.
@@ -26,8 +20,7 @@ async function zipUrl(name, version) {
26
20
  // The registry has no hash to check a zip against, so the caller brings
27
21
  // one, as soldeer.lock records it.
28
22
  export async function getZip(name, version, checksum) {
29
- assertName('getZip', 'name', name)
30
- assertVersion('getZip', 'version', version)
23
+ assertSoldeerPackage('getZip', name, version)
31
24
  assertSha256('getZip', 'checksum', checksum)
32
25
  return await verifiedDownload({ method: 'getZip', dir: DIR, what: `${name}@${version}`, ext: 'zip', algorithm: 'sha256', expected: checksum, locate: () => zipUrl(name, version) })
33
26
  }
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
+ }