@preventive/upstream 1.0.0-alpha.7 → 1.0.0-alpha.9

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 CHANGED
@@ -39,6 +39,11 @@ export interface AdvisoryOptions {
39
39
  // the one npm's metadata, crates.io or Packagist names, looked up
40
40
  // through the cache. Needs `github`.
41
41
  repoAdvisories?: boolean
42
+ // Also returns each advisory's full text, as `details`. OSV's records
43
+ // and a repository's listing already carry it; npm's registry does not,
44
+ // and its rows take OSV's record of their GHSA, one more request each
45
+ // (no GitHub client needed).
46
+ details?: boolean
42
47
  }
43
48
 
44
49
  // One advisory on one package, with the versions asked about that it
@@ -62,8 +67,21 @@ export interface Advisory {
62
67
  id: string
63
68
  // The id itself, or an OSV record's one GHSA alias.
64
69
  ghsa?: string
70
+ // GitHub's page for the GHSA, with it: a `repository` row's on its
71
+ // repository (https://github.com/owner/name/security/advisories/GHSA-…),
72
+ // which has it before GitHub reviews it into its advisory database; any
73
+ // other's in that database (https://github.com/advisories/GHSA-…). An
74
+ // `osv` row without one has osv.dev's page for its record
75
+ // (https://osv.dev/vulnerability/RUSTSEC-…). Built, never fetched: only
76
+ // a registry row without a GHSA (npm:<id>) has none.
77
+ url?: string
65
78
  aliases: string[]
66
79
  title?: string
80
+ // With `details`, the advisory's full text, Markdown as published: an
81
+ // OSV record's `details`, a repository advisory's `description`, or for
82
+ // a registry row, OSV's record of its GHSA. Absent where there is none:
83
+ // a registry row without a GHSA, or with one OSV does not have yet.
84
+ details?: string
67
85
  // `critical`, `high`, `moderate`, `low`; GitHub's `medium` reads
68
86
  // `moderate`. RustSec has none.
69
87
  severity?: string
@@ -84,10 +102,12 @@ export interface Advisory {
84
102
  // Every package, and every one of its versions, is checked against its
85
103
  // ecosystem's rules before the first request; a name given twice is
86
104
  // merged, and must not name two repositories. npm is asked 250 names a
87
- // request, OSV 1000 versions, Soldeer one project; a project Soldeer does
88
- // not have or that names no GitHub repository, and a repository gone,
89
- // renamed or blocked, add nothing. Any other failure, a malformed answer,
90
- // or one about something not asked, throws: nothing is left out quietly.
105
+ // request, OSV 1000 versions, Soldeer one project, and with `details`, OSV
106
+ // one GHSA among npm's rows; a project Soldeer does not have or that names
107
+ // no GitHub repository, and a repository gone, renamed or blocked, add
108
+ // nothing, and a GHSA OSV does not have adds no `details`. Any other
109
+ // failure, a malformed answer, or one about something not asked, throws:
110
+ // nothing is left out quietly.
91
111
  // Sorted by ecosystem and name. Versions are matched by npm's semver, from
92
112
  // the npm beside node, or by the semver peer where there is no npm.
93
113
  export function advisories(packages: Iterable<Package>, options?: AdvisoryOptions): Promise<Advisory[]>
package/github.d.ts CHANGED
@@ -84,14 +84,21 @@ export interface Client {
84
84
  // Its files are hashed back into git's tree and the id must be `tree`,
85
85
  // whether downloaded or read from setCacheDir's cache (npm.js), where it
86
86
  // is kept by the id alone, for good; a cached copy that does not match
87
- // throws. A tarball shows a submodule as an empty directory, and leaves
88
- // out a subtree with no file in it: the submodule's commit and the
89
- // subtrees come from GitHub's listings of the trees, asked only then, and
90
- // the id must still come out `tree`: the bytes are the tree's own, the
91
- // same for every repo. So GitHub's tarball is refused for a tree with
92
- // files marked `export-ignore`, which it leaves out, and from a repo set
93
- // to include Git LFS objects in archives, which replace the pointers git
94
- // has; that repo still gets a tree already cached, pointers and all.
87
+ // throws. A tarball shows a submodule as an empty directory, leaves out
88
+ // a subtree with no file in it, and has a file marked `eol=crlf` with
89
+ // CRLF where git has LF, as a checkout writes it: the submodule's commit,
90
+ // the subtrees and the blobs come from GitHub's listings of the trees,
91
+ // asked only then, cached or not, a CRLF file is taken only where the
92
+ // tree's .gitattributes have git write it so, and the id must still come
93
+ // out `tree`. The bytes are GitHub's: the tree as a checkout with no
94
+ // `core.autocrlf` writes it, the same for every repo. So GitHub's tarball
95
+ // is refused for a tree with files marked `export-ignore`, which it
96
+ // leaves out, or `ident`, `filter` or `working-tree-encoding` beside
97
+ // `eol=crlf`, or `eol=crlf` on a file git has with both CRLF and LF; for
98
+ // one with .gitattributes rules src/attributes.js does not read; and from
99
+ // a repo set to include Git LFS objects in archives, which replace the
100
+ // pointers git has; that repo still gets a tree already cached, pointers
101
+ // and all.
95
102
  getRepoTreeTarball(options: { repo: RepoName; tree: string }): Promise<Uint8Array>
96
103
  // The repository's published security advisories, as GitHub's
97
104
  // repository advisory objects: every page of 100, each found by the
package/npm.d.ts CHANGED
@@ -10,8 +10,27 @@
10
10
  // Where NPM_TOKEN is set, it is sent with the requests for a scoped
11
11
  // package, so a private one can be read.
12
12
 
13
- // Where cached answers live, resolved when set. Unset by default, and
14
- // unset means no cache: every read misses and every write is skipped.
13
+ // Where a program called `name` keeps its cache for this user, as each
14
+ // platform has it, read from the environment when called. macOS:
15
+ // ~/Library/Caches/<name>. Windows: %LOCALAPPDATA%\<name>\Cache, the
16
+ // `Cache` apart from the program's other local data there. Elsewhere, the
17
+ // XDG Base Directory spec's: $XDG_CACHE_HOME/<name>, or ~/.cache/<name>
18
+ // where it is unset, empty or relative, which the spec has ignored.
19
+ // Undefined where no absolute directory is found to start from: a home
20
+ // directory that is relative, empty or unknown. Nothing is made. `name` is
21
+ // 6 to 32 of a-z, A-Z, 0-9, `_` and `-`, so one directory name the same on
22
+ // every platform.
23
+ export function cacheDirFor(name: string): string | undefined
24
+
25
+ // cacheDirFor('PreventiveMeasures'), read once, when this module is first
26
+ // imported: where setCacheDir() caches.
27
+ export const defaultCacheDir: string | undefined
28
+
29
+ // Where cached answers are written and read, resolved when set: `dir`, or
30
+ // defaultCacheDir where it is left out, which throws where there is none.
31
+ // Unset until this is called, and unset again by `false`, which means no
32
+ // cache: every read of it misses and every write is skipped. getTarball
33
+ // reads ours all the same, set or not.
15
34
  //
16
35
  // The directory is trusted: a package's repo is answered from it as it
17
36
  // was written, with no request. Point it only at storage that nothing
@@ -19,7 +38,7 @@
19
38
  // restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
20
39
  // (soldeer.js) and tree tarballs (github.js) don't rely on this, since
21
40
  // they are checked against their integrity on every call.
22
- export function setCacheDir(dir: string): void
41
+ export function setCacheDir(dir?: string | false): void
23
42
 
24
43
  // A failed request: `status` is the HTTP status the registry answered
25
44
  // with (404 for a package or version it does not have).
@@ -85,10 +104,11 @@ export function verifyDist(name: string, version: string, dist: Dist): Promise<v
85
104
  // for. Either way the bytes are checked against `dist.integrity`, whether
86
105
  // they were downloaded (before they are cached) or read from a cache.
87
106
  //
88
- // Other tools' caches are read first, and never written: npm's (cacache
89
- // under npm_config_cache, else npm's default, `~/.npm` outside Windows;
90
- // and npm 4's `<name>/<version>/package.tgz` there), then
91
- // `~/.audit/cache/tgz/<org>:<name>-<version>.tgz`. A file there that does
92
- // not match is passed over. One in setCacheDir's cache that does not
93
- // match throws.
107
+ // Other caches are read first, and never written: npm's (cacache under
108
+ // npm_config_cache, else npm's default, `~/.npm` outside Windows; and npm
109
+ // 4's `<name>/<version>/package.tgz` there), then
110
+ // `~/.audit/cache/tgz/<org>:<name>-<version>.tgz`, then defaultCacheDir
111
+ // and cacheDirFor('stasis'), as setCacheDir files a tarball in either,
112
+ // whether set or not. A file there that does not match is passed over.
113
+ // Then setCacheDir's cache, where one that does not match throws.
94
114
  export function getTarball(name: string, version: string, dist?: Dist): Promise<Uint8Array>
package/npm.js CHANGED
@@ -1,4 +1,4 @@
1
- export { setCacheDir } from './src/cache.js'
1
+ export { cacheDirFor, defaultCacheDir, setCacheDir } from './src/cache.js'
2
2
  export { HttpError } from './src/http.js'
3
3
  export { getGitHub, readPackageRepoCache, resolvePackageRepos, writePackageRepoCache } from './src/npm/repos.js'
4
4
  export { getMeta, getTarball, verifyDist } from './src/npm/tarball.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,6 +39,10 @@
39
39
  "./soldeer.js": {
40
40
  "types": "./soldeer.d.ts",
41
41
  "default": "./soldeer.js"
42
+ },
43
+ "./weak-edges.js": {
44
+ "types": "./weak-edges.d.ts",
45
+ "default": "./weak-edges.js"
42
46
  }
43
47
  },
44
48
  "files": [
@@ -62,6 +66,8 @@
62
66
  "semver.js",
63
67
  "soldeer.d.ts",
64
68
  "soldeer.js",
69
+ "weak-edges.d.ts",
70
+ "weak-edges.js",
65
71
  "src/advisories/common.js",
66
72
  "src/advisories/github.js",
67
73
  "src/advisories/index.js",
@@ -69,6 +75,7 @@
69
75
  "src/advisories/osv.js",
70
76
  "src/advisories/repos.js",
71
77
  "src/args.js",
78
+ "src/attributes.js",
72
79
  "src/cache.js",
73
80
  "src/cargo/crate.js",
74
81
  "src/cargo/repos.js",
@@ -87,7 +94,8 @@
87
94
  "src/semver-peer.cjs",
88
95
  "src/semver.js",
89
96
  "src/tree.js",
90
- "src/soldeer.js"
97
+ "src/soldeer.js",
98
+ "src/weak-edges.js"
91
99
  ],
92
100
  "engines": {
93
101
  "node": ">=24.14.0"
@@ -1,8 +1,18 @@
1
- import { isStrings, matches } from '../args.js'
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { isGhsa, isRepo, isStrings, matches, show } from '../args.js'
2
4
  import { satisfies, valid, validRange } from '../semver.js'
3
5
 
4
6
  export const order = (a, b) => (a > b) - (a < b)
5
7
 
8
+ // GitHub's page for a GHSA: the advisory database's, which has every one
9
+ // the registry and OSV answer with, or the page of the `repo` that
10
+ // published it, which has it before GitHub reviews it into the database.
11
+ export function advisoryUrl(ghsa, repo) {
12
+ assert.ok(isGhsa(ghsa) && (repo === undefined || isRepo(repo)), `advisories: no GitHub advisory page for ${show(ghsa)} on ${show(repo)}`)
13
+ return repo === undefined ? `https://github.com/advisories/${ghsa}` : `https://github.com/${repo}/security/advisories/${ghsa}`
14
+ }
15
+
6
16
  // As `npm audit` matches: a prerelease is in a range around it.
7
17
  const SEMVER = { includePrerelease: true, loose: true }
8
18
 
@@ -20,6 +30,14 @@ export const covered = (asked, range, covers = mayBeInRange) => (validRange(rang
20
30
  // malformed by whoever reads it.
21
31
  export const isText = (value) => typeof value === 'string' && value.isWellFormed()
22
32
 
33
+ // An advisory's full text, for `details`: none where its source has none,
34
+ // and refused where it is not well-formed.
35
+ export function detailsOf(text, id) {
36
+ if (text == null || text === '') return {}
37
+ assert.ok(isText(text), `advisories: malformed details in ${id}`)
38
+ return { details: text }
39
+ }
40
+
23
41
  const SEVERITIES = new Set(['critical', 'high', 'moderate', 'low', 'info'])
24
42
  const isCvssVector = matches(/^CVSS:[34]\.\d(?:\/[A-Z]{1,4}:[A-Z]{1,2})+$/u)
25
43
  const isCwe = matches(/^CWE-\d{1,6}$/u)
@@ -4,7 +4,7 @@ import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa,
4
4
  import { isGone } from '../github/client.js'
5
5
  import { recover } from '../http.js'
6
6
  import { pool } from '../pool.js'
7
- import { covered, isText, mayBeInRange, metrics } from './common.js'
7
+ import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from './common.js'
8
8
  import { soldeerRepos } from './repos.js'
9
9
 
10
10
  const REPOS_AT_ONCE = 4
@@ -19,16 +19,20 @@ const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).cat
19
19
 
20
20
  // GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
21
21
  // Maintainers write these unreviewed: one without a range, or with one
22
- // semver cannot read, covers every version.
23
- function fromRepository(name, advisory, range, asked, covers) {
22
+ // semver cannot read, covers every version. Its page is on `repo`, which
23
+ // may publish it long before the advisory database has one, and its
24
+ // `description` is the text `details` asks for: the listing has it all.
25
+ function fromRepository(repo, name, advisory, range, asked, { covers, details }) {
24
26
  const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
25
27
  return {
26
28
  name,
27
29
  source: 'repository',
28
30
  id: advisory.ghsa_id,
29
31
  ghsa: advisory.ghsa_id,
32
+ url: advisoryUrl(advisory.ghsa_id, repo),
30
33
  aliases: [],
31
34
  title: advisory.summary,
35
+ ...(details && detailsOf(advisory.description, advisory.ghsa_id)),
32
36
  ...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
33
37
  range,
34
38
  versions: covered(asked, range.replaceAll(',', ' '), covers),
@@ -38,8 +42,8 @@ function fromRepository(name, advisory, range, asked, covers) {
38
42
  // Each asked name's repository, `repoOf` it, is asked once, and its
39
43
  // advisories' vulnerable ranges become rows for the names that `takes` a
40
44
  // vulnerability, one per name, advisory and range, holding the asked
41
- // versions it `covers`.
42
- async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers }) {
45
+ // versions it `covers`, and with `details`, its text.
46
+ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details }) {
43
47
  // GitHub's names are case-insensitive: one spelling asks for all.
44
48
  const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
45
49
  const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0])) }))
@@ -51,7 +55,7 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
51
55
  const range = vulnerability?.vulnerable_version_range ?? ''
52
56
  assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
53
57
  for (const name of names.filter((candidate) => takes(candidate, vulnerability?.package))) {
54
- rows.set(`${name} ${advisory.ghsa_id} ${range}`, fromRepository(name, advisory, range, asked.get(name), covers))
58
+ rows.set(`${name} ${advisory.ghsa_id} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
55
59
  }
56
60
  }
57
61
  }
@@ -70,12 +74,12 @@ async function reposOf(asked, known, lookUp) {
70
74
  // each package's repository, `known` or else looked up, adds what it
71
75
  // publishes for GitHub's `ecosystem` entries naming the package, for the
72
76
  // versions `rows` do not already report under that GHSA.
73
- export async function withRepositories(rows, asked, { github, repoAdvisories, known }, { ecosystem, lookUp, covers }) {
77
+ export async function withRepositories(rows, asked, { github, repoAdvisories, known, details }, { ecosystem, lookUp, covers }) {
74
78
  if (!repoAdvisories) return rows
75
79
  const reported = new Set(rows.flatMap((row) => [row.id, row.ghsa, ...row.aliases].filter(isGhsa).flatMap((id) => row.versions.map((version) => `${row.name} ${id} ${version}`))))
76
80
  const repoOf = await reposOf(asked, known, lookUp)
77
81
  const takes = (name, pkg) => pkg?.ecosystem === ecosystem && pkg.name === name
78
- const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers })
82
+ const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details })
79
83
  return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
80
84
  }
81
85
 
@@ -89,7 +93,7 @@ export const GITHUB = {
89
93
  repositoryOnly: true,
90
94
  assertName: assertRepo,
91
95
  assertVersion: assertion('a version or a branch name', isRefName),
92
- advisories: (asked, { github }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder }),
96
+ advisories: (asked, { github, details }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details }),
93
97
  }
94
98
 
95
99
  // Soldeer packages, which no advisory database has: the repository each
@@ -100,5 +104,5 @@ export const SOLDEER = {
100
104
  repositoryOnly: true,
101
105
  assertName: assertSoldeerName,
102
106
  assertVersion: assertSoldeerVersion,
103
- advisories: async (asked, { github, known }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos) }),
107
+ advisories: async (asked, { github, known, details }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos), details }),
104
108
  }
@@ -34,7 +34,7 @@ function collect(packages) {
34
34
  }
35
35
 
36
36
  export async function advisories(packages, options = {}) {
37
- assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean) })
37
+ assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean) })
38
38
  assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
39
39
  const byEcosystem = collect(packages)
40
40
  for (const ecosystem of byEcosystem.keys()) assert.ok(!ECOSYSTEMS[ecosystem].repositoryOnly || options.github, `advisories: ${ecosystem} packages need a github client`)
@@ -43,7 +43,7 @@ export async function advisories(packages, options = {}) {
43
43
  const names = [...named.keys()].toSorted()
44
44
  const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
45
45
  const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
46
- const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, known })
46
+ const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, known })
47
47
  return rows.map((row) => ({ ecosystem, ...row }))
48
48
  }))
49
49
  return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
@@ -1,11 +1,12 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
3
  import { assertPackageName, assertPackageVersion, isGhsa, isStrings, show } from '../args.js'
4
- import { NPM_REGISTRY, buildUrl, request } from '../http.js'
5
- import { chunks } from '../pool.js'
4
+ import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
5
+ import { chunks, pool } from '../pool.js'
6
6
  import { compareVersions } from '../semver.js'
7
- import { covered, isText, metrics } from './common.js'
7
+ import { advisoryUrl, covered, detailsOf, isText, metrics } from './common.js'
8
8
  import { withRepositories } from './github.js'
9
+ import { RECORDS_AT_ONCE, getVuln } from './osv.js'
9
10
  import { npmRepos } from './repos.js'
10
11
 
11
12
  const BULK_URL = buildUrl(NPM_REGISTRY, ['-', 'npm', 'v1', 'security', 'advisories', 'bulk'])
@@ -23,7 +24,7 @@ function fromRegistry(name, row, asked) {
23
24
  name,
24
25
  source: 'registry',
25
26
  id: ghsa ?? `npm:${row.id}`,
26
- ...(ghsa && { ghsa }),
27
+ ...(ghsa && { ghsa, url: advisoryUrl(ghsa) }),
27
28
  aliases: [],
28
29
  title: row.title,
29
30
  ...metrics({ severity: row.severity, score: row.cvss?.score, vector: row.cvss?.vectorString, cwe: row.cwe }),
@@ -49,10 +50,23 @@ async function registryAdvisories(asked) {
49
50
  return advisories
50
51
  }
51
52
 
53
+ // The registry's rows carry no text: each GHSA's is OSV's record of it,
54
+ // GitHub's advisory database as published, asked once for the rows that
55
+ // cover an asked version. One OSV does not have yet has none.
56
+ async function withDetails(rows) {
57
+ const ghsas = [...new Set(rows.filter((row) => row.ghsa && row.versions.length > 0).map((row) => row.ghsa))]
58
+ const records = await pool(ghsas, RECORDS_AT_ONCE, (ghsa) => getVuln(ghsa).catch(recover(isNotFound, undefined)))
59
+ const text = new Map(ghsas.map((ghsa, i) => [ghsa, records[i]?.details]))
60
+ return rows.map((row) => ({ ...row, ...detailsOf(text.get(row.ghsa), row.ghsa) }))
61
+ }
62
+
52
63
  // What `npm audit` asks the registry, one row per vulnerable range.
53
64
  export const NPM = {
54
65
  assertName: assertPackageName,
55
66
  assertVersion: assertPackageVersion,
56
67
  compare: compareVersions,
57
- advisories: async (asked, options) => await withRepositories(await registryAdvisories(asked), asked, options, { ecosystem: 'npm', lookUp: npmRepos }),
68
+ advisories: async (asked, options) => {
69
+ const rows = await registryAdvisories(asked)
70
+ return await withRepositories(options.details ? await withDetails(rows) : rows, asked, options, { ecosystem: 'npm', lookUp: npmRepos })
71
+ },
58
72
  }
@@ -4,14 +4,17 @@ import { assertCrateName, assertCrateVersion, assertion, isGhsa, isStrings, matc
4
4
  import { OSV_API, buildUrl, request } from '../http.js'
5
5
  import { chunks, pool } from '../pool.js'
6
6
  import { isExactVersion } from '../semver.js'
7
- import { inRange, isText, metrics, order } from './common.js'
7
+ import { advisoryUrl, detailsOf, inRange, isText, metrics, order } from './common.js'
8
8
  import { withRepositories } from './github.js'
9
9
  import { composerRepos, crateRepos } from './repos.js'
10
10
 
11
11
  const QUERY_URL = buildUrl(OSV_API, ['v1', 'querybatch'])
12
12
  const QUERIES_PER_REQUEST = 1000
13
- const RECORDS_AT_ONCE = 8
13
+ export const RECORDS_AT_ONCE = 8
14
14
  const isOsvId = matches(/^(?=.{1,128}$)[A-Z][\dA-Z]*(?:-[\dA-Za-z]+)+$/u)
15
+ // osv.dev's page for one of its records. An id that is only another's
16
+ // alias (PKSA-…) has none, and redirects to a search.
17
+ const osvPage = (id) => `https://osv.dev/vulnerability/${id}`
15
18
  const INFORMATIONAL = new Set(['unmaintained', 'unsound', 'notice'])
16
19
  // Of the records that publish one advisory, the first answered for a
17
20
  // version stands for the rest on it: RustSec's, what `cargo audit` reads
@@ -46,7 +49,7 @@ export const COMPOSER = {
46
49
  advisories: (asked, options) => osvAdvisories(COMPOSER, asked, options),
47
50
  }
48
51
 
49
- async function getVuln(id) {
52
+ export async function getVuln(id) {
50
53
  const record = await request(buildUrl(OSV_API, ['v1', 'vulns', id]), { as: 'json' })
51
54
  assert.ok(record?.id === id, `advisories: OSV answered for ${show(record?.id)}, not ${id}`)
52
55
  assert.ok((record.aliases === undefined || isStrings(record.aliases)) && (record.summary === undefined || isText(record.summary))
@@ -54,7 +57,7 @@ async function getVuln(id) {
54
57
  return { ...record, aliases: (record.aliases ?? []).filter(isOsvId) }
55
58
  }
56
59
 
57
- function toAdvisory(ecosystem, name, versions, record) {
60
+ function toAdvisory(ecosystem, name, versions, record, details) {
58
61
  const { aliases } = record
59
62
  const ghsas = aliases.filter(isGhsa)
60
63
  const ghsa = isGhsa(record.id) ? record.id : (ghsas.length === 1 ? ghsas[0] : undefined)
@@ -66,8 +69,10 @@ function toAdvisory(ecosystem, name, versions, record) {
66
69
  source: 'osv',
67
70
  id: record.id,
68
71
  ...(ghsa && { ghsa }),
72
+ url: ghsa ? advisoryUrl(ghsa) : osvPage(record.id),
69
73
  aliases,
70
74
  ...(record.summary && { title: record.summary }),
75
+ ...(details && detailsOf(record.details, record.id)),
71
76
  ...metrics({ severity: record.database_specific?.severity, vector, cwe: record.database_specific?.cwe_ids }),
72
77
  ...(INFORMATIONAL.has(informational) && { informational }),
73
78
  versions,
@@ -113,7 +118,7 @@ async function osvAdvisories(ecosystem, asked, options) {
113
118
  const aliases = [...new Set([...record.aliases, ...group.map((other) => other.id)])].filter((id) => id !== record.id)
114
119
  for (const [name, versions] of hits.get(record.id)) {
115
120
  const rest = [...versions].filter((version) => !above.some((other) => hits.get(other.id).get(name)?.has(version)))
116
- if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases }))
121
+ if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases }, options.details))
117
122
  }
118
123
  }
119
124
  rows.sort((a, b) => order(a.name, b.name) || order(a.id, b.id))
package/src/args.js CHANGED
@@ -72,6 +72,9 @@ export const assertLine = assertion('a non-empty single line', (value) => isStri
72
72
  // 65,536 is GitHub's own limit on a body.
73
73
  export const assertText = assertion('text with no control characters', (value) => isString(value, 65_536, '\t\n\r'))
74
74
  export const assertDirectoryPath = assertion('a directory path', (value) => isString(value, 4096) && value !== '')
75
+ // A short ASCII name, which no filesystem refuses, reads as another or takes
76
+ // for a device: none of those is six characters of these alone.
77
+ export const assertCacheName = assertion('6 to 32 of a-z, A-Z, 0-9, _ and -', matches(/^[a-zA-Z0-9_-]{6,32}$/u))
75
78
  export const assertLogin = assertion('a GitHub login', isLogin)
76
79
  export const assertRepoName = assertion('a repository name', isRepoName)
77
80
  export const assertRepo = assertion('"owner/name"', isRepo)
@@ -0,0 +1,81 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ // Whether git writes a file out with CRLF, from the .gitattributes in its
4
+ // tree alone, as `git archive` and a checkout with no config read them. Only
5
+ // `*`, `*.ext`, name and path rules are read: any other rule that sets an
6
+ // attribute line endings depend on refuses its file, and all below it.
7
+
8
+ const RELEVANT = new Set(['text', 'crlf', 'eol', 'binary', 'ident', 'filter', 'working-tree-encoding'])
9
+ const BINARY = [['diff', false], ['merge', false], ['text', false]]
10
+ const GLOB = /[*?[\\]/u
11
+ const PRINTING = new Set([0x08, 0x09, 0x0c, 0x1b]) // BS, HT, FF, ESC
12
+
13
+ function matcher(pattern) {
14
+ const name = (path) => path.slice(path.lastIndexOf('/') + 1)
15
+ if (pattern.endsWith('/')) return () => false // a directory
16
+ if (!GLOB.test(pattern)) return pattern.includes('/') ? (path) => path === pattern.replace(/^\//u, '') : (path) => name(path) === pattern
17
+ const end = pattern.slice(1)
18
+ return pattern[0] === '*' && !GLOB.test(end) && !end.includes('/') ? (path) => name(path).endsWith(end) : null
19
+ }
20
+
21
+ // Read to the first NUL, as git reads one from a tree. A line with a name
22
+ // git takes for none, or a negative pattern, git leaves out.
23
+ function parseAttributes(bytes) {
24
+ const nul = bytes.indexOf(0)
25
+ const rules = []
26
+ for (const line of Buffer.from(bytes.buffer, bytes.byteOffset, nul === -1 ? bytes.length : nul).toString('latin1').split('\n')) {
27
+ const [pattern, ...tokens] = line.split(/[ \t\r]+/u).filter(Boolean)
28
+ if (pattern?.startsWith('"')) return null
29
+ if (pattern === undefined || pattern.startsWith('#') || pattern.startsWith('!')) continue
30
+ const states = tokens.map((token) => /^([-!]?)([\w.][\w.-]*)(?:=(.*))?$/u.exec(token))
31
+ if (states.includes(null) || !states.some(([, , name]) => RELEVANT.has(name))) continue
32
+ const match = matcher(pattern)
33
+ if (!match) return null
34
+ rules.push({ match, states: states.map(([, sign, name, value = true]) => [name, sign === '-' ? false : sign === '!' ? null : value]) })
35
+ }
36
+ return rules
37
+ }
38
+
39
+ // `base` is the directory's path and a `/`; `bytes` is undefined where it
40
+ // has no .gitattributes, null where that is no file.
41
+ export function withAttributes(above, base, bytes) {
42
+ if (above === null || bytes === null) return null
43
+ const rules = bytes === undefined ? [] : parseAttributes(bytes)
44
+ return rules && [...above, { base, rules }]
45
+ }
46
+
47
+ // convert.c's text test for `text=auto`, on the blob these bytes are written from.
48
+ function isAutoText(written) {
49
+ let printable = 0
50
+ let other = written.at(-1) === 0x1a ? -1 : 0
51
+ for (let i = 0; i < written.length; i++) {
52
+ const c = written[i]
53
+ if (c === 0x0d && written[i + 1] !== 0x0a) return false
54
+ if (c === 0x0d || c === 0x0a) continue
55
+ if (c === 0) return false
56
+ if (c >= 0x20 ? c !== 0x7f : PRINTING.has(c)) printable++
57
+ else other++
58
+ }
59
+ return printable >> 7 >= other
60
+ }
61
+
62
+ // attr.c's order: the deepest file and its last line first. Anything else
63
+ // rewriting the file (ident, filter, working-tree-encoding) refuses it.
64
+ export function writtenWithCrlf(files, path, written) {
65
+ if (files === null) return false
66
+ const states = new Map()
67
+ const fill = (list) => {
68
+ for (const [name, value] of list.toReversed()) {
69
+ if (states.has(name)) continue
70
+ states.set(name, value)
71
+ if (name === 'binary' && value === true) fill(BINARY)
72
+ }
73
+ }
74
+ for (const { base, rules } of files.toReversed()) {
75
+ for (const { match, states: list } of rules.toReversed()) if (match(path.slice(base.length))) fill(list)
76
+ }
77
+ if (states.get('ident') === true || typeof states.get('filter') === 'string' || states.get('working-tree-encoding')) return false
78
+ const action = [states.get('text'), states.get('crlf')].find((value) => typeof value === 'boolean' || value === 'input' || value === 'auto')
79
+ if (action === false || states.get('eol') !== 'crlf') return false
80
+ return action !== 'auto' || isAutoText(written)
81
+ }
package/src/cache.js CHANGED
@@ -1,18 +1,50 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { constants } from 'node:fs'
3
3
  import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
4
- import { dirname, join, resolve } from 'node:path'
4
+ import { homedir } from 'node:os'
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
5
6
 
6
- import { assertDirectoryPath, isRepo } from './args.js'
7
+ import { assertCacheName, assertDirectoryPath, isRepo } from './args.js'
7
8
  import { MAX_BYTES, decode } from './http.js'
8
9
 
9
10
  const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees'])
10
11
  const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
11
12
 
13
+ // An environment variable may hold anything: only an absolute path is one.
14
+ const absolute = (path) => (typeof path === 'string' && isAbsolute(path) ? path : undefined)
15
+ const within = (base, ...names) => (base === undefined ? undefined : join(base, ...names))
16
+
17
+ // os.homedir() takes $HOME as it is, relative or empty, and throws where
18
+ // neither it nor the user's passwd entry has one.
19
+ function home() {
20
+ try {
21
+ return absolute(homedir())
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ // Only Linux and the BSDs follow the XDG Base Directory spec, so macOS and
28
+ // Windows take no XDG_CACHE_HOME, as Go's os.UserCacheDir and Rust's dirs
29
+ // take none.
30
+ export function cacheDirFor(name) {
31
+ assertCacheName('cacheDirFor', 'name', name)
32
+ if (process.platform === 'darwin') return within(home(), 'Library', 'Caches', name)
33
+ if (process.platform === 'win32') return within(absolute(process.env.LOCALAPPDATA) ?? within(home(), 'AppData', 'Local'), name, 'Cache')
34
+ return within(absolute(process.env.XDG_CACHE_HOME) ?? within(home(), '.cache'), name)
35
+ }
36
+
37
+ export const defaultCacheDir = cacheDirFor('PreventiveMeasures')
38
+
12
39
  let root
13
40
  let tmpSeq = 0
14
41
 
15
- export function setCacheDir(dir) {
42
+ export function setCacheDir(dir = defaultCacheDir) {
43
+ if (dir === false) {
44
+ root = undefined
45
+ return
46
+ }
47
+ assert.ok(dir !== undefined, 'setCacheDir: there is no default cache directory, as no absolute home directory is known: give one')
16
48
  assertDirectoryPath('setCacheDir', 'dir', dir)
17
49
  root = resolve(dir)
18
50
  }
@@ -24,12 +56,18 @@ export function setCacheDir(dir) {
24
56
  // would still meet, so a capital is written `!` and the letter, and a `!`
25
57
  // as `!!`. A Windows device name (`con.json` is the console) has its first
26
58
  // letter escaped, which encodeURIComponent never does.
27
- function cachePath(dir, key) {
59
+ function cachePath(dir, key, base = root) {
28
60
  assert.ok(DIRS.has(dir) && key && typeof key === 'string', `Unexpected cache entry: ${dir}`)
29
61
  const name = encodeURIComponent(key.replace(/[!A-Z]/gu, (char) => `!${char.toLowerCase()}`)).replaceAll('%40', '@').replaceAll('%2F', '+')
30
- return root === undefined ? null : join(root, dir, name.replace(/^(?=(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))./u, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`))
62
+ return base === undefined ? null : join(base, dir, name.replace(/^(?=(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))./u, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`))
31
63
  }
32
64
 
65
+ // Caches of ours, read whether set or not for what is checked whatever its
66
+ // source: the default one, and stasis's, which it sets with setCacheDir.
67
+ const OURS = [defaultCacheDir, cacheDirFor('stasis')].filter((base) => base !== undefined)
68
+
69
+ export const ourCachePaths = (dir, key) => OURS.map((base) => cachePath(dir, key, base))
70
+
33
71
  // Only a regular file, checked on the file as opened, so one swapped in
34
72
  // after a check can't pass: a FIFO or a device would block or never end.
35
73
  // Read to the size it had when opened, and no further.
package/src/download.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { createHash } from 'node:crypto'
3
3
 
4
- import { readCache, readRegularFile, writeCache } from './cache.js'
4
+ import { ourCachePaths, readCache, readRegularFile, writeCache } from './cache.js'
5
5
  import { request } from './http.js'
6
6
  import { gitTreeOfTarball } from './tree.js'
7
7
 
@@ -16,13 +16,15 @@ const DIGESTS = {
16
16
 
17
17
  // Bytes whose `algorithm` hash is `expected`, which the registry or the
18
18
  // caller gives, never a disk; without it nothing is read. From `local`,
19
- // other tools' caches (read, never written; a mismatch is passed over),
20
- // else ours (a mismatch throws), else what `locate` answers, checked and
21
- // then cached.
22
- export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], locate, options = {}, list }) {
19
+ // other tools' caches, and with `ours` our default cache and stasis's,
20
+ // whether set or not (read, never written; a mismatch is passed over),
21
+ // else the cache set (a mismatch throws), else what `locate` answers,
22
+ // checked and then cached.
23
+ export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], ours = false, locate, options = {}, list }) {
23
24
  assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
24
25
  const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list })
25
- for (const path of local) {
26
+ const key = `${what}.${ext}`
27
+ for (const path of ours ? [...local, ...ourCachePaths(dir, key)] : local) {
26
28
  const bytes = await readRegularFile(path)
27
29
  if (bytes && await digest(bytes) === expected) return bytes
28
30
  }
@@ -31,7 +33,6 @@ export async function verifiedDownload({ method, dir, what, ext, algorithm, expe
31
33
  assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
32
34
  return bytes
33
35
  }
34
- const key = `${what}.${ext}`
35
36
  const cached = await readCache(dir, key)
36
37
  if (cached) return await check(cached, 'the cache')
37
38
  const url = await locate()
@@ -113,10 +113,10 @@ async function getRepoFile(headers, options) {
113
113
 
114
114
  // Follows the redirect to codeload.github.com. A tree id names its content,
115
115
  // so the bytes are held to it, downloaded or cached, and cached by it alone,
116
- // for good. What a tarball cannot show, a submodule's commit or a subtree
117
- // with nothing in it, comes from GitHub's listings of the trees, which the
118
- // id checks as well: a directory at a time, as a recursive listing of a
119
- // large tree is cut short.
116
+ // for good. What a tarball cannot show, a submodule's commit, a subtree
117
+ // with nothing in it or the LF blob of a file marked `eol=crlf`, comes
118
+ // from GitHub's listings of the trees, which the id checks as well: a
119
+ // directory at a time, as a recursive listing of a large tree is cut short.
120
120
  async function treeTarball(method, headers, repo, tree) {
121
121
  const listings = new Map()
122
122
  const list = (sha) => {
@@ -71,5 +71,5 @@ export async function verifyDist(name, version, dist) {
71
71
  export async function getTarball(name, version, dist) {
72
72
  assertPackage('getTarball', name, version)
73
73
  const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
74
- return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball, options: withNpmToken(name, 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) })
75
75
  }
package/src/tree.js CHANGED
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { gunzipSync } from 'node:zlib'
4
4
 
5
5
  import { isSha1, matches } from './args.js'
6
+ import { withAttributes, writtenWithCrlf } from './attributes.js'
6
7
 
7
8
  const BLOCK = 512
8
9
  const MAX_UNPACKED_BYTES = 2 ** 30
@@ -85,16 +86,56 @@ async function holdsNothing(sha, listed) {
85
86
  return true
86
87
  }
87
88
 
88
- // `git archive` leaves out a subtree with no file in it, which only
89
- // plumbing makes. Where a directory's id is not the one listed, its listing
90
- // names any, and one is put back only once its own listings show it holds
91
- // nothing.
92
- async function putBackEmptyTrees(dir, sha, listed) {
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))
93
130
  for (const entry of await listed(sha)) {
94
- if (entry.type !== 'tree') continue
95
131
  const here = dir.get(entry.name)
96
- if (here instanceof Map && treeId(here).toString('hex') !== entry.sha) await putBackEmptyTrees(here, entry.sha, listed)
97
- else if (here === undefined && await holdsNothing(entry.sha, listed)) dir.set(entry.name, subtree(entry.sha))
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
+ }
98
139
  }
99
140
  }
100
141
 
@@ -104,8 +145,9 @@ async function putBackEmptyTrees(dir, sha, listed) {
104
145
  // which answers a listing of a tree by its id, as GitHub's trees API does
105
146
  // ({ type, path, sha } entries), walked from `expected`: a submodule's
106
147
  // commit for an empty directory, and, where the id comes out otherwise, the
107
- // subtrees with nothing in them it leaves out. The id is `expected` only if
108
- // those are right. Where there is no such tree, a reason, never an id.
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.
109
151
  export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
110
152
  let bytes
111
153
  try {
@@ -160,7 +202,7 @@ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
160
202
  }
161
203
  if (name === null) continue
162
204
  if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
163
- dir.set(name, type === '0' ? { mode: mode & 0o100 ? '100755' : '100644', id: objectId('blob', body) } : { mode: '120000', id: objectId('blob', Buffer.from(target, 'latin1')) })
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')) })
164
206
  }
165
207
  if (top === undefined) return 'no tree: an empty tarball'
166
208
  const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
@@ -178,7 +220,7 @@ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
178
220
  }
179
221
  const id = treeId(root).toString('hex')
180
222
  if (id === expected) return id
181
- await putBackEmptyTrees(root, expected, listed)
223
+ await mend(root, expected, listed)
182
224
  return treeId(root).toString('hex')
183
225
  }
184
226
 
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ /** A resolved file, identified by its owning package rather than a display path. */
2
+ export interface WeakEdgeFile {
3
+ /** Exact npm package name, including scope. May be absent for own source. */
4
+ package?: string
5
+ /** Normalized /-separated path relative to the owning package or own-source root. */
6
+ path: string
7
+ /** Authoritative project ownership, not inferred from a package name or path. */
8
+ ownSource?: boolean
9
+ }
10
+
11
+ export interface WeakEdgeSelector {
12
+ /** Absent means any package or own source. Regular expressions match package names. */
13
+ readonly package?: string | RegExp
14
+ readonly path: RegExp
15
+ }
16
+
17
+ export interface WeakEdgeRule {
18
+ readonly id: string
19
+ readonly reason: string
20
+ readonly from: WeakEdgeSelector
21
+ readonly to: WeakEdgeSelector
22
+ readonly ownSourceOnly?: boolean
23
+ }
24
+
25
+ /** Curated weak loads: omit these file edges from cycles and default inclusion explanations. */
26
+ export const weakEdges: readonly WeakEdgeRule[]
27
+
28
+ /**
29
+ * Returns the matching inventory rule, or undefined for an unrecognized edge.
30
+ * Own-source importers never match. import-fresh requires to.ownSource === true.
31
+ * Own-source targets only match selectors without a package constraint.
32
+ * Resolve nested installations to their actual owning package before calling;
33
+ * absolute, non-normalized, or nested node_modules paths do not match.
34
+ * Does not remove files, alter raw load records, or change runtime resolution.
35
+ */
36
+ export function getWeakEdge(from: WeakEdgeFile, to: WeakEdgeFile): WeakEdgeRule | undefined
package/weak-edges.js ADDED
@@ -0,0 +1 @@
1
+ export { getWeakEdge, weakEdges } from './src/weak-edges.js'