@foss.global/gitmanager 2.9.0 → 2.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foss.global/gitmanager",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "private": false,
5
5
  "description": "Headless Git manager for the foss.global Git platform, with a TypeScript API and Rust engine.",
6
6
  "exports": {
@@ -10,17 +10,14 @@
10
10
  "author": "Task Venture Capital GmbH",
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
- "@foss.global/interfaces": "^2.4.0",
13
+ "@foss.global/interfaces": "^3.5.0",
14
14
  "@push.rocks/smartrust": "^1.7.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@foss.global/ci-spec": "1.1.1",
18
- "@git.zone/tsbuild": "^6.0.2",
19
- "@git.zone/tsdoc": "^3.0.2",
20
- "@git.zone/tsrun": "^3.0.3",
21
- "@git.zone/tsrust": "^4.5.1",
22
- "@git.zone/tstest": "^8.0.0",
23
- "@types/node": "^26.6.4"
18
+ "@git.zone/rsrepo": "^1.3.1",
19
+ "@git.zone/tsrepo": "^2.2.0",
20
+ "@types/node": "^26.6.6"
24
21
  },
25
22
  "files": [
26
23
  "ts/**/*",
@@ -57,11 +54,11 @@
57
54
  "last 1 chrome versions"
58
55
  ],
59
56
  "scripts": {
60
- "pretest": "(tsrust)",
61
- "test": "tstest test/ --verbose --logfile --timeout 60",
57
+ "pretest": "(rsrepo)",
58
+ "test": "tsrepo test test/ --verbose --logfile --timeout 60",
62
59
  "test:rust": "(cd rust && cargo test)",
63
- "build": "(tsbuild tsfolders) && (tsrust)",
64
- "build:release": "(tsbuild tsfolders) && (tsrust --configured-targets) && (tsrust verify)",
65
- "buildDocs": "tsdoc"
60
+ "build": "(tsrepo build tsfolders) && (rsrepo)",
61
+ "build:release": "(tsrepo build tsfolders) && (rsrepo --configured-targets) && (rsrepo verify)",
62
+ "buildDocs": "tsrepo docs"
66
63
  }
67
64
  }
package/readme.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Headless Git manager for the foss.global Git platform.
4
4
 
5
- This package follows the push.rocks Rust-backed package pattern: TypeScript owns the public package API and lifecycle, while Rust owns the Git/storage/protocol engine. TypeScript controls the Rust binary through `@push.rocks/smartrust`, and builds use `@git.zone/tsrust`.
5
+ This package follows the push.rocks Rust-backed package pattern: TypeScript owns the public package API and lifecycle, while Rust owns the Git/storage/protocol engine. TypeScript controls the Rust binary through `@push.rocks/smartrust`, and builds use `@git.zone/rsrepo`.
6
6
 
7
7
  The current implementation provides lifecycle IPC, local bare repository and mirror management, bounded read APIs, durable repository bundle transfer, public and trusted-ingress private clone/fetch, trusted-ingress receive-pack, bounded raw Git services for SSH ingress, and revision-fenced protection for exact branch refs. User identity, permissions, SSH/TLS termination, durable protected-ref policy storage, and durable post-push repository storage remain the embedding service's responsibility.
8
8
 
@@ -169,7 +169,7 @@ const result = await gitManager.pushRefs({
169
169
  { ref: 'refs/heads/old', expectedOldObjectId: oldOid, newObjectId: null }, // delete
170
170
  ],
171
171
  });
172
- // { org, repo, status: 'applied' | 'rejected', refs: [{ ref, status: 'ok' | 'rejected' | 'stale', reason?, message? }] }
172
+ // { org, repo, status: 'applied' | 'rejected', refs: [{ ref, status: 'ok' | 'rejected' | 'stale', reason?, message? }], remoteMessages: string[] }
173
173
 
174
174
  await gitManager.setDefaultBranch({ org: 'mirror.org', repo: 'interfaces', defaultBranch: 'main' });
175
175
  ```
@@ -184,13 +184,48 @@ await gitManager.setDefaultBranch({ org: 'mirror.org', repo: 'interfaces', defau
184
184
  - `non-fast-forward`: the remote or the local Git refused the ref as not a fast-forward.
185
185
  - `local-rejected`: the local Git refused the ref for another reason, such as an existing ref it would not overwrite.
186
186
  - `remote-rejected`: the remote refused the ref with a reason not listed above, or with none.
187
+ - `remoteMessages` holds the messages the remote sent during the push, such as a hook's output, for `https` and `ssh` remotes alike: the text of each `remote: ` line of Git's standard error in order, without the prefix and the trailing padding. It is `[]` when the remote sent none and is never omitted. It holds at most 100 lines and 16 KiB of text; when the remote sent more, the last element is `[remote messages truncated]` (a line cut at the byte bound keeps its first part). Credentials are redacted as in `message`. It is free text: look for a value you chose yourself, such as a nonce, and never branch on anything else in it.
187
188
  - Git sends nothing for a ref that already holds `newObjectId`, whatever its lease says, and reports it `ok` with the message `up to date` while the push's other refs are sent as usual, so a retried push whose first response was lost reads as `applied`. Such a ref is not changed, but its lease was not checked either: `ok` never means the lease matched. Deleting a ref that is already gone is `stale`.
188
189
  - The remote must advertise the `atomic` receive-pack capability. Without it Git refuses before it sends anything, and `pushRefs()` throws a `GitManagerError` with code `atomicPushUnsupported`; it never falls back to ref-by-ref updates. That holds for Smart HTTP remotes only: against a dumb-HTTP remote Git runs `http-push`, which updates ref by ref and is not atomic, so a push remote must serve Smart HTTP.
189
190
  - Each `newObjectId` must be in the managed repository; a missing one throws a `GitManagerError` with code `objectNotFound` before any connection. A push holds one or more updates of distinct full ref names under `refs/`; object IDs are full lowercase hexadecimal. GitManager sets no limit on the number of updates and never splits a push. It reads at most 128 MiB of the remote helper's output per push (the remote's ref listing and one status line per listed or pushed ref) and at most 64 KiB per line, and throws when the helper writes more. A remote that refuses the push request as too large (HTTP 413) makes `pushRefs()` throw a `GitManagerError` with code `remoteLimitExceeded`; nothing was pushed.
190
- - URL policy, enforced by GitManager: `https://`, or `http://` only to a loopback IP literal in its canonical spelling, `127.0.0.0/8` as four decimal octets without leading zeros or exactly `[::1]`. Other spellings of a loopback address (an uppercase scheme, octal, hexadecimal or single-integer forms, a trailing dot, other IPv6 forms) are refused rather than normalized, so Git receives exactly the URL the policy checked. Host names over `http`, `localhost` included, are refused, because a credential sent over plain `http` to a name would go wherever that name resolves. `ssh://`, `git://`, `file://`, `ext::`, local paths, scp-like URLs and URLs with user information are refused. The policy runs before any process starts, and its errors never quote the URL.
191
+ - URL policy, enforced by GitManager: `https://`, or `http://` only to a loopback IP literal in its canonical spelling, `127.0.0.0/8` as four decimal octets without leading zeros or exactly `[::1]`. Other spellings of a loopback address (an uppercase scheme, octal, hexadecimal or single-integer forms, a trailing dot, other IPv6 forms) are refused rather than normalized, so Git receives exactly the URL the policy checked. Host names over `http`, `localhost` included, are refused, because a credential sent over plain `http` to a name would go wherever that name resolves. `git://`, `file://`, `ext::`, local paths, scp-like URLs and URLs with user information are refused. `ssh://` is the transport of an `sshKey` credential and of nothing else (see [SSH pushes](#ssh-pushes)): an `ssh://` URL without an `sshKey` credential, an `sshKey` credential with any other URL, and `caCertificates` with an `sshKey` credential throw a `GitManagerError` with code `remoteTransportMismatch`. The policy runs before any process starts, and its errors never quote the URL.
191
192
  - The credential, `caCertificates` and `timeoutMs` behave as for mirrors: the credential travels only in the Git process environment as an `Authorization` header, with no credential helper, no hooks, no redirects and no global or system Git config; error text replaces the remote URL with `<push-remote>`. The remote URL is never stored in the repository.
192
193
  - `{ signal }` aborts a push as described in [Cancellation, concurrency and stop](#cancellation-concurrency-and-stop). An abort before the updates are sent (the signal already aborted, or an abort during the local object check or the remote ref listing) changes nothing anywhere. An abort after that rejects with `transferAborted` as well, but the remote may have applied the push: the remote applies an atomic push completely or not at all, and the abort can land before or after it did. Read the remote refs again before retrying.
193
194
  - A loopback `http` push never goes through a proxy, so the credential cannot leave the machine; see the proxy note above.
195
+ ### SSH pushes
196
+
197
+ An `sshKey` credential pushes to an `ssh://` remote with the remote's host key pinned:
198
+
199
+ ```ts
200
+ const result = await gitManager.pushRefs({
201
+ org: 'canary.org',
202
+ repo: 'canary',
203
+ remoteUrl: 'ssh://git@code.example:29419/canary.org/canary.git',
204
+ credential: { kind: 'sshKey', privateKey: canaryKeyPem, hostKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...' },
205
+ updates: [{ ref: 'refs/heads/main', expectedOldObjectId: oldOid, newObjectId: newOid }],
206
+ });
207
+ if (result.refs[0].reason === 'hook-declined' && result.remoteMessages.some((line) => line.includes(nonce))) {
208
+ // refused by the hook that printed the nonce
209
+ }
210
+ ```
211
+
212
+ - `remoteUrl` is `ssh://<user>@<host>[:<port>]/<path>`. The user is required and may hold `[A-Za-z0-9._-]` (not starting with `-` or `.`); the host is a name, an IPv4 address or a bracketed IPv6 address in canonical form; the port defaults to 22; the path holds only `[A-Za-z0-9._/-]`. Anything else is refused, never escaped or quoted, before any process starts. The remote runs `git-receive-pack '<path>'`.
213
+ - `hostKey` is the remote's public host key as one OpenSSH key line, `<type> <base64>` without host name or comment (`ssh-ed25519`, `ecdsa-sha2-nistp256`/`384`/`521` or `ssh-rsa`); the key blob is checked against its type. `ssh` accepts that key and no other: there is no known-hosts file, no trust on first use and no host key update. A fingerprint alone is not accepted.
214
+ - `privateKey` is one PEM private key the OpenSSH client reads, without a passphrase: `-----BEGIN OPENSSH PRIVATE KEY-----` (what `ssh-keygen` writes), or PEM RSA, EC or PKCS #8. An encrypted key is refused.
215
+ - The push runs the system `ssh` client, which has to be on the engine's `PATH`. The key exists for the push only, as a `0600` file in a `0700` `remote-ssh-<pid>-<nonce>` directory under `transferDir`, next to a known-hosts file holding only `hostKey`; it is removed as soon as the remote's ref advertisement is read, and on every error, abort and timeout. A directory a killed engine left behind is removed when the next engine starts. Only the file's path is on the `ssh` command line; the key is never in an argument, an environment variable, an error or a log. `ssh` runs with `-F none -T`, `BatchMode`, `IdentitiesOnly`, no agent, `PreferredAuthentications=publickey`, `StrictHostKeyChecking=yes`, `UpdateHostKeys=no`, `ConnectTimeout` (at most 30 s), `LogLevel=ERROR` and `--` before the destination, and with only `PATH` and `LC_ALL=C` in its environment.
216
+ - The push has the same semantics as over `https`: the engine reads the remote's ref advertisement, checks every lease against it, and hands the refspecs and the advertisement to `git send-pack --stateless-rpc --atomic`, relaying its request to the remote as remote-curl does over HTTP. Each command carries the advertised old object, so the remote checks every lease again inside its atomic ref transaction; command lines stay the same size however many updates a push holds. A remote without the `atomic` capability throws `atomicPushUnsupported` before anything is sent.
217
+ - A failure before the remote's ref advertisement is complete throws a `GitManagerError`, and nothing was pushed. The code comes from `ssh`'s exit status 255 and the fixed texts of the local OpenSSH client (pinned against the system `ssh` in `test/test.sshpush.node.ts`); the message carries `ssh`'s redacted standard error for operator logs only, never as something to judge by:
218
+
219
+ | Code | When |
220
+ | --- | --- |
221
+ | `sshHostKeyMismatch` | `Host key verification failed.`: the remote's host key is not `hostKey`; the private key was never offered. |
222
+ | `sshAuthenticationFailed` | `<user>@<host>: Permission denied (<methods>).`: the remote refused the key. |
223
+ | `remoteConnectionRefused` | `ssh: connect to host <host> port <port>: Connection refused`. |
224
+ | `remoteConnectionFailed` | Any other `ssh` failure (exit 255): an unknown host, a connection timeout, or a server that does not offer the type of `hostKey` (OpenSSH reports that at a level `LogLevel=ERROR` hides). |
225
+ | `remoteProtocolError` | The remote command ran but did not answer with a Git receive-pack advertisement with `report-status`, such as a forge that refuses SSH with a message, or a missing repository. GitManager never matches a forge's own text. |
226
+
227
+ - After the advertisement, the outcome is read from `send-pack`'s report exactly as over `https`: `applied`, `rejected` with a `reason` per ref, or an error when the report does not say what the remote did.
228
+
194
229
  - `setDefaultBranch()` points HEAD at `refs/heads/<defaultBranch>` and returns the repository info. The branch name is given without `refs/heads/`; a branch that does not exist is refused and HEAD stays unchanged. A mirror's next `refreshMirror()` points HEAD at the source's default branch again.
195
230
  - With `allowUnborn: true` the branch may not exist yet: HEAD names it unborn, as in a new repository, until a push creates it, and clones check out nothing until then. receive-pack refuses to delete the branch HEAD names (`deletion of the current branch prohibited`), which fails a whole atomic push, so a push that renames the default branch (creates the new one and deletes the old one) needs HEAD on the new name first. If that push is refused, HEAD stays unborn; `setDefaultBranch()` without the flag to an existing branch points it back. The name is validated either way.
196
231
 
@@ -453,6 +488,22 @@ const inspection = await gitManager.inspectArchive({
453
488
 
454
489
  The metadata is recomputed from the bytes, so compare it with what the sender declared rather than trusting the declaration. Every limit is inclusive and must be a non-negative safe integer; `maxPathMetadataBytes` is 1 to 134217728 and defaults to the ceiling. A refused archive throws a `GitManagerError` with code `archiveLimitExceeded` (a requested limit) or `archiveRejected` (a format, path, or link rule), and the inspector stops reading at the first violation and closes the source. Aborting the signal or calling `stop()` kills the inspector; the call rejects only after the process exited. The inspection does not use the management channel, so several can run at once.
455
490
 
491
+ ## Git LFS pointers
492
+
493
+ `listRepositoryLfsPointers()` returns the distinct Git LFS pointers (`{ oid, sizeBytes }`) in the blobs reachable from any ref; pointers that disagree about an object's size make it throw. The scan is bounded: at most 250,000 reachable objects, 100,000 pointer candidates (blobs of 1 to 1,024 bytes), 64 MiB of object listing and of object metadata, and 128 MiB of candidate content. A scan over any bound throws a `GitManagerError` with code `lfsScanLimitExceeded` and returns nothing; its message names the bound, and the pointer set is unknown rather than empty. Other failures stay plain errors.
494
+
495
+ ```ts
496
+ let pointers: IGitManagerRepositoryLfsPointer[] | null;
497
+ try {
498
+ pointers = await gitManager.listRepositoryLfsPointers({ org, repo });
499
+ } catch (error) {
500
+ if (!(error instanceof GitManagerError) || error.code !== 'lfsScanLimitExceeded') {
501
+ throw error;
502
+ }
503
+ pointers = null; // too large to scan: the LFS state is unread, not empty
504
+ }
505
+ ```
506
+
456
507
  ## Public API
457
508
 
458
509
  - `GitManager`: TypeScript facade for the Rust engine lifecycle.
@@ -468,7 +519,7 @@ The metadata is recomputed from the bytes, so compare it with what the sender de
468
519
  - `GitManager.importMirror()`: imports a remote repository with `git clone --mirror`. Supported remote forms are `https://`, `http://`, `ssh://`, `git://`, `file://`, and SCP-like `git@host:org/repo.git` URLs.
469
520
  - `GitManager.refreshMirror()`: atomically fetches and verifies the configured mirror source and mirrors its default branch.
470
521
  - `GitManager.importMirror()` and `GitManager.refreshMirror()` accept a `credential`, `caCertificates` and `timeoutMs`; see [Credentialed mirrors and repository size](#credentialed-mirrors-and-repository-size).
471
- - `GitManager.pushRefs()`: pushes leased ref updates to an `https` or loopback-IP `http` remote as one atomic transaction; see [Atomic pushes and the default branch](#atomic-pushes-and-the-default-branch).
522
+ - `GitManager.pushRefs()`: pushes leased ref updates to an `https` or loopback-IP `http` remote, or with an `sshKey` credential to an `ssh://` remote, as one atomic transaction and returns the remote's messages; see [Atomic pushes and the default branch](#atomic-pushes-and-the-default-branch) and [SSH pushes](#ssh-pushes).
472
523
  - `GitManager.importMirror(request, { signal? })`, `GitManager.refreshMirror(request, { signal? })` and `GitManager.pushRefs(request, { signal? })` run concurrently with other commands, hold mirror repositories against writes (`repositoryBusy`) and reject with `transferAborted` when aborted; see [Cancellation, concurrency and stop](#cancellation-concurrency-and-stop).
473
524
  - `GitManager.setDefaultBranch()`: points a repository's HEAD at an existing branch, or with `allowUnborn` at one a later push creates.
474
525
  - `GitManager.getRepositorySize()`: returns the exact object count, uncompressed object bytes and on-disk object storage bytes of a repository.
@@ -481,6 +532,7 @@ The metadata is recomputed from the bytes, so compare it with what the sender de
481
532
  - `GitManager.releaseRepositoryService(ticketId)`: releases a receive-pack's guard ticket; unknown or released tickets are a no-op.
482
533
  - `GitManager.listRepositories()`: lists local repositories managed below the configured data directory.
483
534
  - `GitManager.listRefs()`: lists one coherent page of the complete ref inventory of every namespace, with peeled tags, symbolic refs, HEAD, and a digest-bound cursor.
535
+ - `GitManager.listRepositoryLfsPointers()`: lists the distinct Git LFS pointers reachable from any ref; a scan over its bounds throws `lfsScanLimitExceeded`; see [Git LFS pointers](#git-lfs-pointers).
484
536
  - `GitManager.listTree()`: lists tree entries at a ref and optional path.
485
537
  - `GitManager.readFile()`: reads a blob as base64 after checking the blob size limit.
486
538
  - `GitManager.listCommits()`: lists commit summaries for a ref.
@@ -503,7 +555,7 @@ Read APIs and Smart HTTP use the system `git` CLI with explicit arguments, non-i
503
555
 
504
556
  ## Rust Build
505
557
 
506
- The package builds a Rust binary named `rustgitmanager` from `rust/` using `@git.zone/tsrust`.
558
+ The package builds a Rust binary named `rustgitmanager` from `rust/` using `@git.zone/rsrepo`.
507
559
 
508
560
  Configured targets:
509
561
 
@@ -520,7 +572,7 @@ pnpm run test:rust
520
572
  pnpm run build
521
573
  ```
522
574
 
523
- `pnpm test` runs the `pretest` hook first, which builds the Rust binaries with `tsrust`, and then runs lifecycle, bounded readiness, local mirror/read API, raw upload-pack/receive-pack preparation, clone/fetch denial, trusted receive-pack denial, explicit policy initialization, protected create/delete/non-fast-forward enforcement, inherited self-test hardening, restart replay, and real push tests through `smartrust`. `test/test.refinventory.node.ts` covers every ref namespace, peeled and nested tags, symbolic and dangling symbolic refs, unborn HEAD, prefix selection and exact lookup, ref names that are not valid UTF-8, pagination of 1,001 refs, stale and foreign cursors, 50,000 loose and packed refs, and a bundle round trip across namespaces. `test/test.pushlimits.node.ts` covers fixed-length and chunked Smart HTTP pushes over the limit, a complete pack followed by late excess bytes, an over-limit SSH-style receive-pack, an SSH-style receive-pack cut after Git already moved a ref (the update stays), and a bounded `stop()` while a service's output is never read, the rejected cases proving the refs unchanged, no Git or hook process left, no uncaught stream error, the engine serving, and a later push succeeding. `test/test.repositorybundles.node.ts` covers bundles of a repository whose only refs are notes and of one without its default branch (restored with HEAD on the unborn default branch), the refusal of a repository without refs, `bundleLimitExceeded` leaving no file, `replaceRepository()` with a bundle and without one, and a failed replacement that keeps the live repository and leaves no scratch. `test/test.protectedrefs.node.ts` covers `evaluateProtectedRefUpdates()` for every rule, unprotected refs, missing old objects and invalid requests, names the one protected ref among 1,003 updates and counts the engine's Git processes through a PATH wrapper (3 for one protected update, 66 for 64 protected updates on a 2,000-commit history with 500 tags), pins the pre-receive hook's decline messages over Smart HTTP, shows that the hook refuses an update and a deletion naming a missing old object even where the rule allows force pushes and deletions, and renames a default branch in one atomic Smart HTTP push after `setDefaultBranch()` with `allowUnborn` (refused while HEAD names the deleted branch, HEAD left unborn by a declined push and recovered without the flag, a missing branch still refused without it). `test/test.ancestry.node.ts` covers ancestors, the reverse direction, siblings, merges, unrelated histories, the same commit, invalid, missing and non-commit objects, a budget exceeded on a 20,000-commit history next to the exact answer, and a stalled walk whose Git process is killed. `test/test.remotetransfer.node.ts` serves fixtures through `git http-backend` over HTTPS with a private CA and over plain HTTP on `127.0.0.1` and `[::1]`, and covers credentialed mirror imports and refreshes, the source default branch on import and refresh, `setDefaultBranch()`, the push URL policy, atomic creates, updates and deletes, stale leases that change no ref, a remote without the `atomic` capability, 5,000 creates and 5,000 deletes in one receive-pack request with no command line naming a ref, a stale lease on the last of 1,500 updates that changes no ref, a pre-receive hook that declines a push and an update hook that declines one ref (the others failing as collateral), the `reason` of every ref that is not `ok`, and the credential staying out of every command line, error and file. Its fixture can hold or refuse a receive-pack request, which covers a ref that moves between the listing and the push (the remote fails all 1,500 updates) and a `413` answer (`remoteLimitExceeded`, no ref changed). It can also withhold pack requests to stall a transfer midway, which covers aborting an import (no repository or scratch left), a refresh (refs and HEAD unchanged) and a push, a push aborted before it sends its updates that changes nothing, other commands and transfers completing while transfers stall, `repositoryBusy` (including `replaceRepository()`) and the Smart HTTP `503` while a mirror refreshes, an atomic refresh that prunes nothing when one ref update fails, a refresh that times out inside its ref transaction without leaving lock files, a refresh refused during a Smart HTTP push, the receive-pack guard ticket (refused while held, admitted after release, idempotent release, released by `stop()`, released when its preparation times out on the bridge after the engine issued it), a bounded `stop()` during a stalled transfer, and a credentialed loopback `http` push that never reaches the `http_proxy`, `all_proxy` and `ALL_PROXY` set in the engine's environment. `test/test.archiveinspection.node.ts` runs every ci-spec archive conformance vector through `inspectArchive()` as bytes and as a chunked stream, the compressed limit at its boundary, an 8 MiB streamed archive, an early refusal that closes the source, and abort, source failure and `stop()` during an inspection.
575
+ `pnpm test` runs the `pretest` hook first, which builds the Rust binaries with `rsrepo`, and then runs lifecycle, bounded readiness, local mirror/read API, raw upload-pack/receive-pack preparation, clone/fetch denial, trusted receive-pack denial, explicit policy initialization, protected create/delete/non-fast-forward enforcement, inherited self-test hardening, restart replay, and real push tests through `smartrust`. `test/test.refinventory.node.ts` covers every ref namespace, peeled and nested tags, symbolic and dangling symbolic refs, unborn HEAD, prefix selection and exact lookup, ref names that are not valid UTF-8, pagination of 1,001 refs, stale and foreign cursors, 50,000 loose and packed refs, and a bundle round trip across namespaces. `test/test.pushlimits.node.ts` covers fixed-length and chunked Smart HTTP pushes over the limit, a complete pack followed by late excess bytes, an over-limit SSH-style receive-pack, an SSH-style receive-pack cut after Git already moved a ref (the update stays), and a bounded `stop()` while a service's output is never read, the rejected cases proving the refs unchanged, no Git or hook process left, no uncaught stream error, the engine serving, and a later push succeeding. `test/test.repositorybundles.node.ts` covers bundles of a repository whose only refs are notes and of one without its default branch (restored with HEAD on the unborn default branch), the refusal of a repository without refs, `bundleLimitExceeded` leaving no file, `replaceRepository()` with a bundle and without one, and a failed replacement that keeps the live repository and leaves no scratch. `test/test.protectedrefs.node.ts` covers `evaluateProtectedRefUpdates()` for every rule, unprotected refs, missing old objects and invalid requests, names the one protected ref among 1,003 updates and counts the engine's Git processes through a PATH wrapper (3 for one protected update, 66 for 64 protected updates on a 2,000-commit history with 500 tags), pins the pre-receive hook's decline messages over Smart HTTP, shows that the hook refuses an update and a deletion naming a missing old object even where the rule allows force pushes and deletions, and renames a default branch in one atomic Smart HTTP push after `setDefaultBranch()` with `allowUnborn` (refused while HEAD names the deleted branch, HEAD left unborn by a declined push and recovered without the flag, a missing branch still refused without it). `test/test.ancestry.node.ts` covers ancestors, the reverse direction, siblings, merges, unrelated histories, the same commit, invalid, missing and non-commit objects, a budget exceeded on a 20,000-commit history next to the exact answer, and a stalled walk whose Git process is killed. `test/test.remotetransfer.node.ts` serves fixtures through `git http-backend` over HTTPS with a private CA and over plain HTTP on `127.0.0.1` and `[::1]`, and covers credentialed mirror imports and refreshes, the source default branch on import and refresh, `setDefaultBranch()`, the push URL policy, atomic creates, updates and deletes, stale leases that change no ref, a remote without the `atomic` capability, 5,000 creates and 5,000 deletes in one receive-pack request with no command line naming a ref, a stale lease on the last of 1,500 updates that changes no ref, a pre-receive hook that declines a push and an update hook that declines one ref (the others failing as collateral), with their `remoteMessages`, the `reason` of every ref that is not `ok`, and the credential staying out of every command line, error and file. Its fixture can hold or refuse a receive-pack request, which covers a ref that moves between the listing and the push (the remote fails all 1,500 updates) and a `413` answer (`remoteLimitExceeded`, no ref changed). It can also withhold pack requests to stall a transfer midway, which covers aborting an import (no repository or scratch left), a refresh (refs and HEAD unchanged) and a push, a push aborted before it sends its updates that changes nothing, other commands and transfers completing while transfers stall, `repositoryBusy` (including `replaceRepository()`) and the Smart HTTP `503` while a mirror refreshes, an atomic refresh that prunes nothing when one ref update fails, a refresh that times out inside its ref transaction without leaving lock files, a refresh refused during a Smart HTTP push, the receive-pack guard ticket (refused while held, admitted after release, idempotent release, released by `stop()`, released when its preparation times out on the bridge after the engine issued it), a bounded `stop()` during a stalled transfer, and a credentialed loopback `http` push that never reaches the `http_proxy`, `all_proxy` and `ALL_PROXY` set in the engine's environment. `test/test.sshpush.node.ts` needs `/usr/sbin/sshd`, `ssh`, `ssh-keygen` and `git-shell`: it starts an unprivileged `sshd` on `127.0.0.1` whose authorized keys run `git-shell`, and records every `ssh` and `git` command line through PATH wrappers. It pins the OpenSSH client texts the engine classifies against the system `ssh`, and covers an accepted push with the key only as a `0600` file in a `0700` directory that is gone afterwards, leased updates and deletes, a stale lease and an up-to-date ref decided without `send-pack`, a PEM RSA key, a declining hook (`hook-declined` with the nonce in `remoteMessages`), the bounds, truncation marker and redaction of `remoteMessages`, `sshHostKeyMismatch` before the key is offered, an unoffered host key type, `sshAuthenticationFailed`, `remoteConnectionRefused`, `remoteProtocolError` for a missing repository, `atomicPushUnsupported`, the refusals of URLs, users, hosts, paths, keys and transport mismatches before `ssh` runs, an abort while `ssh` runs (directory removed), the startup sweep of a stale key directory, and no key text in any command line, error or engine log. `test/test.archiveinspection.node.ts` runs every ci-spec archive conformance vector through `inspectArchive()` as bytes and as a chunked stream, the compressed limit at its boundary, an 8 MiB streamed archive, an early refusal that closes the source, and abort, source failure and `stop()` during an inspection.
524
576
 
525
577
  ## License and Legal Information
526
578
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@foss.global/gitmanager',
6
- version: '2.9.0',
6
+ version: '2.11.0',
7
7
  description: 'Headless Git manager for the foss.global Git platform, with a TypeScript API and Rust engine.'
8
8
  }
@@ -61,25 +61,25 @@ const readinessDefaultTimeoutMs = 2_000;
61
61
  const readinessMaximumTimeoutMs = 30_000;
62
62
  const remoteTransferMaximumTimeoutMs = 21_600_000;
63
63
 
64
- /**
65
- * Checks transfer options before they reach the engine, which answers a
66
- * malformed credential-bearing request with fixed text only. Messages never
67
- * quote a credential value.
68
- */
69
- const assertRemoteTransferOptions = (optionsArg: IGitManagerRemoteTransferOptions): void => {
70
- const { credential, caCertificates, timeoutMs } = optionsArg;
71
- if (credential !== undefined) {
72
- if (
73
- credential === null
74
- || typeof credential !== 'object'
75
- || credential.kind !== 'basic'
76
- || typeof credential.username !== 'string'
77
- || typeof credential.password !== 'string'
78
- || Object.keys(credential).some((key) => !['kind', 'username', 'password'].includes(key))
79
- ) {
80
- throw new TypeError('credential must be { kind: "basic", username: string, password: string }');
81
- }
64
+ /** Whether `valueArg` is an object with exactly `kind` and the named string fields. */
65
+ const isCredentialShape = (valueArg: unknown, kindArg: string, fieldsArg: string[]): boolean => {
66
+ if (valueArg === null || typeof valueArg !== 'object') {
67
+ return false;
82
68
  }
69
+ const record = valueArg as Record<string, unknown>;
70
+ return record.kind === kindArg
71
+ && fieldsArg.every((field) => typeof record[field] === 'string')
72
+ && Object.keys(record).every((key) => key === 'kind' || fieldsArg.includes(key));
73
+ };
74
+
75
+ const basicCredentialFields = ['username', 'password'];
76
+ const sshKeyCredentialFields = ['privateKey', 'hostKey'];
77
+
78
+ /** Checks the CA bundle and the time limit of a remote transfer. */
79
+ const assertTransferLimits = (
80
+ optionsArg: Pick<IGitManagerRemoteTransferOptions, 'caCertificates' | 'timeoutMs'>,
81
+ ): void => {
82
+ const { caCertificates, timeoutMs } = optionsArg;
83
83
  if (
84
84
  caCertificates !== undefined
85
85
  && (!Array.isArray(caCertificates) || caCertificates.some((certificate) => typeof certificate !== 'string'))
@@ -94,6 +94,38 @@ const assertRemoteTransferOptions = (optionsArg: IGitManagerRemoteTransferOption
94
94
  }
95
95
  };
96
96
 
97
+ /**
98
+ * Checks transfer options before they reach the engine, which answers a
99
+ * malformed credential-bearing request with fixed text only. Messages never
100
+ * quote a credential value.
101
+ */
102
+ const assertRemoteTransferOptions = (optionsArg: IGitManagerRemoteTransferOptions): void => {
103
+ const { credential } = optionsArg;
104
+ if (credential !== undefined && !isCredentialShape(credential, 'basic', basicCredentialFields)) {
105
+ throw new TypeError('credential must be { kind: "basic", username: string, password: string }');
106
+ }
107
+ assertTransferLimits(optionsArg);
108
+ };
109
+
110
+ /**
111
+ * Checks the options of a push, whose credential may also be an SSH key, as
112
+ * `assertRemoteTransferOptions` does. The engine validates the key, the host
113
+ * key and the URL.
114
+ */
115
+ const assertAtomicPushOptions = (requestArg: IGitManagerAtomicPushRequest): void => {
116
+ const { credential } = requestArg;
117
+ if (
118
+ credential !== undefined
119
+ && !isCredentialShape(credential, 'basic', basicCredentialFields)
120
+ && !isCredentialShape(credential, 'sshKey', sshKeyCredentialFields)
121
+ ) {
122
+ throw new TypeError(
123
+ 'credential must be { kind: "basic", username: string, password: string } or { kind: "sshKey", privateKey: string, hostKey: string }',
124
+ );
125
+ }
126
+ assertTransferLimits(requestArg);
127
+ };
128
+
97
129
  /**
98
130
  * Checks the shape of push updates before they reach the engine, which
99
131
  * answers a malformed request with fixed text only. Ref names and object IDs
@@ -463,7 +495,22 @@ export class GitManager extends plugins.EventEmitter {
463
495
  * throws `remoteLimitExceeded`, and nothing is pushed. `remoteUrl` must
464
496
  * be `https`, or `http` to a loopback IP address (`127.0.0.0/8`, `[::1]`;
465
497
  * never a host name such as `localhost`), without user information; the
466
- * credential travels as for `importMirror`. A ref that already holds its
498
+ * credential travels as for `importMirror`.
499
+ *
500
+ * With an `sshKey` credential, and only with one, `remoteUrl` is
501
+ * `ssh://<user>@<host>[:<port>]/<path>` and the push runs over the system
502
+ * `ssh` client: the key exists for the push only, as a file readable by the
503
+ * engine alone, and the remote must present exactly the pinned `hostKey`.
504
+ * A failure before the remote's ref advertisement throws one of
505
+ * `sshHostKeyMismatch`, `sshAuthenticationFailed`,
506
+ * `remoteConnectionRefused`, `remoteConnectionFailed` or
507
+ * `remoteProtocolError`, and nothing is pushed. An `sshKey` credential
508
+ * without an `ssh://` URL, an `ssh://` URL without one, and
509
+ * `caCertificates` with one throw `remoteTransportMismatch` before anything
510
+ * runs. `remoteMessages` holds the remote's `remote:` lines, such as a
511
+ * hook's output, for every transport.
512
+ *
513
+ * A ref that already holds its
467
514
  * `newObjectId` is reported `ok` without its lease being checked; it is not
468
515
  * changed, so `ok` never means the lease matched. Any refusal the remote
469
516
  * reports per ref returns status `rejected`, with the refused refs `stale`
@@ -484,7 +531,7 @@ export class GitManager extends plugins.EventEmitter {
484
531
  optionsArg: IGitManagerTransferControlOptions = {},
485
532
  ): Promise<IGitManagerAtomicPushResult> {
486
533
  this.assertRunning();
487
- assertRemoteTransferOptions(requestArg);
534
+ assertAtomicPushOptions(requestArg);
488
535
  assertAtomicPushUpdates(requestArg.updates);
489
536
  return this.bridge.pushRefs(requestArg, optionsArg);
490
537
  }
@@ -733,6 +780,13 @@ export class GitManager extends plugins.EventEmitter {
733
780
  return this.bridge.listRefs(requestArg);
734
781
  }
735
782
 
783
+ /**
784
+ * Lists the distinct Git LFS pointers reachable from any ref. A scan that
785
+ * exceeds one of its bounds (250,000 reachable objects, 100,000 blobs of
786
+ * 1 to 1,024 bytes, 64 MiB of object listing or metadata, 128 MiB of
787
+ * candidate content) throws a `GitManagerError` with code
788
+ * `lfsScanLimitExceeded`, whose message names the bound.
789
+ */
736
790
  public async listRepositoryLfsPointers(
737
791
  requestArg: IGitManagerRepositorySelector,
738
792
  ): Promise<IGitManagerRepositoryLfsPointer[]> {
@@ -12,6 +12,13 @@ const gitManagerErrorCodes: ReadonlySet<string> = new Set<TGitManagerErrorCode>(
12
12
  'remoteLimitExceeded',
13
13
  'transferAborted',
14
14
  'repositoryBusy',
15
+ 'lfsScanLimitExceeded',
16
+ 'sshHostKeyMismatch',
17
+ 'sshAuthenticationFailed',
18
+ 'remoteConnectionRefused',
19
+ 'remoteConnectionFailed',
20
+ 'remoteProtocolError',
21
+ 'remoteTransportMismatch',
15
22
  ]);
16
23
 
17
24
  export const isGitManagerErrorCode = (valueArg: unknown): valueArg is TGitManagerErrorCode =>
package/ts/models.ts CHANGED
@@ -177,6 +177,32 @@ export interface IGitManagerArchiveInspection {
177
177
  * remote; read its refs again.
178
178
  * - `repositoryBusy`: a mirror transfer holds the repository, or a write to
179
179
  * it is in progress when a mirror transfer starts; nothing was changed.
180
+ * - `lfsScanLimitExceeded`: the Git LFS pointer scan of
181
+ * `listRepositoryLfsPointers()` exceeded one of its bounds; the message
182
+ * names the bound. The pointer set is unknown, never empty.
183
+ *
184
+ * An SSH push (`pushRefs()` with an `sshKey` credential) that fails before
185
+ * the remote's ref advertisement is complete throws one of the following;
186
+ * nothing was pushed. They are classified from the exit status 255 and the
187
+ * fixed texts of the local OpenSSH client, and the message carries ssh's
188
+ * redacted standard error for operator logs only.
189
+ *
190
+ * - `sshHostKeyMismatch`: the remote's host key is not the pinned `hostKey`;
191
+ * the key was never offered.
192
+ * - `sshAuthenticationFailed`: the remote refused the SSH key.
193
+ * - `remoteConnectionRefused`: the remote host refused the TCP connection.
194
+ * - `remoteConnectionFailed`: any other failure of the SSH connection, such
195
+ * as an unknown host, a timeout of the connection, or a server that does
196
+ * not offer the pinned key's type.
197
+ * - `remoteProtocolError`: the remote command ran but did not answer with a
198
+ * Git receive-pack advertisement (or one without `report-status`), such as
199
+ * a forge that refuses SSH with a message or a missing repository.
200
+ *
201
+ * - `remoteTransportMismatch`: a push's credential or options do not fit the
202
+ * transport of its `remoteUrl`: an `sshKey` credential without an
203
+ * `ssh://` URL, an `ssh://` URL without an `sshKey` credential, or
204
+ * `caCertificates` with an `sshKey` credential. Refused before anything
205
+ * runs; nothing was pushed.
180
206
  */
181
207
  export type TGitManagerErrorCode =
182
208
  | 'invalidObjectId'
@@ -189,7 +215,14 @@ export type TGitManagerErrorCode =
189
215
  | 'atomicPushUnsupported'
190
216
  | 'remoteLimitExceeded'
191
217
  | 'transferAborted'
192
- | 'repositoryBusy';
218
+ | 'repositoryBusy'
219
+ | 'lfsScanLimitExceeded'
220
+ | 'sshHostKeyMismatch'
221
+ | 'sshAuthenticationFailed'
222
+ | 'remoteConnectionRefused'
223
+ | 'remoteConnectionFailed'
224
+ | 'remoteProtocolError'
225
+ | 'remoteTransportMismatch';
193
226
 
194
227
  export interface IGitManagerRepositoryLfsPointer {
195
228
  oid: string;
@@ -381,6 +414,7 @@ export type {
381
414
  IGitManagerRefRequest,
382
415
  IGitManagerRefreshMirrorRequest,
383
416
  IGitManagerRemoteBasicCredential,
417
+ IGitManagerRemoteSshKeyCredential,
384
418
  IGitManagerRemoteTransferOptions,
385
419
  IGitManagerRepositoryInfo,
386
420
  IGitManagerRepositorySelector,
@@ -390,6 +424,7 @@ export type {
390
424
  IGitManagerTagRequest,
391
425
  IGitManagerTreeEntry,
392
426
  IGitManagerTreeRequest,
427
+ TGitManagerAtomicPushCredential,
393
428
  TGitManagerAtomicPushRefReason,
394
429
  TGitManagerAtomicPushRefStatus,
395
430
  TGitManagerDiffFileStatus,
@@ -450,7 +450,7 @@ export class RustGitManagerBridge extends plugins.EventEmitter {
450
450
  public async listRepositoryLfsPointers(
451
451
  requestArg: IGitManagerRepositorySelector,
452
452
  ): Promise<IGitManagerRepositoryLfsPointer[]> {
453
- return this.bridge.sendCommand('listRepositoryLfsPointers', requestArg);
453
+ return this.bridge.sendCommand('listRepositoryLfsPointers', requestArg).catch(rethrowCodedError);
454
454
  }
455
455
 
456
456
  public async mergeRepository(
@@ -1,3 +0,0 @@
1
- This project is dual-licensed under the Unlicense and MIT licenses.
2
-
3
- You may use this code under the terms of either license.
@@ -1,21 +0,0 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2015 Andrew Gallant
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
@@ -1,24 +0,0 @@
1
- This is free and unencumbered software released into the public domain.
2
-
3
- Anyone is free to copy, modify, publish, use, compile, sell, or
4
- distribute this software, either in source code form or as a compiled
5
- binary, for any purpose, commercial or non-commercial, and by any
6
- means.
7
-
8
- In jurisdictions that recognize copyright laws, the author or authors
9
- of this software dedicate any and all copyright interest in the
10
- software to the public domain. We make this dedication for the benefit
11
- of the public at large and to the detriment of our heirs and
12
- successors. We intend this dedication to be an overt act of
13
- relinquishment in perpetuity of all present and future rights to this
14
- software under copyright law.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
- IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
- OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
- ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
- OTHER DEALINGS IN THE SOFTWARE.
23
-
24
- For more information, please refer to <http://unlicense.org/>