@preventive/upstream 1.2.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/github.d.ts CHANGED
@@ -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
- getRepoTreeTarball(options: { repo: RepoName; tree: string }): Promise<Uint8Array>
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.2.0",
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/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
- export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], ours = false, cache, locate, options = {}, list }) {
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)
@@ -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
- async function treeTarball(method, headers, repo, tree) {
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/tree.js CHANGED
@@ -1,56 +1,34 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { createHash } from 'node:crypto'
3
- import { gunzipSync } from 'node:zlib'
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
- // Only what git writes is read: anything else, a pax `size` or a mode of
11
- // 0000, would be extracted as it is not hashed. Modes are as its umask,
12
- // 002 or 022, leaves them: a file 0664 or 0644, 0775 or 0755 executable;
13
- // a directory 0775 or 0755; a symlink 0777.
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
- export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
152
- let bytes
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
- bytes = gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES })
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
- let pax = null
161
- for (let at = 0; ;) {
162
- const header = bytes.subarray(at, at + BLOCK)
163
- if (header.length < BLOCK) return 'no tree: the tarball is cut short'
164
- if (isZero(header)) {
165
- if (!isZero(bytes.subarray(at))) return 'no tree: data after the end of the tarball'
166
- break
167
- }
168
- if (Buffer.from(header.subarray(257, 265)).toString('latin1') !== 'ustar\u000000') return 'no tree: a header that is not POSIX ustar'
169
- if (octal(header, 148, 156) !== checksum(header)) return 'no tree: a header that fails its checksum'
170
- const size = octal(header, 124, 136)
171
- const body = bytes.subarray(at + BLOCK, at + BLOCK + size)
172
- if (body.length !== size) return 'no tree: the tarball is cut short'
173
- at += BLOCK + Math.ceil(size / BLOCK) * BLOCK
174
- const type = String.fromCodePoint(header[156])
175
- if (type === 'x') {
176
- const records = paxRecords(body)
177
- if (records === null) return 'no tree: a malformed pax header'
178
- const other = [...records.keys()].find((key) => !PAX_KEYS.has(key))
179
- if (other !== undefined) return `no tree: a pax record git does not write, ${JSON.stringify(other)}`
180
- if (pax !== null) return 'no tree: two pax headers for one entry'
181
- pax = records
182
- continue
183
- }
184
- const prefix = field(header, 345, 500)
185
- const path = pax?.get('path') ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100))
186
- const target = pax?.get('linkpath') ?? field(header, 157, 257)
187
- pax = null
188
- if (!Object.hasOwn(MODES, type)) return `no tree: an entry of type ${JSON.stringify(type)}`
189
- const mode = octal(header, 100, 108)
190
- const asGitWrites = MODES[type].includes(mode) && (type === '0' || size === 0) && path.endsWith('/') === (type === '5')
191
- && octal(header, 108, 116) === 0 && octal(header, 116, 124) === 0 && field(header, 265, 297) === 'root' && field(header, 297, 329) === 'root'
192
- if (!asGitWrites) return `no tree: a header git does not write, ${JSON.stringify(path)}`
193
- const [first, ...parts] = path.replace(/\/$/u, '').split('/')
194
- top ??= first
195
- const name = type === '5' ? null : parts.pop()
196
- if (!isTop(first) || first !== top || name === undefined || [...parts, name].some((part) => ['', '.', '..'].includes(part))) return `no tree: an entry outside one top directory, ${JSON.stringify(path)}`
197
- let dir = root
198
- for (const part of parts) {
199
- if (!dir.has(part)) dir.set(part, new Map())
200
- dir = dir.get(part)
201
- if (!(dir instanceof Map)) return `no tree: a file where a directory is, ${JSON.stringify(path)}`
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
- if (name === null) continue
204
- if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
205
- dir.set(name, type === '0' ? { mode: mode & 0o100 ? '100755' : '100644', id: objectId('blob', body), body } : { mode: '120000', id: objectId('blob', Buffer.from(target, 'latin1')) })
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) : [])