@preventive/upstream 1.1.0 → 1.3.0
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 +32 -17
- package/github.d.ts +9 -3
- package/npm.d.ts +6 -1
- package/package.json +4 -1
- package/src/advisories/index.js +53 -2
- package/src/advisories/npm.js +1 -1
- package/src/args.js +1 -0
- package/src/download.js +4 -3
- package/src/github/read.js +9 -8
- package/src/http.js +1 -1
- package/src/npm/tarball.js +8 -4
- package/src/tree.js +67 -89
package/advisories.d.ts
CHANGED
|
@@ -56,28 +56,37 @@ export interface AdvisoryOptions {
|
|
|
56
56
|
// those of npm releases taken down that GitHub's database has no
|
|
57
57
|
// advisory of. A version a registry row reports under the same advisory,
|
|
58
58
|
// its id among an OSV record's id and aliases, is left to that row,
|
|
59
|
-
// which takes the record's ids among its aliases
|
|
60
|
-
//
|
|
59
|
+
// which takes the record's ids among its aliases; the versions a record
|
|
60
|
+
// of the row's own id covers beyond the registry's range join it too.
|
|
61
|
+
// Sends each npm package's name and versions to OSV, so not for a
|
|
62
|
+
// private package.
|
|
61
63
|
osv?: boolean
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// One advisory on one package, with the versions asked about that it
|
|
65
|
-
// covers (never empty)
|
|
67
|
+
// covers (never empty): one row an id, however many of its sources answer
|
|
68
|
+
// it, or however often one does.
|
|
66
69
|
export interface Advisory {
|
|
67
70
|
ecosystem: Ecosystem
|
|
68
71
|
name: string
|
|
69
|
-
// `registry`: npm's, as `npm audit` has it
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// `repository`: published on the package's repository,
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
72
|
+
// `registry`: npm's, as `npm audit` has it. `osv`: OSV's records for
|
|
73
|
+
// cargo and composer, and with `osv` for npm, the versions they cover
|
|
74
|
+
// that no registry row reports under the same advisory; RustSec's,
|
|
75
|
+
// GitHub's and malicious packages' (MAL-...) among them; of the records
|
|
76
|
+
// naming each other as aliases, directly or through others, a version is
|
|
77
|
+
// reported under one only: RustSec's, else the GHSA, else the first by
|
|
78
|
+
// id. Each has the others' ids among its aliases, and stays a row of its
|
|
79
|
+
// own, as its source rates it.
|
|
80
|
+
// `repository`: published on the package's repository, holding only the
|
|
81
|
+
// versions the others' answer does not report under that GHSA. The only
|
|
82
|
+
// source for `soldeer` and `github`, where every range counts, whichever
|
|
83
|
+
// package it names.
|
|
84
|
+
// An id answered more than once -- by npm's registry once a range, by
|
|
85
|
+
// OSV's record of it or its repository for versions beyond the registry's
|
|
86
|
+
// range, by its repository once a range -- is one row: the registry's,
|
|
87
|
+
// else OSV's, else the repository's, with what it lacks of theirs (title,
|
|
88
|
+
// details), the worst severity of them, the highest CVSS score with its
|
|
89
|
+
// own vector, and all their ranges, CWEs and versions.
|
|
81
90
|
source: 'registry' | 'osv' | 'repository'
|
|
82
91
|
// A GHSA, RUSTSEC-..., MAL-..., DRUPAL-CORE-... or another OSV id, or
|
|
83
92
|
// npm:<id> for a registry row without a GHSA.
|
|
@@ -109,11 +118,17 @@ export interface Advisory {
|
|
|
109
118
|
cvssVector?: string
|
|
110
119
|
cwe: string[]
|
|
111
120
|
// As written: npm's syntax from the registry, GitHub's
|
|
112
|
-
// (`>= 1.0.0, < 1.2.6`) from a repository; OSV
|
|
121
|
+
// (`>= 1.0.0, < 1.2.6`) from a repository; OSV's records have none. A
|
|
113
122
|
// repository range semver cannot read, or none, covers every version.
|
|
123
|
+
// Each range its id was answered with that covers an asked version,
|
|
124
|
+
// joined by ` || `; none ('') where one is none. A version only OSV
|
|
125
|
+
// reports may be outside it: hermes-engine's GHSA-f5x2-xv93-4p23 is
|
|
126
|
+
// `<=0.4.3` on npm, the last version known affected, and fixed in 0.5.2
|
|
127
|
+
// by OSV's record of it, which reports 0.5.0 too.
|
|
114
128
|
range?: string
|
|
115
129
|
// RustSec's kind for an advisory that is not a vulnerability as such:
|
|
116
|
-
// `unmaintained`, `unsound`, `notice`.
|
|
130
|
+
// `unmaintained`, `unsound`, `notice`. For an id answered more than once,
|
|
131
|
+
// only where each answer gives it.
|
|
117
132
|
informational?: string
|
|
118
133
|
versions: string[]
|
|
119
134
|
}
|
package/github.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// encoded segments and has to come back out of URL parsing unchanged.
|
|
8
8
|
// Redirects are refused, so an answer is about the repo asked for, except
|
|
9
9
|
// the tarballs' and, where asked, getRepo's for a repo renamed or
|
|
10
|
-
// transferred. A response is read up to a size limit (
|
|
10
|
+
// transferred. A response is read up to a size limit (128 MiB of JSON, 128
|
|
11
11
|
// MiB of a file, 512 MiB of a tarball) and within a timeout (30 seconds, 5
|
|
12
12
|
// minutes for a tarball), and what it says goes into an error message
|
|
13
13
|
// escaped.
|
|
@@ -127,7 +127,7 @@ export interface Client {
|
|
|
127
127
|
// getRepoTreeTarball's for the tree GitHub names for that commit, asked
|
|
128
128
|
// on every call, cached or not. So its top directory is named for the
|
|
129
129
|
// tree, and files marked `export-subst` are as committed, not rewritten.
|
|
130
|
-
getRepoTarball(options: { repo: RepoName; sha: string }): Promise<Uint8Array>
|
|
130
|
+
getRepoTarball(options: { repo: RepoName; sha: string; symlinksAbove?: number }): Promise<Uint8Array>
|
|
131
131
|
// The id of the tree at `directory`, `/`-separated as npm's is, in the
|
|
132
132
|
// full commit `sha`, or of its root without one: from the tree GitHub
|
|
133
133
|
// names for the commit, down GitHub's listings a directory at a time,
|
|
@@ -153,7 +153,13 @@ export interface Client {
|
|
|
153
153
|
// a repo set to include Git LFS objects in archives, which replace the
|
|
154
154
|
// pointers git has; that repo still gets a tree already cached, pointers
|
|
155
155
|
// and all.
|
|
156
|
-
|
|
156
|
+
// It is read as @preventive/archive reads a tarball, and refused for
|
|
157
|
+
// anything it refuses: a pax record repeated, a name or a symlink that
|
|
158
|
+
// climbs out of it, an absolute symlink. `symlinksAbove` lets a symlink
|
|
159
|
+
// climb that many levels above the tarball's root, none unless given: a
|
|
160
|
+
// subdirectory's, its links reaching the repo's root as a monorepo
|
|
161
|
+
// package's LICENSE does, needs as many as it is deep, less one.
|
|
162
|
+
getRepoTreeTarball(options: { repo: RepoName; tree: string; symlinksAbove?: number }): Promise<Uint8Array>
|
|
157
163
|
// The repository's published security advisories, as GitHub's
|
|
158
164
|
// repository advisory objects: every page of 100, each found by the
|
|
159
165
|
// cursor in the one before's Link header. More than 100 pages is an
|
package/npm.d.ts
CHANGED
|
@@ -127,7 +127,11 @@ export interface Dist {
|
|
|
127
127
|
// `bugs` an object, of which only the strings named below are read. Each is
|
|
128
128
|
// absent where the document has none, or has another shape, or an object
|
|
129
129
|
// with none of those strings. All of it is the publisher's word, held to no
|
|
130
|
-
// repository.
|
|
130
|
+
// repository. `unpackedSize` is the document's dist.unpackedSize: the bytes
|
|
131
|
+
// of the files in the tarball, as the registry counted them when the
|
|
132
|
+
// version was published, held to no tarball. It is absent where the
|
|
133
|
+
// document has none, as for a version published before the registry
|
|
134
|
+
// counted them, or has one that is not a whole number of bytes.
|
|
131
135
|
export interface PackageMeta {
|
|
132
136
|
name: string
|
|
133
137
|
version: string
|
|
@@ -136,6 +140,7 @@ export interface PackageMeta {
|
|
|
136
140
|
repository?: string | PackageRepository
|
|
137
141
|
homepage?: string
|
|
138
142
|
bugs?: string | PackageBugs
|
|
143
|
+
unpackedSize?: number
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
export interface PackageRepository {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/upstream",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Minimal npm, crates.io, Soldeer, GitHub and git checkout clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -124,6 +124,9 @@
|
|
|
124
124
|
"publishConfig": {
|
|
125
125
|
"access": "public"
|
|
126
126
|
},
|
|
127
|
+
"dependencies": {
|
|
128
|
+
"@preventive/archive": "^1.2.0"
|
|
129
|
+
},
|
|
127
130
|
"peerDependencies": {
|
|
128
131
|
"semver": "^7.5.2"
|
|
129
132
|
},
|
package/src/advisories/index.js
CHANGED
|
@@ -34,6 +34,57 @@ function collect(packages) {
|
|
|
34
34
|
return byEcosystem
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Which of an advisory's rows stands for it: npm's registry's, then OSV's,
|
|
38
|
+
// then its repository's.
|
|
39
|
+
const SOURCES = ['registry', 'osv', 'repository']
|
|
40
|
+
const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info']
|
|
41
|
+
const worse = (a, b) => (b === undefined || (a !== undefined && SEVERITIES.indexOf(a) < SEVERITIES.indexOf(b)) ? a : b)
|
|
42
|
+
|
|
43
|
+
// An advisory's rows as one: the first's by source, what it lacks from the
|
|
44
|
+
// first of the rest that has it, the others' aliases, and of them all the
|
|
45
|
+
// worst severity, the highest CVSS score with its own vector (else the first
|
|
46
|
+
// vector), the ranges (` || `), CWEs and versions (in the order `asked` has
|
|
47
|
+
// them). RustSec's kind of advisory that is not a vulnerability as such only
|
|
48
|
+
// where each of them has that kind: a row without one is a vulnerability.
|
|
49
|
+
function joinRows(rows, asked) {
|
|
50
|
+
const [head, ...rest] = rows.toSorted((a, b) => SOURCES.indexOf(a.source) - SOURCES.indexOf(b.source))
|
|
51
|
+
const all = [head, ...rest]
|
|
52
|
+
const ranges = [...new Set(all.map((row) => row.range).filter((range) => range !== undefined))]
|
|
53
|
+
const versions = new Set(all.flatMap((row) => row.versions))
|
|
54
|
+
const joined = {
|
|
55
|
+
...head,
|
|
56
|
+
aliases: [...new Set(all.flatMap((row) => row.aliases))].filter((id) => id !== head.id),
|
|
57
|
+
cwe: [...new Set(all.flatMap((row) => row.cwe))],
|
|
58
|
+
versions: asked.filter((version) => versions.has(version)),
|
|
59
|
+
}
|
|
60
|
+
if (rest.some((row) => row.informational !== head.informational)) delete joined.informational
|
|
61
|
+
for (const key of ['title', 'details']) {
|
|
62
|
+
const from = joined[key] === undefined && rest.find((row) => row[key] !== undefined)
|
|
63
|
+
if (from) joined[key] = from[key]
|
|
64
|
+
}
|
|
65
|
+
const severity = all.map((row) => row.severity).reduce(worse)
|
|
66
|
+
if (severity !== undefined) joined.severity = severity
|
|
67
|
+
const scored = all.filter((row) => row.cvss !== undefined).toSorted((a, b) => b.cvss - a.cvss)[0] ?? all.find((row) => row.cvssVector !== undefined)
|
|
68
|
+
delete joined.cvss
|
|
69
|
+
delete joined.cvssVector
|
|
70
|
+
if (scored?.cvss !== undefined) joined.cvss = scored.cvss
|
|
71
|
+
if (scored?.cvssVector !== undefined) joined.cvssVector = scored.cvssVector
|
|
72
|
+
// A range of none ('') covers every version, as they then do together.
|
|
73
|
+
if (ranges.length > 0) joined.range = ranges.includes('') ? '' : ranges.join(' || ')
|
|
74
|
+
return joined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// One row for each advisory id on a package, where its sources answer more
|
|
78
|
+
// than one: npm's registry one per range, and OSV's record of it, or its
|
|
79
|
+
// repository, another for the versions they cover beyond that. Records of
|
|
80
|
+
// other ids that name it as an alias stay rows of their own, each version
|
|
81
|
+
// reported under one, as their sources rate them. Rows keep the place of the
|
|
82
|
+
// first answered.
|
|
83
|
+
function oneRowPerAdvisory(rows, asked) {
|
|
84
|
+
const byId = Map.groupBy(rows, (row) => `${row.name}\0${row.id}`)
|
|
85
|
+
return [...byId.values()].map((same) => (same.length === 1 ? same[0] : joinRows(same, asked.get(same[0].name))))
|
|
86
|
+
}
|
|
87
|
+
|
|
37
88
|
export async function advisories(packages, options = {}) {
|
|
38
89
|
assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), osv: optional(assertBoolean), cache: optional(assertCache) })
|
|
39
90
|
assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
|
|
@@ -45,7 +96,7 @@ export async function advisories(packages, options = {}) {
|
|
|
45
96
|
const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
|
|
46
97
|
const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
|
|
47
98
|
const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, osv: options.osv === true, known, cache: options.cache })
|
|
48
|
-
return rows.map((row) => ({ ecosystem, ...row }))
|
|
99
|
+
return oneRowPerAdvisory(rows.filter((row) => row.versions.length > 0), asked).map((row) => ({ ecosystem, ...row }))
|
|
49
100
|
}))
|
|
50
|
-
return found.flat().
|
|
101
|
+
return found.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
|
|
51
102
|
}
|
package/src/advisories/npm.js
CHANGED
|
@@ -68,7 +68,7 @@ async function withDetails(rows) {
|
|
|
68
68
|
// advisory, the row's id one of an OSV row's ids or aliases, is left to
|
|
69
69
|
// that row, which takes the OSV row's ids among its aliases, and its text
|
|
70
70
|
// where it is the same record; an OSV row keeps the versions no such row
|
|
71
|
-
// reports.
|
|
71
|
+
// reports, which advisories() joins to a registry row of its own id.
|
|
72
72
|
function withOsv(registry, osv) {
|
|
73
73
|
const rows = registry.map((row) => ({ ...row }))
|
|
74
74
|
const added = []
|
package/src/args.js
CHANGED
|
@@ -68,6 +68,7 @@ export function assertNames(method, what, names, options, assertName) {
|
|
|
68
68
|
|
|
69
69
|
export const assertBoolean = assertion('a boolean', (value) => typeof value === 'boolean')
|
|
70
70
|
export const assertNumber = assertion('a positive integer', (value) => Number.isSafeInteger(value) && value > 0)
|
|
71
|
+
export const assertLevels = assertion('a whole number of levels', (value) => Number.isSafeInteger(value) && value >= 0)
|
|
71
72
|
export const assertLine = assertion('a non-empty single line', (value) => isString(value, 1024) && value.trim() !== '')
|
|
72
73
|
// 65,536 is GitHub's own limit on a body.
|
|
73
74
|
export const assertText = assertion('text with no control characters', (value) => isString(value, 65_536, '\t\n\r'))
|
package/src/download.js
CHANGED
|
@@ -20,10 +20,11 @@ const DIGESTS = {
|
|
|
20
20
|
// whether set or not (read, never written; a mismatch is passed over),
|
|
21
21
|
// else the cache set, or a `cache` store in its place (readBytes; a
|
|
22
22
|
// mismatch throws), else what `locate` answers, checked and then kept as
|
|
23
|
-
// writeBytes keeps it.
|
|
24
|
-
|
|
23
|
+
// writeBytes keeps it. A tree's `list` and `symlinksAbove` are
|
|
24
|
+
// gitTreeOfTarball's.
|
|
25
|
+
export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], ours = false, cache, locate, options = {}, list, symlinksAbove }) {
|
|
25
26
|
assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
|
|
26
|
-
const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list })
|
|
27
|
+
const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list, symlinksAbove })
|
|
27
28
|
const key = `${what}.${ext}`
|
|
28
29
|
for (const path of ours ? [...local, ...ourCachePaths(dir, key)] : local) {
|
|
29
30
|
const bytes = await readRegularFile(path)
|
package/src/github/read.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { Buffer } from 'node:buffer'
|
|
3
3
|
|
|
4
|
-
import { assertArgs, assertBoolean, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isRepo, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
|
|
4
|
+
import { assertArgs, assertBoolean, assertGhsa, assertLevels, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isRepo, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
|
|
5
5
|
import { verifiedDownload } from '../download.js'
|
|
6
6
|
import { HttpError, decode, encodeSegment, requestWithHeaders } from '../http.js'
|
|
7
7
|
import { FORMATS, lockfileOf, projectFormats } from '../lockfiles.js'
|
|
@@ -137,14 +137,15 @@ async function getRepoFile(headers, options) {
|
|
|
137
137
|
// with nothing in it or the LF blob of a file marked `eol=crlf`, comes
|
|
138
138
|
// from GitHub's listings of the trees, which the id checks as well: a
|
|
139
139
|
// directory at a time, as a recursive listing of a large tree is cut short.
|
|
140
|
-
|
|
140
|
+
// Its symlinks climb no more than `symlinksAbove` levels above its root.
|
|
141
|
+
async function treeTarball(method, headers, repo, tree, symlinksAbove) {
|
|
141
142
|
const listings = new Map()
|
|
142
143
|
const list = (sha) => {
|
|
143
144
|
if (!listings.has(sha)) listings.set(sha, call(headers, repoApi(repo, ['git', 'trees', sha])).then((listing) => (Array.isArray(listing?.tree) ? listing.tree : [])))
|
|
144
145
|
return listings.get(sha)
|
|
145
146
|
}
|
|
146
147
|
const locate = () => repoApi(repo, ['tarball', tree])
|
|
147
|
-
return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list })
|
|
148
|
+
return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list, symlinksAbove })
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
async function commitTree(method, headers, repo, sha) {
|
|
@@ -157,15 +158,15 @@ async function commitTree(method, headers, repo, sha) {
|
|
|
157
158
|
// now. Its tree's tarball, not its own, in which `git archive` rewrites the
|
|
158
159
|
// files marked `export-subst`.
|
|
159
160
|
async function getRepoTarball(headers, options) {
|
|
160
|
-
assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
|
|
161
|
-
const { repo, sha } = options
|
|
161
|
+
assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha, symlinksAbove: optional(assertLevels) })
|
|
162
|
+
const { repo, sha, symlinksAbove } = options
|
|
162
163
|
const tree = await commitTree('getRepoTarball', headers, repo, sha)
|
|
163
|
-
return await treeTarball('getRepoTarball', headers, repo, tree)
|
|
164
|
+
return await treeTarball('getRepoTarball', headers, repo, tree, symlinksAbove)
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
async function getRepoTreeTarball(headers, options) {
|
|
167
|
-
assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId })
|
|
168
|
-
return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
|
|
168
|
+
assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId, symlinksAbove: optional(assertLevels) })
|
|
169
|
+
return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree, options.symlinksAbove)
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
// Not recursive: GitHub cuts short a recursive listing of a large tree.
|
package/src/http.js
CHANGED
|
@@ -26,7 +26,7 @@ const isQuery = ([key, value]) => isQueryKey(key) && (key.endsWith('[]') ? Array
|
|
|
26
26
|
// A line break in a value would start a header of its own.
|
|
27
27
|
const isHeader = ([name, value]) => /^[A-Za-z][\w-]*$/u.test(name) && matches(/^[ -~]*$/u)(value)
|
|
28
28
|
const LIMITS = {
|
|
29
|
-
json: { bytes:
|
|
29
|
+
json: { bytes: 128 * 1024 * 1024, ms: 30_000 },
|
|
30
30
|
text: { bytes: 128 * 1024 * 1024, ms: 30_000 },
|
|
31
31
|
bytes: { bytes: MAX_BYTES, ms: 300_000 },
|
|
32
32
|
}
|
package/src/npm/tarball.js
CHANGED
|
@@ -73,24 +73,28 @@ async function npmCachedVersion(name, version, given) {
|
|
|
73
73
|
return undefined
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
const countOr = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : undefined)
|
|
77
|
+
|
|
76
78
|
// The version's document: with the `dist` the caller has, npm's own cache's
|
|
77
79
|
// where it matches (npmCachedVersion), and neither `cache` nor the cache
|
|
78
80
|
// set is read or written; else as getVersion has it. Beside its dist, the
|
|
79
81
|
// document's gitHead where that is a full commit id, and its repository,
|
|
80
|
-
// homepage and bugs, in the shapes a package.json gives them
|
|
81
|
-
// dist is
|
|
82
|
+
// homepage and bugs, in the shapes a package.json gives them; and its
|
|
83
|
+
// dist's unpackedSize where that is a count. Each but dist is left out
|
|
84
|
+
// where there is none.
|
|
82
85
|
export async function getMeta(name, version, options = {}) {
|
|
83
86
|
assertPackage('getMeta', name, version)
|
|
84
87
|
assertArgs('getMeta', options, { dist: null, cache: optional(assertCache) })
|
|
85
88
|
const given = options.dist === undefined ? undefined : checkedDist('getMeta', name, version, options.dist)
|
|
86
89
|
const { dist, json } = (given && await npmCachedVersion(name, version, given)) ?? await getVersion('getMeta', name, version, given, options.cache)
|
|
87
|
-
const
|
|
90
|
+
const said = {
|
|
88
91
|
gitHead: isSha(own(json, 'gitHead')) ? json.gitHead : undefined,
|
|
89
92
|
repository: linkOf(own(json, 'repository'), ['type', 'url', 'directory']),
|
|
90
93
|
homepage: stringOr(own(json, 'homepage')),
|
|
91
94
|
bugs: linkOf(own(json, 'bugs'), ['url', 'email']),
|
|
95
|
+
unpackedSize: countOr(own(json.dist, 'unpackedSize')),
|
|
92
96
|
}
|
|
93
|
-
return { name, version, dist, ...Object.fromEntries(Object.entries(
|
|
97
|
+
return { name, version, dist, ...Object.fromEntries(Object.entries(said).filter(([, value]) => value !== undefined)) }
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
// Only the whole package's document has `time`: megabytes for some.
|
package/src/tree.js
CHANGED
|
@@ -1,56 +1,34 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import { CompressionError, decompress } from '@preventive/archive/compression.js'
|
|
5
|
+
import { ArchiveError, unpackStream } from '@preventive/archive/tar.js'
|
|
4
6
|
|
|
5
7
|
import { isSha1, matches } from './args.js'
|
|
6
8
|
import { withAttributes, writtenWithCrlf } from './attributes.js'
|
|
7
9
|
|
|
8
|
-
const BLOCK = 512
|
|
9
10
|
const MAX_UNPACKED_BYTES = 2 ** 30
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// Read by @preventive/archive, which holds a tarball to its checks -- each
|
|
12
|
+
// header's checksum and fields, the padding and the end, pax records of
|
|
13
|
+
// which none repeats, names and link targets that stay within it, no name
|
|
14
|
+
// twice as another entry -- and past those to only what git writes:
|
|
15
|
+
// anything else, a pax `size` or a mode of 0000, would be extracted as it
|
|
16
|
+
// is not hashed. POSIX ustar headers, as under GNU's magic a reader that
|
|
17
|
+
// knows none takes GNU's own bytes for a name's prefix, their numbers as
|
|
18
|
+
// octal digits; no extended header but an entry's own pax one; type flags
|
|
19
|
+
// and modes as stored. Modes are as
|
|
20
|
+
// its umask, 002 or 022, leaves them: a file 0664 or 0644, 0775 or 0755
|
|
21
|
+
// executable; a directory 0775 or 0755; a symlink 0777.
|
|
14
22
|
const PAX_KEYS = new Set(['path', 'linkpath'])
|
|
15
23
|
const MODES = { 0: [0o664, 0o644, 0o775, 0o755], 2: [0o777], 5: [0o775, 0o755] }
|
|
24
|
+
// The extended headers git does not write, by the type a tar names them by.
|
|
25
|
+
const FLAGS = { global: 'g', longname: 'L', longlink: 'K' }
|
|
16
26
|
// The top directory is not hashed, so it is held to GitHub's name for it,
|
|
17
27
|
// owner-repo-id: another, `..\..`, `C:` or a Windows device such as
|
|
18
28
|
// `con.txt`, could take an extractor elsewhere.
|
|
19
29
|
const isTop = matches(/^(?!(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[\da-z][\w.-]*$/iu)
|
|
20
30
|
|
|
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
31
|
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
32
|
|
|
55
33
|
// Git keeps no empty directory: in a tarball, one is a submodule. Each as
|
|
56
34
|
// [the directory holding it, its name, its path].
|
|
@@ -73,6 +51,8 @@ function treeId(dir) {
|
|
|
73
51
|
const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
|
|
74
52
|
const isName = matches(/^[^\0/]+$/u)
|
|
75
53
|
const isEntry = (entry) => isName(entry?.path) && isSha1(entry.sha)
|
|
54
|
+
// Names are kept as latin1 strings of their UTF-8, a char per byte, so they
|
|
55
|
+
// sort and hash as the bytes git has.
|
|
76
56
|
const nameOf = (path) => Buffer.from(path).toString('latin1')
|
|
77
57
|
const subtree = (sha) => ({ mode: '40000', id: Buffer.from(sha, 'hex') })
|
|
78
58
|
|
|
@@ -139,6 +119,35 @@ async function mend(dir, sha, listed, base = '', above = []) {
|
|
|
139
119
|
}
|
|
140
120
|
}
|
|
141
121
|
|
|
122
|
+
// One entry of a tarball into `root`, the tree being built, whose top
|
|
123
|
+
// directory is `top`, the first entry's: a reason where git writes no such
|
|
124
|
+
// entry there, else nothing.
|
|
125
|
+
function take(root, { storedName, storedLinkname, typeflag, storedMode, uid, gid, uname, gname, data, pax, extended, magic, octal }, top) {
|
|
126
|
+
const path = nameOf(storedName)
|
|
127
|
+
const flag = extended.find(({ kind }) => kind !== 'pax')
|
|
128
|
+
if (flag !== undefined) return `no tree: an entry of type ${JSON.stringify(FLAGS[flag.kind])}`
|
|
129
|
+
if (magic !== 'ustar' || extended.some((header) => header.magic !== 'ustar')) return 'no tree: a header that is not POSIX ustar'
|
|
130
|
+
const other = [...pax.keys()].find((key) => !PAX_KEYS.has(key))
|
|
131
|
+
if (other !== undefined) return `no tree: a pax record git does not write, ${JSON.stringify(other)}`
|
|
132
|
+
if (!Object.hasOwn(MODES, typeflag)) return `no tree: an entry of type ${JSON.stringify(typeflag)}`
|
|
133
|
+
const asGitWrites = MODES[typeflag].includes(storedMode) && path.endsWith('/') === (typeflag === '5')
|
|
134
|
+
&& uid === 0 && gid === 0 && uname === 'root' && gname === 'root'
|
|
135
|
+
&& octal && extended.every((header) => header.octal)
|
|
136
|
+
if (!asGitWrites) return `no tree: a header git does not write, ${JSON.stringify(path)}`
|
|
137
|
+
const [first, ...parts] = path.replace(/\/$/u, '').split('/')
|
|
138
|
+
const name = typeflag === '5' ? null : parts.pop()
|
|
139
|
+
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)}`
|
|
140
|
+
let dir = root
|
|
141
|
+
for (const part of parts) {
|
|
142
|
+
if (!dir.has(part)) dir.set(part, new Map())
|
|
143
|
+
dir = dir.get(part)
|
|
144
|
+
if (!(dir instanceof Map)) return `no tree: a file where a directory is, ${JSON.stringify(path)}`
|
|
145
|
+
}
|
|
146
|
+
if (name === null) return
|
|
147
|
+
if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
|
|
148
|
+
dir.set(name, typeflag === '0' ? { mode: storedMode & 0o100 ? '100755' : '100644', id: objectId('blob', data), body: data } : { mode: '120000', id: objectId('blob', Buffer.from(storedLinkname)) })
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
// The id of the git tree a gzipped tarball holds, as `git archive` writes
|
|
143
152
|
// one, under a single top directory: a file's mode is its exec bit, a
|
|
144
153
|
// symlink's blob its target. What a tarball cannot show comes from `list`,
|
|
@@ -147,62 +156,31 @@ async function mend(dir, sha, listed, base = '', above = []) {
|
|
|
147
156
|
// commit for an empty directory, and, where the id comes out otherwise, the
|
|
148
157
|
// subtrees with nothing in them it leaves out and the blobs of the files it
|
|
149
158
|
// 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
|
-
|
|
152
|
-
|
|
159
|
+
// right. Where there is no such tree, a reason, never an id. A symlink may
|
|
160
|
+
// climb `symlinksAbove` levels above the tarball's root, none unless given,
|
|
161
|
+
// as @preventive/archive lets it: a subtree's, linking to the tree's root
|
|
162
|
+
// as a monorepo package's LICENSE does, as many as it is deep, less one.
|
|
163
|
+
export async function gitTreeOfTarball(gzipped, { expected, list, symlinksAbove = 0 } = {}) {
|
|
164
|
+
let tar
|
|
153
165
|
try {
|
|
154
|
-
|
|
155
|
-
} catch {
|
|
156
|
-
return 'no tree: not gzip, or larger than 1 GiB unpacked'
|
|
166
|
+
tar = await decompress(gzipped, 'gzip', { limit: MAX_UNPACKED_BYTES })
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof CompressionError) return 'no tree: not gzip, or larger than 1 GiB unpacked'
|
|
169
|
+
throw error
|
|
157
170
|
}
|
|
158
171
|
const root = new Map()
|
|
159
172
|
let top
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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)}`
|
|
173
|
+
// An entry at a time, each taken into the tree as it is read, its data a
|
|
174
|
+
// view of the tar: a header that repeats an earlier one, which git never
|
|
175
|
+
// writes, is refused where it stands, so no run of them is held.
|
|
176
|
+
try {
|
|
177
|
+
for await (const entry of unpackStream([tar], { symlinksAbove })) {
|
|
178
|
+
const reason = take(root, entry, top ??= nameOf(entry.storedName).split('/')[0])
|
|
179
|
+
if (reason !== undefined) return reason
|
|
202
180
|
}
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof ArchiveError) return `no tree: ${error.message}`
|
|
183
|
+
throw error
|
|
206
184
|
}
|
|
207
185
|
if (top === undefined) return 'no tree: an empty tarball'
|
|
208
186
|
const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
|