diff --git a/CHANGELOG.md b/CHANGELOG.md index f087f778..e3db8409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 removing retired compatibility surfaces. - Upgraded `@git-stunts/git-cas` to `^6.1.0` so Git-backed state caches can use the library's crash-safe `RootSet` retention API. +- Moved shadow-trie leaf and branch storage from unretained raw Git blobs and + trees to bounded git-cas pages and composable bundle handles. Production + storage now shares one git-cas facade with the trie adapter, and the storage + ownership gate rejects direct raw Git object writers. ### Removed diff --git a/scripts/check-git-cas-invariants.sh b/scripts/check-git-cas-invariants.sh index 8f51b143..7b6409c0 100755 --- a/scripts/check-git-cas-invariants.sh +++ b/scripts/check-git-cas-invariants.sh @@ -1,10 +1,8 @@ #!/bin/bash -# check-git-cas-invariants.sh — Detect direct git storage CLI usage bypassing @git-stunts/git-cas. +# check-git-cas-invariants.sh — Detect direct Git object writes bypassing @git-stunts/git-cas. # -# git-warp mandates that ALL CAS operations MUST route through @git-stunts/git-cas. -# Direct invocation of raw git storage commands (git hash-object, git cat-file, etc.) -# is strictly banned to guarantee Buzhash Content-Defined Chunking (CDC) deduplication, -# constant-memory streaming, and CAS-First memoized materialization. +# git-warp mandates that CAS object creation route through @git-stunts/git-cas. +# Git history reads and ref operations remain owned by the history adapter. # # Runs as part of CI (strict-policy job) and can be run locally: # bash scripts/check-git-cas-invariants.sh @@ -18,33 +16,23 @@ SCAN_DIRS="src/" # Pattern 1: Raw object hashing — git hash-object # Bypasses Buzhash CDC chunking and streaming deduplication. # ───────────────────────────────────────────────────────────── -if grep -rn --include='*.ts' -E '\bgit hash-object\b' "$SCAN_DIRS"; then +if grep -rn --include='*.ts' -E "git[[:space:]]+hash-object|['\"]hash-object['\"]" "$SCAN_DIRS"; then echo "" - echo "::error::Detected raw git hash-object invocation. All CAS writes MUST use @git-stunts/git-cas cas.writeStream()." + echo "::error::Detected raw git hash-object invocation. Use @git-stunts/git-cas page or asset capabilities." EXIT_CODE=1 fi # ───────────────────────────────────────────────────────────── -# Pattern 2: Raw object inspection — git cat-file -# Bypasses streaming decompression and chunk reassembly. +# Pattern 2: Raw tree/pack writes — git mktree, git write-tree, git unpack-objects # ───────────────────────────────────────────────────────────── -if grep -rn --include='*.ts' -E '\bgit cat-file\b' "$SCAN_DIRS"; then - echo "" - echo "::error::Detected raw git cat-file invocation. All CAS reads MUST use @git-stunts/git-cas cas.readStream() or cas.has()." - EXIT_CODE=1 -fi - -# ───────────────────────────────────────────────────────────── -# Pattern 3: Raw tree/pack manipulation — git mktree, git write-tree, git unpack-objects -# ───────────────────────────────────────────────────────────── -if grep -rn --include='*.ts' -E '\bgit mktree\b|\bgit write-tree\b|\bgit unpack-objects\b' "$SCAN_DIRS"; then +if grep -rn --include='*.ts' -E "git[[:space:]]+(mktree|write-tree|unpack-objects)|['\"](mktree|write-tree|unpack-objects)['\"]" "$SCAN_DIRS"; then echo "" echo "::error::Detected raw git tree/pack manipulation. All structural storage operations MUST route through @git-stunts/git-cas." EXIT_CODE=1 fi if [ $EXIT_CODE -eq 0 ]; then - echo "✓ No raw git storage CLI violations detected. All CAS operations cleanly encapsulated in @git-stunts/git-cas." + echo "✓ No raw Git object-write violations detected. CAS object creation is owned by @git-stunts/git-cas." fi exit $EXIT_CODE diff --git a/scripts/source-size-gate.ts b/scripts/source-size-gate.ts index 28e3220b..b7b28108 100644 --- a/scripts/source-size-gate.ts +++ b/scripts/source-size-gate.ts @@ -66,7 +66,6 @@ export const SOURCE_SIZE_RELAXATIONS = Object.freeze([ 'test/unit/domain/services/controllers/SyncController.test.ts', 'test/unit/domain/services/strand/ConflictAnalyzerService.test.ts', 'test/unit/domain/services/strand/StrandService.test.ts', - 'test/unit/infrastructure/adapters/GitTrieStoreAdapter.test.ts', 'test/unit/scripts/visible-state-upgrade.test.ts', 'test/unit/specs/audit-receipt-vectors.test.ts', ]); diff --git a/src/domain/errors/TrieStoreError.ts b/src/domain/errors/TrieStoreError.ts index 7f7c5021..c42decd9 100644 --- a/src/domain/errors/TrieStoreError.ts +++ b/src/domain/errors/TrieStoreError.ts @@ -11,10 +11,10 @@ import WarpError, { type WarpErrorOptions } from "./WarpError.ts"; * * | Code | Meaning | * |-------------------------|-------------------------------------------------------| - * | `E_TRIE_STORE_READ` | A read call failed against the backing Git store. | - * | `E_TRIE_STORE_WRITE` | A write call failed against the backing Git store. | - * | `E_TRIE_STORE_MISSING` | The requested OID does not exist. | - * | `E_TRIE_STORE_CORRUPT` | The OID resolved but its bytes failed trie decoding. | + * | `E_TRIE_STORE_READ` | A read call failed against the backing store. | + * | `E_TRIE_STORE_WRITE` | A write call failed against the backing store. | + * | `E_TRIE_STORE_MISSING` | The requested root does not exist. | + * | `E_TRIE_STORE_CORRUPT` | The root resolved but failed trie decoding. | * * The default code is `E_TRIE_STORE_READ` because the most common * trie-store failure in practice is a read miss or a transport diff --git a/src/domain/orset/trie/TrieStorePort.ts b/src/domain/orset/trie/TrieStorePort.ts index a31794e5..39f95b53 100644 --- a/src/domain/orset/trie/TrieStorePort.ts +++ b/src/domain/orset/trie/TrieStorePort.ts @@ -55,25 +55,25 @@ import type { TrieBranchEntries } from "./TrieBranchEntries.ts"; */ export default interface TrieStorePort { /** - * Read a leaf blob's raw bytes by OID. + * Read a leaf page's raw bytes by opaque root handle. * * Throws `TrieStoreError` with code `E_TRIE_STORE_MISSING` if the - * OID does not exist, or `E_TRIE_STORE_READ` if the backing store + * root does not exist, or `E_TRIE_STORE_READ` if the backing store * fails for any other reason. */ - readLeaf(oid: string): Promise; + readLeaf(root: string): Promise; /** - * Read a branch tree's nibble-indexed child map by OID. + * Read a branch bundle's nibble-indexed child map by opaque root handle. * * Throws `TrieStoreError` with code `E_TRIE_STORE_MISSING` if the - * OID does not exist, `E_TRIE_STORE_CORRUPT` if the stored bytes - * fail branch decoding, or `E_TRIE_STORE_READ` otherwise. + * root does not exist, `E_TRIE_STORE_CORRUPT` if the stored bundle + * fails branch decoding, or `E_TRIE_STORE_READ` otherwise. */ - readBranch(oid: string): Promise; + readBranch(root: string): Promise; /** - * Write a leaf blob and return its content-addressed OID. + * Write a leaf page and return its content-addressed root handle. * * Throws `TrieStoreError` with code `E_TRIE_STORE_WRITE` if the * backing store rejects the write. @@ -81,8 +81,8 @@ export default interface TrieStorePort { writeLeaf(data: Uint8Array): Promise; /** - * Write a branch tree from its nibble-indexed child map and return - * its content-addressed OID. + * Write a branch bundle from its nibble-indexed child map and return + * its content-addressed root handle. * * Throws `TrieStoreError` with code `E_TRIE_STORE_WRITE` if the * backing store rejects the write. diff --git a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts index fb1b22ff..43c50a24 100644 --- a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts +++ b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts @@ -25,7 +25,7 @@ import { CborIndexStoreAdapter } from './CborIndexStoreAdapter.ts'; import { CborPatchJournalAdapter } from './CborPatchJournalAdapter.ts'; import { GitCasWarpStateCacheAdapter } from './GitCasWarpStateCacheAdapter.ts'; import type { GitCasRootSetClient } from './GitCasStateCacheRootSetCoordinator.ts'; -import GitTrieStoreAdapter from './GitTrieStoreAdapter.ts'; +import GitCasTrieStoreAdapter from './GitCasTrieStoreAdapter.ts'; import GitTrustChainAdapter from './GitTrustChainAdapter.ts'; import type { GitPlumbing } from './gitErrorClassification.ts'; import LoggerObservabilityBridge from './LoggerObservabilityBridge.ts'; @@ -44,7 +44,10 @@ export type GitCasFacade = Pick< | 'store' > & { readonly assets: Pick; - readonly bundles: Pick; + readonly bundles: Pick< + BundleCapability, + 'getMember' | 'putOrdered' | 'iterateMembers' + >; readonly caches: GitCasMaterializationFacade['caches']; readonly pages: GitCasMaterializationFacade['pages']; readonly publications: Pick; @@ -101,7 +104,7 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo indexes: this._createIndexStore(request, content), materializations: this._createMaterializationStore(request), stateSnapshots: this._createStateSnapshots(request), - trie: new GitTrieStoreAdapter({ plumbing: this._plumbing }), + trie: new GitCasTrieStoreAdapter({ cas: this._cas }), }) ); } diff --git a/src/infrastructure/adapters/GitCasTrieStoreAdapter.ts b/src/infrastructure/adapters/GitCasTrieStoreAdapter.ts new file mode 100644 index 00000000..548a1e30 --- /dev/null +++ b/src/infrastructure/adapters/GitCasTrieStoreAdapter.ts @@ -0,0 +1,243 @@ +import { + BundleHandle, + type BundleCapability, + type BundleMember, + type BundleMemberInput, + type PageCapability, +} from '@git-stunts/git-cas'; +import TrieStoreError from '../../domain/errors/TrieStoreError.ts'; +import type { TrieBranchEntries } from '../../domain/orset/trie/TrieBranchEntries.ts'; +import type TrieStorePort from '../../domain/orset/trie/TrieStorePort.ts'; +import { parseNibbleName } from './trieNibbleName.ts'; + +const BRANCH_PREFIX = 'children/'; +const LEAF_PATH = 'leaf/data'; +const MAX_TRIE_LEAF_BYTES = 16 * 1024 * 1024; + +const E_TRIE_STORE_READ = 'E_TRIE_STORE_READ'; +const E_TRIE_STORE_WRITE = 'E_TRIE_STORE_WRITE'; +const E_TRIE_STORE_MISSING = 'E_TRIE_STORE_MISSING'; +const E_TRIE_STORE_CORRUPT = 'E_TRIE_STORE_CORRUPT'; + +const MISSING_CODES = new Set([ + 'BUNDLE_NOT_FOUND', + 'HANDLE_TARGET_MISSING', + 'OBJECT_NOT_FOUND', +]); + +export type GitCasTrieFacade = { + readonly bundles: Pick< + BundleCapability, + 'getMember' | 'iterateMembers' | 'putOrdered' + >; + readonly pages: Pick; +}; + +/** Stores trie leaves and branches as composable git-cas bundle graphs. */ +export default class GitCasTrieStoreAdapter implements TrieStorePort { + readonly #cas: GitCasTrieFacade; + + constructor(options: { readonly cas: GitCasTrieFacade }) { + if ( + options === null || options === undefined || + options.cas === null || options.cas === undefined + ) { + throw new TrieStoreError('cas dependency is required', { + code: E_TRIE_STORE_WRITE, + }); + } + this.#cas = options.cas; + } + + async readLeaf(root: string): Promise { + const bundle = parseReadRoot(root); + let member: BundleMember | null; + try { + member = await this.#cas.bundles.getMember({ + handle: bundle, + path: LEAF_PATH, + }); + } catch (raw) { + throw readFailure(raw, 'read leaf bundle', root); + } + if (member === null) { + throw missingRoot('leaf', root); + } + if (member.handle.kind !== 'page') { + throw corruptRoot('leaf member is not a git-cas page', root); + } + try { + return await this.#cas.pages.get({ + handle: member.handle, + maxBytes: MAX_TRIE_LEAF_BYTES, + }); + } catch (raw) { + throw readFailure(raw, 'read leaf page', root); + } + } + + async readBranch(root: string): Promise { + const bundle = parseReadRoot(root); + const entries = new Map(); + try { + for await (const member of this.#cas.bundles.iterateMembers({ handle: bundle })) { + collectBranchMember(entries, member, root); + } + } catch (raw) { + if (raw instanceof TrieStoreError) { + throw raw; + } + throw readFailure(raw, 'read branch bundle', root); + } + return entries; + } + + async writeLeaf(data: Uint8Array): Promise { + try { + const page = await this.#cas.pages.put({ + source: data, + maxBytes: MAX_TRIE_LEAF_BYTES, + }); + const bundle = await this.#cas.bundles.putOrdered({ + members: [[LEAF_PATH, page.handle]], + }); + return bundle.handle.toString(); + } catch (raw) { + throw writeFailure(raw, 'write leaf'); + } + } + + async writeBranch(children: TrieBranchEntries): Promise { + const members = branchMembers(children); + try { + const bundle = await this.#cas.bundles.putOrdered({ members }); + return bundle.handle.toString(); + } catch (raw) { + throw writeFailure(raw, 'write branch'); + } + } +} + +function collectBranchMember( + entries: Map, + member: BundleMember, + root: string, +): void { + const name = branchMemberName(member.path, root); + if (member.handle.kind !== 'bundle') { + throw corruptRoot(`branch member ${name} is not a git-cas bundle`, root); + } + const nibble = parseNibbleName(name); + if (entries.has(nibble)) { + throw corruptRoot(`branch contains duplicate nibble ${name}`, root); + } + entries.set(nibble, member.handle.toString()); +} + +function branchMemberName(path: string, root: string): string { + if (!path.startsWith(BRANCH_PREFIX)) { + throw corruptRoot(`unexpected branch member path ${path}`, root); + } + const name = path.slice(BRANCH_PREFIX.length); + if (name.includes('/')) { + throw corruptRoot(`nested branch member path ${path}`, root); + } + return name; +} + +function branchMembers(children: TrieBranchEntries): Array<[string, BundleMemberInput]> { + const ordered = [...children].sort(([left], [right]) => left - right); + const width = nibbleNameWidth(ordered); + return ordered.map(([nibble, child]) => [ + `${BRANCH_PREFIX}${formatNibble(nibble, width)}`, + parseChildRoot(child), + ]); +} + +function nibbleNameWidth(entries: readonly (readonly [number, string])[]): number { + const largest = entries.at(-1)?.[0] ?? 0; + requireNibble(largest); + return Math.max(1, largest.toString(16).length); +} + +function formatNibble(nibble: number, width: number): string { + requireNibble(nibble); + return nibble.toString(16).padStart(width, '0'); +} + +function requireNibble(nibble: number): void { + if (Number.isSafeInteger(nibble) && nibble >= 0) { + return; + } + throw new TrieStoreError(`invalid branch nibble ${String(nibble)}`, { + code: E_TRIE_STORE_WRITE, + context: { nibble }, + }); +} + +function parseChildRoot(root: string): BundleHandle { + try { + return BundleHandle.parse(root); + } catch (raw) { + throw writeFailure(raw, 'parse child root'); + } +} + +function parseReadRoot(root: string): BundleHandle { + try { + return BundleHandle.parse(root); + } catch (raw) { + throw new TrieStoreError('trie root is not a git-cas bundle handle', { + code: E_TRIE_STORE_CORRUPT, + context: { operation: 'parse root', root, reason: errorMessage(raw) }, + }); + } +} + +function missingRoot(kind: 'leaf' | 'branch', root: string): TrieStoreError { + return new TrieStoreError(`trie ${kind} root is missing`, { + code: E_TRIE_STORE_MISSING, + context: { root }, + }); +} + +function corruptRoot(message: string, root: string): TrieStoreError { + return new TrieStoreError(message, { + code: E_TRIE_STORE_CORRUPT, + context: { root }, + }); +} + +function readFailure(raw: unknown, operation: string, root: string): TrieStoreError { + if (raw instanceof TrieStoreError) { + return raw; + } + const code = errorCode(raw); + return new TrieStoreError(`${operation} failed: ${errorMessage(raw)}`, { + code: code !== null && MISSING_CODES.has(code) + ? E_TRIE_STORE_MISSING + : E_TRIE_STORE_READ, + context: { operation, root, storageCode: code }, + }); +} + +function writeFailure(raw: unknown, operation: string): TrieStoreError { + if (raw instanceof TrieStoreError) { + return raw; + } + return new TrieStoreError(`${operation} failed: ${errorMessage(raw)}`, { + code: E_TRIE_STORE_WRITE, + context: { operation, storageCode: errorCode(raw) }, + }); +} + +function errorCode(raw: unknown): string | null { + if (!(raw instanceof Error) || !('code' in raw) || typeof raw.code !== 'string') { + return null; + } + return raw.code; +} + +function errorMessage(raw: unknown): string { + return raw instanceof Error ? raw.message : String(raw); +} diff --git a/src/infrastructure/adapters/GitTrieStoreAdapter.ts b/src/infrastructure/adapters/GitTrieStoreAdapter.ts deleted file mode 100644 index f71e1f97..00000000 --- a/src/infrastructure/adapters/GitTrieStoreAdapter.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * Git-backed adapter for {@link TrieStorePort}. - * - * Implements the shadow-trie ORSet's four storage methods against - * native Git objects: - * - * - `writeLeaf` / `readLeaf` -> Git blobs - * - `writeBranch` / `readBranch` -> Git trees - * - * Branch tree entries are named by nibble index in lowercase hex, - * zero-padded to the minimum width required to cover the largest - * nibble in the write-side map: - * - * | Fanout | Name width | Example names | - * |--------|------------|----------------------| - * | 2 | 1 | `0`, `1` | - * | 16 | 1 | `0`..`f` | - * | 64 | 2 | `00`..`3f` | - * | 256 | 2 | `00`..`ff` | - * - * The adapter does not know the trie geometry — it picks the - * minimum hex width at write time and decodes whatever width it - * finds at read time. Geometry enforcement belongs to the codec - * cycle. - * - * All failures are raised as {@link TrieStoreError} with a typed - * code. Raw `Error` is banned per anti-sludge policy; consumers - * `instanceof`-dispatch on `TrieStoreError` and branch on `code`. - * - * This adapter performs pure blob / tree object I/O. It does not - * create commits, does not update refs, and does not route through - * git-cas — per design 0018 git-cas carve-out, core trie - * publication stays on native Git. - * - * @see TrieStorePort - * @see TrieStoreError - */ -import type TrieStorePort from '../../domain/orset/trie/TrieStorePort.ts'; -import type { TrieBranchEntries } from '../../domain/orset/trie/TrieBranchEntries.ts'; -import TrieStoreError from '../../domain/errors/TrieStoreError.ts'; -import { - type GitPlumbing, - type GitError, - getExitCode, - gitDiagnosticText, - toGitError, -} from './gitErrorClassification.ts'; -import { parseNibbleName } from './trieNibbleName.ts'; - -// -- Error codes ------------------------------------------------------------- - -const E_TRIE_STORE_READ = 'E_TRIE_STORE_READ'; -const E_TRIE_STORE_WRITE = 'E_TRIE_STORE_WRITE'; -const E_TRIE_STORE_MISSING = 'E_TRIE_STORE_MISSING'; -const E_TRIE_STORE_CORRUPT = 'E_TRIE_STORE_CORRUPT'; - -// -- Git mode / type constants (native Git object encoding) ------------------ - -const BLOB_MODE = '100644'; -const TREE_MODE = '040000'; -const BLOB_TYPE = 'blob'; -const TREE_TYPE = 'tree'; - -// -- Missing-object detection (for disambiguating read errors) --------------- - -const MISSING_OBJECT_HINTS: readonly string[] = [ - 'bad object', - 'bad file', - 'not a valid object name', - 'does not point to a valid object', - 'missing object', - 'could not read', - 'not a tree object', -]; - -// -- Dependencies ------------------------------------------------------------ - -export interface GitTrieStoreAdapterDeps { - readonly plumbing: GitPlumbing; -} - -// -- Adapter ---------------------------------------------------------------- - -export default class GitTrieStoreAdapter implements TrieStorePort { - private readonly plumbing: GitPlumbing; - - constructor(deps: GitTrieStoreAdapterDeps) { - if (deps === null || deps === undefined) { - throw new TrieStoreError('plumbing dependency is required', { - code: E_TRIE_STORE_WRITE, - context: {}, - }); - } - this.plumbing = deps.plumbing; - } - - async readLeaf(oid: string): Promise { - return await this._readLeafBytes(oid); - } - - async writeLeaf(data: Uint8Array): Promise { - const input = Buffer.from(data.buffer, data.byteOffset, data.byteLength); - const out = await this._execWrite({ - args: ['hash-object', '-w', '-t', BLOB_TYPE, '--stdin'], - input, - }); - return out.trim(); - } - - async readBranch(oid: string): Promise { - const raw = await this._execReadString({ - args: ['ls-tree', '-z', oid], - oid, - }); - return parseBranchTreeOutput(raw); - } - - async writeBranch(children: TrieBranchEntries): Promise { - const width = nibbleNameWidth(children); - const lines: string[] = []; - for (const [nibble, childOid] of children) { - const kind = await this._probeObjectKind(childOid); - lines.push(mktreeLine({ nibble, width, childOid, kind })); - } - const input = lines.length === 0 ? '' : `${lines.join('\n')}\n`; - const out = await this._execWrite({ args: ['mktree'], input }); - return out.trim(); - } - - // -- Internal helpers ------------------------------------------------------ - - private async _readLeafBytes(oid: string): Promise { - try { - const stream = await this.plumbing.executeStream({ - args: ['cat-file', BLOB_TYPE, oid], - }); - const collected = await stream.collect({ - asString: false, - maxBytes: Number.POSITIVE_INFINITY, - }); - const bytes = bufferToUint8Array(collected); - if (bytes.length === 0) { - await this._assertObjectExists(oid); - } - return bytes; - } catch (raw) { - throw classifyReadFailure(raw, { oid }); - } - } - - private async _assertObjectExists(oid: string): Promise { - try { - await this.plumbing.execute({ args: ['cat-file', '-e', oid] }); - } catch (raw) { - // `cat-file -e` exits 1 with no stderr for a missing object — - // that silent exit carries no diagnostic text the pattern - // classifier can match, so short-circuit here. - if (isSilentMissing(raw)) { - throw new TrieStoreError(`Git object ${oid} does not exist`, { - code: E_TRIE_STORE_MISSING, - context: { oid }, - }); - } - throw classifyReadFailure(raw, { oid }); - } - } - - private async _execReadString(opts: { - args: string[]; - oid: string; - }): Promise { - try { - return await this.plumbing.execute({ args: opts.args }); - } catch (raw) { - throw classifyReadFailure(raw, { oid: opts.oid }); - } - } - - private async _execWrite(opts: { - args: string[]; - input: string | Buffer; - }): Promise { - try { - return await this.plumbing.execute({ args: opts.args, input: opts.input }); - } catch (raw) { - throw classifyWriteFailure(raw); - } - } - - private async _probeObjectKind( - childOid: string, - ): Promise { - let out: string; - try { - out = await this.plumbing.execute({ args: ['cat-file', '-t', childOid] }); - } catch (raw) { - throw classifyReadFailure(raw, { oid: childOid }); - } - const kind = out.trim(); - if (kind === BLOB_TYPE || kind === TREE_TYPE) { - return kind; - } - throw new TrieStoreError( - `child object ${childOid} has unsupported type "${kind}"`, - { - code: E_TRIE_STORE_WRITE, - context: { oid: childOid, type: kind }, - }, - ); - } -} - -// -- Branch tree encoding --------------------------------------------------- - -interface MktreeLineInput { - readonly nibble: number; - readonly width: number; - readonly childOid: string; - readonly kind: typeof BLOB_TYPE | typeof TREE_TYPE; -} - -function mktreeLine(input: MktreeLineInput): string { - const name = input.nibble.toString(16).padStart(input.width, '0'); - const mode = input.kind === BLOB_TYPE ? BLOB_MODE : TREE_MODE; - return `${mode} ${input.kind} ${input.childOid}\t${name}`; -} - -function nibbleNameWidth(children: TrieBranchEntries): number { - let maxNibble = 0; - for (const nibble of children.keys()) { - if (nibble > maxNibble) { - maxNibble = nibble; - } - } - const hexDigits = maxNibble === 0 ? 1 : Math.ceil(Math.log2(maxNibble + 1) / 4); - return Math.max(1, hexDigits); -} - -// -- Branch tree decoding --------------------------------------------------- - -function parseBranchTreeOutput(raw: string): TrieBranchEntries { - const entries = new Map(); - if (raw.length === 0) { - return entries; - } - for (const record of raw.split('\0')) { - if (record === '') { - continue; - } - const { nibble, childOid } = parseBranchTreeRecord(record); - entries.set(nibble, childOid); - } - return entries; -} - -function parseBranchTreeRecord(record: string): { - nibble: number; - childOid: string; -} { - const tabIndex = record.indexOf('\t'); - if (tabIndex === -1) { - throw new TrieStoreError( - `malformed ls-tree record: ${record}`, - { code: E_TRIE_STORE_CORRUPT, context: { record } }, - ); - } - const meta = record.slice(0, tabIndex); - const name = record.slice(tabIndex + 1); - const parts = meta.split(' '); - const childOid = parts[2]; - if (typeof childOid !== 'string' || childOid.length === 0) { - throw new TrieStoreError( - `ls-tree record missing OID: ${record}`, - { code: E_TRIE_STORE_CORRUPT, context: { record } }, - ); - } - const nibble = parseNibbleName(name); - return { nibble, childOid }; -} - -// -- Byte boundary helpers -------------------------------------------------- - -function bufferToUint8Array(collected: Buffer | string): Uint8Array { - if (typeof collected === 'string') { - return new TextEncoder().encode(collected); - } - return new Uint8Array( - collected.buffer.slice( - collected.byteOffset, - collected.byteOffset + collected.byteLength, - ), - ); -} - -// -- Error classification --------------------------------------------------- - -function classifyReadFailure( - raw: unknown, - hint: { readonly oid: string }, -): TrieStoreError { - const err = toGitError(raw); - if (err instanceof TrieStoreError) { - return err; - } - if (isMissingObject(err)) { - return new TrieStoreError(`Git object ${hint.oid} does not exist`, { - code: E_TRIE_STORE_MISSING, - context: { oid: hint.oid, cause: err.message }, - }); - } - return new TrieStoreError(`read failed for ${hint.oid}: ${err.message}`, { - code: E_TRIE_STORE_READ, - context: { oid: hint.oid, cause: err.message }, - }); -} - -function classifyWriteFailure(raw: unknown): TrieStoreError { - const err = toGitError(raw); - if (err instanceof TrieStoreError) { - return err; - } - return new TrieStoreError(`Git write failed: ${err.message}`, { - code: E_TRIE_STORE_WRITE, - context: { cause: err.message }, - }); -} - -function isMissingObject(err: GitError): boolean { - const code = getExitCode(err); - if (code !== 128 && code !== 1) { - return false; - } - const diag = gitDiagnosticText(err); - const msg = err.message.toLowerCase(); - const haystack = `${diag} ${msg}`; - return MISSING_OBJECT_HINTS.some((hint) => haystack.includes(hint)); -} - -function isSilentMissing(raw: unknown): boolean { - const err = toGitError(raw); - if (err instanceof TrieStoreError) { - return false; - } - if (getExitCode(err) !== 1) { - return false; - } - return gitDiagnosticText(err) === ''; -} diff --git a/src/infrastructure/adapters/trieNibbleName.ts b/src/infrastructure/adapters/trieNibbleName.ts index d288b66e..b3bd0d36 100644 --- a/src/infrastructure/adapters/trieNibbleName.ts +++ b/src/infrastructure/adapters/trieNibbleName.ts @@ -1,7 +1,7 @@ /** - * Nibble-name parser for `GitTrieStoreAdapter`'s branch tree reads. + * Nibble-name parser for trie storage adapter branch reads. * - * Branch tree entries in the shadow-trie ORSet are named by nibble + * Branch entries in the shadow-trie ORSet are named by nibble * index in lowercase hex, zero-padded to the minimum width required * by the branch's fanout. Valid names are non-empty strings * consisting only of lowercase hex characters (`0`-`9`, `a`-`f`). @@ -15,7 +15,7 @@ * error class and branch on `code` if they care which failure they * hit. * - * @see GitTrieStoreAdapter + * @see GitCasTrieStoreAdapter * @see TrieStoreError */ import TrieStoreError from '../../domain/errors/TrieStoreError.ts'; @@ -35,11 +35,14 @@ const LOWERCASE_HEX_NAME = /^[0-9a-f]+$/; export function parseNibbleName(name: string): number { assertNonEmpty(name); assertLowercaseHex(name); - // After the two guards above, `name` is a non-empty lowercase hex - // string. `Number.parseInt` over a hex digit-only string always - // yields a non-negative integer, so no further range check is - // needed — the regex already established the post-condition. - return Number.parseInt(name, 16); + const nibble = Number.parseInt(name, 16); + if (Number.isSafeInteger(nibble) && nibble >= 0) { + return nibble; + } + throw new TrieStoreError(`tree entry name "${name}" exceeds the safe nibble range`, { + code: E_TRIE_STORE_CORRUPT, + context: { name }, + }); } function assertNonEmpty(name: string): void { diff --git a/test/helpers/InMemoryGitCasFacade.ts b/test/helpers/InMemoryGitCasFacade.ts index 79d61dc6..818f741c 100644 --- a/test/helpers/InMemoryGitCasFacade.ts +++ b/test/helpers/InMemoryGitCasFacade.ts @@ -46,7 +46,7 @@ const ENCRYPTED_ASSET_NONCE_BYTES = 12; /** Minimal high-level git-cas facade used to exercise production adapters in memory. */ export default class InMemoryGitCasFacade { readonly assets: Pick; - readonly bundles: Pick; + readonly bundles: Pick; readonly caches: { open(options: { readonly namespace: string }): Promise>; }; @@ -75,6 +75,7 @@ export default class InMemoryGitCasFacade { }); this.bundles = Object.freeze({ putOrdered: async (request) => await this.#putBundle(request.members), + getMember: async (request) => await this.#getBundleMember(request.handle, request.path), iterateMembers: (request) => this.#iterateBundleMembers(request.handle), }); this.caches = Object.freeze({ @@ -239,6 +240,18 @@ export default class InMemoryGitCasFacade { } } + async #getBundleMember( + handleInput: BundleHandleInput, + path: string, + ): Promise { + for await (const member of this.#iterateBundleMembers(handleInput)) { + if (member.path === path) { + return member; + } + } + return null; + } + async #putPage( request: Parameters[0], ): Promise>> { diff --git a/test/integration/domain/orset/trie/TrieCursor.flush.integration.test.ts b/test/integration/domain/orset/trie/TrieCursor.flush.integration.test.ts index d3a0f7d4..7cbf161c 100644 --- a/test/integration/domain/orset/trie/TrieCursor.flush.integration.test.ts +++ b/test/integration/domain/orset/trie/TrieCursor.flush.integration.test.ts @@ -1,9 +1,9 @@ /** * Integration test: TrieCursor + TrieFlusher round-trip through a - * real Git repository via GitTrieStoreAdapter. + * real Git repository via GitCasTrieStoreAdapter. * - * Cursor writes pending OIDs into branch entries; flusher resolves - * those into real Git OIDs. A fresh cursor re-opened at the new + * Cursor writes pending roots into branch entries; flusher resolves + * those into git-cas bundle handles. A fresh cursor re-opened at the new * root must observe every element the source cursor wrote. */ import { mkdtemp, rm } from "node:fs/promises"; @@ -11,27 +11,20 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import ContentAddressableStore, { BundleHandle } from "@git-stunts/git-cas"; import Plumbing from "@git-stunts/plumbing"; import { Dot } from "../../../../../src/domain/crdt/Dot.ts"; -import GitTrieStoreAdapter from "../../../../../src/infrastructure/adapters/GitTrieStoreAdapter.ts"; +import GitCasTrieStoreAdapter from "../../../../../src/infrastructure/adapters/GitCasTrieStoreAdapter.ts"; import TrieCursor from "../../../../../src/domain/orset/trie/TrieCursor.ts"; import TrieFlusher from "../../../../../src/domain/orset/trie/TrieFlusher.ts"; import TrieGeometry from "../../../../../src/domain/orset/trie/TrieGeometry.ts"; import PageCache from "../../../../../src/domain/orset/trie/PageCache.ts"; import cborCodec from "../../../../../src/infrastructure/codecs/CborCodec.ts"; -interface PlumbingRuntime { - execute(opts: { args: string[]; input?: string | Buffer }): Promise; - executeStream(opts: { - args: string[]; - }): Promise<{ collect(opts: { asString: boolean; maxBytes?: number }): Promise }>; -} - interface Harness { readonly tempDir: string; - readonly plumbing: PlumbingRuntime; - readonly adapter: GitTrieStoreAdapter; + readonly adapter: GitCasTrieStoreAdapter; cleanup(): Promise; } @@ -46,10 +39,14 @@ async function createHarness(): Promise { await plumbing.execute({ args: ["init", "-q"] }); await plumbing.execute({ args: ["config", "user.email", "test@test.com"] }); await plumbing.execute({ args: ["config", "user.name", "Test"] }); - const adapter = new GitTrieStoreAdapter({ plumbing }); + const cas = ContentAddressableStore.createCbor({ + plumbing, + chunking: { strategy: "cdc" }, + applicationRefPrefixes: ["refs/warp/"], + }); + const adapter = new GitCasTrieStoreAdapter({ cas }); return { tempDir, - plumbing, adapter, async cleanup(): Promise { await rm(tempDir, { recursive: true, force: true }); @@ -61,7 +58,7 @@ async function createHarness(): Promise { } } -describe("TrieCursor + TrieFlusher integration (real Git)", () => { +describe("TrieCursor + TrieFlusher integration (real git-cas)", () => { let harness: Harness; beforeEach(async () => { @@ -122,12 +119,7 @@ describe("TrieCursor + TrieFlusher integration (real Git)", () => { const result = await flusher.flush(cursor.snapshot()); expect(result.rootOid).not.toBeNull(); - const rootType = ( - await harness.plumbing.execute({ - args: ["cat-file", "-t", result.rootOid ?? ""], - }) - ).trim(); - expect(rootType).toBe("tree"); + expect(BundleHandle.parse(result.rootOid ?? "").kind).toBe("bundle"); const replay = new TrieCursor({ rootOid: result.rootOid, diff --git a/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts b/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts index dea086c9..885fe873 100644 --- a/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts +++ b/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts @@ -12,6 +12,7 @@ import MaterializationCoordinate from '../../../../src/domain/materialization/Ma import MaterializationRoots from '../../../../src/domain/materialization/MaterializationRoots.ts'; import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; +import GitCasTrieStoreAdapter from '../../../../src/infrastructure/adapters/GitCasTrieStoreAdapter.ts'; import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; @@ -36,7 +37,8 @@ describe('GitCasMaterializationStoreAdapter integration', () => { frontier: new Map([['writer-a', 'a'.repeat(40)]]), ceiling: null, }); - const rootFixture = await createRoots(harness.cas); + const trieFixture = await createTrieRoot(harness.cas); + const rootFixture = await createRoots(harness.cas, trieFixture); const retained = await harness.materializations.retain({ coordinate, roots: rootFixture.roots, @@ -49,11 +51,21 @@ describe('GitCasMaterializationStoreAdapter integration', () => { reopenedCas, ); const resolved = await reopened.findExact(coordinate); + if (resolved === null) { + throw new Error('Retained materialization was not reopened'); + } + const reopenedTrie = new GitCasTrieStoreAdapter({ cas: reopenedCas }); + const children = await reopenedTrie.readBranch(resolved.roots.nodeAlive.toString()); + const child = children.get(0); + if (child === undefined) { + throw new Error('Retained trie root did not contain its leaf child'); + } const unreachable = await prunableOids(harness.path); - expect(resolved?.bundle.equals(retained.bundle)).toBe(true); - expect(resolved?.roots.entries().map(([name, handle]) => [name, handle.toString()])) + expect(resolved.bundle.equals(retained.bundle)).toBe(true); + expect(resolved.roots.entries().map(([name, handle]) => [name, handle.toString()])) .toEqual(rootFixture.roots.entries().map(([name, handle]) => [name, handle.toString()])); + expect(await reopenedTrie.readLeaf(child)).toEqual(trieFixture.bytes); expect(unreachable).not.toContain(GitCasBundleHandle.parse(retained.bundle.toString()).oid); for (const oid of rootFixture.retainedOids) { expect(unreachable).not.toContain(oid); @@ -111,13 +123,50 @@ async function createMaterializations( return services.materializations; } -async function createRoots(cas: ContentAddressableStore): Promise; + +async function createTrieRoot(cas: ContentAddressableStore): Promise { + const adapter = new GitCasTrieStoreAdapter({ cas }); + const bytes = new Uint8Array([7, 8, 9]); + const leafRoot = await adapter.writeLeaf(bytes); + const branchRoot = await adapter.writeBranch(new Map([[0, leafRoot]])); + const leafMember = await cas.bundles.getMember({ + handle: leafRoot, + path: 'leaf/data', + }); + if (leafMember === null || leafMember.handle.kind !== 'page') { + throw new Error('Trie integration fixture did not create a leaf page'); + } + return Object.freeze({ + bytes, + retainedOids: Object.freeze([ + GitCasBundleHandle.parse(branchRoot).oid, + GitCasBundleHandle.parse(leafRoot).oid, + leafMember.handle.oid, + ]), + root: new BundleHandle(branchRoot), + }); +} + +async function createRoots( + cas: ContentAddressableStore, + trie: TrieRootFixture, +): Promise> { const handles: BundleHandle[] = []; const retainedOids: string[] = []; for (let index = 0; index < 8; index += 1) { + if (index === 4) { + handles.push(trie.root); + retainedOids.push(...trie.retainedOids); + continue; + } const page = await cas.pages.put({ source: new Uint8Array([index]) }); const bundle = await cas.bundles.put({ members: { root: page.handle } }); handles.push(new BundleHandle(bundle.handle.toString())); diff --git a/test/integration/infrastructure/adapters/GitCasTrieStoreAdapter.integration.test.ts b/test/integration/infrastructure/adapters/GitCasTrieStoreAdapter.integration.test.ts new file mode 100644 index 00000000..56059205 --- /dev/null +++ b/test/integration/infrastructure/adapters/GitCasTrieStoreAdapter.integration.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import ContentAddressableStore, { + BundleHandle, + PageHandle, +} from '@git-stunts/git-cas'; +import Plumbing from '@git-stunts/plumbing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import GitCasTrieStoreAdapter from '../../../../src/infrastructure/adapters/GitCasTrieStoreAdapter.ts'; + +describe('GitCasTrieStoreAdapter integration', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await rm(harness.path, { recursive: true, force: true }); + }); + + it('stores leaves as pages wrapped by bundle roots', async () => { + const bytes = new Uint8Array([0, 1, 2, 255]); + const root = await harness.adapter.writeLeaf(bytes); + const bundle = BundleHandle.parse(root); + const member = await harness.cas.bundles.getMember({ + handle: bundle, + path: 'leaf/data', + }); + if (member === null || member.handle.kind !== 'page') { + throw new Error('Trie leaf bundle did not contain a page member'); + } + + expect(member.handle.kind).toBe('page'); + expect(PageHandle.from(member.handle)).toBeInstanceOf(PageHandle); + expect(await harness.adapter.readLeaf(root)).toEqual(bytes); + }); + + it('stores branches as bundles of child bundle handles', async () => { + const left = await harness.adapter.writeLeaf(new Uint8Array([1])); + const right = await harness.adapter.writeLeaf(new Uint8Array([2])); + const root = await harness.adapter.writeBranch(new Map([ + [0, left], + [15, right], + ])); + + expect([...await harness.adapter.readBranch(root)]).toEqual([ + [0, left], + [15, right], + ]); + expect((await harness.cas.bundles.getMember({ + handle: root, + path: 'children/0', + }))?.handle.kind).toBe('bundle'); + }); + + it('round-trips an empty branch bundle', async () => { + const root = await harness.adapter.writeBranch(new Map()); + + expect(await harness.adapter.readBranch(root)).toEqual(new Map()); + }); +}); + +type Harness = Readonly<{ + adapter: GitCasTrieStoreAdapter; + cas: ContentAddressableStore; + path: string; +}>; + +async function createHarness(): Promise { + const path = await mkdtemp(join(tmpdir(), 'git-warp-cas-trie-')); + const plumbing = await Plumbing.createDefault({ cwd: path }); + await plumbing.execute({ args: ['init', '-q'] }); + const cas = ContentAddressableStore.createCbor({ + plumbing, + chunking: { strategy: 'cdc' }, + applicationRefPrefixes: ['refs/warp/'], + }); + return Object.freeze({ + adapter: new GitCasTrieStoreAdapter({ cas }), + cas, + path, + }); +} diff --git a/test/integration/infrastructure/adapters/GitTrieStoreAdapter.integration.test.ts b/test/integration/infrastructure/adapters/GitTrieStoreAdapter.integration.test.ts deleted file mode 100644 index f72b5314..00000000 --- a/test/integration/infrastructure/adapters/GitTrieStoreAdapter.integration.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Integration tests for GitTrieStoreAdapter against a real Git repo. - * - * Spins up a temp directory, `git init`s it, and drives the adapter - * through the real `@git-stunts/plumbing` runtime. Validates that - * written leaves are recognisable as Git blobs by `git cat-file blob` - * and that written branches are recognisable as Git trees by - * `git ls-tree`. - */ -import { mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import Plumbing from '@git-stunts/plumbing'; - -import GitTrieStoreAdapter from '../../../../src/infrastructure/adapters/GitTrieStoreAdapter.ts'; -import TrieStoreError from '../../../../src/domain/errors/TrieStoreError.ts'; -import type { TrieBranchEntries } from '../../../../src/domain/orset/trie/TrieBranchEntries.ts'; - -interface PlumbingRuntime { - execute(opts: { args: string[]; input?: string | Buffer }): Promise; -} - -interface HarnessContext { - readonly tempDir: string; - readonly plumbing: PlumbingRuntime; - readonly adapter: GitTrieStoreAdapter; - cleanup(): Promise; -} - -async function createHarness(): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'warp-trie-store-adapter-')); - try { - const plumbing = await Plumbing.createDefault({ cwd: tempDir }); - await plumbing.execute({ args: ['init', '-q'] }); - await plumbing.execute({ args: ['config', 'user.email', 'test@test.com'] }); - await plumbing.execute({ args: ['config', 'user.name', 'Test'] }); - const adapter = new GitTrieStoreAdapter({ plumbing }); - return { - tempDir, - plumbing, - adapter, - async cleanup(): Promise { - await rm(tempDir, { recursive: true, force: true }); - }, - }; - } catch (err) { - await rm(tempDir, { recursive: true, force: true }); - throw err; - } -} - -describe('GitTrieStoreAdapter integration', () => { - let harness: HarnessContext; - - beforeEach(async () => { - harness = await createHarness(); - }); - - afterEach(async () => { - await harness.cleanup(); - }); - - describe('writeLeaf produces real Git blobs', () => { - it('yields a blob recognisable by `git cat-file -t` and `git cat-file blob`', async () => { - const bytes = new TextEncoder().encode('leaf-payload'); - const oid = await harness.adapter.writeLeaf(bytes); - - const type = await harness.plumbing.execute({ - args: ['cat-file', '-t', oid], - }); - expect(type.trim()).toBe('blob'); - - const contents = await harness.plumbing.execute({ - args: ['cat-file', 'blob', oid], - }); - expect(contents).toBe('leaf-payload'); - }); - - it('returns the same bytes when reading back through the adapter', async () => { - const bytes = new Uint8Array([0, 1, 2, 3, 255, 128]); - const oid = await harness.adapter.writeLeaf(bytes); - const read = await harness.adapter.readLeaf(oid); - expect(Array.from(read)).toEqual(Array.from(bytes)); - }); - - it('produces a stable OID — repeated writes of the same bytes return the same OID', async () => { - const bytes = new TextEncoder().encode('deterministic'); - const oid1 = await harness.adapter.writeLeaf(bytes); - const oid2 = await harness.adapter.writeLeaf(bytes); - expect(oid1).toBe(oid2); - }); - }); - - describe('writeBranch produces real Git trees', () => { - it('yields a tree recognisable by `git cat-file -t` and `git ls-tree`', async () => { - const leafA = await harness.adapter.writeLeaf(new TextEncoder().encode('a')); - const leafB = await harness.adapter.writeLeaf(new TextEncoder().encode('b')); - const children: TrieBranchEntries = new Map([ - [0, leafA], - [15, leafB], - ]); - const treeOid = await harness.adapter.writeBranch(children); - - const type = await harness.plumbing.execute({ - args: ['cat-file', '-t', treeOid], - }); - expect(type.trim()).toBe('tree'); - - const listing = await harness.plumbing.execute({ - args: ['ls-tree', treeOid], - }); - expect(listing).toMatch(/100644 blob [0-9a-f]{40}\t0(\n|$)/); - expect(listing).toMatch(/100644 blob [0-9a-f]{40}\tf(\n|$)/); - }); - - it('round-trips a 16-way sparse branch through the adapter', async () => { - const oids: Record = {}; - const children = new Map(); - for (const nibble of [0, 3, 7, 11, 15]) { - const oid = await harness.adapter.writeLeaf(new Uint8Array([nibble])); - oids[nibble] = oid; - children.set(nibble, oid); - } - const treeOid = await harness.adapter.writeBranch(children); - const read = await harness.adapter.readBranch(treeOid); - expect(read.size).toBe(5); - for (const nibble of [0, 3, 7, 11, 15]) { - expect(read.get(nibble)).toBe(oids[nibble]); - } - }); - - it('round-trips a wide 256-way branch through the adapter', async () => { - const children = new Map(); - for (let i = 0; i < 256; i += 1) { - const oid = await harness.adapter.writeLeaf( - new Uint8Array([i & 0xff, (i >> 8) & 0xff]), - ); - children.set(i, oid); - } - const treeOid = await harness.adapter.writeBranch(children); - const read = await harness.adapter.readBranch(treeOid); - expect(read.size).toBe(256); - expect(read.get(0)).toBe(children.get(0)); - expect(read.get(128)).toBe(children.get(128)); - expect(read.get(255)).toBe(children.get(255)); - }); - - it('branch-of-branches-of-leaves forms a valid Git tree hierarchy', async () => { - // Build a two-level tree: - // rootTree -> [ 0 => innerTree, 1 => leafOuter ] - // innerTree -> [ 2 => leafInnerA, 3 => leafInnerB ] - const leafInnerA = await harness.adapter.writeLeaf(new TextEncoder().encode('inner-a')); - const leafInnerB = await harness.adapter.writeLeaf(new TextEncoder().encode('inner-b')); - const innerTree = await harness.adapter.writeBranch( - new Map([ - [2, leafInnerA], - [3, leafInnerB], - ]), - ); - const leafOuter = await harness.adapter.writeLeaf(new TextEncoder().encode('outer')); - const rootTree = await harness.adapter.writeBranch( - new Map([ - [0, innerTree], - [1, leafOuter], - ]), - ); - - // Root level: has one tree entry (0) and one blob entry (1). - const rootListing = await harness.plumbing.execute({ - args: ['ls-tree', rootTree], - }); - expect(rootListing).toMatch(/040000 tree [0-9a-f]{40}\t0(\n|$)/); - expect(rootListing).toMatch(/100644 blob [0-9a-f]{40}\t1(\n|$)/); - - // Inner level: two blob entries (2 and 3). - const innerListing = await harness.plumbing.execute({ - args: ['ls-tree', innerTree], - }); - expect(innerListing).toMatch(/100644 blob [0-9a-f]{40}\t2(\n|$)/); - expect(innerListing).toMatch(/100644 blob [0-9a-f]{40}\t3(\n|$)/); - - // Adapter can walk the hierarchy end-to-end. - const readRoot = await harness.adapter.readBranch(rootTree); - expect(readRoot.get(0)).toBe(innerTree); - expect(readRoot.get(1)).toBe(leafOuter); - const readInner = await harness.adapter.readBranch( - readRoot.get(0) ?? 'missing', - ); - expect(readInner.get(2)).toBe(leafInnerA); - expect(readInner.get(3)).toBe(leafInnerB); - - // git cat-file -p on the root walks the tree naturally. - const rootPretty = await harness.plumbing.execute({ - args: ['cat-file', '-p', rootTree], - }); - expect(rootPretty).toMatch(/040000 tree [0-9a-f]{40}\t0(\n|$)/); - expect(rootPretty).toMatch(/100644 blob [0-9a-f]{40}\t1(\n|$)/); - }); - - it('raises E_TRIE_STORE_MISSING when readBranch is asked for a nonexistent OID', async () => { - try { - await harness.adapter.readBranch( - '0000000000000000000000000000000000000000', - ); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - } - } - }); - - it('raises E_TRIE_STORE_MISSING when readLeaf is asked for a nonexistent OID', async () => { - try { - await harness.adapter.readLeaf( - '0000000000000000000000000000000000000000', - ); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - } - } - }); - }); -}); diff --git a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts index 3719940b..5aa6714e 100644 --- a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts @@ -9,6 +9,7 @@ import { TrustRecord } from '../../../../src/domain/trust/TrustRecord.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; import GitCasMaterializationStoreAdapter from '../../../../src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts'; +import GitCasTrieStoreAdapter from '../../../../src/infrastructure/adapters/GitCasTrieStoreAdapter.ts'; import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; @@ -161,6 +162,7 @@ describe('GitCasRepositoryAdapter', () => { state: WarpState.empty(), }); expect(services.materializations).toBeInstanceOf(GitCasMaterializationStoreAdapter); + expect(services.trie).toBeInstanceOf(GitCasTrieStoreAdapter); plumbing.execute .mockResolvedValueOnce('f'.repeat(40)) diff --git a/test/unit/infrastructure/adapters/GitCasTrieStoreAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasTrieStoreAdapter.test.ts new file mode 100644 index 00000000..db7d8f11 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasTrieStoreAdapter.test.ts @@ -0,0 +1,206 @@ +import { BundleHandle } from '@git-stunts/git-cas'; +import { beforeEach, describe, expect, it } from 'vitest'; +import GitCasTrieStoreAdapter, { + type GitCasTrieFacade, +} from '../../../../src/infrastructure/adapters/GitCasTrieStoreAdapter.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; + +describe('GitCasTrieStoreAdapter', () => { + let adapter: GitCasTrieStoreAdapter; + let cas: InMemoryGitCasFacade; + + beforeEach(() => { + cas = new InMemoryGitCasFacade({ + history: new InMemoryGraphAdapter(), + storage: new InMemoryBlobStorageAdapter(), + }); + adapter = new GitCasTrieStoreAdapter({ cas }); + }); + + it('round-trips deterministic leaf bundle roots', async () => { + const bytes = new Uint8Array([0, 1, 2, 255]); + + const first = await adapter.writeLeaf(bytes); + const second = await adapter.writeLeaf(bytes); + + expect(BundleHandle.parse(first).kind).toBe('bundle'); + expect(second).toBe(first); + expect(await adapter.readLeaf(first)).toEqual(bytes); + await expect(adapter.readBranch(first)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + }); + + it('round-trips deterministic, nibble-sorted branch bundles', async () => { + const left = await adapter.writeLeaf(new Uint8Array([1])); + const right = await adapter.writeLeaf(new Uint8Array([2])); + + const root = await adapter.writeBranch(new Map([ + [15, right], + [0, left], + ])); + const reordered = await adapter.writeBranch(new Map([ + [0, left], + [15, right], + ])); + + expect(reordered).toBe(root); + expect([...await adapter.readBranch(root)]).toEqual([ + [0, left], + [15, right], + ]); + await expect(adapter.readLeaf(root)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_MISSING', + }); + }); + + it('maps an absent bundle to a typed missing-root error', async () => { + const stored = BundleHandle.parse( + await adapter.writeLeaf(new Uint8Array([1])), + ); + const missing = new BundleHandle({ + codec: stored.codec, + hashAlgorithm: stored.hashAlgorithm, + oid: 'f'.repeat(stored.oid.length), + }).toString(); + + await expect(adapter.readLeaf(missing)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_MISSING', + }); + await expect(adapter.readBranch(missing)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_MISSING', + }); + }); + + it('rejects raw OIDs and malformed child roots', async () => { + await expect(adapter.readBranch('a'.repeat(40))).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + await expect(adapter.writeBranch(new Map([[0, 'a'.repeat(40)]]))) + .rejects.toMatchObject({ code: 'E_TRIE_STORE_WRITE' }); + }); + + it('rejects branch members that do not reference bundle roots', async () => { + const leaf = await adapter.writeLeaf(new Uint8Array([1])); + const branch = await adapter.writeBranch(new Map([[0, leaf]])); + const page = requireMember(leaf, 'leaf/data'); + cas.replaceBundleMembers(branch, [['children/0', page]]); + + await expect(adapter.readBranch(branch)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + }); + + it('rejects leaf members that do not reference pages', async () => { + const leaf = await adapter.writeLeaf(new Uint8Array([1])); + cas.replaceBundleMembers(leaf, [['leaf/data', leaf]]); + + await expect(adapter.readLeaf(leaf)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + }); + + it('rejects malformed and duplicate branch nibble paths', async () => { + const leaf = await adapter.writeLeaf(new Uint8Array([1])); + const branch = await adapter.writeBranch(new Map([[0, leaf]])); + + cas.replaceBundleMembers(branch, [['children/G', leaf]]); + await expect(adapter.readBranch(branch)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + + cas.replaceBundleMembers(branch, [['children/', leaf]]); + await expect(adapter.readBranch(branch)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + + cas.replaceBundleMembers(branch, [['children/0/nested', leaf]]); + await expect(adapter.readBranch(branch)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + + cas.replaceBundleMembers(branch, [ + ['children/0', leaf], + ['children/00', leaf], + ]); + await expect(adapter.readBranch(branch)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + + cas.replaceBundleMembers(branch, [[`children/${'f'.repeat(400)}`, leaf]]); + await expect(adapter.readBranch(branch)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_CORRUPT', + }); + }); + + it('maps git-cas page failures to typed read and write errors', async () => { + const leaf = await adapter.writeLeaf(new Uint8Array([1])); + const readFailure = new GitCasTrieStoreAdapter({ + cas: failingPages(cas, 'read'), + }); + const writeFailure = new GitCasTrieStoreAdapter({ + cas: failingPages(cas, 'write'), + }); + + await expect(readFailure.readLeaf(leaf)).rejects.toMatchObject({ + code: 'E_TRIE_STORE_READ', + }); + await expect(writeFailure.writeLeaf(new Uint8Array([2]))) + .rejects.toMatchObject({ code: 'E_TRIE_STORE_WRITE' }); + }); + + it('maps git-cas branch writes to typed errors and rejects invalid nibbles', async () => { + const leaf = await adapter.writeLeaf(new Uint8Array([1])); + const failing = new GitCasTrieStoreAdapter({ cas: failingBundleWrites(cas) }); + + await expect(failing.writeBranch(new Map([[0, leaf]]))).rejects.toMatchObject({ + code: 'E_TRIE_STORE_WRITE', + }); + await expect(adapter.writeBranch(new Map([[-1, leaf]]))).rejects.toMatchObject({ + code: 'E_TRIE_STORE_WRITE', + }); + }); + + it('requires a git-cas dependency at runtime', () => { + expect(() => Reflect.construct(GitCasTrieStoreAdapter, [null])) + .toThrow(expect.objectContaining({ code: 'E_TRIE_STORE_WRITE' })); + }); + + function requireMember(root: string, path: string): string { + const member = cas.readBundleMembers(root).find(([candidate]) => candidate === path); + if (member === undefined) { + throw new Error(`Missing test bundle member ${path}`); + } + return member[1]; + } +}); + +function failingPages( + cas: InMemoryGitCasFacade, + operation: 'read' | 'write', +): GitCasTrieFacade { + return { + bundles: cas.bundles, + pages: { + get: operation === 'read' + ? async () => { throw new Error('page read unavailable'); } + : cas.pages.get, + put: operation === 'write' + ? async () => { throw new Error('page write unavailable'); } + : cas.pages.put, + }, + }; +} + +function failingBundleWrites(cas: InMemoryGitCasFacade): GitCasTrieFacade { + return { + bundles: { + getMember: cas.bundles.getMember, + iterateMembers: cas.bundles.iterateMembers, + putOrdered: async () => { throw new Error('bundle write unavailable'); }, + }, + pages: cas.pages, + }; +} diff --git a/test/unit/infrastructure/adapters/GitTrieStoreAdapter.test.ts b/test/unit/infrastructure/adapters/GitTrieStoreAdapter.test.ts deleted file mode 100644 index 2ad7de30..00000000 --- a/test/unit/infrastructure/adapters/GitTrieStoreAdapter.test.ts +++ /dev/null @@ -1,891 +0,0 @@ -/** - * Unit tests for GitTrieStoreAdapter. - * - * Uses an inline in-memory `GitPlumbing` double that emulates just - * enough of git plumbing to exercise leaf/branch round-trips and the - * four TrieStoreError codes. No real git subprocess runs here — the - * integration suite covers that. - */ -import { describe, it, expect, beforeEach } from 'vitest'; - -import GitTrieStoreAdapter from '../../../../src/infrastructure/adapters/GitTrieStoreAdapter.ts'; -import TrieStoreError from '../../../../src/domain/errors/TrieStoreError.ts'; -import type { TrieBranchEntries } from '../../../../src/domain/orset/trie/TrieBranchEntries.ts'; -import type { - CollectableStream, - GitPlumbing, -} from '../../../../src/infrastructure/adapters/gitErrorClassification.ts'; - -// -- In-memory plumbing double ---------------------------------------------- - -class MissingObjectError extends Error { - readonly exitCode = 128; - readonly details: { readonly stderr: string; readonly code: number }; - - constructor(oid: string) { - super(`fatal: Not a valid object name ${oid}`); - this.details = { - stderr: `fatal: Not a valid object name ${oid}`, - code: 128, - }; - } -} - -class OpaqueGitError extends Error { - readonly exitCode = 128; - readonly details: { readonly stderr: string; readonly code: number }; - - constructor(message: string) { - super(message); - this.details = { stderr: message, code: 128 }; - } -} - -interface StoredBranchEntry { - readonly nibbleName: string; - readonly childOid: string; - readonly kind: 'blob' | 'tree'; -} - -interface CorruptEntry { - readonly rawName: string; - readonly childOid: string; - readonly kind: 'blob' | 'tree'; -} - -class InMemoryGitPlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - private readonly blobs = new Map(); - private readonly trees = new Map(); - private readonly corruptTrees = new Map(); - private nextOid = 1; - public lastMktreeInput = ''; - - async execute(opts: { - args: string[]; - input?: string | Buffer; - }): Promise { - const [cmd, ...rest] = opts.args; - switch (cmd) { - case 'hash-object': - return this._handleHashObject(opts.input); - case 'mktree': - return this._handleMktree(opts.input); - case 'cat-file': - return this._handleCatFile(rest); - case 'ls-tree': - return this._handleLsTree(rest); - default: - throw new OpaqueGitError(`unsupported command: ${cmd ?? ''}`); - } - } - - async executeStream(opts: { args: string[] }): Promise { - const [cmd, sub, oid] = opts.args; - if (cmd === 'cat-file' && sub === 'blob' && typeof oid === 'string') { - const blob = this.blobs.get(oid); - if (!blob) { - return makeStream(new Uint8Array(0)); - } - return makeStream(blob); - } - throw new OpaqueGitError(`unsupported stream command: ${opts.args.join(' ')}`); - } - - putBlobRaw(bytes: Uint8Array): string { - return this._storeBlob(bytes); - } - - putTreeRaw(entries: readonly StoredBranchEntry[]): string { - const oid = this._fabricateOid('tree'); - this.trees.set(oid, entries); - return oid; - } - - putCorruptTree(entries: readonly CorruptEntry[]): string { - const oid = this._fabricateOid('tree'); - this.corruptTrees.set(oid, entries); - return oid; - } - - private _handleHashObject(input?: string | Buffer): string { - if (!Buffer.isBuffer(input)) { - throw new OpaqueGitError('hash-object requires Buffer input'); - } - return this._storeBlob(new Uint8Array(input)); - } - - private _handleMktree(input?: string | Buffer): string { - const raw = typeof input === 'string' ? input : (input?.toString('utf8') ?? ''); - this.lastMktreeInput = raw; - const entries: StoredBranchEntry[] = []; - for (const line of raw.split('\n')) { - if (line === '') { - continue; - } - const tab = line.indexOf('\t'); - if (tab === -1) { - throw new OpaqueGitError(`malformed mktree input: ${line}`); - } - const meta = line.slice(0, tab).split(' '); - const nibbleName = line.slice(tab + 1); - const [, type, childOid] = meta; - if ((type !== 'blob' && type !== 'tree') || typeof childOid !== 'string') { - throw new OpaqueGitError(`bad mktree entry: ${line}`); - } - entries.push({ nibbleName, childOid, kind: type }); - } - entries.sort((a, b) => (a.nibbleName < b.nibbleName ? -1 : a.nibbleName > b.nibbleName ? 1 : 0)); - const oid = this._fabricateOid('tree'); - this.trees.set(oid, entries); - return oid; - } - - private _handleCatFile(rest: readonly string[]): string { - const [flag, oid] = rest; - if (flag === '-t' && typeof oid === 'string') { - if (this.blobs.has(oid)) { - return 'blob\n'; - } - if (this.trees.has(oid) || this.corruptTrees.has(oid)) { - return 'tree\n'; - } - throw new MissingObjectError(oid); - } - if (flag === '-e' && typeof oid === 'string') { - if (this.blobs.has(oid) || this.trees.has(oid) || this.corruptTrees.has(oid)) { - return ''; - } - throw new MissingObjectError(oid); - } - throw new OpaqueGitError(`unsupported cat-file flags: ${rest.join(' ')}`); - } - - private _handleLsTree(rest: readonly string[]): string { - const oid = rest[rest.length - 1]; - if (typeof oid !== 'string') { - throw new OpaqueGitError('ls-tree missing oid'); - } - const corrupt = this.corruptTrees.get(oid); - if (corrupt) { - return formatCorruptLsTreeOutput(corrupt); - } - const entries = this.trees.get(oid); - if (!entries) { - throw new MissingObjectError(oid); - } - return formatLsTreeOutput(entries); - } - - private _storeBlob(bytes: Uint8Array): string { - const existing = this._findBlobOid(bytes); - if (existing !== null) { - return existing; - } - const oid = this._fabricateOid('blob'); - this.blobs.set(oid, bytes); - return oid; - } - - private _findBlobOid(bytes: Uint8Array): string | null { - for (const [oid, stored] of this.blobs) { - if (bytesEqual(stored, bytes)) { - return oid; - } - } - return null; - } - - private _fabricateOid(tag: 'blob' | 'tree'): string { - const n = this.nextOid; - this.nextOid += 1; - const hex = n.toString(16).padStart(8, '0'); - return `${tag === 'blob' ? 'bb' : 'tt'}${'0'.repeat(30)}${hex}`.slice(0, 40); - } -} - -function makeStream(bytes: Uint8Array): CollectableStream { - return { - [Symbol.asyncIterator](): AsyncIterator { - let done = false; - return { - next(): Promise> { - if (done) { - return Promise.resolve({ value: undefined, done: true }); - } - done = true; - return Promise.resolve({ value: bytes, done: false }); - }, - }; - }, - async collect(collectOpts?: { asString?: boolean; maxBytes?: number }): Promise { - if (collectOpts?.asString === true) { - return Buffer.from(bytes).toString('utf8'); - } - return Buffer.from(bytes); - }, - }; -} - -function formatLsTreeOutput(entries: readonly StoredBranchEntry[]): string { - // Real `ls-tree -z` NUL-terminates every record, including the last. - // Emulate that so the empty-tail-record branch in the adapter's - // parser is exercised. - return entries - .map((e) => `${e.kind === 'blob' ? '100644' : '040000'} ${e.kind} ${e.childOid}\t${e.nibbleName}\0`) - .join(''); -} - -function formatCorruptLsTreeOutput(entries: readonly CorruptEntry[]): string { - return entries - .map((e) => `${e.kind === 'blob' ? '100644' : '040000'} ${e.kind} ${e.childOid}\t${e.rawName}\0`) - .join(''); -} - -function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -} - -// -- Test suite ------------------------------------------------------------- - -describe('GitTrieStoreAdapter', () => { - let plumbing: InMemoryGitPlumbing; - let adapter: GitTrieStoreAdapter; - - beforeEach(() => { - plumbing = new InMemoryGitPlumbing(); - adapter = new GitTrieStoreAdapter({ plumbing }); - }); - - describe('constructor', () => { - it('rejects a null deps payload with a typed TrieStoreError', () => { - // Simulate a bad-faith caller who passes null at the JS boundary. - // We funnel through `unknown` and a factory so the type system - // only sees a typed constructor call at the edge. - const makeWithNull = (): GitTrieStoreAdapter => { - const factory = GitTrieStoreAdapter as unknown as new ( - deps: unknown, - ) => GitTrieStoreAdapter; - return new factory(null); - }; - expect(makeWithNull).toThrow(TrieStoreError); - }); - }); - - describe('writeLeaf / readLeaf round-trip', () => { - it('round-trips an empty leaf', async () => { - const oid = await adapter.writeLeaf(new Uint8Array()); - const read = await adapter.readLeaf(oid); - expect(read).toEqual(new Uint8Array()); - }); - - it('round-trips a single-byte leaf', async () => { - const oid = await adapter.writeLeaf(new Uint8Array([42])); - const read = await adapter.readLeaf(oid); - expect(read.length).toBe(1); - expect(read[0]).toBe(42); - }); - - it('round-trips a multi-byte leaf with binary content', async () => { - const payload = new Uint8Array([0, 1, 2, 255, 128, 64, 32, 0]); - const oid = await adapter.writeLeaf(payload); - const read = await adapter.readLeaf(oid); - expect(read).toEqual(payload); - }); - - it('round-trips a 1 MiB leaf', async () => { - const big = new Uint8Array(1024 * 1024); - for (let i = 0; i < big.length; i += 1) { - big[i] = (i * 31) & 0xff; - } - const oid = await adapter.writeLeaf(big); - const read = await adapter.readLeaf(oid); - expect(read.byteLength).toBe(big.byteLength); - expect(read[0]).toBe(big[0]); - expect(read[big.length - 1]).toBe(big[big.length - 1]); - }); - - it('raises E_TRIE_STORE_MISSING when the OID does not exist', async () => { - await expect(adapter.readLeaf('deadbeef')).rejects.toBeInstanceOf(TrieStoreError); - try { - await adapter.readLeaf('deadbeef'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - expect(err.context['oid']).toBe('deadbeef'); - } - } - }); - }); - - describe('writeBranch / readBranch round-trip', () => { - it('round-trips a 2-way branch', async () => { - const leafA = await adapter.writeLeaf(new Uint8Array([1])); - const leafB = await adapter.writeLeaf(new Uint8Array([2])); - const children: TrieBranchEntries = new Map([ - [0, leafA], - [1, leafB], - ]); - const branchOid = await adapter.writeBranch(children); - const read = await adapter.readBranch(branchOid); - expect(read.size).toBe(2); - expect(read.get(0)).toBe(leafA); - expect(read.get(1)).toBe(leafB); - }); - - it('round-trips a sparse 16-way branch', async () => { - const oids: Record = {}; - const children = new Map(); - for (const nibble of [0, 7, 15]) { - const oid = await adapter.writeLeaf(new Uint8Array([nibble])); - oids[nibble] = oid; - children.set(nibble, oid); - } - const branchOid = await adapter.writeBranch(children); - const read = await adapter.readBranch(branchOid); - expect(read.size).toBe(3); - expect(read.get(0)).toBe(oids[0]); - expect(read.get(7)).toBe(oids[7]); - expect(read.get(15)).toBe(oids[15]); - }); - - it('round-trips a 64-way branch', async () => { - const children = new Map(); - for (let i = 0; i < 64; i += 1) { - const oid = await adapter.writeLeaf(new Uint8Array([i])); - children.set(i, oid); - } - const branchOid = await adapter.writeBranch(children); - const read = await adapter.readBranch(branchOid); - expect(read.size).toBe(64); - for (let i = 0; i < 64; i += 1) { - expect(read.get(i)).toBe(children.get(i)); - } - }); - - it('round-trips a wide 256-way branch', async () => { - const children = new Map(); - for (let i = 0; i < 256; i += 1) { - const oid = await adapter.writeLeaf(new Uint8Array([i & 0xff, (i >> 8) & 0xff])); - children.set(i, oid); - } - const branchOid = await adapter.writeBranch(children); - const read = await adapter.readBranch(branchOid); - expect(read.size).toBe(256); - expect(read.get(0)).toBe(children.get(0)); - expect(read.get(128)).toBe(children.get(128)); - expect(read.get(255)).toBe(children.get(255)); - }); - - it('writes entries named `0` and `f` for a 16-way branch with keys {0, 15}', async () => { - const leafA = await adapter.writeLeaf(new Uint8Array([10])); - const leafB = await adapter.writeLeaf(new Uint8Array([15])); - const children = new Map([ - [0, leafA], - [15, leafB], - ]); - const branchOid = await adapter.writeBranch(children); - expect(plumbing.lastMktreeInput).toContain('\t0\n'); - expect(plumbing.lastMktreeInput).toContain('\tf\n'); - const read = await adapter.readBranch(branchOid); - expect(read.get(0)).toBe(leafA); - expect(read.get(15)).toBe(leafB); - }); - - it('widens the nibble-name width to 2 when a nibble >= 16 appears', async () => { - const leafA = await adapter.writeLeaf(new Uint8Array([1])); - const leafB = await adapter.writeLeaf(new Uint8Array([2])); - const children = new Map([ - [0, leafA], - [200, leafB], - ]); - await adapter.writeBranch(children); - expect(plumbing.lastMktreeInput).toContain('\t00\n'); - expect(plumbing.lastMktreeInput).toContain('\tc8\n'); - }); - - it('tags branch-of-branch children with tree mode 040000', async () => { - const leaf = await adapter.writeLeaf(new Uint8Array([9])); - const innerBranch = await adapter.writeBranch(new Map([[0, leaf]])); - const outer = new Map([[3, innerBranch]]); - await adapter.writeBranch(outer); - expect(plumbing.lastMktreeInput).toMatch(/^040000 tree /m); - }); - - it('tags leaf children with blob mode 100644', async () => { - const leaf = await adapter.writeLeaf(new Uint8Array([9])); - const outer = new Map([[3, leaf]]); - await adapter.writeBranch(outer); - expect(plumbing.lastMktreeInput).toMatch(/^100644 blob /m); - }); - - it('writes an empty mktree input for an empty child map', async () => { - const oid = await adapter.writeBranch(new Map()); - const read = await adapter.readBranch(oid); - expect(plumbing.lastMktreeInput).toBe(''); - expect(read.size).toBe(0); - }); - - it('raises E_TRIE_STORE_MISSING when the branch OID does not exist', async () => { - await expect(adapter.readBranch('notfound')).rejects.toBeInstanceOf(TrieStoreError); - try { - await adapter.readBranch('notfound'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - } - } - }); - }); - - describe('error paths', () => { - it('raises E_TRIE_STORE_CORRUPT for a non-hex tree entry name', async () => { - const oid = plumbing.putCorruptTree([ - { rawName: 'ZZ', childOid: 'aabb', kind: 'blob' }, - ]); - try { - await adapter.readBranch(oid); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_CORRUPT'); - expect(err.context['name']).toBe('ZZ'); - } - } - }); - - it('raises E_TRIE_STORE_CORRUPT for an empty tree entry name', async () => { - const oid = plumbing.putCorruptTree([ - { rawName: '', childOid: 'aabb', kind: 'blob' }, - ]); - try { - await adapter.readBranch(oid); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_CORRUPT'); - } - } - }); - - it('raises E_TRIE_STORE_CORRUPT for a malformed ls-tree record with no tab', async () => { - class TabLessPlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { - args: string[]; - input?: string | Buffer; - }): Promise { - const [cmd] = opts.args; - if (cmd === 'ls-tree') { - return '040000 tree aabb'; // no tab/name - } - throw new Error(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - throw new Error('unused'); - } - } - const tabLess = new GitTrieStoreAdapter({ plumbing: new TabLessPlumbing() }); - try { - await tabLess.readBranch('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_CORRUPT'); - } - } - }); - - it('raises E_TRIE_STORE_CORRUPT when an ls-tree record is missing an OID column', async () => { - class ShortMetaPlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { - args: string[]; - input?: string | Buffer; - }): Promise { - const [cmd] = opts.args; - if (cmd === 'ls-tree') { - return '040000 tree\t0'; - } - throw new Error(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - throw new Error('unused'); - } - } - const shortMeta = new GitTrieStoreAdapter({ plumbing: new ShortMetaPlumbing() }); - try { - await shortMeta.readBranch('aabb'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_CORRUPT'); - } - } - }); - - it('raises E_TRIE_STORE_MISSING when writeBranch probes a child OID that does not exist', async () => { - try { - await adapter.writeBranch(new Map([[0, 'ghost-oid']])); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - expect(err.context['oid']).toBe('ghost-oid'); - } - } - }); - - it('raises E_TRIE_STORE_WRITE when writeBranch encounters a non-blob, non-tree child', async () => { - class TaggedPlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { - args: string[]; - input?: string | Buffer; - }): Promise { - const [cmd, flag] = opts.args; - if (cmd === 'cat-file' && flag === '-t') { - return 'commit\n'; - } - throw new Error(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - throw new Error('unused'); - } - } - const tagged = new GitTrieStoreAdapter({ plumbing: new TaggedPlumbing() }); - try { - await tagged.writeBranch(new Map([[0, 'aaaabbbb']])); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_WRITE'); - expect(err.context['type']).toBe('commit'); - } - } - }); - - it('raises E_TRIE_STORE_WRITE when mktree itself fails', async () => { - class FailingMktree implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { - args: string[]; - input?: string | Buffer; - }): Promise { - const [cmd, flag] = opts.args; - if (cmd === 'cat-file' && flag === '-t') { - return 'blob\n'; - } - if (cmd === 'mktree') { - throw new OpaqueGitError('mktree failed'); - } - throw new Error(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - throw new Error('unused'); - } - } - const failing = new GitTrieStoreAdapter({ plumbing: new FailingMktree() }); - try { - await failing.writeBranch(new Map([[0, 'aaaabbbb']])); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_WRITE'); - } - } - }); - - it('raises E_TRIE_STORE_WRITE when hash-object itself fails', async () => { - class FailingHash implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { args: string[] }): Promise { - if (opts.args[0] === 'hash-object') { - throw new OpaqueGitError('hash-object failed'); - } - throw new Error(`unsupported: ${opts.args[0] ?? ''}`); - } - async executeStream(): Promise { - throw new Error('unused'); - } - } - const failing = new GitTrieStoreAdapter({ plumbing: new FailingHash() }); - try { - await failing.writeLeaf(new Uint8Array([1])); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_WRITE'); - } - } - }); - - it('raises E_TRIE_STORE_READ for opaque non-missing read failures', async () => { - class TransientReader implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(): Promise { - throw new OpaqueGitError('connection reset'); - } - async executeStream(): Promise { - throw new OpaqueGitError('connection reset'); - } - } - const opaque = new GitTrieStoreAdapter({ plumbing: new TransientReader() }); - try { - await opaque.readLeaf('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_READ'); - } - } - }); - - it('raises E_TRIE_STORE_READ for a failure with an exit code other than 128 or 1', async () => { - class WeirdCodePlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(): Promise { - const err: Error & { - exitCode?: number; - details?: { stderr?: string; code?: number }; - } = new Error('signal killed'); - err.exitCode = 137; - err.details = { stderr: 'killed by SIGKILL', code: 137 }; - throw err; - } - async executeStream(): Promise { - const err: Error & { - exitCode?: number; - details?: { stderr?: string; code?: number }; - } = new Error('signal killed'); - err.exitCode = 137; - err.details = { stderr: 'killed by SIGKILL', code: 137 }; - throw err; - } - } - const weird = new GitTrieStoreAdapter({ plumbing: new WeirdCodePlumbing() }); - try { - await weird.readLeaf('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_READ'); - } - } - }); - - it('passes a pre-wrapped TrieStoreError through writeBranch unchanged', async () => { - class PreWrappingPlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { - args: string[]; - input?: string | Buffer; - }): Promise { - const [cmd, flag] = opts.args; - if (cmd === 'cat-file' && flag === '-t') { - return 'blob\n'; - } - if (cmd === 'mktree') { - throw new TrieStoreError('pre-wrapped write error', { - code: 'E_TRIE_STORE_WRITE', - context: { preWrapped: true }, - }); - } - throw new OpaqueGitError(`unexpected: ${cmd ?? ''}`); - } - async executeStream(): Promise { - throw new OpaqueGitError('unused'); - } - } - const preWrap = new GitTrieStoreAdapter({ plumbing: new PreWrappingPlumbing() }); - try { - await preWrap.writeBranch(new Map([[0, 'aabb']])); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_WRITE'); - expect(err.context['preWrapped']).toBe(true); - } - } - }); - - it('decodes a string-returned stream via TextEncoder', async () => { - class StringStreamPlumbing implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(): Promise { - throw new OpaqueGitError('unused'); - } - async executeStream(): Promise { - return { - [Symbol.asyncIterator](): AsyncIterator { - return { - async next(): Promise> { - return { value: undefined, done: true }; - }, - }; - }, - async collect(): Promise { - return 'hello-as-string'; - }, - }; - } - } - const stringPlumbing = new GitTrieStoreAdapter({ - plumbing: new StringStreamPlumbing(), - }); - const bytes = await stringPlumbing.readLeaf('aabb'); - expect(new TextDecoder().decode(bytes)).toBe('hello-as-string'); - }); - - it('treats a silent exit-1 existence-probe failure as a missing object', async () => { - class SilentProbe implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { args: string[] }): Promise { - const [cmd, flag, oid] = opts.args; - if (cmd === 'cat-file' && flag === '-e' && typeof oid === 'string') { - const err: Error & { - exitCode?: number; - details?: { stderr?: string; code?: number }; - } = new Error(''); - err.exitCode = 1; - err.details = { stderr: '', code: 1 }; - throw err; - } - throw new OpaqueGitError(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - return makeStream(new Uint8Array()); - } - } - const silent = new GitTrieStoreAdapter({ plumbing: new SilentProbe() }); - try { - await silent.readLeaf('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - } - } - }); - - it('isSilentMissing returns false for a pre-wrapped TrieStoreError on the probe path', async () => { - class PrewrapProbe implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { args: string[] }): Promise { - const [cmd, flag] = opts.args; - if (cmd === 'cat-file' && flag === '-e') { - throw new TrieStoreError('pre-wrapped', { - code: 'E_TRIE_STORE_READ', - context: { reason: 'synthetic' }, - }); - } - throw new OpaqueGitError(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - return makeStream(new Uint8Array()); - } - } - const prewrap = new GitTrieStoreAdapter({ plumbing: new PrewrapProbe() }); - try { - await prewrap.readLeaf('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.context['reason']).toBe('synthetic'); - } - } - }); - - it('isSilentMissing returns false for a non-1 exit code on the probe path', async () => { - class BigCodeProbe implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { args: string[] }): Promise { - const [cmd, flag] = opts.args; - if (cmd === 'cat-file' && flag === '-e') { - const err: Error & { - exitCode?: number; - details?: { stderr?: string; code?: number }; - } = new Error('fatal: bad object 0000'); - err.exitCode = 128; - err.details = { stderr: 'fatal: bad object 0000', code: 128 }; - throw err; - } - throw new OpaqueGitError(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - return makeStream(new Uint8Array()); - } - } - const bigCode = new GitTrieStoreAdapter({ plumbing: new BigCodeProbe() }); - try { - await bigCode.readLeaf('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - // Missing-object classification kicks in via pattern - // matching on the stderr text. - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - } - } - }); - - it('propagates the existence probe error when a zero-byte read hits a missing object', async () => { - class EmptyStream implements GitPlumbing { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - async execute(opts: { args: string[] }): Promise { - const [cmd, flag, oid] = opts.args; - if (cmd === 'cat-file' && flag === '-e' && typeof oid === 'string') { - throw new MissingObjectError(oid); - } - throw new OpaqueGitError(`unsupported: ${cmd ?? ''}`); - } - async executeStream(): Promise { - return makeStream(new Uint8Array()); - } - } - const empty = new GitTrieStoreAdapter({ plumbing: new EmptyStream() }); - try { - await empty.readLeaf('aabbccdd'); - throw new Error('expected TrieStoreError'); - } catch (err) { - expect(err).toBeInstanceOf(TrieStoreError); - if (err instanceof TrieStoreError) { - expect(err.code).toBe('E_TRIE_STORE_MISSING'); - } - } - }); - }); - - describe('Uint8Array boundary', () => { - it('returns a Uint8Array — not a Buffer — from readLeaf', async () => { - const oid = await adapter.writeLeaf(new Uint8Array([1, 2, 3])); - const read = await adapter.readLeaf(oid); - expect(read).toBeInstanceOf(Uint8Array); - expect(Buffer.isBuffer(read)).toBe(false); - }); - }); -}); diff --git a/test/unit/scripts/storage-ownership-boundary.test.ts b/test/unit/scripts/storage-ownership-boundary.test.ts index 0bb26b50..dce7a04f 100644 --- a/test/unit/scripts/storage-ownership-boundary.test.ts +++ b/test/unit/scripts/storage-ownership-boundary.test.ts @@ -24,12 +24,19 @@ const FORBIDDEN_DOMAIN_STORAGE_IDENTIFIERS = new Set([ 'writeBlob', 'writeTree', ]); +const RAW_GIT_OBJECT_WRITE_COMMANDS = new Set([ + 'hash-object', + 'mktree', + 'unpack-objects', + 'write-tree', +]); const REMOVED_PRODUCTION_SYMBOLS = new Set([ 'CachedValue', 'CasFirstMemoizationEngine', 'CasIndexStorageAdapter', 'CasSeekCacheAdapter', 'HealthCheckService', + 'GitTrieStoreAdapter', 'InMemoryBlobStorageAdapter', 'InMemoryGraphAdapter', 'IndexRebuildService', @@ -136,6 +143,32 @@ function forbiddenDomainStorageReferences( return [...violations]; } +function forbiddenRawGitObjectWrites( + path: string, + sourceText = readFileSync(path, 'utf8'), +): string[] { + const source = ts.createSourceFile( + path, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const relativePath = relative(REPO_ROOT.pathname, path); + const violations = new Set(); + const visit = (node: ts.Node): void => { + if ( + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && + RAW_GIT_OBJECT_WRITE_COMMANDS.has(node.text) + ) { + violations.add(`${relativePath} invokes raw Git object writer ${node.text}`); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...violations]; +} + function importedModuleName(node: ts.Node): string | null { if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== undefined @@ -209,4 +242,29 @@ describe('storage ownership boundary', () => { expect(violations).toEqual([]); }); + + it('rejects raw Git object writers in an AST mutation fixture', () => { + const fixturePath = new URL('raw-git-writer-fixture.ts', REPO_ROOT).pathname; + const violations = forbiddenRawGitObjectWrites(fixturePath, ` + plumbing.execute({ args: ['hash-object', '-w', '--stdin'] }); + plumbing.execute({ args: [\`mktree\`] }); + `).sort(); + + expect(violations).toEqual([ + 'raw-git-writer-fixture.ts invokes raw Git object writer hash-object', + 'raw-git-writer-fixture.ts invokes raw Git object writer mktree', + ]); + }); + + it('keeps raw Git object writers out of production', () => { + const productionFiles = [ + ...PRODUCTION_ROOTS.flatMap(productionTypeScriptFiles), + ...PRODUCTION_ENTRYPOINTS.map((path) => new URL(path, REPO_ROOT).pathname), + ]; + const violations = productionFiles + .flatMap((path) => forbiddenRawGitObjectWrites(path)) + .sort(); + + expect(violations).toEqual([]); + }); });