@preventive/upstream 1.2.0 → 1.4.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/cocoapods.d.ts +33 -0
- package/cocoapods.js +2 -0
- package/github.d.ts +23 -2
- package/npm.d.ts +6 -5
- package/package.json +12 -2
- package/src/advisories/common.js +2 -2
- package/src/advisories/index.js +1 -2
- package/src/args.js +10 -0
- package/src/cache.js +1 -1
- package/src/cocoapods.js +51 -0
- package/src/download.js +11 -8
- package/src/git.js +1 -2
- package/src/github/read.js +53 -9
- package/src/http.js +2 -1
- package/src/npm/cacache.js +2 -1
- package/src/npm/tarball.js +3 -5
- package/src/tree.js +67 -89
package/cocoapods.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The name is asserted to be a root's as Podfile.lock writes it, printable
|
|
2
|
+
// ASCII but for spaces, parentheses, slashes and backslashes, with no `.`
|
|
3
|
+
// first; the version one of Pod::Version's; and the checksum 40 lowercase
|
|
4
|
+
// hex digits, before any request or cache path. The cache is setCacheDir's
|
|
5
|
+
// (npm.js).
|
|
6
|
+
|
|
7
|
+
export { HttpError } from './npm.js'
|
|
8
|
+
|
|
9
|
+
// A version's podspec from trunk, the JSON CocoaPods reads, whole, in
|
|
10
|
+
// memory, as the CDN serves it under Specs/<a>/<b>/<c>/<name>/<version>/,
|
|
11
|
+
// the first three hex digits of the md5 of the name: the files of
|
|
12
|
+
// CocoaPods/Specs, so a pod from https://github.com/CocoaPods/Specs.git
|
|
13
|
+
// too. Trunk says nothing of what a podspec hashes to, so `checksum` is
|
|
14
|
+
// required: its sha1, as Podfile.lock records it in SPEC CHECKSUMS. The
|
|
15
|
+
// bytes are checked against it, whether they were downloaded (before they
|
|
16
|
+
// are cached) or read from a cache. The CDN's redirect to jsDelivr is
|
|
17
|
+
// followed, as the bytes are checked whoever answers.
|
|
18
|
+
//
|
|
19
|
+
// A podspec is not fixed once trunk has it: `pod trunk deprecate` adds a
|
|
20
|
+
// field to the latest version's. So one is cached by its checksum as well
|
|
21
|
+
// as its version, and a lockfile written before such a change has a
|
|
22
|
+
// checksum no download matches any longer, which throws.
|
|
23
|
+
//
|
|
24
|
+
// CocoaPods' own spec repos are read first, and never written: that path
|
|
25
|
+
// in each directory under $CP_REPOS_DIR, else $CP_HOME_DIR/repos, else
|
|
26
|
+
// `~/.cocoapods/repos`. A file there that does not match is passed over.
|
|
27
|
+
// One in setCacheDir's cache that does not match throws. A cached podspec
|
|
28
|
+
// that matches is answered with no request at all.
|
|
29
|
+
//
|
|
30
|
+
// What the pod is built from is not trunk's, and is not fetched here: a
|
|
31
|
+
// commit of a GitHub repo, as a podspec's `source` or CHECKOUT OPTIONS
|
|
32
|
+
// may name, is github.js's getRepoTarball's.
|
|
33
|
+
export function getPodspec(name: string, version: string, checksum: string): Promise<Uint8Array>
|
package/cocoapods.js
ADDED
package/github.d.ts
CHANGED
|
@@ -89,6 +89,17 @@ export interface Client {
|
|
|
89
89
|
// `node_modules`, of packages installed. A symlink is no file. Refused
|
|
90
90
|
// where a path found is one getRepoFile does not take, such as one with a
|
|
91
91
|
// control character in it.
|
|
92
|
+
// How `head` stands to `base` in the repo (each a full commit sha, or a
|
|
93
|
+
// branch or tag name), as GitHub's compare tells it: `ahead` where `base`
|
|
94
|
+
// is in `head`'s history, `behind` the other way round, `identical`, or
|
|
95
|
+
// `diverged`; with the commits each has that the other hasn't, and their
|
|
96
|
+
// merge base. Refused unless the status agrees with those counts, the
|
|
97
|
+
// merge base is `base` where `head` is ahead of it or the same, and the
|
|
98
|
+
// base GitHub compared is `base` where that is a commit. GitHub answers
|
|
99
|
+
// for a commit of any fork of the repo too, through the repo's own name:
|
|
100
|
+
// whether one is the repo's own is whether a ref of the repo (its default
|
|
101
|
+
// branch, say) is ahead of it or the same.
|
|
102
|
+
compareCommits(options: { repo: RepoName; base: string; head: string }): Promise<{ status: 'ahead' | 'behind' | 'identical' | 'diverged'; aheadBy: number; behindBy: number; mergeBase: string }>
|
|
92
103
|
findRepoLockfiles(options: { repo: RepoName; sha: string; directory?: string }): Promise<{ lockfiles: RepoLockfile[]; projects: RepoProjectFile[] }>
|
|
93
104
|
// An advisory by its GHSA id. With `repo`, that repository's published
|
|
94
105
|
// copy: the maintainer's latest text, there before GitHub reviews it,
|
|
@@ -127,7 +138,7 @@ export interface Client {
|
|
|
127
138
|
// getRepoTreeTarball's for the tree GitHub names for that commit, asked
|
|
128
139
|
// on every call, cached or not. So its top directory is named for the
|
|
129
140
|
// tree, and files marked `export-subst` are as committed, not rewritten.
|
|
130
|
-
getRepoTarball(options: { repo: RepoName; sha: string }): Promise<Uint8Array>
|
|
141
|
+
getRepoTarball(options: { repo: RepoName; sha: string; symlinksAbove?: number }): Promise<Uint8Array>
|
|
131
142
|
// The id of the tree at `directory`, `/`-separated as npm's is, in the
|
|
132
143
|
// full commit `sha`, or of its root without one: from the tree GitHub
|
|
133
144
|
// names for the commit, down GitHub's listings a directory at a time,
|
|
@@ -153,7 +164,13 @@ export interface Client {
|
|
|
153
164
|
// a repo set to include Git LFS objects in archives, which replace the
|
|
154
165
|
// pointers git has; that repo still gets a tree already cached, pointers
|
|
155
166
|
// and all.
|
|
156
|
-
|
|
167
|
+
// It is read as @preventive/archive reads a tarball, and refused for
|
|
168
|
+
// anything it refuses: a pax record repeated, a name or a symlink that
|
|
169
|
+
// climbs out of it, an absolute symlink. `symlinksAbove` lets a symlink
|
|
170
|
+
// climb that many levels above the tarball's root, none unless given: a
|
|
171
|
+
// subdirectory's, its links reaching the repo's root as a monorepo
|
|
172
|
+
// package's LICENSE does, needs as many as it is deep, less one.
|
|
173
|
+
getRepoTreeTarball(options: { repo: RepoName; tree: string; symlinksAbove?: number }): Promise<Uint8Array>
|
|
157
174
|
// The repository's published security advisories, as GitHub's
|
|
158
175
|
// repository advisory objects: every page of 100, each found by the
|
|
159
176
|
// cursor in the one before's Link header. More than 100 pages is an
|
|
@@ -163,6 +180,10 @@ export interface Client {
|
|
|
163
180
|
// found as getRepoTreeId finds it: GitHub's listing, hashed back to the
|
|
164
181
|
// tree's id, so none is left out or changed.
|
|
165
182
|
listRepoDir(options: { repo: RepoName; sha: string; directory?: string }): Promise<RepoDirEntry[]>
|
|
183
|
+
// Every page of the repo's branches, as GitHub lists them, each with the
|
|
184
|
+
// commit at its head; more than `maxPages` (100 by default) pages of 100
|
|
185
|
+
// is an error, as is a branch with no commit.
|
|
186
|
+
listRepoBranches(options: { repo: RepoName; maxPages?: number }): Promise<{ branch: string; oid: string }[]>
|
|
166
187
|
// Every page of the repo's tags, as GitHub lists them, each with the
|
|
167
188
|
// commit GitHub names for it, an annotated tag's included, which
|
|
168
189
|
// getRepoTag checks is one; more than `maxPages` (100 by default) pages
|
package/npm.d.ts
CHANGED
|
@@ -37,11 +37,12 @@ export const defaultCacheDir: string | undefined
|
|
|
37
37
|
// with no request. Point it only at storage that
|
|
38
38
|
// nothing less trusted than the caller can write, never a cache shared
|
|
39
39
|
// with or restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
|
|
40
|
-
// (soldeer.js) and tree tarballs (github.js) don't
|
|
41
|
-
// they are checked against their integrity on every
|
|
42
|
-
// documents kept here (getMeta) never supply an
|
|
43
|
-
// one a caller gives (verifyDist, and getMeta with
|
|
44
|
-
//
|
|
40
|
+
// (soldeer.js), podspecs (cocoapods.js) and tree tarballs (github.js) don't
|
|
41
|
+
// rely on this, since they are checked against their integrity on every
|
|
42
|
+
// call; and the version documents kept here (getMeta) never supply an
|
|
43
|
+
// integrity, only confirm one a caller gives (verifyDist, and getMeta with
|
|
44
|
+
// a `dist`). A call's `cache` option, where it has one, keeps what it
|
|
45
|
+
// caches elsewhere, or writes none of it here.
|
|
45
46
|
export function setCacheDir(dir?: string | false): void
|
|
46
47
|
|
|
47
48
|
// A store of the caller's, a database's say, for what would otherwise be
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/upstream",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Minimal npm, crates.io, Soldeer, GitHub and git checkout clients",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Minimal npm, crates.io, Soldeer, CocoaPods trunk, GitHub and git checkout clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./advisories.js": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"types": "./cargo.d.ts",
|
|
13
13
|
"default": "./cargo.js"
|
|
14
14
|
},
|
|
15
|
+
"./cocoapods.js": {
|
|
16
|
+
"types": "./cocoapods.d.ts",
|
|
17
|
+
"default": "./cocoapods.js"
|
|
18
|
+
},
|
|
15
19
|
"./github.js": {
|
|
16
20
|
"types": "./github.d.ts",
|
|
17
21
|
"default": "./github.js"
|
|
@@ -52,6 +56,8 @@
|
|
|
52
56
|
"advisories.js",
|
|
53
57
|
"cargo.d.ts",
|
|
54
58
|
"cargo.js",
|
|
59
|
+
"cocoapods.d.ts",
|
|
60
|
+
"cocoapods.js",
|
|
55
61
|
"github.d.ts",
|
|
56
62
|
"github.js",
|
|
57
63
|
"git.d.ts",
|
|
@@ -79,6 +85,7 @@
|
|
|
79
85
|
"src/cache.js",
|
|
80
86
|
"src/cargo/crate.js",
|
|
81
87
|
"src/cargo/repos.js",
|
|
88
|
+
"src/cocoapods.js",
|
|
82
89
|
"src/download.js",
|
|
83
90
|
"src/git.js",
|
|
84
91
|
"src/github/client.js",
|
|
@@ -124,6 +131,9 @@
|
|
|
124
131
|
"publishConfig": {
|
|
125
132
|
"access": "public"
|
|
126
133
|
},
|
|
134
|
+
"dependencies": {
|
|
135
|
+
"@preventive/archive": "^1.2.0"
|
|
136
|
+
},
|
|
127
137
|
"peerDependencies": {
|
|
128
138
|
"semver": "^7.5.2"
|
|
129
139
|
},
|
package/src/advisories/common.js
CHANGED
|
@@ -38,7 +38,7 @@ export function detailsOf(text, id) {
|
|
|
38
38
|
return { details: text }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const SEVERITIES =
|
|
41
|
+
export const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info']
|
|
42
42
|
const isCvssVector = matches(/^CVSS:[34]\.\d(?:\/[A-Z]{1,4}:[A-Z]{1,2})+$/u)
|
|
43
43
|
const isCwe = matches(/^CWE-\d{1,6}$/u)
|
|
44
44
|
|
|
@@ -48,7 +48,7 @@ const isCwe = matches(/^CWE-\d{1,6}$/u)
|
|
|
48
48
|
export function metrics({ severity, score, vector, cwe }) {
|
|
49
49
|
const word = typeof severity === 'string' ? severity.toLowerCase().replace(/^medium$/u, 'moderate') : undefined
|
|
50
50
|
return {
|
|
51
|
-
...(SEVERITIES.
|
|
51
|
+
...(SEVERITIES.includes(word) && { severity: word }),
|
|
52
52
|
...(typeof score === 'number' && score > 0 && score <= 10 && { cvss: score }),
|
|
53
53
|
...(isCvssVector(vector) && { cvssVector: vector }),
|
|
54
54
|
cwe: isStrings(cwe) ? cwe.filter(isCwe) : [],
|
package/src/advisories/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
|
|
3
3
|
import { assertArgs, assertBoolean, assertRepo, assertion, isIterable, optional, sameName } from '../args.js'
|
|
4
4
|
import { assertCache } from '../cache.js'
|
|
5
|
-
import { order } from './common.js'
|
|
5
|
+
import { SEVERITIES, order } from './common.js'
|
|
6
6
|
import { GITHUB, SOLDEER, assertClient } from './github.js'
|
|
7
7
|
import { NPM } from './npm.js'
|
|
8
8
|
import { CARGO, COMPOSER } from './osv.js'
|
|
@@ -37,7 +37,6 @@ function collect(packages) {
|
|
|
37
37
|
// Which of an advisory's rows stands for it: npm's registry's, then OSV's,
|
|
38
38
|
// then its repository's.
|
|
39
39
|
const SOURCES = ['registry', 'osv', 'repository']
|
|
40
|
-
const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info']
|
|
41
40
|
const worse = (a, b) => (b === undefined || (a !== undefined && SEVERITIES.indexOf(a) < SEVERITIES.indexOf(b)) ? a : b)
|
|
42
41
|
|
|
43
42
|
// An advisory's rows as one: the first's by source, what it lacks from the
|
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'))
|
|
@@ -98,6 +99,12 @@ export const assertSoldeerName = assertion('a Soldeer package name', matches(/^(
|
|
|
98
99
|
// Soldeer takes any version but an empty one. Its registry has semver
|
|
99
100
|
// (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
|
|
100
101
|
export const assertSoldeerVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
|
|
102
|
+
// A root's name as Podfile.lock has it, and @preventive/lockfile reads it:
|
|
103
|
+
// printable ASCII but for spaces, parentheses, slashes and backslashes, no
|
|
104
|
+
// `.` first, which the spec linter refuses, and 1024 characters at most.
|
|
105
|
+
const assertPodName = assertion('a pod\'s root name', matches(/^(?=.{1,1024}$)(?!\.)[!-'*-.0-[\]-~]+$/u))
|
|
106
|
+
// Pod::Version's pattern, as Podfile.lock writes a version.
|
|
107
|
+
const assertPodVersion = assertion('a CocoaPods version', matches(/^(?=.{1,256}$)\d+(?:\.[\dA-Za-z-]+)*(?:\+[\dA-Za-z.-]+)?$/u))
|
|
101
108
|
// A package's name and version, as a registry's getters take them.
|
|
102
109
|
const assertNameVersion = (assertName, assertVersion) => (method, name, version) => {
|
|
103
110
|
assertName(method, 'name', name)
|
|
@@ -106,5 +113,8 @@ const assertNameVersion = (assertName, assertVersion) => (method, name, version)
|
|
|
106
113
|
export const assertCrate = assertNameVersion(assertCrateName, assertCrateVersion)
|
|
107
114
|
export const assertPackage = assertNameVersion(assertPackageName, assertPackageVersion)
|
|
108
115
|
export const assertSoldeerPackage = assertNameVersion(assertSoldeerName, assertSoldeerVersion)
|
|
116
|
+
export const assertPod = assertNameVersion(assertPodName, assertPodVersion)
|
|
109
117
|
// As Cargo.lock and soldeer.lock write a checksum.
|
|
110
118
|
export const assertSha256 = assertion('a sha256 in lowercase hex', matches(/^[\da-f]{64}$/u))
|
|
119
|
+
// As Podfile.lock writes a podspec's checksum.
|
|
120
|
+
export const assertSha1 = assertion('a sha1 in lowercase hex', isSha1)
|
package/src/cache.js
CHANGED
|
@@ -10,7 +10,7 @@ import { assertCacheName, assertDirectoryPath, assertion, isRepo } from './args.
|
|
|
10
10
|
import { MAX_BYTES, decode } from './http.js'
|
|
11
11
|
import { pool } from './pool.js'
|
|
12
12
|
|
|
13
|
-
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'npm/versions', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees', 'github/advisories'])
|
|
13
|
+
const DIRS = new Set(['npm/repos', 'npm/tarballs', 'npm/versions', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'cocoapods/podspecs', 'github/trees', 'github/advisories'])
|
|
14
14
|
const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
|
|
15
15
|
const RECORDS_AT_ONCE = 8
|
|
16
16
|
// JSON filed gzipped, as `<name>.json.gz`: a listing carries each
|
package/src/cocoapods.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readdir } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { assertPod, assertSha1 } from './args.js'
|
|
7
|
+
import { verifiedDownload } from './download.js'
|
|
8
|
+
import { COCOAPODS_CDN, buildUrl, encodeSegment } from './http.js'
|
|
9
|
+
|
|
10
|
+
// No expiry, but kept by checksum as well: trunk refuses a version it
|
|
11
|
+
// already has, yet rewrites a podspec it has, as `pod trunk deprecate`
|
|
12
|
+
// adds a field to the latest version's.
|
|
13
|
+
const DIR = 'cocoapods/podspecs'
|
|
14
|
+
|
|
15
|
+
// Where a spec repo keeps a version's podspec: under a directory each for
|
|
16
|
+
// the first three hex digits of the md5 of the name, as
|
|
17
|
+
// Pod::Source::Metadata shards trunk, and CocoaPods/Specs with it.
|
|
18
|
+
function specPath(name, version) {
|
|
19
|
+
const shard = createHash('md5').update(name).digest('hex').slice(0, 3)
|
|
20
|
+
return ['Specs', ...shard, name, version, `${name}.podspec.json`]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// CocoaPods' spec repos, a directory each under $CP_REPOS_DIR, else
|
|
24
|
+
// $CP_HOME_DIR/repos, else ~/.cocoapods/repos: `trunk` with the podspecs
|
|
25
|
+
// it read off the CDN as they were served, `master` a clone of
|
|
26
|
+
// CocoaPods/Specs, others a team's own. os.homedir() throws where there is
|
|
27
|
+
// no home directory to be found.
|
|
28
|
+
async function localPaths(name, version) {
|
|
29
|
+
const { CP_REPOS_DIR, CP_HOME_DIR } = process.env
|
|
30
|
+
let root
|
|
31
|
+
try {
|
|
32
|
+
root = resolve(CP_REPOS_DIR || join(CP_HOME_DIR || join(homedir(), '.cocoapods'), 'repos'))
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
return (await readdir(root).catch(() => [])).map((repo) => join(root, repo, ...specPath(name, version)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Trunk has no hash to check a podspec against, so the caller brings one,
|
|
40
|
+
// as Podfile.lock records it. The CDN redirects to jsDelivr, which is
|
|
41
|
+
// followed: whoever answers, the bytes are held to the checksum.
|
|
42
|
+
export async function getPodspec(name, version, checksum) {
|
|
43
|
+
assertPod('getPodspec', name, version)
|
|
44
|
+
assertSha1('getPodspec', 'checksum', checksum)
|
|
45
|
+
const what = `${name}@${version}`
|
|
46
|
+
const locate = () => buildUrl(COCOAPODS_CDN, specPath(name, version).map(encodeSegment))
|
|
47
|
+
return await verifiedDownload({
|
|
48
|
+
method: 'getPodspec', dir: DIR, what, entry: `${what}-${checksum}`, ext: 'podspec.json', algorithm: 'sha1', expected: checksum,
|
|
49
|
+
local: await localPaths(name, version), locate, options: { redirect: 'follow' },
|
|
50
|
+
})
|
|
51
|
+
}
|
package/src/download.js
CHANGED
|
@@ -6,11 +6,13 @@ import { request } from './http.js'
|
|
|
6
6
|
import { gitTreeOfTarball } from './tree.js'
|
|
7
7
|
|
|
8
8
|
// A hash as each registry writes it: npm's integrity, one sha512 in base64
|
|
9
|
-
// after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex;
|
|
10
|
-
//
|
|
9
|
+
// after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex;
|
|
10
|
+
// Podfile.lock's checksum of a podspec, a sha1 in hex; a git tree id for a
|
|
11
|
+
// tarball of that tree.
|
|
11
12
|
const DIGESTS = {
|
|
12
13
|
sha512: (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
|
|
13
14
|
sha256: (bytes) => createHash('sha256').update(bytes).digest('hex'),
|
|
15
|
+
sha1: (bytes) => createHash('sha1').update(bytes).digest('hex'),
|
|
14
16
|
tree: gitTreeOfTarball,
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -20,11 +22,12 @@ const DIGESTS = {
|
|
|
20
22
|
// whether set or not (read, never written; a mismatch is passed over),
|
|
21
23
|
// else the cache set, or a `cache` store in its place (readBytes; a
|
|
22
24
|
// mismatch throws), else what `locate` answers, checked and then kept as
|
|
23
|
-
// writeBytes keeps it.
|
|
24
|
-
|
|
25
|
+
// writeBytes keeps it, as `entry`, `what` unless given. A tree's `list`
|
|
26
|
+
// and `symlinksAbove` are gitTreeOfTarball's.
|
|
27
|
+
export async function verifiedDownload({ method, dir, what, entry = what, ext, algorithm, expected, local = [], ours = false, cache, locate, options = {}, list, symlinksAbove }) {
|
|
25
28
|
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 key = `${
|
|
29
|
+
const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list, symlinksAbove })
|
|
30
|
+
const key = `${entry}.${ext}`
|
|
28
31
|
for (const path of ours ? [...local, ...ourCachePaths(dir, key)] : local) {
|
|
29
32
|
const bytes = await readRegularFile(path)
|
|
30
33
|
if (bytes && await digest(bytes) === expected) return bytes
|
|
@@ -34,10 +37,10 @@ export async function verifiedDownload({ method, dir, what, ext, algorithm, expe
|
|
|
34
37
|
assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
|
|
35
38
|
return bytes
|
|
36
39
|
}
|
|
37
|
-
const cached = await readBytes(dir,
|
|
40
|
+
const cached = await readBytes(dir, entry, key, cache)
|
|
38
41
|
if (cached) return await check(cached, cache ? 'the store' : 'the cache')
|
|
39
42
|
const url = await locate()
|
|
40
43
|
const bytes = await check(await request(url, { ...options, as: 'bytes' }), url)
|
|
41
|
-
await writeBytes(dir,
|
|
44
|
+
await writeBytes(dir, entry, key, bytes, cache)
|
|
42
45
|
return bytes
|
|
43
46
|
}
|
package/src/git.js
CHANGED
|
@@ -52,7 +52,6 @@ export async function findGitCheckout(dir) {
|
|
|
52
52
|
const dirs = start && await isDirectory(start) ? await findGitDirs(start) : null
|
|
53
53
|
if (dirs === null) return {}
|
|
54
54
|
const directory = relative(dirs.root, start).split(sep).join('/')
|
|
55
|
-
const github = await originGitHub(dirs.commonDir)
|
|
56
|
-
const commit = await headCommit(dirs.gitDir, dirs.commonDir)
|
|
55
|
+
const [github, commit] = await Promise.all([originGitHub(dirs.commonDir), headCommit(dirs.gitDir, dirs.commonDir)])
|
|
57
56
|
return { ...(github && { github }), directory, ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
|
|
58
57
|
}
|
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, assertion, isRefName, 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'
|
|
@@ -111,6 +111,49 @@ async function listRepoTags(headers, options) {
|
|
|
111
111
|
})
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// GitHub lists each branch with the commit at its head.
|
|
115
|
+
async function listRepoBranches(headers, options) {
|
|
116
|
+
assertArgs('listRepoBranches', options, { repo: assertRepo, maxPages: optional(assertNumber) })
|
|
117
|
+
const { repo, maxPages } = options
|
|
118
|
+
const branches = await paginate('listRepoBranches', headers, (paging) => repoApi(repo, ['branches'], paging), maxPages)
|
|
119
|
+
return branches.map((entry) => {
|
|
120
|
+
assert.ok(isRefName(entry?.name) && isSha(entry.commit?.sha), `listRepoBranches: GitHub listed ${show(entry?.name)} in ${repo}, which is no branch name with a commit`)
|
|
121
|
+
return { branch: entry.name, oid: entry.commit.sha }
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const assertRevision = assertion('a full commit sha, or a branch or tag name', (value) => isSha(value) || isRefName(value))
|
|
126
|
+
// What GitHub's compare says of the counts it gives with it.
|
|
127
|
+
const COMPARED = {
|
|
128
|
+
ahead: (ahead, behind) => ahead > 0 && behind === 0,
|
|
129
|
+
behind: (ahead, behind) => ahead === 0 && behind > 0,
|
|
130
|
+
identical: (ahead, behind) => ahead === 0 && behind === 0,
|
|
131
|
+
diverged: (ahead, behind) => ahead > 0 && behind > 0,
|
|
132
|
+
}
|
|
133
|
+
const isCount = (value) => Number.isSafeInteger(value) && value >= 0
|
|
134
|
+
|
|
135
|
+
// How `head` stands to `base` in the repo, as GitHub's compare tells it:
|
|
136
|
+
// `ahead` where `base` is in `head`'s history, `behind` the other way round,
|
|
137
|
+
// `identical`, or `diverged`; with the commits each has that the other
|
|
138
|
+
// hasn't, and their merge base. Refused unless the status agrees with those
|
|
139
|
+
// counts, the merge base is `base` where `head` is ahead of it or the same,
|
|
140
|
+
// and the base GitHub compared is `base` where that is a commit. GitHub
|
|
141
|
+
// answers for a commit of any fork of the repo too: whether one is the
|
|
142
|
+
// repo's own is whether a ref of the repo has it in its history.
|
|
143
|
+
async function compareCommits(headers, options) {
|
|
144
|
+
assertArgs('compareCommits', options, { repo: assertRepo, base: assertRevision, head: assertRevision })
|
|
145
|
+
const { repo, base, head } = options
|
|
146
|
+
const body = await call(headers, repoApi(repo, ['compare', `${encodeSegment(base)}...${encodeSegment(head)}`], { per_page: 1 }))
|
|
147
|
+
const { status, ahead_by: aheadBy, behind_by: behindBy } = body ?? {}
|
|
148
|
+
const baseCommit = body?.base_commit?.sha
|
|
149
|
+
const mergeBase = body?.merge_base_commit?.sha
|
|
150
|
+
assert.ok(Object.hasOwn(COMPARED, status) && isCount(aheadBy) && isCount(behindBy) && COMPARED[status](aheadBy, behindBy),
|
|
151
|
+
`compareCommits: GitHub compared ${repo} ${base}...${head} as ${show(status)}, ${show(aheadBy)} ahead and ${show(behindBy)} behind`)
|
|
152
|
+
assert.ok(isSha(baseCommit) && isSha(mergeBase) && (!isSha(base) || baseCommit === base) && (!['ahead', 'identical'].includes(status) || mergeBase === baseCommit),
|
|
153
|
+
`compareCommits: GitHub compared ${repo} ${base}...${head} from ${show(baseCommit)}, merge base ${show(mergeBase)}`)
|
|
154
|
+
return { status, aheadBy, behindBy, mergeBase }
|
|
155
|
+
}
|
|
156
|
+
|
|
114
157
|
// GitHub's object for the path first: a directory, symlink or submodule is
|
|
115
158
|
// refused rather than read as a file's text. Up to 1 MB the object holds
|
|
116
159
|
// the content; past it, `encoding: none`, and the blob it names is read,
|
|
@@ -137,14 +180,15 @@ async function getRepoFile(headers, options) {
|
|
|
137
180
|
// with nothing in it or the LF blob of a file marked `eol=crlf`, comes
|
|
138
181
|
// from GitHub's listings of the trees, which the id checks as well: a
|
|
139
182
|
// directory at a time, as a recursive listing of a large tree is cut short.
|
|
140
|
-
|
|
183
|
+
// Its symlinks climb no more than `symlinksAbove` levels above its root.
|
|
184
|
+
async function treeTarball(method, headers, repo, tree, symlinksAbove) {
|
|
141
185
|
const listings = new Map()
|
|
142
186
|
const list = (sha) => {
|
|
143
187
|
if (!listings.has(sha)) listings.set(sha, call(headers, repoApi(repo, ['git', 'trees', sha])).then((listing) => (Array.isArray(listing?.tree) ? listing.tree : [])))
|
|
144
188
|
return listings.get(sha)
|
|
145
189
|
}
|
|
146
190
|
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 })
|
|
191
|
+
return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list, symlinksAbove })
|
|
148
192
|
}
|
|
149
193
|
|
|
150
194
|
async function commitTree(method, headers, repo, sha) {
|
|
@@ -157,15 +201,15 @@ async function commitTree(method, headers, repo, sha) {
|
|
|
157
201
|
// now. Its tree's tarball, not its own, in which `git archive` rewrites the
|
|
158
202
|
// files marked `export-subst`.
|
|
159
203
|
async function getRepoTarball(headers, options) {
|
|
160
|
-
assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
|
|
161
|
-
const { repo, sha } = options
|
|
204
|
+
assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha, symlinksAbove: optional(assertLevels) })
|
|
205
|
+
const { repo, sha, symlinksAbove } = options
|
|
162
206
|
const tree = await commitTree('getRepoTarball', headers, repo, sha)
|
|
163
|
-
return await treeTarball('getRepoTarball', headers, repo, tree)
|
|
207
|
+
return await treeTarball('getRepoTarball', headers, repo, tree, symlinksAbove)
|
|
164
208
|
}
|
|
165
209
|
|
|
166
210
|
async function getRepoTreeTarball(headers, options) {
|
|
167
|
-
assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId })
|
|
168
|
-
return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
|
|
211
|
+
assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId, symlinksAbove: optional(assertLevels) })
|
|
212
|
+
return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree, options.symlinksAbove)
|
|
169
213
|
}
|
|
170
214
|
|
|
171
215
|
// Not recursive: GitHub cuts short a recursive listing of a large tree.
|
|
@@ -304,5 +348,5 @@ async function listRepoAdvisories(headers, options) {
|
|
|
304
348
|
return (await Array.fromAsync(cursorPages('listRepoAdvisories', headers, pageUrl))).flat()
|
|
305
349
|
}
|
|
306
350
|
|
|
307
|
-
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, findRepoLockfiles, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
351
|
+
export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoTag, listRepoTags, listRepoBranches, compareCommits, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, findRepoLockfiles, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
|
|
308
352
|
export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
|
package/src/http.js
CHANGED
|
@@ -15,9 +15,10 @@ export const CRATES_INDEX = 'https://index.crates.io'
|
|
|
15
15
|
export const CRATES_STATIC = 'https://static.crates.io'
|
|
16
16
|
export const SOLDEER_API = 'https://api.soldeer.xyz'
|
|
17
17
|
export const SOLDEER_REVISIONS = 'https://soldeer-revisions.s3.amazonaws.com'
|
|
18
|
+
export const COCOAPODS_CDN = 'https://cdn.cocoapods.org'
|
|
18
19
|
export const MAX_BYTES = 512 * 1024 * 1024
|
|
19
20
|
|
|
20
|
-
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO, CRATES_INDEX, CRATES_STATIC, SOLDEER_API, SOLDEER_REVISIONS])
|
|
21
|
+
const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO, CRATES_INDEX, CRATES_STATIC, SOLDEER_API, SOLDEER_REVISIONS, COCOAPODS_CDN])
|
|
21
22
|
const isSegment = matches(/^(?!\.\.?$)(?:[\w.~@-]|%[\dA-F]{2})+$/u)
|
|
22
23
|
const isQueryKey = matches(/^[a-z_]+(?:\[\])?$/u)
|
|
23
24
|
const isQueryValue = (value) => (typeof value === 'string' && value !== '') || (Number.isSafeInteger(value) && value >= 0)
|
package/src/npm/cacache.js
CHANGED
|
@@ -13,6 +13,7 @@ import { readRegularFile } from '../cache.js'
|
|
|
13
13
|
const KEY = 'make-fetch-happen:request-cache:'
|
|
14
14
|
const digest = (algorithm, data, encoding) => createHash(algorithm).update(data).digest(encoding)
|
|
15
15
|
const shard = (hex) => [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)]
|
|
16
|
+
export const contentPath = (cache, sha512) => join(cache, 'content-v2', 'sha512', ...shard(Buffer.from(sha512, 'base64').toString('hex')))
|
|
16
17
|
const decoder = new TextDecoder('utf-8')
|
|
17
18
|
|
|
18
19
|
async function lastEntry(root, key) {
|
|
@@ -39,6 +40,6 @@ export async function readNpmCached(root, url) {
|
|
|
39
40
|
const integrity = typeof entry?.integrity === 'string' ? entry.integrity : ''
|
|
40
41
|
const sha512 = /(?:^|\s)sha512-([\d+/A-Za-z]+={0,2})(?=[\s?]|$)/u.exec(integrity)?.[1]
|
|
41
42
|
if (sha512 === undefined) return null
|
|
42
|
-
const body = await readRegularFile(
|
|
43
|
+
const body = await readRegularFile(contentPath(root, sha512))
|
|
43
44
|
return body && digest('sha512', body, 'base64') === sha512 ? { body, metadata: entry.metadata } : null
|
|
44
45
|
}
|
package/src/npm/tarball.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Buffer } from 'node:buffer'
|
|
2
1
|
import { homedir } from 'node:os'
|
|
3
2
|
import { join, resolve } from 'node:path'
|
|
4
3
|
|
|
@@ -6,7 +5,7 @@ import { assertArgs, assertPackage, assertPackageName, isPlainObject, isSha, opt
|
|
|
6
5
|
import { assertCache } from '../cache.js'
|
|
7
6
|
import { verifiedDownload } from '../download.js'
|
|
8
7
|
import { NPM_REGISTRY, decode } from '../http.js'
|
|
9
|
-
import { readNpmCached } from './cacache.js'
|
|
8
|
+
import { contentPath, readNpmCached } from './cacache.js'
|
|
10
9
|
import { getDocument, withNpmToken } from './registry.js'
|
|
11
10
|
import { checkedDist, getVersion, matchingDist } from './versions.js'
|
|
12
11
|
|
|
@@ -25,8 +24,7 @@ function npmCacheDirs() {
|
|
|
25
24
|
// npm 5+ (cacache) files a tarball by its sha512, npm 4 and before as
|
|
26
25
|
// <name>/<version>/package.tgz; ~/.audit as <org>:<name>-<version>.tgz.
|
|
27
26
|
function localPaths(name, version, integrity) {
|
|
28
|
-
const
|
|
29
|
-
const npm = npmCacheDirs().flatMap((root) => [join(root, '_cacache/content-v2/sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)), join(root, name, version, 'package.tgz')])
|
|
27
|
+
const npm = npmCacheDirs().flatMap((root) => [contentPath(join(root, '_cacache'), integrity.slice('sha512-'.length)), join(root, name, version, 'package.tgz')])
|
|
30
28
|
return [...npm, join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)]
|
|
31
29
|
}
|
|
32
30
|
|
|
@@ -117,7 +115,7 @@ export async function getPublishTimes(name) {
|
|
|
117
115
|
const time = record(json.time)
|
|
118
116
|
const times = new Map()
|
|
119
117
|
for (const version of Object.keys(record(json.versions))) {
|
|
120
|
-
const at = readTime(
|
|
118
|
+
const at = readTime(own(time, version))
|
|
121
119
|
if (at !== undefined) times.set(version, at)
|
|
122
120
|
}
|
|
123
121
|
return times
|
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) : [])
|