@preventive/upstream 1.0.0-alpha.1 → 1.0.0-alpha.3

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
@@ -1,16 +1,17 @@
1
1
  // Hand-written against github.js; a change to either belongs with the other.
2
2
  //
3
- // Every method checks its arguments hard before any request is built:
4
- // an options object holding only the keys listed here, a repo that is
3
+ // Every method checks its arguments hard before any request is built: an
4
+ // options object holding only the keys listed here, a repo that is
5
5
  // `owner/name` by GitHub's rules, branches and tags that git would take,
6
- // full commit shas, paths with no empty, `.` or `..` component, titles
7
- // and headlines on one line. A bad one is a rejection naming the method;
8
- // nothing is sent. Every URL is built from checked, encoded segments and
9
- // has to come back out of URL parsing unchanged. Redirects are refused,
10
- // except the tarball's, so an answer is about the repo asked for. A
11
- // response is read up to a size limit (64 MiB of JSON, 128 MiB of a file,
12
- // 512 MiB of a tarball) and within a timeout (30 seconds, 5 minutes for a
13
- // tarball), and what it says goes into an error message escaped.
6
+ // full commit shas and tree ids, paths with no empty, `.` or `..`
7
+ // component, titles and headlines on one line. A bad one is a rejection
8
+ // naming the method; nothing is sent. Every URL is built from checked,
9
+ // encoded segments and has to come back out of URL parsing unchanged.
10
+ // Redirects are refused, except the tarballs', so an answer is about the
11
+ // repo asked for. A response is read up to a size limit (64 MiB of JSON,
12
+ // 128 MiB of a file, 512 MiB of a tarball) and within a timeout (30
13
+ // seconds, 5 minutes for a tarball), and what it says goes into an error
14
+ // message escaped.
14
15
 
15
16
  // `owner/name`.
16
17
  export type RepoName = string
@@ -24,6 +25,16 @@ export interface ClientOptions {
24
25
 
25
26
  export type PullRequestStatus = 'open' | 'draft' | 'closed' | 'merged'
26
27
 
28
+ // An entry of a directory, as git has it, `path` its name: a file
29
+ // (`100644`, `100755` executable) or a symlink (`120000`) is a blob, a
30
+ // directory (`040000`) a tree, a submodule (`160000`) the commit it is at.
31
+ export interface RepoDirEntry {
32
+ path: string
33
+ mode: '100644' | '100755' | '120000' | '040000' | '160000'
34
+ type: 'blob' | 'tree' | 'commit'
35
+ sha: string
36
+ }
37
+
27
38
  // A failed request: `status` is the HTTP status GitHub answered with.
28
39
  export class HttpError extends Error {
29
40
  name: 'HttpError'
@@ -54,12 +65,38 @@ export interface Client {
54
65
  getRepoFile(options: { repo: RepoName; path: string; ref?: string }): Promise<string>
55
66
  // The head of `branch`, or of the default branch without one.
56
67
  getRepoHead(options: { repo: RepoName; branch?: string }): Promise<{ branch: string; oid: string }>
57
- // The repo's gzipped tarball at the full commit `sha`, whole, in memory.
68
+ // The repo's gzipped tarball at the full commit `sha`, whole, in memory:
69
+ // getRepoTreeTarball's for the tree GitHub names for that commit, asked
70
+ // on every call, cached or not. So its top directory is named for the
71
+ // tree, and files marked `export-subst` are as committed, not rewritten.
58
72
  getRepoTarball(options: { repo: RepoName; sha: string }): Promise<Uint8Array>
73
+ // The id of the tree at `path`, a directory, in the full commit `sha`, or
74
+ // of its root without one: from the tree GitHub names for the commit,
75
+ // down GitHub's listings a directory at a time, each hashed back to its
76
+ // id. Refused where `path` is no directory.
77
+ getRepoTreeId(options: { repo: RepoName; sha: string; path?: string }): Promise<string>
78
+ // The gzipped tarball of a tree, the repo's own or any subdirectory's, by
79
+ // its id, whole, in memory: GitHub's, its files under one top directory.
80
+ // Its files are hashed back into git's tree and the id must be `tree`,
81
+ // whether downloaded or read from setCacheDir's cache (npm.js), where it
82
+ // is kept by the id alone, for good; a cached copy that does not match
83
+ // throws. A tarball shows a submodule as an empty directory, and leaves
84
+ // out a subtree with no file in it: the submodule's commit and the
85
+ // subtrees come from GitHub's listings of the trees, asked only then, and
86
+ // the id must still come out `tree`: the bytes are the tree's own, the
87
+ // same for every repo. So GitHub's tarball is refused for a tree with
88
+ // files marked `export-ignore`, which it leaves out, and from a repo set
89
+ // to include Git LFS objects in archives, which replace the pointers git
90
+ // has; that repo still gets a tree already cached, pointers and all.
91
+ getRepoTreeTarball(options: { repo: RepoName; tree: string }): Promise<Uint8Array>
59
92
  // The repository's published security advisories, as GitHub's
60
93
  // repository advisory objects. One page of 100: GitHub pages this list
61
94
  // by cursor, so a repository with a full page is refused.
62
95
  listRepoAdvisories(options: { repo: RepoName }): Promise<any[]>
96
+ // The entries of the directory at `path` in the full commit `sha`, or of
97
+ // its root, found as getRepoTreeId finds it: GitHub's listing, hashed
98
+ // back to the tree's id, so none is left out or changed.
99
+ listRepoDir(options: { repo: RepoName; sha: string; path?: string }): Promise<RepoDirEntry[]>
63
100
  // Every page of `GET /user/repos`, as GitHub's repository objects; more
64
101
  // than `maxPages` (100 by default) pages of 100 is an error.
65
102
  listUserRepos(options?: { maxPages?: number }): Promise<any[]>
package/npm.d.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  // The directory is trusted: a package's repo is answered from it as it
14
14
  // was written, with no request. Point it only at storage that nothing
15
15
  // less trusted than the caller can write, never a cache shared with or
16
- // restored from lower-trust jobs. Tarballs, crates (cargo.js) and zips
17
- // (soldeer.js) don't rely on this, since they are checked against their
18
- // integrity on every call.
16
+ // restored from lower-trust jobs. Tarballs, crates (cargo.js), zips
17
+ // (soldeer.js) and tree tarballs (github.js) don't rely on this, since
18
+ // they are checked against their integrity on every call.
19
19
  export function setCacheDir(dir: string): void
20
20
 
21
21
  // A failed request: `status` is the HTTP status the registry answered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,6 +81,7 @@
81
81
  "src/pool.js",
82
82
  "src/remote.js",
83
83
  "src/semver.js",
84
+ "src/tree.js",
84
85
  "src/soldeer.js"
85
86
  ],
86
87
  "engines": {
package/semver.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // as it is. Without it, every call throws, except valid() on a plain
5
5
  // MAJOR.MINOR.PATCH with no options.
6
6
  export function satisfies(version: string, range: string, options?: { includePrerelease?: boolean; loose?: boolean }): boolean
7
- export function compareVersions(a: string, b: string): -1 | 0 | 1
7
+ export function compareVersions(a: string, b: string, options?: { loose?: boolean }): -1 | 0 | 1
8
8
  // The version as semver spells it, or null: `v1.2.3` answers `1.2.3`.
9
9
  export function valid(version: unknown, options?: { loose?: boolean }): string | null
10
10
  // A string that valid() answers unchanged.
@@ -13,3 +13,8 @@ export function isExactVersion(version: unknown): version is string
13
13
  export function validRange(range: unknown, options?: { loose?: boolean; includePrerelease?: boolean }): string | null
14
14
  // Whether some version is in both ranges; throws for one that is not a range.
15
15
  export function intersects(a: string, b: string, options?: { loose?: boolean; includePrerelease?: boolean }): boolean
16
+ // The version as semver cleans it, spaces and a leading `=` or `v` dropped,
17
+ // or null: ` =v1.2.3 ` answers `1.2.3`.
18
+ export function clean(version: string, options?: { loose?: boolean } | boolean): string | null
19
+ // The version's major, as semver reads it; throws for one that is not a version.
20
+ export function major(version: string, options?: { loose?: boolean } | boolean): number
package/semver.js CHANGED
@@ -1 +1 @@
1
- export { compareVersions, intersects, isExactVersion, satisfies, valid, validRange } from './src/semver.js'
1
+ export { clean, compareVersions, intersects, isExactVersion, major, satisfies, valid, validRange } from './src/semver.js'
package/src/args.js CHANGED
@@ -63,6 +63,8 @@ export const assertRepoName = assertion('a repository name', isRepoName)
63
63
  export const assertRepo = assertion('"owner/name"', isRepo)
64
64
  export const assertRef = assertion('a branch or tag name', isRefName)
65
65
  export const assertSha = assertion('a full commit sha', isSha)
66
+ export const isSha1 = matches(/^[\da-f]{40}$/u)
67
+ export const assertTreeId = assertion('a full tree id', isSha1)
66
68
  export const assertPath = assertion('a path inside a repository', isRepoPath)
67
69
  export const assertToken = assertion('a token', isToken)
68
70
  export const assertTokenOrNull = assertion('a token, or null for anonymous access', (value) => value === null || isToken(value))
package/src/cache.js CHANGED
@@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'node:path'
6
6
  import { assertDirectoryPath } from './args.js'
7
7
  import { MAX_BYTES, decode } from './http.js'
8
8
 
9
- const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/zips'])
9
+ const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/zips', 'github/trees'])
10
10
  const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
11
11
 
12
12
  let root
package/src/download.js CHANGED
@@ -3,12 +3,15 @@ import { createHash } from 'node:crypto'
3
3
 
4
4
  import { readCache, readRegularFile, writeCache } from './cache.js'
5
5
  import { request } from './http.js'
6
+ import { gitTreeOfTarball } from './tree.js'
6
7
 
7
8
  // A hash as each registry writes it: npm's integrity, one sha512 in base64
8
- // after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex.
9
+ // after `sha512-`; crates.io's and Soldeer's checksum, a sha256 in hex; a
10
+ // git tree id for a tarball of that tree.
9
11
  const DIGESTS = {
10
12
  sha512: (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
11
13
  sha256: (bytes) => createHash('sha256').update(bytes).digest('hex'),
14
+ tree: gitTreeOfTarball,
12
15
  }
13
16
 
14
17
  // Bytes whose `algorithm` hash is `expected`, which the registry or the
@@ -16,23 +19,23 @@ const DIGESTS = {
16
19
  // other tools' caches (read, never written; a mismatch is passed over),
17
20
  // else ours (a mismatch throws), else what `locate` answers, checked and
18
21
  // then cached.
19
- export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], locate }) {
22
+ export async function verifiedDownload({ method, dir, what, ext, algorithm, expected, local = [], locate, options = {}, list }) {
20
23
  assert.ok(Object.hasOwn(DIGESTS, algorithm) && typeof expected === 'string' && expected !== '', `${method}: nothing to check ${what} against`)
21
- const digest = DIGESTS[algorithm]
24
+ const digest = (bytes) => DIGESTS[algorithm](bytes, { expected, list })
22
25
  for (const path of local) {
23
26
  const bytes = await readRegularFile(path)
24
- if (bytes && digest(bytes) === expected) return bytes
27
+ if (bytes && await digest(bytes) === expected) return bytes
25
28
  }
26
- const check = (bytes, from) => {
27
- const actual = digest(bytes)
29
+ const check = async (bytes, from) => {
30
+ const actual = await digest(bytes)
28
31
  assert.ok(actual === expected, `${method}: integrity mismatch for ${what} from ${from}: expected ${expected}, got ${actual}`)
29
32
  return bytes
30
33
  }
31
34
  const key = `${what}.${ext}`
32
35
  const cached = await readCache(dir, key)
33
- if (cached) return check(cached, 'the cache')
36
+ if (cached) return await check(cached, 'the cache')
34
37
  const url = await locate()
35
- const bytes = check(await request(url, { as: 'bytes' }), url)
38
+ const bytes = await check(await request(url, { ...options, as: 'bytes' }), url)
36
39
  await writeCache(dir, key, bytes)
37
40
  return bytes
38
41
  }
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
2
2
  import { Buffer } from 'node:buffer'
3
3
  import { createHash } from 'node:crypto'
4
4
 
5
- import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, isSha, optional, sameName, show } from '../args.js'
5
+ import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTreeId, isSha, isSha1, optional, sameName, show } from '../args.js'
6
+ import { verifiedDownload } from '../download.js'
6
7
  import { decode, encodeSegment } from '../http.js'
8
+ import { gitTreeOfListing } from '../tree.js'
7
9
  import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
8
10
 
9
11
  const PER_PAGE = 100
@@ -66,11 +68,67 @@ async function getRepoFile(headers, options) {
66
68
  return decode(bytes, url)
67
69
  }
68
70
 
69
- // The one request that follows a redirect, to codeload.github.com. A full
70
- // sha only, so the bytes are that commit's, not wherever a ref points now.
71
+ // Follows the redirect to codeload.github.com. A tree id names its content,
72
+ // so the bytes are held to it, downloaded or cached, and cached by it alone,
73
+ // for good. What a tarball cannot show, a submodule's commit or a subtree
74
+ // with nothing in it, comes from GitHub's listings of the trees, which the
75
+ // id checks as well: a directory at a time, as a recursive listing of a
76
+ // large tree is cut short.
77
+ async function treeTarball(method, headers, repo, tree) {
78
+ const listings = new Map()
79
+ const list = (sha) => {
80
+ if (!listings.has(sha)) listings.set(sha, call(headers, repoApi(repo, ['git', 'trees', sha])).then((listing) => (Array.isArray(listing?.tree) ? listing.tree : [])))
81
+ return listings.get(sha)
82
+ }
83
+ const locate = () => repoApi(repo, ['tarball', tree])
84
+ return await verifiedDownload({ method, dir: 'github/trees', what: tree, ext: 'tgz', algorithm: 'tree', expected: tree, locate, options: { headers, redirect: 'follow' }, list })
85
+ }
86
+
87
+ async function commitTree(method, headers, repo, sha) {
88
+ const commit = await call(headers, repoApi(repo, ['git', 'commits', sha]))
89
+ assert.ok(commit?.sha === sha && isSha1(commit.tree?.sha), `${method}: GitHub names no tree for ${repo}@${sha}`)
90
+ return commit.tree.sha
91
+ }
92
+
93
+ // A full sha only, so the bytes are that commit's, not wherever a ref points
94
+ // now. Its tree's tarball, not its own, in which `git archive` rewrites the
95
+ // files marked `export-subst`.
71
96
  async function getRepoTarball(headers, options) {
72
97
  assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
73
- return await call(headers, repoApi(options.repo, ['tarball', options.sha]), { as: 'bytes', redirect: 'follow' })
98
+ const { repo, sha } = options
99
+ const tree = await commitTree('getRepoTarball', headers, repo, sha)
100
+ return await treeTarball('getRepoTarball', headers, repo, tree)
101
+ }
102
+
103
+ async function getRepoTreeTarball(headers, options) {
104
+ assertArgs('getRepoTreeTarball', options, { repo: assertRepo, tree: assertTreeId })
105
+ return await treeTarball('getRepoTreeTarball', headers, options.repo, options.tree)
106
+ }
107
+
108
+ // Not recursive: GitHub cuts short a recursive listing of a large tree.
109
+ async function listTree(method, headers, repo, tree) {
110
+ const entries = (await call(headers, repoApi(repo, ['git', 'trees', tree])))?.tree
111
+ assert.ok(Array.isArray(entries) && gitTreeOfListing(entries) === tree, `${method}: GitHub's listing of tree ${tree} in ${repo} is not that tree`)
112
+ return entries.map(({ path, mode, type, sha }) => ({ path, mode, type, sha }))
113
+ }
114
+
115
+ async function treeAt(method, headers, options) {
116
+ assertArgs(method, options, { repo: assertRepo, sha: assertSha, path: optional(assertPath) })
117
+ const { repo, sha, path } = options
118
+ let tree = await commitTree(method, headers, repo, sha)
119
+ for (const name of path?.split('/') ?? []) {
120
+ const entry = (await listTree(method, headers, repo, tree)).find((candidate) => candidate.path === name)
121
+ assert.ok(entry?.type === 'tree', `${method}: ${repo}@${sha} has no directory at ${show(path)}`)
122
+ tree = entry.sha
123
+ }
124
+ return tree
125
+ }
126
+
127
+ const getRepoTreeId = (headers, options) => treeAt('getRepoTreeId', headers, options)
128
+
129
+ async function listRepoDir(headers, options) {
130
+ const tree = await treeAt('listRepoDir', headers, options)
131
+ return await listTree('listRepoDir', headers, options.repo, tree)
74
132
  }
75
133
 
76
134
  async function getPullRequest(headers, options) {
@@ -120,5 +178,5 @@ async function listRepoAdvisories(headers, options) {
120
178
  return list
121
179
  }
122
180
 
123
- export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
181
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getRepoTreeTarball, getRepoTreeId, listRepoDir, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
124
182
  export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)
package/src/semver.js CHANGED
@@ -40,3 +40,5 @@ export const intersects = (...args) => semver().intersects(...args)
40
40
  export const compareVersions = (...args) => semver().compare(...args)
41
41
  export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
42
42
  export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version
43
+ export const clean = (...args) => semver().clean(...args)
44
+ export const major = (...args) => semver().major(...args)
package/src/tree.js ADDED
@@ -0,0 +1,194 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { gunzipSync } from 'node:zlib'
4
+
5
+ import { isSha1, matches } from './args.js'
6
+
7
+ const BLOCK = 512
8
+ const MAX_UNPACKED_BYTES = 2 ** 30
9
+ // Only what git writes is read: anything else, a pax `size` or a mode of
10
+ // 0000, would be extracted as it is not hashed. Modes are as its umask,
11
+ // 002 or 022, leaves them: a file 0664 or 0644, 0775 or 0755 executable;
12
+ // a directory 0775 or 0755; a symlink 0777.
13
+ const PAX_KEYS = new Set(['path', 'linkpath'])
14
+ const MODES = { 0: [0o664, 0o644, 0o775, 0o755], 2: [0o777], 5: [0o775, 0o755] }
15
+ // The top directory is not hashed, so it is held to GitHub's name for it,
16
+ // owner-repo-id: another, `..\..`, `C:` or a Windows device such as
17
+ // `con.txt`, could take an extractor elsewhere.
18
+ const isTop = matches(/^(?!(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[\da-z][\w.-]*$/iu)
19
+
20
+ // Names are kept as latin1 strings, a char per byte, so they sort and hash
21
+ // as the bytes git has.
22
+ const field = (header, start, end) => {
23
+ const bytes = header.subarray(start, end)
24
+ const nul = bytes.indexOf(0)
25
+ return Buffer.from(nul === -1 ? bytes : bytes.subarray(0, nul)).toString('latin1')
26
+ }
27
+ const octal = (header, start, end) => {
28
+ const text = field(header, start, end).trim()
29
+ return /^[0-7]{1,12}$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
30
+ }
31
+ const objectId = (type, content) => createHash('sha1').update(`${type} ${content.length}\0`).update(content).digest()
32
+ const checksum = (header) => header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0)
33
+ const isZero = (bytes) => bytes.every((byte) => byte === 0)
34
+
35
+ // `<length> <key>=<value>\n`, the length counting the whole record. A NUL
36
+ // would end a path for an extractor, and a name in a tree for git.
37
+ function paxRecords(body) {
38
+ const text = Buffer.from(body).toString('latin1')
39
+ const records = new Map()
40
+ for (let at = 0; at < text.length;) {
41
+ const space = text.indexOf(' ', at)
42
+ const length = text.slice(at, space)
43
+ const end = at + Number(length)
44
+ if (!/^[1-9]\d*$/u.test(length) || end > text.length || text[end - 1] !== '\n') return null
45
+ const record = text.slice(space + 1, end - 1)
46
+ const equals = record.indexOf('=')
47
+ if (equals < 1 || record.includes('\0')) return null
48
+ records.set(record.slice(0, equals), record.slice(equals + 1))
49
+ at = end
50
+ }
51
+ return records
52
+ }
53
+
54
+ // Git keeps no empty directory: in a tarball, one is a submodule. Each as
55
+ // [the directory holding it, its name, its path].
56
+ function emptyDirs(dir, prefix = '') {
57
+ return [...dir].flatMap(([name, entry]) => {
58
+ if (!(entry instanceof Map)) return []
59
+ const path = `${prefix}${name}`
60
+ return entry.size === 0 ? [[dir, name, path]] : emptyDirs(entry, `${path}/`)
61
+ })
62
+ }
63
+
64
+ // Git sorts a subtree as if its name ended in `/`.
65
+ function treeId(dir) {
66
+ const entries = [...dir].map(([name, entry]) => (entry instanceof Map ? { mode: '40000', name, id: treeId(entry) } : { ...entry, name }))
67
+ const key = ({ mode, name }) => (mode === '40000' ? `${name}/` : name)
68
+ entries.sort((a, b) => (key(a) < key(b) ? -1 : Number(key(a) > key(b))))
69
+ return objectId('tree', Buffer.concat(entries.flatMap(({ mode, name, id }) => [Buffer.from(`${mode} ${name}\0`, 'latin1'), id])))
70
+ }
71
+
72
+ const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
73
+ const isName = matches(/^[^\0/]+$/u)
74
+ const isEntry = (entry) => isName(entry?.path) && isSha1(entry.sha)
75
+ const nameOf = (path) => Buffer.from(path).toString('latin1')
76
+ const subtree = (sha) => ({ mode: '40000', id: Buffer.from(sha, 'hex') })
77
+
78
+ // A subtree with nothing in it but subtrees like it, down to the empty
79
+ // tree: its listing, read as subtrees alone, hashes to its id.
80
+ async function holdsNothing(sha, listed) {
81
+ if (sha === EMPTY_TREE) return true
82
+ const entries = await listed(sha)
83
+ if (treeId(new Map(entries.map((entry) => [entry.name, subtree(entry.sha)]))).toString('hex') !== sha) return false
84
+ for (const entry of entries) if (!await holdsNothing(entry.sha, listed)) return false
85
+ return true
86
+ }
87
+
88
+ // `git archive` leaves out a subtree with no file in it, which only
89
+ // plumbing makes. Where a directory's id is not the one listed, its listing
90
+ // names any, and one is put back only once its own listings show it holds
91
+ // nothing.
92
+ async function putBackEmptyTrees(dir, sha, listed) {
93
+ if (treeId(dir).toString('hex') === sha) return
94
+ for (const entry of await listed(sha)) {
95
+ if (entry.type !== 'tree') continue
96
+ const here = dir.get(entry.name)
97
+ if (here instanceof Map) await putBackEmptyTrees(here, entry.sha, listed)
98
+ else if (here === undefined && await holdsNothing(entry.sha, listed)) dir.set(entry.name, subtree(entry.sha))
99
+ }
100
+ }
101
+
102
+ // The id of the git tree a gzipped tarball holds, as `git archive` writes
103
+ // one, under a single top directory: a file's mode is its exec bit, a
104
+ // symlink's blob its target. What a tarball cannot show comes from `list`,
105
+ // which answers a listing of a tree by its id, as GitHub's trees API does
106
+ // ({ type, path, sha } entries), walked from `expected`: a submodule's
107
+ // commit for an empty directory, and, where the id comes out otherwise, the
108
+ // subtrees with nothing in them it leaves out. The id is `expected` only if
109
+ // those are right. Where there is no such tree, a reason, never an id.
110
+ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
111
+ let bytes
112
+ try {
113
+ bytes = gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES })
114
+ } catch {
115
+ return 'no tree: not gzip, or larger than 1 GiB unpacked'
116
+ }
117
+ const root = new Map()
118
+ let top
119
+ let pax = null
120
+ for (let at = 0; ;) {
121
+ const header = bytes.subarray(at, at + BLOCK)
122
+ if (header.length < BLOCK) return 'no tree: the tarball is cut short'
123
+ if (isZero(header)) {
124
+ if (!isZero(bytes.subarray(at))) return 'no tree: data after the end of the tarball'
125
+ break
126
+ }
127
+ if (Buffer.from(header.subarray(257, 265)).toString('latin1') !== 'ustar\u000000') return 'no tree: a header that is not POSIX ustar'
128
+ if (octal(header, 148, 156) !== checksum(header)) return 'no tree: a header that fails its checksum'
129
+ const size = octal(header, 124, 136)
130
+ const body = bytes.subarray(at + BLOCK, at + BLOCK + size)
131
+ if (body.length !== size) return 'no tree: the tarball is cut short'
132
+ at += BLOCK + Math.ceil(size / BLOCK) * BLOCK
133
+ const type = String.fromCodePoint(header[156])
134
+ if (type === 'x') {
135
+ const records = paxRecords(body)
136
+ if (records === null) return 'no tree: a malformed pax header'
137
+ const other = [...records.keys()].find((key) => !PAX_KEYS.has(key))
138
+ if (other !== undefined) return `no tree: a pax record git does not write, ${JSON.stringify(other)}`
139
+ if (pax !== null) return 'no tree: two pax headers for one entry'
140
+ pax = records
141
+ continue
142
+ }
143
+ const prefix = field(header, 345, 500)
144
+ const path = pax?.get('path') ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100))
145
+ const target = pax?.get('linkpath') ?? field(header, 157, 257)
146
+ pax = null
147
+ if (!Object.hasOwn(MODES, type)) return `no tree: an entry of type ${JSON.stringify(type)}`
148
+ const mode = octal(header, 100, 108)
149
+ const asGitWrites = MODES[type].includes(mode) && (type === '0' || size === 0) && path.endsWith('/') === (type === '5')
150
+ && octal(header, 108, 116) === 0 && octal(header, 116, 124) === 0 && field(header, 265, 297) === 'root' && field(header, 297, 329) === 'root'
151
+ if (!asGitWrites) return `no tree: a header git does not write, ${JSON.stringify(path)}`
152
+ const [first, ...parts] = path.replace(/\/$/u, '').split('/')
153
+ top ??= first
154
+ const name = type === '5' ? null : parts.pop()
155
+ 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)}`
156
+ let dir = root
157
+ for (const part of parts) {
158
+ if (!dir.has(part)) dir.set(part, new Map())
159
+ dir = dir.get(part)
160
+ if (!(dir instanceof Map)) return `no tree: a file where a directory is, ${JSON.stringify(path)}`
161
+ }
162
+ if (name === null) continue
163
+ if (dir.has(name)) return `no tree: ${JSON.stringify(path)} twice`
164
+ dir.set(name, type === '0' ? { mode: mode & 0o100 ? '100755' : '100644', id: objectId('blob', body) } : { mode: '120000', id: objectId('blob', Buffer.from(target, 'latin1')) })
165
+ }
166
+ if (top === undefined) return 'no tree: an empty tarball'
167
+ const listed = async (sha) => (list && isSha1(sha) ? await list(sha) : [])
168
+ .filter(isEntry)
169
+ .map((entry) => ({ ...entry, name: nameOf(entry.path) }))
170
+ for (const [dir, name, path] of emptyDirs(root)) {
171
+ let sha = expected
172
+ const parts = path.split('/')
173
+ for (const [i, part] of parts.entries()) {
174
+ const type = i === parts.length - 1 ? 'commit' : 'tree'
175
+ sha = (await listed(sha)).find((entry) => entry.type === type && entry.name === part)?.sha
176
+ }
177
+ if (!isSha1(sha)) return `no tree: an empty directory, ${JSON.stringify(path)}, and no submodule there`
178
+ dir.set(name, { mode: '160000', id: Buffer.from(sha, 'hex') })
179
+ }
180
+ await putBackEmptyTrees(root, expected, listed)
181
+ return treeId(root).toString('hex')
182
+ }
183
+
184
+ // GitHub lists a subtree's mode as `040000`, which git writes `40000`.
185
+ const LISTED = new Set(['100644 blob', '100755 blob', '120000 blob', '040000 tree', '160000 commit'])
186
+
187
+ // The id of the tree a listing such as GitHub's names, its entries
188
+ // { path, mode, type, sha }, or null where one is not an entry a tree can
189
+ // hold, or a name is there twice.
190
+ export function gitTreeOfListing(entries) {
191
+ if (!entries.every((entry) => isEntry(entry) && LISTED.has(`${entry.mode} ${entry.type}`))) return null
192
+ const dir = new Map(entries.map(({ path, mode, sha }) => [nameOf(path), { mode: mode.replace(/^0/u, ''), id: Buffer.from(sha, 'hex') }]))
193
+ return dir.size === entries.length ? treeId(dir).toString('hex') : null
194
+ }