From 4b16a625c2ede8b8829b57a44971e4411b41a095 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 20:59:36 -0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(cas):=20M2=20Boomerang=20=E2=80=94?= =?UTF-8?q?=20restore=20round-trip,=20CLI,=20integration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CasService.restore() with per-chunk SHA-256 integrity verification and encrypted restore support. Add readTree() to GitPersistencePort for parsing Git trees via ls-tree. Wrap stream errors as STREAM_ERROR with chunksWritten metadata. Add git-cas CLI (bin/git-cas.js) with store, tree, and restore subcommands using commander. All three README CLI promises now work. Add 59 Docker-gated integration tests exercising real Git bare repos (JSON + CBOR codecs, plaintext + encrypted, fuzz around chunk boundaries). Hard gate prevents accidental host execution. Fix readBlob Uint8Array→Buffer normalisation for codec compatibility. Update CHANGELOG, README, and ROADMAP. --- CHANGELOG.md | 35 ++- README.md | 24 +- ROADMAP.md | 151 +++++++++- bin/git-cas.js | 134 +++++++++ index.js | 25 +- package-lock.json | 10 + package.json | 8 + src/domain/services/CasService.js | 90 ++++-- .../adapters/GitPersistenceAdapter.js | 43 ++- src/ports/GitPersistencePort.js | 8 + test/integration/round-trip.test.js | 278 ++++++++++++++++++ .../services/CasService.restore.test.js | 257 ++++++++++++++++ .../services/CasService.stream-error.test.js | 174 +++++++++++ .../GitPersistenceAdapter.readTree.test.js | 164 +++++++++++ 14 files changed, 1370 insertions(+), 31 deletions(-) create mode 100755 bin/git-cas.js create mode 100644 test/integration/round-trip.test.js create mode 100644 test/unit/domain/services/CasService.restore.test.js create mode 100644 test/unit/domain/services/CasService.stream-error.test.js create mode 100644 test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c08809..0fe6508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] — M2 Boomerang (v1.2.0) ### Added -- None. +- `CasService.restore()` — reconstruct files from manifests with per-chunk SHA-256 integrity verification. +- `ContentAddressableStore.restoreFile()` — facade method that restores and writes to disk. +- `readTree()` on `GitPersistencePort` / `GitPersistenceAdapter` — parse Git trees via `ls-tree`. +- `STREAM_ERROR` wrapping — stream failures during `store()` surface as `CasError('STREAM_ERROR')` with `{ chunksWritten }` metadata. +- CLI: `git cas store`, `git cas tree`, `git cas restore` subcommands via `bin/git-cas.js`. +- Integration test suite (59 tests) running against real Git bare repos inside Docker. +- `commander` dependency for CLI. ### Changed +- `readBlob()` now normalises `Uint8Array` from plumbing into `Buffer` for codec/crypto compatibility. + +### Fixed +- None. + +### Security - None. +## [1.1.0] — M1 Bedrock + +### Added +- `CryptoPort` interface and `NodeCryptoAdapter` — extracted all `node:crypto` usage from the domain layer. +- `CasService.store()` — accepts `AsyncIterable` sources (renamed from `storeFile`). +- Multi-stage Dockerfile (Node 22, Bun, Deno) with `docker-compose.yml` for per-runtime testing. +- BATS parallel test runner (`test/platform/runtimes.bats`). +- Devcontainer setup (`.devcontainer/`) with all three runtimes + BATS. +- Encryption key validation (`INVALID_KEY_TYPE`, `INVALID_KEY_LENGTH` error codes). +- Encryption round-trip unit tests (110 tests including fuzz). +- Empty file (0-byte) edge case tests. +- Error-path unit tests for constructors and core failures. +- Deterministic test digest helper (`digestOf`). + +### Changed +- `CasService` domain layer has zero `node:*` imports — all platform dependencies injected via ports. +- Constructor requires `crypto` and `codec` params (no defaults); facade supplies them. +- Facade `storeFile()` now opens the file and delegates to `CasService.store()`. + ### Fixed - None. diff --git a/README.md b/README.md index fc27dae..8a02f66 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ We use the object database. - **Optional AES-256-GCM encryption** store secrets without leaking plaintext into the ODB. - **Manifests** a tiny explicit index of chunks + metadata (JSON/CBOR). - **Tree output** generates standard Git trees so assets snap into commits cleanly. +- **Full round-trip** store, tree, and restore — get your bytes back, verified. **Use it for:** binary assets, build artifacts, model weights, data packs, secret bundles, weird experiments, etc. @@ -43,9 +44,10 @@ const manifest = await cas.storeFile({ }); // Turn the manifest into a Git tree OID -const treeOid = cas.createTree({ manifest }); +const treeOid = await cas.createTree({ manifest }); -// Now you can point a ref/commit at that tree like a normal Git artifact. +// Restore later — get your bytes back, integrity-verified +await cas.restoreFile({ manifest, outputPath: './restored.png' }); ``` ## CLI (git plugin) @@ -53,9 +55,21 @@ const treeOid = cas.createTree({ manifest }); `git-cas` installs as a Git subcommand: ```bash -git cas store ./image.png --slug my-image -git cas tree --slug my-image -git cas restore --out ./image.png +# Store a file — prints manifest JSON +git cas store ./image.png --slug my-image + +# Store and get a tree OID directly +git cas store ./image.png --slug my-image --tree + +# Create a tree from an existing manifest +git cas tree --manifest manifest.json + +# Restore from a tree OID +git cas restore --out ./restored.png + +# Encrypted round-trip (32-byte raw key file) +git cas store ./secret.bin --slug vault --key-file ./my.key --tree +git cas restore --out ./decrypted.bin --key-file ./my.key ``` ## Why not Git LFS? diff --git a/ROADMAP.md b/ROADMAP.md index f1533f7..d710d5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ This roadmap is structured as: 3. **Contracts** — Return/throw semantics for all public methods 4. **Version Plan** — Table mapping versions to milestones 5. **Milestone Dependency Graph** — ASCII diagram -6. **Milestones & Task Cards** — 7 milestones, 24 tasks (uniform task card template) +6. **Milestones & Task Cards** — 7 milestones, 26 tasks (uniform task card template) --- @@ -106,6 +106,21 @@ Return and throw semantics for every public method (current and planned). - **Algorithms:** `pbkdf2` (default), `scrypt` — both Node.js built-ins. - **Throws:** Standard Node.js crypto errors on invalid parameters. +### CLI: `git cas store --slug [--key-file ]` *(planned — Task 2.5)* +- **Output:** Prints manifest JSON to stdout (tree OID if `--tree` flag is passed). +- **Exit 0:** Store succeeded. +- **Exit 1:** Store failed (error message to stderr). + +### CLI: `git cas tree --slug ` *(planned — Task 2.5)* +- **Output:** Prints Git tree OID to stdout. +- **Exit 0:** Tree created. +- **Exit 1:** Invalid manifest or Git error (message to stderr). + +### CLI: `git cas restore --out [--key-file ]` *(planned — Task 2.6)* +- **Output:** Writes restored file to `--out` path. +- **Exit 0:** Restore succeeded, prints bytes written to stdout. +- **Exit 1:** Integrity error, missing manifest, or I/O error (message to stderr). + --- ## 4) Version Plan @@ -113,7 +128,7 @@ Return and throw semantics for every public method (current and planned). | Version | Milestone | Codename | Theme | |--------:|-----------|----------|-------| | v1.1.0 | M1 | Bedrock | Foundation hardening | -| v1.2.0 | M2 | Boomerang| File retrieval round trip | +| v1.2.0 | M2 | Boomerang| File retrieval round trip + CLI | | v1.3.0 | M3 | Launchpad| CI/CD pipeline | | v1.4.0 | M4 | Compass | Lifecycle management | | v1.5.0 | M5 | Sonar | Observability | @@ -152,13 +167,13 @@ M3 Launchpad (v1.3.0) M4 Compass (v1.4.0) | # | Codename | Theme | Version | Tasks | ~LoC | ~Hours | |---:|--------------|----------------------------|:-------:|------:|-------:|------:| | M1 | Bedrock | Foundation hardening | v1.1.0 | 7 | ~475 | ~6.5h | -| M2 | Boomerang | File retrieval round trip | v1.2.0 | 4 | ~295 | ~9.5h | +| M2 | Boomerang | File retrieval round trip + CLI | v1.2.0 | 6 | ~435 | ~14h | | M3 | Launchpad | CI/CD pipeline | v1.3.0 | 2 | ~110 | ~4h | | M4 | Compass | Lifecycle management | v1.4.0 | 3 | ~180 | ~5.5h | | M5 | Sonar | Observability | v1.5.0 | 2 | ~210 | ~5.5h | | M6 | Cartographer | Documentation | v1.6.0 | 3 | ~750 | ~10h | | M7 | Horizon | Advanced features | v2.0.0 | 3 | ~450 | ~17h | -| | **Total** | | | **24**| **~2,470** | **~58h** | +| | **Total** | | | **26**| **~2,610** | **~62.5h** | --- @@ -535,7 +550,7 @@ As a maintainer, I want error conditions covered by tests so regressions in vali --- # M2 — Boomerang (v1.2.0) -**Theme:** Complete store→retrieve round trip. +**Theme:** Complete store→retrieve round trip + CLI. --- @@ -761,6 +776,132 @@ As a developer, I want storeFile to fail safely on stream errors so partial stor --- +## Task 2.5: CLI scaffold + `store` and `tree` subcommands + +**User Story** +As a developer, I want `git cas store` and `git cas tree` commands so I can use CAS from the terminal without writing Node scripts. + +**Requirements** +- R1: Add `bin/git-cas.js` entry point (Git discovers `git-cas` on PATH for `git cas` subcommands). +- R2: Add `"bin": { "git-cas": "./bin/git-cas.js" }` to `package.json`. +- R3: Use a lightweight CLI framework (e.g., `commander`) for subcommand routing. +- R4: `git cas store --slug [--key-file ] [--tree]`: + - Reads the file, calls `storeFile()`. + - Prints manifest JSON to stdout by default. + - If `--tree` is passed, also calls `createTree()` and prints tree OID. + - `--key-file` reads a 32-byte raw key from a file for encryption. +- R5: `git cas tree --manifest `: + - Reads a manifest JSON from file/stdin, calls `createTree()`. + - Prints tree OID to stdout. +- R6: Exit 0 on success, exit 1 on error with message to stderr. +- R7: `--cwd` flag to set Git working directory (defaults to `.`). + +**Acceptance Criteria** +- AC1: `npx git-cas store ./test.txt --slug test` prints manifest JSON. +- AC2: `npx git-cas store ./test.txt --slug test --tree` prints tree OID. +- AC3: `npx git-cas tree --manifest manifest.json` prints tree OID. +- AC4: Invalid arguments produce helpful usage message and exit 1. +- AC5: `--key-file` with valid 32-byte file encrypts successfully. +- AC6: `--key-file` with wrong-size file exits 1 with clear error. + +**Scope** +- In scope: CLI scaffold, store subcommand, tree subcommand, key-file reading. +- Out of scope: `restore` subcommand (Task 2.6), shell completions, config files. + +**Est. Complexity (LoC)** +- Prod: ~80 +- Tests: ~30 +- Total: ~110 + +**Est. Human Working Hours** +- ~3h + +**Test Plan** +- Golden path: + - store a file via CLI → valid manifest JSON on stdout. + - store with `--tree` → tree OID on stdout. + - tree from manifest file → tree OID on stdout. +- Failures: + - missing file → exit 1 with error. + - missing `--slug` → exit 1 with usage message. + - bad key file → exit 1 with INVALID_KEY_LENGTH/TYPE error. +- Edges: + - 0-byte file store. + - manifest piped via stdin (if supported). +- Fuzz/stress: + - None (thin wrapper over tested API). + +**Definition of Done** +- DoD1: `bin/git-cas.js` exists with store and tree subcommands. +- DoD2: `package.json` declares bin entry. +- DoD3: `npx git-cas --help` prints usage. +- DoD4: Integration smoke test passes against real Git repo. + +**Blocking** +- Blocks: Task 2.6 + +**Blocked By** +- Blocked by: None + +--- + +## Task 2.6: CLI `restore` subcommand + +**User Story** +As a developer, I want `git cas restore --out ` so I can retrieve stored assets from the terminal. + +**Requirements** +- R1: `git cas restore --out [--key-file ]`: + - Reads the tree, extracts the manifest, restores the file to `--out`. + - Prints bytes written to stdout on success. + - `--key-file` supplies decryption key for encrypted assets. +- R2: Exit 0 on success, exit 1 on error (INTEGRITY_ERROR, MANIFEST_NOT_FOUND, etc.) with message to stderr. +- R3: Requires `restoreFile()` (Task 2.1) and `readManifest()` or equivalent tree-reading capability. + +**Acceptance Criteria** +- AC1: `npx git-cas restore --out ./restored.txt` writes correct file. +- AC2: Encrypted asset with `--key-file` restores correctly. +- AC3: Wrong key exits 1 with INTEGRITY_ERROR message. +- AC4: Invalid tree OID exits 1 with clear error. + +**Scope** +- In scope: restore subcommand wired to restoreFile API. +- Out of scope: Streaming output to stdout, partial restore, resume. + +**Est. Complexity (LoC)** +- Prod: ~30 +- Tests: ~20 +- Total: ~50 + +**Est. Human Working Hours** +- ~1.5h + +**Test Plan** +- Golden path: + - store → tree → restore → byte-compare original. + - encrypted store → tree → restore with key → byte-compare. +- Failures: + - wrong key → exit 1 INTEGRITY_ERROR. + - nonexistent tree OID → exit 1. + - missing `--out` → exit 1 with usage. +- Edges: + - 0-byte file round-trip via CLI. +- Fuzz/stress: + - None (thin wrapper over tested API). + +**Definition of Done** +- DoD1: `restore` subcommand added to `bin/git-cas.js`. +- DoD2: Full CLI round-trip (store → tree → restore) documented and tested. +- DoD3: README CLI section is now accurate and deliverable. + +**Blocking** +- Blocks: None + +**Blocked By** +- Blocked by: Task 2.1, Task 2.5 + +--- + # M3 — Launchpad (v1.3.0) **Theme:** Automated quality gates and release process. diff --git a/bin/git-cas.js b/bin/git-cas.js new file mode 100755 index 0000000..6d4e59f --- /dev/null +++ b/bin/git-cas.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'node:fs'; +import { program } from 'commander'; +import GitPlumbing from '@git-stunts/plumbing'; +import ContentAddressableStore from '../index.js'; +import Manifest from '../src/domain/value-objects/Manifest.js'; + +program + .name('git-cas') + .description('Content Addressable Storage backed by Git') + .version('1.2.0'); + +/** + * Read a 32-byte raw encryption key from a file. + */ +function readKeyFile(keyFilePath) { + const key = readFileSync(keyFilePath); + return key; +} + +/** + * Create a CAS instance for the given working directory. + */ +function createCas(cwd) { + const plumbing = new GitPlumbing({ cwd }); + return new ContentAddressableStore({ plumbing }); +} + +// --------------------------------------------------------------------------- +// store +// --------------------------------------------------------------------------- +program + .command('store ') + .description('Store a file into Git CAS') + .requiredOption('--slug ', 'Asset slug identifier') + .option('--key-file ', 'Path to 32-byte raw encryption key file') + .option('--tree', 'Also create a Git tree and print its OID') + .option('--cwd ', 'Git working directory', '.') + .action(async (file, opts) => { + try { + const cas = createCas(opts.cwd); + const storeOpts = { + filePath: file, + slug: opts.slug, + }; + + if (opts.keyFile) { + storeOpts.encryptionKey = readKeyFile(opts.keyFile); + } + + const manifest = await cas.storeFile(storeOpts); + + if (opts.tree) { + const treeOid = await cas.createTree({ manifest }); + process.stdout.write(treeOid + '\n'); + } else { + process.stdout.write(JSON.stringify(manifest.toJSON(), null, 2) + '\n'); + } + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// tree +// --------------------------------------------------------------------------- +program + .command('tree') + .description('Create a Git tree from a manifest') + .requiredOption('--manifest ', 'Path to manifest JSON file') + .option('--cwd ', 'Git working directory', '.') + .action(async (opts) => { + try { + const cas = createCas(opts.cwd); + const raw = readFileSync(opts.manifest, 'utf8'); + const manifest = new Manifest(JSON.parse(raw)); + const treeOid = await cas.createTree({ manifest }); + process.stdout.write(treeOid + '\n'); + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// restore +// --------------------------------------------------------------------------- +program + .command('restore ') + .description('Restore a file from a Git CAS tree') + .requiredOption('--out ', 'Output file path') + .option('--key-file ', 'Path to 32-byte raw encryption key file') + .option('--cwd ', 'Git working directory', '.') + .action(async (treeOid, opts) => { + try { + const cas = createCas(opts.cwd); + + // Read the tree to find the manifest + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find( + (e) => e.name.startsWith('manifest.'), + ); + if (!manifestEntry) { + process.stderr.write('error: No manifest found in tree\n'); + process.exit(1); + } + + const manifestBlob = await cas.service.persistence.readBlob( + manifestEntry.oid, + ); + const manifest = new Manifest( + cas.service.codec.decode(manifestBlob), + ); + + const restoreOpts = { manifest }; + if (opts.keyFile) { + restoreOpts.encryptionKey = readKeyFile(opts.keyFile); + } + + const { bytesWritten } = await cas.restoreFile({ + ...restoreOpts, + outputPath: opts.out, + }); + + process.stdout.write(`${bytesWritten}\n`); + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +program.parse(); diff --git a/index.js b/index.js index fbba901..a96e742 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ * @fileoverview Content Addressable Store - Managed blob storage in Git. */ -import { createReadStream } from 'node:fs'; +import { createReadStream, writeFileSync } from 'node:fs'; import path from 'node:path'; import CasService from './src/domain/services/CasService.js'; import GitPersistenceAdapter from './src/infrastructure/adapters/GitPersistenceAdapter.js'; @@ -93,7 +93,30 @@ export default class ContentAddressableStore { return this.service.store(options); } + /** + * Restores a file from its manifest and writes it to outputPath. + */ + async restoreFile({ manifest, encryptionKey, outputPath }) { + const { buffer, bytesWritten } = await this.service.restore({ + manifest, + encryptionKey, + }); + writeFileSync(outputPath, buffer); + return { bytesWritten }; + } + + /** + * Restores a file from its manifest, returning the buffer directly. + */ + async restore(options) { + return this.service.restore(options); + } + async createTree(options) { return this.service.createTree(options); } + + async verifyIntegrity(manifest) { + return this.service.verifyIntegrity(manifest); + } } diff --git a/package-lock.json b/package-lock.json index e37effe..94f849a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^2.8.0", "cbor-x": "^1.6.0", + "commander": "^14.0.3", "zod": "^3.24.1" }, "devDependencies": { @@ -1399,6 +1400,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 61f3a02..0453459 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Git Stunts Lego Block: cas", "type": "module", "main": "index.js", + "bin": { + "git-cas": "./bin/git-cas.js" + }, "exports": { ".": "./index.js", "./service": "./src/domain/services/CasService.js", @@ -15,6 +18,10 @@ "test:node": "docker compose run --build --rm test-node", "test:bun": "docker compose run --build --rm test-bun", "test:deno": "docker compose run --build --rm test-deno", + "test:integration": "vitest run test/integration", + "test:integration:node": "docker compose run --build --rm test-node npx vitest run test/integration", + "test:integration:bun": "docker compose run --build --rm test-bun bunx vitest run test/integration", + "test:integration:deno": "docker compose run --build --rm test-deno deno run -A npm:vitest run test/integration", "test:platforms": "bats --jobs 3 test/platform/runtimes.bats", "benchmark": "vitest bench test/benchmark", "benchmark:local": "vitest bench test/benchmark", @@ -27,6 +34,7 @@ "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^2.8.0", "cbor-x": "^1.6.0", + "commander": "^14.0.3", "zod": "^3.24.1" }, "devDependencies": { diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index f4d3945..b9b39ab 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -39,24 +39,33 @@ export default class CasService { async _chunkAndStore(source, manifestData) { let buffer = Buffer.alloc(0); - for await (const chunk of source) { - buffer = Buffer.concat([buffer, chunk]); - - while (buffer.length >= this.chunkSize) { - const chunkBuf = buffer.slice(0, this.chunkSize); - buffer = buffer.slice(this.chunkSize); - - const digest = this._sha256(chunkBuf); - const blob = await this.persistence.writeBlob(chunkBuf); - - manifestData.chunks.push({ - index: manifestData.chunks.length, - size: chunkBuf.length, - digest, - blob - }); - manifestData.size += chunkBuf.length; + try { + for await (const chunk of source) { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= this.chunkSize) { + const chunkBuf = buffer.slice(0, this.chunkSize); + buffer = buffer.slice(this.chunkSize); + + const digest = this._sha256(chunkBuf); + const blob = await this.persistence.writeBlob(chunkBuf); + + manifestData.chunks.push({ + index: manifestData.chunks.length, + size: chunkBuf.length, + digest, + blob + }); + manifestData.size += chunkBuf.length; + } } + } catch (err) { + if (err instanceof CasError) throw err; + throw new CasError( + `Stream error during store: ${err.message}`, + 'STREAM_ERROR', + { chunksWritten: manifestData.chunks.length, originalError: err }, + ); } // Process remaining buffer @@ -171,6 +180,53 @@ export default class CasService { return await this.persistence.writeTree(treeEntries); } + /** + * Restores a file from its manifest by reading and reassembling chunks. + * + * If the manifest has encryption metadata, decrypts the reassembled + * ciphertext using the provided key. + * + * @param {Object} options + * @param {import('../value-objects/Manifest.js').default} options.manifest + * @param {Buffer} [options.encryptionKey] + * @returns {Promise<{ buffer: Buffer, bytesWritten: number }>} + */ + async restore({ manifest, encryptionKey }) { + if (encryptionKey) { + this._validateKey(encryptionKey); + } + + if (manifest.chunks.length === 0) { + return { buffer: Buffer.alloc(0), bytesWritten: 0 }; + } + + const chunks = []; + for (const chunk of manifest.chunks) { + const blob = await this.persistence.readBlob(chunk.blob); + const digest = this._sha256(blob); + if (digest !== chunk.digest) { + throw new CasError( + `Chunk ${chunk.index} integrity check failed`, + 'INTEGRITY_ERROR', + { chunkIndex: chunk.index, expected: chunk.digest, actual: digest }, + ); + } + chunks.push(blob); + } + + let buffer = Buffer.concat(chunks); + + if (manifest.encryption?.encrypted && encryptionKey) { + buffer = this.decrypt({ + buffer, + key: encryptionKey, + meta: manifest.encryption, + }); + } + + return { buffer, bytesWritten: buffer.length }; + } + /** * Verifies the integrity of a stored file by re-hashing its chunks. * @param {import('../value-objects/Manifest.js').default} manifest diff --git a/src/infrastructure/adapters/GitPersistenceAdapter.js b/src/infrastructure/adapters/GitPersistenceAdapter.js index 41fb734..773f8ab 100644 --- a/src/infrastructure/adapters/GitPersistenceAdapter.js +++ b/src/infrastructure/adapters/GitPersistenceAdapter.js @@ -1,5 +1,6 @@ import { Policy } from '@git-stunts/alfred'; import GitPersistencePort from '../../ports/GitPersistencePort.js'; +import CasError from '../../domain/errors/CasError.js'; const DEFAULT_POLICY = Policy.timeout(30_000).wrap( Policy.retry({ @@ -48,7 +49,47 @@ export default class GitPersistenceAdapter extends GitPersistencePort { const stream = await this.plumbing.executeStream({ args: ['cat-file', 'blob', oid], }); - return stream.collect({ asString: false }); + const data = await stream.collect({ asString: false }); + // Plumbing returns Uint8Array; ensure we return a Buffer for codec/crypto compat + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + }); + } + + async readTree(treeOid) { + return this.policy.execute(async () => { + const output = await this.plumbing.execute({ + args: ['ls-tree', treeOid], + }); + + if (!output || output.trim() === '') { + return []; + } + + return output.trim().split('\n').map((line) => { + // Format: \t + const tabIndex = line.indexOf('\t'); + if (tabIndex === -1) { + throw new CasError( + `Malformed ls-tree line: ${line}`, + 'TREE_PARSE_ERROR', + { line }, + ); + } + const meta = line.slice(0, tabIndex).split(' '); + if (meta.length !== 3) { + throw new CasError( + `Malformed ls-tree line: ${line}`, + 'TREE_PARSE_ERROR', + { line }, + ); + } + return { + mode: meta[0], + type: meta[1], + oid: meta[2], + name: line.slice(tabIndex + 1), + }; + }); }); } } diff --git a/src/ports/GitPersistencePort.js b/src/ports/GitPersistencePort.js index ef01d6a..97d5e6f 100644 --- a/src/ports/GitPersistencePort.js +++ b/src/ports/GitPersistencePort.js @@ -25,4 +25,12 @@ export default class GitPersistencePort { async readBlob(_oid) { throw new Error('Not implemented'); } + + /** + * @param {string} treeOid + * @returns {Promise>} + */ + async readTree(_treeOid) { + throw new Error('Not implemented'); + } } diff --git a/test/integration/round-trip.test.js b/test/integration/round-trip.test.js new file mode 100644 index 0000000..30bbdf6 --- /dev/null +++ b/test/integration/round-trip.test.js @@ -0,0 +1,278 @@ +/** + * Integration tests — store → createTree → readTree → restore round trip. + * + * These tests run against a real Git bare repo and exercise the full stack: + * GitPlumbing → GitPersistenceAdapter → CasService → Facade. + * + * MUST run inside Docker (GIT_STUNTS_DOCKER=1). Refuses to run on the host. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import os from 'node:os'; +import GitPlumbing from '@git-stunts/plumbing'; +import ContentAddressableStore from '../../index.js'; +import CborCodec from '../../src/infrastructure/codecs/CborCodec.js'; +import Manifest from '../../src/domain/value-objects/Manifest.js'; +import CasError from '../../src/domain/errors/CasError.js'; + +// Hard gate: refuse to run outside Docker +if (process.env.GIT_STUNTS_DOCKER !== '1') { + throw new Error( + 'Integration tests MUST run inside Docker (GIT_STUNTS_DOCKER=1). ' + + 'Use: npm run test:integration:node', + ); +} + +let repoDir; +let cas; +let casCbor; + +beforeAll(() => { + repoDir = mkdtempSync(path.join(os.tmpdir(), 'cas-integ-')); + execSync('git init --bare', { cwd: repoDir, stdio: 'ignore' }); + + const plumbing = GitPlumbing.createDefault({ cwd: repoDir }); + cas = new ContentAddressableStore({ plumbing }); + casCbor = new ContentAddressableStore({ plumbing, codec: new CborCodec() }); +}); + +afterAll(() => { + rmSync(repoDir, { recursive: true, force: true }); +}); + +/** + * Helper: write a temp file with the given content, return path. + */ +function tempFile(content) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'cas-file-')); + const fp = path.join(dir, 'input.bin'); + writeFileSync(fp, content); + return { filePath: fp, dir }; +} + +// --------------------------------------------------------------------------- +// Plaintext round trip — JSON codec +// --------------------------------------------------------------------------- +describe('plaintext round trip (JSON)', () => { + it('10 KB file', async () => { + const original = randomBytes(10 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-10k' }); + const treeOid = await cas.createTree({ manifest }); + + // Read tree back + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.json'); + expect(manifestEntry).toBeDefined(); + + const manifestBlob = await cas.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(cas.service.codec.decode(manifestBlob)); + + const { buffer, bytesWritten } = await cas.restore({ manifest: restored }); + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('0-byte file', async () => { + const original = Buffer.alloc(0); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-empty' }); + const treeOid = await cas.createTree({ manifest }); + + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.json'); + const manifestBlob = await cas.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(cas.service.codec.decode(manifestBlob)); + + const { buffer, bytesWritten } = await cas.restore({ manifest: restored }); + expect(buffer.length).toBe(0); + expect(bytesWritten).toBe(0); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('exact chunkSize file (256 KiB)', async () => { + const original = randomBytes(256 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-exact' }); + expect(manifest.chunks.length).toBe(1); + + const { buffer } = await cas.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('3x chunkSize file', async () => { + const original = randomBytes(3 * 256 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-3x' }); + expect(manifest.chunks.length).toBe(3); + + const { buffer } = await cas.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Encrypted round trip — JSON codec +// --------------------------------------------------------------------------- +describe('encrypted round trip (JSON)', () => { + const key = randomBytes(32); + + it('10 KB encrypted file', async () => { + const original = randomBytes(10 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ + filePath, + slug: 'enc-10k', + encryptionKey: key, + }); + expect(manifest.encryption).toBeDefined(); + expect(manifest.encryption.encrypted).toBe(true); + + const treeOid = await cas.createTree({ manifest }); + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.json'); + const manifestBlob = await cas.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(cas.service.codec.decode(manifestBlob)); + + const { buffer } = await cas.restore({ manifest: restored, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('wrong key throws INTEGRITY_ERROR', async () => { + const original = randomBytes(1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ + filePath, + slug: 'enc-wrong-key', + encryptionKey: key, + }); + + const wrongKey = randomBytes(32); + await expect( + cas.restore({ manifest, encryptionKey: wrongKey }), + ).rejects.toThrow(CasError); + + try { + await cas.restore({ manifest, encryptionKey: wrongKey }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// CBOR codec round trip +// --------------------------------------------------------------------------- +describe('CBOR codec round trip', () => { + it('10 KB plaintext via CBOR', async () => { + const original = randomBytes(10 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await casCbor.storeFile({ filePath, slug: 'cbor-10k' }); + const treeOid = await casCbor.createTree({ manifest }); + + const entries = await casCbor.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.cbor'); + expect(manifestEntry).toBeDefined(); + + const manifestBlob = await casCbor.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(casCbor.service.codec.decode(manifestBlob)); + + const { buffer } = await casCbor.restore({ manifest: restored }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('encrypted via CBOR', async () => { + const key = randomBytes(32); + const original = randomBytes(5 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await casCbor.storeFile({ + filePath, + slug: 'cbor-enc', + encryptionKey: key, + }); + + const { buffer } = await casCbor.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// restoreFile — write to disk +// --------------------------------------------------------------------------- +describe('restoreFile (write to disk)', () => { + it('restores to a file on disk', async () => { + const original = randomBytes(4096); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'disk-restore' }); + + const outDir = mkdtempSync(path.join(os.tmpdir(), 'cas-out-')); + const outPath = path.join(outDir, 'restored.bin'); + + const { bytesWritten } = await cas.restoreFile({ + manifest, + outputPath: outPath, + }); + + expect(bytesWritten).toBe(original.length); + const restored = readFileSync(outPath); + expect(restored.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz: 50 file sizes around chunk boundaries +// --------------------------------------------------------------------------- +describe('fuzz: 50 file sizes around chunk boundaries', () => { + const chunkSize = 256 * 1024; + + for (let i = 0; i < 50; i++) { + // Spread sizes: 0, near chunkSize, 2x, 3x, with ±1 offsets + const base = Math.floor((i / 49) * 3 * chunkSize); + const offset = (i % 3) - 1; // -1, 0, +1 + const size = Math.max(0, base + offset); + + it(`round-trips ${size} bytes (iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) original[b] = (i + b) & 0xff; + + const { filePath, dir } = tempFile(original); + const manifest = await cas.storeFile({ filePath, slug: `fuzz-${i}` }); + const { buffer } = await cas.restore({ manifest }); + + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + } +}); diff --git a/test/unit/domain/services/CasService.restore.test.js b/test/unit/domain/services/CasService.restore.test.js new file mode 100644 index 0000000..9f0dd8f --- /dev/null +++ b/test/unit/domain/services/CasService.restore.test.js @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import Manifest from '../../../../src/domain/value-objects/Manifest.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +describe('CasService.restore()', () => { + let service; + let crypto; + let mockPersistence; + /** Map simulating Git ODB */ + let blobStore; + + beforeEach(() => { + crypto = new NodeCryptoAdapter(); + blobStore = new Map(); + + mockPersistence = { + writeBlob: vi.fn().mockImplementation(async (content) => { + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); + const oid = crypto.sha256(buf); + blobStore.set(oid, buf); + return oid; + }), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockImplementation(async (oid) => { + const buf = blobStore.get(oid); + if (!buf) throw new Error(`Blob not found: ${oid}`); + return buf; + }), + }; + + service = new CasService({ + persistence: mockPersistence, + crypto, + codec: new JsonCodec(), + chunkSize: 1024, + }); + }); + + // --------------------------------------------------------------------------- + // Helper: store content via async iterable, return manifest + // --------------------------------------------------------------------------- + async function storeBuffer(svc, buf, opts = {}) { + async function* source() { yield buf; } + return svc.store({ + source: source(), + slug: opts.slug || 'test', + filename: opts.filename || 'test.bin', + encryptionKey: opts.encryptionKey, + }); + } + + // --------------------------------------------------------------------------- + // Golden path — plaintext + // --------------------------------------------------------------------------- + describe('plaintext round-trip', () => { + it('restores a single-chunk file', async () => { + const original = Buffer.from('hello world'); + const manifest = await storeBuffer(service, original); + + const { buffer, bytesWritten } = await service.restore({ manifest }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); + + it('restores a multi-chunk file', async () => { + const original = randomBytes(3 * 1024); // 3 chunks at 1024 + const manifest = await storeBuffer(service, original); + expect(manifest.chunks.length).toBe(3); + + const { buffer, bytesWritten } = await service.restore({ manifest }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); + + it('restores a file that is exact multiple of chunkSize', async () => { + const original = randomBytes(2 * 1024); + const manifest = await storeBuffer(service, original); + expect(manifest.chunks.length).toBe(2); + + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Golden path — encrypted + // --------------------------------------------------------------------------- + describe('encrypted round-trip', () => { + it('restores encrypted content with correct key', async () => { + const key = randomBytes(32); + const original = Buffer.from('secret data here'); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + + expect(manifest.encryption).toBeDefined(); + expect(manifest.encryption.encrypted).toBe(true); + + const { buffer, bytesWritten } = await service.restore({ + manifest, + encryptionKey: key, + }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); + + it('restores multi-chunk encrypted content', async () => { + const key = randomBytes(32); + const original = randomBytes(3 * 1024); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Empty manifest + // --------------------------------------------------------------------------- + describe('empty manifest', () => { + it('returns 0-byte buffer for empty manifest', async () => { + const manifest = new Manifest({ + slug: 'empty', + filename: 'empty.bin', + size: 0, + chunks: [], + }); + + const { buffer, bytesWritten } = await service.restore({ manifest }); + + expect(buffer.length).toBe(0); + expect(bytesWritten).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // Wrong key + // --------------------------------------------------------------------------- + describe('wrong key', () => { + it('throws INTEGRITY_ERROR with wrong decryption key', async () => { + const keyA = randomBytes(32); + const keyB = randomBytes(32); + const original = Buffer.from('encrypted payload'); + const manifest = await storeBuffer(service, original, { encryptionKey: keyA }); + + await expect( + service.restore({ manifest, encryptionKey: keyB }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest, encryptionKey: keyB }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); + }); + + // --------------------------------------------------------------------------- + // Corrupted chunk + // --------------------------------------------------------------------------- + describe('corrupted chunk', () => { + it('throws INTEGRITY_ERROR when chunk data is corrupted', async () => { + const original = Buffer.from('some content to store'); + const manifest = await storeBuffer(service, original); + + // Corrupt the blob in the store + const firstChunk = manifest.chunks[0]; + const corruptBuf = Buffer.from(blobStore.get(firstChunk.blob)); + corruptBuf[0] ^= 0x01; + blobStore.set(firstChunk.blob, corruptBuf); + + // Overwrite readBlob to return corrupted data + // (the oid key still maps, but content is wrong) + await expect( + service.restore({ manifest }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + expect(err.meta.chunkIndex).toBe(0); + } + }); + }); + + // --------------------------------------------------------------------------- + // Key validation + // --------------------------------------------------------------------------- + describe('key validation', () => { + it('throws INVALID_KEY_LENGTH for 16-byte key', async () => { + const manifest = new Manifest({ + slug: 'x', + filename: 'x.bin', + size: 0, + chunks: [], + encryption: { algorithm: 'aes-256-gcm', nonce: 'x', tag: 'x', encrypted: true }, + }); + + await expect( + service.restore({ manifest, encryptionKey: Buffer.alloc(16) }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for string key', async () => { + const manifest = new Manifest({ + slug: 'x', + filename: 'x.bin', + size: 0, + chunks: [], + }); + + await expect( + service.restore({ manifest, encryptionKey: 'bad-key' }), + ).rejects.toThrow(CasError); + }); + }); + + // --------------------------------------------------------------------------- + // Fuzz round-trip + // --------------------------------------------------------------------------- + describe('fuzz round-trip', () => { + const key = randomBytes(32); + + for (let i = 0; i < 50; i++) { + // Sizes from 0 to 3*chunkSize spread across 50 iterations + const size = Math.floor((i / 49) * 3 * 1024); + + it(`round-trips ${size} bytes (plaintext, iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) original[b] = (i + b) & 0xff; + + const manifest = await storeBuffer(service, original); + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); + } + + for (let i = 0; i < 50; i++) { + const size = Math.floor((i / 49) * 3 * 1024); + + it(`round-trips ${size} bytes (encrypted, iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) original[b] = (i * 3 + b) & 0xff; + + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); + } + }); +}); diff --git a/test/unit/domain/services/CasService.stream-error.test.js b/test/unit/domain/services/CasService.stream-error.test.js new file mode 100644 index 0000000..c8da4ac --- /dev/null +++ b/test/unit/domain/services/CasService.stream-error.test.js @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +describe('CasService – stream error recovery (STREAM_ERROR)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + }); + + /** + * Creates an async iterable that yields `n` chunks of `chunkSize` bytes + * then throws an error. + */ + function failingSource(chunksBeforeError, chunkSize = 1024) { + let yielded = 0; + return { + [Symbol.asyncIterator]() { + return { + async next() { + if (yielded >= chunksBeforeError) { + throw new Error('simulated stream failure'); + } + yielded++; + return { value: Buffer.alloc(chunkSize, 0xaa), done: false }; + }, + }; + }, + }; + } + + // --------------------------------------------------------------------------- + // Stream error after N chunks + // --------------------------------------------------------------------------- + it('throws STREAM_ERROR when stream fails after 3 chunks', async () => { + await expect( + service.store({ + source: failingSource(3), + slug: 'fail-test', + filename: 'fail.bin', + }), + ).rejects.toThrow(CasError); + + try { + await service.store({ + source: failingSource(3), + slug: 'fail-test', + filename: 'fail.bin', + }); + } catch (err) { + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(3); + expect(err.message).toContain('simulated stream failure'); + } + }); + + it('throws STREAM_ERROR with chunksWritten=0 when stream fails immediately', async () => { + await expect( + service.store({ + source: failingSource(0), + slug: 'fail-test', + filename: 'fail.bin', + }), + ).rejects.toThrow(CasError); + + try { + await service.store({ + source: failingSource(0), + slug: 'fail-test', + filename: 'fail.bin', + }); + } catch (err) { + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(0); + } + }); + + it('does not return a manifest on stream error', async () => { + let manifest; + try { + manifest = await service.store({ + source: failingSource(2), + slug: 'no-manifest', + filename: 'fail.bin', + }); + } catch { + // expected + } + expect(manifest).toBeUndefined(); + }); + + // --------------------------------------------------------------------------- + // Successful stores still work + // --------------------------------------------------------------------------- + it('succeeds when stream completes normally', async () => { + async function* goodSource() { + yield Buffer.alloc(512, 0xbb); + } + + const manifest = await service.store({ + source: goodSource(), + slug: 'ok', + filename: 'ok.bin', + }); + + expect(manifest).toBeDefined(); + expect(manifest.slug).toBe('ok'); + }); + + // --------------------------------------------------------------------------- + // CasError passthrough (not double-wrapped) + // --------------------------------------------------------------------------- + it('does not wrap CasError as STREAM_ERROR', async () => { + const casErr = new CasError('custom error', 'CUSTOM_CODE'); + const badSource = { + [Symbol.asyncIterator]() { + return { + async next() { throw casErr; }, + }; + }, + }; + + await expect( + service.store({ + source: badSource, + slug: 'x', + filename: 'x.bin', + }), + ).rejects.toThrow(casErr); + + try { + await service.store({ source: badSource, slug: 'x', filename: 'x.bin' }); + } catch (err) { + expect(err.code).toBe('CUSTOM_CODE'); + } + }); + + // --------------------------------------------------------------------------- + // Fuzz: randomized failure points + // --------------------------------------------------------------------------- + describe('fuzz: randomized failure points', () => { + for (let i = 0; i < 20; i++) { + const failAfter = i; + + it(`STREAM_ERROR with chunksWritten=${failAfter} (iteration ${i})`, async () => { + try { + await service.store({ + source: failingSource(failAfter), + slug: `fuzz-${i}`, + filename: 'fuzz.bin', + }); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(failAfter); + } + }); + } + }); +}); diff --git a/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js new file mode 100644 index 0000000..76825ba --- /dev/null +++ b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from 'vitest'; +import GitPersistenceAdapter from '../../../../src/infrastructure/adapters/GitPersistenceAdapter.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +/** + * Create a mock plumbing that returns the given output for `execute`. + */ +function mockPlumbing(output) { + return { + execute: vi.fn().mockResolvedValue(output), + executeStream: vi.fn(), + }; +} + +/** Stub policy that just runs the fn directly. */ +const noPolicy = { execute: (fn) => fn() }; + +describe('GitPersistenceAdapter.readTree()', () => { + // --------------------------------------------------------------------------- + // Golden path + // --------------------------------------------------------------------------- + it('parses a typical ls-tree output with manifest and chunks', async () => { + const output = [ + '100644 blob abc123def456\tmanifest.json', + '100644 blob deadbeef1234\t' + 'a'.repeat(64), + '100644 blob cafebabe5678\t' + 'b'.repeat(64), + ].join('\n'); + + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(output), + policy: noPolicy, + }); + + const entries = await adapter.readTree('some-tree-oid'); + + expect(entries).toHaveLength(3); + expect(entries[0]).toEqual({ + mode: '100644', + type: 'blob', + oid: 'abc123def456', + name: 'manifest.json', + }); + expect(entries[1]).toEqual({ + mode: '100644', + type: 'blob', + oid: 'deadbeef1234', + name: 'a'.repeat(64), + }); + expect(entries[2]).toEqual({ + mode: '100644', + type: 'blob', + oid: 'cafebabe5678', + name: 'b'.repeat(64), + }); + }); + + // --------------------------------------------------------------------------- + // Empty tree + // --------------------------------------------------------------------------- + it('returns [] for empty output', async () => { + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(''), + policy: noPolicy, + }); + + const entries = await adapter.readTree('empty-tree'); + expect(entries).toEqual([]); + }); + + it('returns [] for whitespace-only output', async () => { + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(' \n'), + policy: noPolicy, + }); + + const entries = await adapter.readTree('empty-tree'); + expect(entries).toEqual([]); + }); + + // --------------------------------------------------------------------------- + // Filename with spaces (tab delimiter) + // --------------------------------------------------------------------------- + it('handles filenames with spaces', async () => { + const output = '100644 blob abc123\tfile with spaces.txt'; + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(output), + policy: noPolicy, + }); + + const entries = await adapter.readTree('tree-oid'); + expect(entries[0].name).toBe('file with spaces.txt'); + }); + + // --------------------------------------------------------------------------- + // Malformed output + // --------------------------------------------------------------------------- + it('throws TREE_PARSE_ERROR when line has no tab', async () => { + const output = '100644 blob abc123 no-tab-here'; + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(output), + policy: noPolicy, + }); + + await expect(adapter.readTree('bad-tree')).rejects.toThrow(CasError); + try { + await adapter.readTree('bad-tree'); + } catch (err) { + expect(err.code).toBe('TREE_PARSE_ERROR'); + } + }); + + it('throws TREE_PARSE_ERROR when metadata has wrong number of fields', async () => { + const output = '100644 blob\tmanifest.json'; // only 2 fields before tab + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(output), + policy: noPolicy, + }); + + await expect(adapter.readTree('bad-tree')).rejects.toThrow(CasError); + try { + await adapter.readTree('bad-tree'); + } catch (err) { + expect(err.code).toBe('TREE_PARSE_ERROR'); + } + }); + + // --------------------------------------------------------------------------- + // Fuzz: 1000 entries + // --------------------------------------------------------------------------- + it('parses 1000 synthetic entries', async () => { + const lines = []; + for (let i = 0; i < 1000; i++) { + const oid = i.toString(16).padStart(40, '0'); + lines.push(`100644 blob ${oid}\tchunk-${i}`); + } + const output = lines.join('\n'); + + const adapter = new GitPersistenceAdapter({ + plumbing: mockPlumbing(output), + policy: noPolicy, + }); + + const entries = await adapter.readTree('big-tree'); + expect(entries).toHaveLength(1000); + expect(entries[0].name).toBe('chunk-0'); + expect(entries[999].name).toBe('chunk-999'); + }); + + // --------------------------------------------------------------------------- + // Plumbing error propagation + // --------------------------------------------------------------------------- + it('propagates plumbing errors', async () => { + const plumbing = { + execute: vi.fn().mockRejectedValue(new Error('git failed')), + executeStream: vi.fn(), + }; + const adapter = new GitPersistenceAdapter({ + plumbing, + policy: noPolicy, + }); + + await expect(adapter.readTree('bad-oid')).rejects.toThrow('git failed'); + }); +}); From 66900414971c6b26ca0d1b7815d108c9e98d60e9 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 21:03:55 -0800 Subject: [PATCH 02/10] chore: bump version to 1.2.0 for M2 Boomerang --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0453459..faec651 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/cas", - "version": "1.0.0", + "version": "1.2.0", "description": "Git Stunts Lego Block: cas", "type": "module", "main": "index.js", From c8af59ab6cfdd1672a31ad7121f4db86de6bdded Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 21:04:31 -0800 Subject: [PATCH 03/10] chore: update package.json description to match README --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index faec651..a81ee63 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@git-stunts/cas", "version": "1.2.0", - "description": "Git Stunts Lego Block: cas", + "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs", "type": "module", "main": "index.js", "bin": { From 81ca9949fb5ea0772e3ea7bc47cb809ad4deadd7 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 21:05:00 -0800 Subject: [PATCH 04/10] chore: fix package name to @git-stunts/git-cas --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a81ee63..ac8d35a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@git-stunts/cas", + "name": "@git-stunts/git-cas", "version": "1.2.0", "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs", "type": "module", From d9c7a5bdb2d0e16599de31b0ac51247bac473c93 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 21:32:34 -0800 Subject: [PATCH 05/10] fix: address CodeRabbit PR #2 feedback - Add MISSING_KEY guard in restore() for encrypted manifests without a key - Switch readTree() to git ls-tree -z for NUL-delimited filename safety - Fix fuzz tests to fail explicitly when store() doesn't throw - Align ROADMAP tree CLI signature (--manifest, not --slug) - Clarify store --tree stdout format in ROADMAP contracts --- CHANGELOG.md | 5 ++++- ROADMAP.md | 5 +++-- src/domain/services/CasService.js | 7 +++++++ .../adapters/GitPersistenceAdapter.js | 20 +++++++++---------- .../services/CasService.stream-error.test.js | 9 ++++++++- .../GitPersistenceAdapter.readTree.test.js | 16 +++++++-------- 6 files changed, 40 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe6508..cf78fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,15 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ContentAddressableStore.restoreFile()` — facade method that restores and writes to disk. - `readTree()` on `GitPersistencePort` / `GitPersistenceAdapter` — parse Git trees via `ls-tree`. - `STREAM_ERROR` wrapping — stream failures during `store()` surface as `CasError('STREAM_ERROR')` with `{ chunksWritten }` metadata. +- `MISSING_KEY` error code — `restore()` now fails fast when manifest is encrypted but no decryption key is provided. - CLI: `git cas store`, `git cas tree`, `git cas restore` subcommands via `bin/git-cas.js`. - Integration test suite (59 tests) running against real Git bare repos inside Docker. - `commander` dependency for CLI. ### Changed - `readBlob()` now normalises `Uint8Array` from plumbing into `Buffer` for codec/crypto compatibility. +- `readTree()` uses `git ls-tree -z` (NUL-delimited output) for safe parsing of filenames with leading/trailing spaces. ### Fixed -- None. +- Fuzz tests in stream-error suite now fail explicitly if `store()` does not throw. +- ROADMAP: resolved inconsistent CLI signatures for `git cas tree` (`--slug` vs `--manifest`). ### Security - None. diff --git a/ROADMAP.md b/ROADMAP.md index d710d5b..ceb0573 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,6 +42,7 @@ Single registry of all error codes used across the codebase. Each code is a stri | `INVALID_KEY_TYPE` | Encryption key is not a Buffer. | Task 1.3 | | `INTEGRITY_ERROR` | Decryption auth-tag verification failed (wrong key, tampered ciphertext, or tampered tag), or chunk digest mismatch on restore. | Exists (decrypt); extended by Task 1.6, Task 2.1 | | `STREAM_ERROR` | Read stream failed during `storeFile`. Partial chunks may have been written to Git ODB (unreachable; handled by `git gc`). Meta includes `{ chunksWritten: }`. | Task 2.4 | +| `MISSING_KEY` | Encryption key required to restore encrypted content but none was provided. | Task 2.1 | | `TREE_PARSE_ERROR` | `git ls-tree` output could not be parsed into valid entries. | Task 2.2 | | `MANIFEST_NOT_FOUND` | No manifest entry (e.g. `manifest.json` / `manifest.cbor`) found in the Git tree. | Task 4.1 | | `GIT_ERROR` | Underlying Git plumbing command failed. Wraps the original error from the plumbing layer. | Task 2.2, Task 4.1 | @@ -107,11 +108,11 @@ Return and throw semantics for every public method (current and planned). - **Throws:** Standard Node.js crypto errors on invalid parameters. ### CLI: `git cas store --slug [--key-file ]` *(planned — Task 2.5)* -- **Output:** Prints manifest JSON to stdout (tree OID if `--tree` flag is passed). +- **Output:** Prints manifest JSON to stdout. If `--tree` is passed, prints only the Git tree OID instead. - **Exit 0:** Store succeeded. - **Exit 1:** Store failed (error message to stderr). -### CLI: `git cas tree --slug ` *(planned — Task 2.5)* +### CLI: `git cas tree --manifest ` *(planned — Task 2.5)* - **Output:** Prints Git tree OID to stdout. - **Exit 0:** Tree created. - **Exit 1:** Invalid manifest or Git error (message to stderr). diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index b9b39ab..8a59e40 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -196,6 +196,13 @@ export default class CasService { this._validateKey(encryptionKey); } + if (manifest.encryption?.encrypted && !encryptionKey) { + throw new CasError( + 'Encryption key required to restore encrypted content', + 'MISSING_KEY', + ); + } + if (manifest.chunks.length === 0) { return { buffer: Buffer.alloc(0), bytesWritten: 0 }; } diff --git a/src/infrastructure/adapters/GitPersistenceAdapter.js b/src/infrastructure/adapters/GitPersistenceAdapter.js index 773f8ab..c50c270 100644 --- a/src/infrastructure/adapters/GitPersistenceAdapter.js +++ b/src/infrastructure/adapters/GitPersistenceAdapter.js @@ -58,36 +58,36 @@ export default class GitPersistenceAdapter extends GitPersistencePort { async readTree(treeOid) { return this.policy.execute(async () => { const output = await this.plumbing.execute({ - args: ['ls-tree', treeOid], + args: ['ls-tree', '-z', treeOid], }); - if (!output || output.trim() === '') { + if (!output || output.length === 0) { return []; } - return output.trim().split('\n').map((line) => { + return output.split('\0').filter(Boolean).map((entry) => { // Format: \t - const tabIndex = line.indexOf('\t'); + const tabIndex = entry.indexOf('\t'); if (tabIndex === -1) { throw new CasError( - `Malformed ls-tree line: ${line}`, + `Malformed ls-tree entry: ${entry}`, 'TREE_PARSE_ERROR', - { line }, + { rawEntry: entry }, ); } - const meta = line.slice(0, tabIndex).split(' '); + const meta = entry.slice(0, tabIndex).split(' '); if (meta.length !== 3) { throw new CasError( - `Malformed ls-tree line: ${line}`, + `Malformed ls-tree entry: ${entry}`, 'TREE_PARSE_ERROR', - { line }, + { rawEntry: entry }, ); } return { mode: meta[0], type: meta[1], oid: meta[2], - name: line.slice(tabIndex + 1), + name: entry.slice(tabIndex + 1), }; }); }); diff --git a/test/unit/domain/services/CasService.stream-error.test.js b/test/unit/domain/services/CasService.stream-error.test.js index c8da4ac..0d5bb2e 100644 --- a/test/unit/domain/services/CasService.stream-error.test.js +++ b/test/unit/domain/services/CasService.stream-error.test.js @@ -157,6 +157,14 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { const failAfter = i; it(`STREAM_ERROR with chunksWritten=${failAfter} (iteration ${i})`, async () => { + await expect( + service.store({ + source: failingSource(failAfter), + slug: `fuzz-${i}`, + filename: 'fuzz.bin', + }), + ).rejects.toThrow(CasError); + try { await service.store({ source: failingSource(failAfter), @@ -164,7 +172,6 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { filename: 'fuzz.bin', }); } catch (err) { - expect(err).toBeInstanceOf(CasError); expect(err.code).toBe('STREAM_ERROR'); expect(err.meta.chunksWritten).toBe(failAfter); } diff --git a/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js index 76825ba..af4a87b 100644 --- a/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js +++ b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js @@ -24,7 +24,7 @@ describe('GitPersistenceAdapter.readTree()', () => { '100644 blob abc123def456\tmanifest.json', '100644 blob deadbeef1234\t' + 'a'.repeat(64), '100644 blob cafebabe5678\t' + 'b'.repeat(64), - ].join('\n'); + ].join('\0'); const adapter = new GitPersistenceAdapter({ plumbing: mockPlumbing(output), @@ -67,9 +67,9 @@ describe('GitPersistenceAdapter.readTree()', () => { expect(entries).toEqual([]); }); - it('returns [] for whitespace-only output', async () => { + it('returns [] for NUL-only output', async () => { const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(' \n'), + plumbing: mockPlumbing('\0'), policy: noPolicy, }); @@ -81,7 +81,7 @@ describe('GitPersistenceAdapter.readTree()', () => { // Filename with spaces (tab delimiter) // --------------------------------------------------------------------------- it('handles filenames with spaces', async () => { - const output = '100644 blob abc123\tfile with spaces.txt'; + const output = '100644 blob abc123\tfile with spaces.txt\0'; const adapter = new GitPersistenceAdapter({ plumbing: mockPlumbing(output), policy: noPolicy, @@ -94,8 +94,8 @@ describe('GitPersistenceAdapter.readTree()', () => { // --------------------------------------------------------------------------- // Malformed output // --------------------------------------------------------------------------- - it('throws TREE_PARSE_ERROR when line has no tab', async () => { - const output = '100644 blob abc123 no-tab-here'; + it('throws TREE_PARSE_ERROR when entry has no tab', async () => { + const output = '100644 blob abc123 no-tab-here\0'; const adapter = new GitPersistenceAdapter({ plumbing: mockPlumbing(output), policy: noPolicy, @@ -110,7 +110,7 @@ describe('GitPersistenceAdapter.readTree()', () => { }); it('throws TREE_PARSE_ERROR when metadata has wrong number of fields', async () => { - const output = '100644 blob\tmanifest.json'; // only 2 fields before tab + const output = '100644 blob\tmanifest.json\0'; // only 2 fields before tab const adapter = new GitPersistenceAdapter({ plumbing: mockPlumbing(output), policy: noPolicy, @@ -133,7 +133,7 @@ describe('GitPersistenceAdapter.readTree()', () => { const oid = i.toString(16).padStart(40, '0'); lines.push(`100644 blob ${oid}\tchunk-${i}`); } - const output = lines.join('\n'); + const output = lines.join('\0'); const adapter = new GitPersistenceAdapter({ plumbing: mockPlumbing(output), From d82816d08b09d7d046c582c226e53bc8c8411c63 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 21:53:26 -0800 Subject: [PATCH 06/10] ci: add GitHub Actions workflow for lint, unit, and integration tests Runs lint and unit tests on Node 22 natively, then a Docker matrix across Node/Bun/Deno for both unit and integration tests. --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39f12d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + + test-unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + + test-docker: + runs-on: ubuntu-latest + needs: [lint, test-unit] + strategy: + fail-fast: false + matrix: + runtime: [node, bun, deno] + include: + - runtime: node + integration_cmd: npx vitest run test/integration + - runtime: bun + integration_cmd: bunx vitest run test/integration + - runtime: deno + integration_cmd: deno run -A npm:vitest run test/integration + steps: + - uses: actions/checkout@v4 + - name: Build test-${{ matrix.runtime }} + run: docker compose build test-${{ matrix.runtime }} + - name: Unit tests (${{ matrix.runtime }}) + run: docker compose run --rm test-${{ matrix.runtime }} + - name: Integration tests (${{ matrix.runtime }}) + run: docker compose run --rm test-${{ matrix.runtime }} ${{ matrix.integration_cmd }} From 98993fd19431acf38b0005eef2f52f8416a47ba3 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 22:06:22 -0800 Subject: [PATCH 07/10] fix: resolve ESLint errors across source and test files Fix linting violations including unused imports, prefer-template, curly brace enforcement, and no-new warnings. Extract _readAndVerifyChunks helper in CasService to reduce function complexity. --- bin/git-cas.js | 8 +- src/domain/services/CasService.js | 40 +- test/benchmark/cas.bench.js | 2 - test/integration/round-trip.test.js | 44 +- .../domain/services/CasService.crypto.test.js | 412 +++++++++------- .../services/CasService.empty-file.test.js | 152 ++++-- .../domain/services/CasService.errors.test.js | 218 +++++---- .../CasService.key-validation.test.js | 402 +++++++++------- .../services/CasService.restore.test.js | 452 ++++++++++-------- .../services/CasService.stream-error.test.js | 193 +++++--- test/unit/domain/services/CasService.test.js | 58 ++- test/unit/domain/value-objects/Chunk.test.js | 27 +- .../domain/value-objects/Manifest.test.js | 27 +- .../GitPersistenceAdapter.readTree.test.js | 141 ++---- 14 files changed, 1234 insertions(+), 942 deletions(-) diff --git a/bin/git-cas.js b/bin/git-cas.js index 6d4e59f..0387cde 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { program } from 'commander'; import GitPlumbing from '@git-stunts/plumbing'; import ContentAddressableStore from '../index.js'; @@ -53,9 +53,9 @@ program if (opts.tree) { const treeOid = await cas.createTree({ manifest }); - process.stdout.write(treeOid + '\n'); + process.stdout.write(`${treeOid }\n`); } else { - process.stdout.write(JSON.stringify(manifest.toJSON(), null, 2) + '\n'); + process.stdout.write(`${JSON.stringify(manifest.toJSON(), null, 2) }\n`); } } catch (err) { process.stderr.write(`error: ${err.message}\n`); @@ -77,7 +77,7 @@ program const raw = readFileSync(opts.manifest, 'utf8'); const manifest = new Manifest(JSON.parse(raw)); const treeOid = await cas.createTree({ manifest }); - process.stdout.write(treeOid + '\n'); + process.stdout.write(`${treeOid }\n`); } catch (err) { process.stderr.write(`error: ${err.message}\n`); process.exit(1); diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index 8a59e40..1c84545 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -60,7 +60,7 @@ export default class CasService { } } } catch (err) { - if (err instanceof CasError) throw err; + if (err instanceof CasError) {throw err;} throw new CasError( `Stream error during store: ${err.message}`, 'STREAM_ERROR', @@ -124,7 +124,7 @@ export default class CasService { try { return this.crypto.decryptBuffer(buffer, key, meta); } catch (err) { - if (err instanceof CasError) throw err; + if (err instanceof CasError) {throw err;} throw new CasError('Decryption failed: Integrity check error', 'INTEGRITY_ERROR', { originalError: err }); } } @@ -191,6 +191,27 @@ export default class CasService { * @param {Buffer} [options.encryptionKey] * @returns {Promise<{ buffer: Buffer, bytesWritten: number }>} */ + /** + * Reads chunk blobs and verifies their SHA-256 digests. + * @private + */ + async _readAndVerifyChunks(chunks) { + const buffers = []; + for (const chunk of chunks) { + const blob = await this.persistence.readBlob(chunk.blob); + const digest = this._sha256(blob); + if (digest !== chunk.digest) { + throw new CasError( + `Chunk ${chunk.index} integrity check failed`, + 'INTEGRITY_ERROR', + { chunkIndex: chunk.index, expected: chunk.digest, actual: digest }, + ); + } + buffers.push(blob); + } + return buffers; + } + async restore({ manifest, encryptionKey }) { if (encryptionKey) { this._validateKey(encryptionKey); @@ -207,20 +228,7 @@ export default class CasService { return { buffer: Buffer.alloc(0), bytesWritten: 0 }; } - const chunks = []; - for (const chunk of manifest.chunks) { - const blob = await this.persistence.readBlob(chunk.blob); - const digest = this._sha256(blob); - if (digest !== chunk.digest) { - throw new CasError( - `Chunk ${chunk.index} integrity check failed`, - 'INTEGRITY_ERROR', - { chunkIndex: chunk.index, expected: chunk.digest, actual: digest }, - ); - } - chunks.push(blob); - } - + const chunks = await this._readAndVerifyChunks(manifest.chunks); let buffer = Buffer.concat(chunks); if (manifest.encryption?.encrypted && encryptionKey) { diff --git a/test/benchmark/cas.bench.js b/test/benchmark/cas.bench.js index a438264..4679d67 100644 --- a/test/benchmark/cas.bench.js +++ b/test/benchmark/cas.bench.js @@ -10,8 +10,6 @@ const mockPersistence = { }; describe('CasService Benchmarks', () => { - const service = new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec() }); - bench('service initialization', () => { new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec() }); }); diff --git a/test/integration/round-trip.test.js b/test/integration/round-trip.test.js index 30bbdf6..dba9fb8 100644 --- a/test/integration/round-trip.test.js +++ b/test/integration/round-trip.test.js @@ -55,9 +55,9 @@ function tempFile(content) { } // --------------------------------------------------------------------------- -// Plaintext round trip — JSON codec +// Plaintext round trip (JSON) – basic // --------------------------------------------------------------------------- -describe('plaintext round trip (JSON)', () => { +describe('plaintext round trip (JSON) – basic', () => { it('10 KB file', async () => { const original = randomBytes(10 * 1024); const { filePath, dir } = tempFile(original); @@ -65,7 +65,6 @@ describe('plaintext round trip (JSON)', () => { const manifest = await cas.storeFile({ filePath, slug: 'plain-10k' }); const treeOid = await cas.createTree({ manifest }); - // Read tree back const entries = await cas.service.persistence.readTree(treeOid); const manifestEntry = entries.find((e) => e.name === 'manifest.json'); expect(manifestEntry).toBeDefined(); @@ -98,7 +97,12 @@ describe('plaintext round trip (JSON)', () => { rmSync(dir, { recursive: true, force: true }); }); +}); +// --------------------------------------------------------------------------- +// Plaintext round trip (JSON) – chunk boundaries +// --------------------------------------------------------------------------- +describe('plaintext round trip (JSON) – chunk boundaries', () => { it('exact chunkSize file (256 KiB)', async () => { const original = randomBytes(256 * 1024); const { filePath, dir } = tempFile(original); @@ -127,9 +131,9 @@ describe('plaintext round trip (JSON)', () => { }); // --------------------------------------------------------------------------- -// Encrypted round trip — JSON codec +// Encrypted round trip (JSON) – success // --------------------------------------------------------------------------- -describe('encrypted round trip (JSON)', () => { +describe('encrypted round trip (JSON) – success', () => { const key = randomBytes(32); it('10 KB encrypted file', async () => { @@ -137,33 +141,36 @@ describe('encrypted round trip (JSON)', () => { const { filePath, dir } = tempFile(original); const manifest = await cas.storeFile({ - filePath, - slug: 'enc-10k', - encryptionKey: key, + filePath, slug: 'enc-10k', encryptionKey: key, }); expect(manifest.encryption).toBeDefined(); expect(manifest.encryption.encrypted).toBe(true); const treeOid = await cas.createTree({ manifest }); const entries = await cas.service.persistence.readTree(treeOid); - const manifestEntry = entries.find((e) => e.name === 'manifest.json'); - const manifestBlob = await cas.service.persistence.readBlob(manifestEntry.oid); - const restored = new Manifest(cas.service.codec.decode(manifestBlob)); + const mEntry = entries.find((e) => e.name === 'manifest.json'); + const mBlob = await cas.service.persistence.readBlob(mEntry.oid); + const restored = new Manifest(cas.service.codec.decode(mBlob)); const { buffer } = await cas.restore({ manifest: restored, encryptionKey: key }); expect(buffer.equals(original)).toBe(true); rmSync(dir, { recursive: true, force: true }); }); +}); + +// --------------------------------------------------------------------------- +// Encrypted round trip (JSON) – wrong key +// --------------------------------------------------------------------------- +describe('encrypted round trip (JSON) – wrong key', () => { + const key = randomBytes(32); it('wrong key throws INTEGRITY_ERROR', async () => { const original = randomBytes(1024); const { filePath, dir } = tempFile(original); const manifest = await cas.storeFile({ - filePath, - slug: 'enc-wrong-key', - encryptionKey: key, + filePath, slug: 'enc-wrong-key', encryptionKey: key, }); const wrongKey = randomBytes(32); @@ -211,9 +218,7 @@ describe('CBOR codec round trip', () => { const { filePath, dir } = tempFile(original); const manifest = await casCbor.storeFile({ - filePath, - slug: 'cbor-enc', - encryptionKey: key, + filePath, slug: 'cbor-enc', encryptionKey: key, }); const { buffer } = await casCbor.restore({ manifest, encryptionKey: key }); @@ -237,8 +242,7 @@ describe('restoreFile (write to disk)', () => { const outPath = path.join(outDir, 'restored.bin'); const { bytesWritten } = await cas.restoreFile({ - manifest, - outputPath: outPath, + manifest, outputPath: outPath, }); expect(bytesWritten).toBe(original.length); @@ -264,7 +268,7 @@ describe('fuzz: 50 file sizes around chunk boundaries', () => { it(`round-trips ${size} bytes (iteration ${i})`, async () => { const original = Buffer.alloc(size); - for (let b = 0; b < size; b++) original[b] = (i + b) & 0xff; + for (let b = 0; b < size; b++) {original[b] = (i + b) & 0xff;} const { filePath, dir } = tempFile(original); const manifest = await cas.storeFile({ filePath, slug: `fuzz-${i}` }); diff --git a/test/unit/domain/services/CasService.crypto.test.js b/test/unit/domain/services/CasService.crypto.test.js index 5f679bd..5fe57f0 100644 --- a/test/unit/domain/services/CasService.crypto.test.js +++ b/test/unit/domain/services/CasService.crypto.test.js @@ -5,7 +5,10 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import CasError from '../../../../src/domain/errors/CasError.js'; -describe('CasService encryption round-trip', () => { +// --------------------------------------------------------------------------- +// 1. Round-trip golden path +// --------------------------------------------------------------------------- +describe('CasService encryption – round-trip golden path', () => { let service; let mockPersistence; @@ -23,203 +26,288 @@ describe('CasService encryption round-trip', () => { }); }); - // --------------------------------------------------------------------------- - // Round-trip (Golden path) - // --------------------------------------------------------------------------- - describe('round-trip golden path', () => { - const key = randomBytes(32); + const key = randomBytes(32); - it('encrypts then decrypts a 0-byte buffer', () => { - const plaintext = Buffer.alloc(0); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); + it('encrypts then decrypts a 0-byte buffer', () => { + const plaintext = Buffer.alloc(0); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); - it('encrypts then decrypts a 1-byte buffer', () => { - const plaintext = Buffer.from([0x42]); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); + it('encrypts then decrypts a 1-byte buffer', () => { + const plaintext = Buffer.from([0x42]); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); - it('encrypts then decrypts a 1 KB buffer', () => { - const plaintext = randomBytes(1024); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); + it('encrypts then decrypts a 1 KB buffer', () => { + const plaintext = randomBytes(1024); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); - it('encrypts then decrypts a 1 MB buffer', () => { - const plaintext = randomBytes(1024 * 1024); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); + it('encrypts then decrypts a 1 MB buffer', () => { + const plaintext = randomBytes(1024 * 1024); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2a. Integrity failures – wrong key and tampered ciphertext +// --------------------------------------------------------------------------- +describe('CasService encryption – wrong key and tampered ciphertext', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Wrong key - // --------------------------------------------------------------------------- - describe('wrong key', () => { - it('throws INTEGRITY_ERROR when decrypting with a different key', () => { - const keyA = randomBytes(32); - const keyB = randomBytes(32); - const plaintext = Buffer.from('secret message'); + it('throws INTEGRITY_ERROR when decrypting with a different key', () => { + const keyA = randomBytes(32); + const keyB = randomBytes(32); + const plaintext = Buffer.from('secret message'); - const { buf, meta } = service.encrypt({ buffer: plaintext, key: keyA }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key: keyA }); - expect(() => service.decrypt({ buffer: buf, key: keyB, meta })).toThrow(CasError); - try { - service.decrypt({ buffer: buf, key: keyB, meta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + expect(() => service.decrypt({ buffer: buf, key: keyB, meta })).toThrow(CasError); + try { + service.decrypt({ buffer: buf, key: keyB, meta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); + + it('throws INTEGRITY_ERROR when a bit is flipped in the encrypted buffer', () => { + const key = randomBytes(32); + const plaintext = Buffer.from('this is sensitive data'); + + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + + const tampered = Buffer.from(buf); + tampered[0] ^= 0x01; + + expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); + try { + service.decrypt({ buffer: tampered, key, meta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2b. Integrity failures – tampered auth tag and tampered nonce +// --------------------------------------------------------------------------- +describe('CasService encryption – tampered auth tag', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Tampered ciphertext - // --------------------------------------------------------------------------- - describe('tampered ciphertext', () => { - it('throws INTEGRITY_ERROR when a bit is flipped in the encrypted buffer', () => { - const key = randomBytes(32); - const plaintext = Buffer.from('this is sensitive data'); + it('throws INTEGRITY_ERROR when the auth tag is modified', () => { + const key = randomBytes(32); + const plaintext = Buffer.from('protected payload'); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - // Flip the first bit of the first byte - const tampered = Buffer.from(buf); - tampered[0] ^= 0x01; + const tagBuf = Buffer.from(meta.tag, 'base64'); + tagBuf[0] ^= 0x01; + const tamperedMeta = { ...meta, tag: tagBuf.toString('base64') }; - expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); - try { - service.decrypt({ buffer: tampered, key, meta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); + try { + service.decrypt({ buffer: buf, key, meta: tamperedMeta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2c. Integrity failures – tampered nonce +// --------------------------------------------------------------------------- +describe('CasService encryption – tampered nonce', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Tampered auth tag - // --------------------------------------------------------------------------- - describe('tampered auth tag', () => { - it('throws INTEGRITY_ERROR when the auth tag is modified', () => { - const key = randomBytes(32); - const plaintext = Buffer.from('protected payload'); + it('throws INTEGRITY_ERROR when the nonce is modified', () => { + const key = randomBytes(32); + const plaintext = Buffer.from('nonce-sensitive content'); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - // Decode tag, flip one bit, re-encode - const tagBuf = Buffer.from(meta.tag, 'base64'); - tagBuf[0] ^= 0x01; - const tamperedMeta = { ...meta, tag: tagBuf.toString('base64') }; + const nonceBuf = Buffer.from(meta.nonce, 'base64'); + nonceBuf[0] ^= 0x01; + const tamperedMeta = { ...meta, nonce: nonceBuf.toString('base64') }; - expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); - try { - service.decrypt({ buffer: buf, key, meta: tamperedMeta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); + try { + service.decrypt({ buffer: buf, key, meta: tamperedMeta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 3. Passthrough (no encryption) +// --------------------------------------------------------------------------- +describe('CasService encryption – passthrough', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Tampered nonce - // --------------------------------------------------------------------------- - describe('tampered nonce', () => { - it('throws INTEGRITY_ERROR when the nonce is modified', () => { - const key = randomBytes(32); - const plaintext = Buffer.from('nonce-sensitive content'); + it('returns buffer unchanged when meta.encrypted is false', () => { + const buffer = Buffer.from('not encrypted'); + const result = service.decrypt({ buffer, key: undefined, meta: { encrypted: false } }); + expect(result.equals(buffer)).toBe(true); + }); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + it('returns buffer unchanged when meta is undefined', () => { + const buffer = Buffer.from('no meta at all'); + const result = service.decrypt({ buffer, key: undefined, meta: undefined }); + expect(result.equals(buffer)).toBe(true); + }); +}); - // Decode nonce, flip one bit, re-encode - const nonceBuf = Buffer.from(meta.nonce, 'base64'); - nonceBuf[0] ^= 0x01; - const tamperedMeta = { ...meta, nonce: nonceBuf.toString('base64') }; +// --------------------------------------------------------------------------- +// 4. Fuzz round-trip +// --------------------------------------------------------------------------- +describe('CasService encryption – fuzz round-trip', () => { + let service; + let mockPersistence; - expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); - try { - service.decrypt({ buffer: buf, key, meta: tamperedMeta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Passthrough - meta.encrypted = false - // --------------------------------------------------------------------------- - describe('passthrough', () => { - it('returns buffer unchanged when meta.encrypted is false', () => { - const buffer = Buffer.from('not encrypted'); - const result = service.decrypt({ buffer, key: undefined, meta: { encrypted: false } }); - expect(result.equals(buffer)).toBe(true); + const key = randomBytes(32); + + for (let i = 0; i < 50; i++) { + const size = Math.floor((i / 49) * 100 * 1024); + + it(`round-trips a ${size}-byte buffer (iteration ${i})`, () => { + const plaintext = Buffer.alloc(size); + for (let b = 0; b < size; b++) { + plaintext[b] = (i + b) & 0xff; + } + + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); }); + } +}); + +// --------------------------------------------------------------------------- +// 5. Fuzz tamper detection +// --------------------------------------------------------------------------- +describe('CasService encryption – fuzz tamper', () => { + let service; + let mockPersistence; - it('returns buffer unchanged when meta is undefined', () => { - const buffer = Buffer.from('no meta at all'); - const result = service.decrypt({ buffer, key: undefined, meta: undefined }); - expect(result.equals(buffer)).toBe(true); + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Fuzz round-trip - // --------------------------------------------------------------------------- - describe('fuzz round-trip', () => { - const key = randomBytes(32); + const key = randomBytes(32); - for (let i = 0; i < 50; i++) { - // Deterministic sizes from 0 to ~100 KB spread across 50 iterations - const size = Math.floor((i / 49) * 100 * 1024); - - it(`round-trips a ${size}-byte buffer (iteration ${i})`, () => { - // Build a deterministic buffer: each byte = (i + byteIndex) & 0xff - const plaintext = Buffer.alloc(size); - for (let b = 0; b < size; b++) { - plaintext[b] = (i + b) & 0xff; - } - - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); - } - }); + for (let i = 0; i < 50; i++) { + const size = Math.max(1, Math.floor((i / 49) * 1024)); - // --------------------------------------------------------------------------- - // Fuzz tamper - // --------------------------------------------------------------------------- - describe('fuzz tamper', () => { - const key = randomBytes(32); + it(`detects tamper on a ${size}-byte buffer (iteration ${i})`, () => { + const plaintext = Buffer.alloc(size); + for (let b = 0; b < size; b++) { + plaintext[b] = (i * 7 + b) & 0xff; + } - for (let i = 0; i < 50; i++) { - // Use a minimum size of 1 so we always have at least one byte to tamper - const size = Math.max(1, Math.floor((i / 49) * 1024)); - - it(`detects tamper on a ${size}-byte buffer (iteration ${i})`, () => { - const plaintext = Buffer.alloc(size); - for (let b = 0; b < size; b++) { - plaintext[b] = (i * 7 + b) & 0xff; - } - - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - - // Tamper one byte at a deterministic index - const tampered = Buffer.from(buf); - const tamperIndex = i % tampered.length; - tampered[tamperIndex] ^= 0x01; - - expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); - try { - service.decrypt({ buffer: tampered, key, meta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } - }); - } - }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + + const tampered = Buffer.from(buf); + const tamperIndex = i % tampered.length; + tampered[tamperIndex] ^= 0x01; + + expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); + try { + service.decrypt({ buffer: tampered, key, meta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); + } }); diff --git a/test/unit/domain/services/CasService.empty-file.test.js b/test/unit/domain/services/CasService.empty-file.test.js index 76f89c3..a52c32b 100644 --- a/test/unit/domain/services/CasService.empty-file.test.js +++ b/test/unit/domain/services/CasService.empty-file.test.js @@ -7,44 +7,51 @@ import CasService from '../../../../src/domain/services/CasService.js'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; -describe('CasService – empty (0-byte) file handling', () => { +/** + * Helper: writes a 0-byte file and returns its path. + */ +function emptyFile(tempDir, name = 'empty.bin') { + const fp = path.join(tempDir, name); + writeFileSync(fp, Buffer.alloc(0)); + return fp; +} + +/** + * Shared factory: builds the standard test fixtures. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.alloc(0)), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-empty-')); + return { mockPersistence, service, tempDir }; +} + +// --------------------------------------------------------------------------- +// 1. Store – plaintext +// --------------------------------------------------------------------------- +describe('CasService – empty file store plaintext', () => { let service; - let mockPersistence; let tempDir; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockResolvedValue(Buffer.alloc(0)), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-empty-')); + ({ service, tempDir } = setup()); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - /** - * Helper: writes a 0-byte file and returns its path. - */ - function emptyFile(name = 'empty.bin') { - const fp = path.join(tempDir, name); - writeFileSync(fp, Buffer.alloc(0)); - return fp; - } - - // --------------------------------------------------------------- - // 1. Store 0-byte file -> manifest has size=0 and chunks=[] - // --------------------------------------------------------------- it('stores a 0-byte file and produces a manifest with size=0 and no chunks', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); const manifest = await service.store({ source: createReadStream(filePath), @@ -58,12 +65,25 @@ describe('CasService – empty (0-byte) file handling', () => { expect(manifest.filename).toBe('empty.bin'); expect(manifest.encryption).toBeUndefined(); }); +}); + +// --------------------------------------------------------------------------- +// 2. Store – encrypted +// --------------------------------------------------------------------------- +describe('CasService – empty file store encrypted', () => { + let service; + let tempDir; + + beforeEach(() => { + ({ service, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); - // --------------------------------------------------------------- - // 2. Store 0-byte file with encryption key -> valid manifest, chunks=[] - // --------------------------------------------------------------- it('stores a 0-byte file with an encryption key and produces a valid encrypted manifest', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); const encryptionKey = randomBytes(32); const manifest = await service.store({ @@ -73,11 +93,6 @@ describe('CasService – empty (0-byte) file handling', () => { encryptionKey, }); - // AES-256-GCM produces a 16-byte auth tag even for empty plaintext, - // so cipher.final() may emit a small block. The encrypted stream for - // an empty file still results in a non-zero ciphertext output from - // the cipher finalisation. Regardless of whether the cipher emits - // bytes, the manifest must be structurally valid. expect(manifest.slug).toBe('enc-empty'); expect(manifest.filename).toBe('empty-enc.bin'); expect(manifest.encryption).toBeDefined(); @@ -86,19 +101,33 @@ describe('CasService – empty (0-byte) file handling', () => { expect(manifest.encryption.tag).toEqual(expect.any(String)); expect(manifest.encryption.encrypted).toBe(true); - // Every chunk (if any) must still pass schema validation (index, digest, blob). + // Every chunk (if any) must still pass schema validation. for (const chunk of manifest.chunks) { expect(chunk.index).toBeGreaterThanOrEqual(0); expect(chunk.digest).toHaveLength(64); expect(chunk.blob).toBeTruthy(); } }); +}); + +// --------------------------------------------------------------------------- +// 3. writeBlob not called for chunk data +// --------------------------------------------------------------------------- +describe('CasService – empty file writeBlob not called', () => { + let service; + let mockPersistence; + let tempDir; + + beforeEach(() => { + ({ service, mockPersistence, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); - // --------------------------------------------------------------- - // 3. writeBlob is NOT called for chunk content on a plain empty file - // --------------------------------------------------------------- it('does not call writeBlob for chunk data when storing a plain 0-byte file', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); await service.store({ source: createReadStream(filePath), @@ -110,9 +139,26 @@ describe('CasService – empty (0-byte) file handling', () => { // (it is only called inside _chunkAndStore for chunk data). expect(mockPersistence.writeBlob).not.toHaveBeenCalled(); }); +}); + +// --------------------------------------------------------------------------- +// 4. writeBlob called once for createTree +// --------------------------------------------------------------------------- +describe('CasService – empty file writeBlob createTree', () => { + let service; + let mockPersistence; + let tempDir; + + beforeEach(() => { + ({ service, mockPersistence, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); it('calls writeBlob exactly once (for the manifest) when createTree follows a plain 0-byte store', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); const manifest = await service.store({ source: createReadStream(filePath), @@ -133,13 +179,27 @@ describe('CasService – empty (0-byte) file handling', () => { expect(parsed.slug).toBe('tree-empty'); expect(parsed.chunks).toEqual([]); }); +}); + +// --------------------------------------------------------------------------- +// 5. 100 repeated empty-file stores — no state leakage +// --------------------------------------------------------------------------- +describe('CasService – empty file repeated stores', () => { + let service; + let mockPersistence; + let tempDir; + + beforeEach(() => { + ({ service, mockPersistence, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); - // --------------------------------------------------------------- - // 4. 100 repeated empty-file stores — no state leakage - // --------------------------------------------------------------- it('handles 100 repeated empty-file stores without state leakage', async () => { for (let i = 0; i < 100; i++) { - const filePath = emptyFile(`empty-${i}.bin`); + const filePath = emptyFile(tempDir, `empty-${i}.bin`); const manifest = await service.store({ source: createReadStream(filePath), slug: `iter-${i}`, diff --git a/test/unit/domain/services/CasService.errors.test.js b/test/unit/domain/services/CasService.errors.test.js index 04573e2..e0c4cff 100644 --- a/test/unit/domain/services/CasService.errors.test.js +++ b/test/unit/domain/services/CasService.errors.test.js @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createHash } from 'node:crypto'; -import { createReadStream, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; +import { createReadStream } from 'node:fs'; import CasService from '../../../../src/domain/services/CasService.js'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; @@ -11,7 +9,7 @@ import Manifest from '../../../../src/domain/value-objects/Manifest.js'; /** Deterministic SHA-256 hex digest for a given string. */ const sha256 = (str) => createHash('sha256').update(str).digest('hex'); -describe('CasService – error paths', () => { +describe('CasService – constructor – chunkSize validation', () => { let mockPersistence; beforeEach(() => { @@ -22,121 +20,141 @@ describe('CasService – error paths', () => { }; }); - // ─── constructor validation ───────────────────────────────────────── + it('throws when chunkSize is 0', () => { + expect( + () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 0 }), + ).toThrow('Chunk size must be at least 1024 bytes'); + }); - describe('constructor – chunkSize validation', () => { - it('throws when chunkSize is 0', () => { - expect( - () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 0 }), - ).toThrow('Chunk size must be at least 1024 bytes'); - }); + it('throws when chunkSize is 512', () => { + expect( + () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 512 }), + ).toThrow('Chunk size must be at least 1024 bytes'); + }); - it('throws when chunkSize is 512', () => { - expect( - () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 512 }), - ).toThrow('Chunk size must be at least 1024 bytes'); + it('accepts chunkSize of exactly 1024', () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); + expect(service.chunkSize).toBe(1024); + }); +}); - it('accepts chunkSize of exactly 1024', () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - expect(service.chunkSize).toBe(1024); - }); +describe('CasService – store', () => { + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; }); - // ─── store – nonexistent file ─────────────────────────────────── - - describe('store', () => { - it('rejects when source stream errors (nonexistent file)', async () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - - await expect( - service.store({ - source: createReadStream('/no/such/file.bin'), - slug: 'bad-path', - filename: 'file.bin', - }), - ).rejects.toThrow(); + it('rejects when source stream errors (nonexistent file)', async () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); + + await expect( + service.store({ + source: createReadStream('/no/such/file.bin'), + slug: 'bad-path', + filename: 'file.bin', + }), + ).rejects.toThrow(); }); +}); - // ─── verifyIntegrity – corrupted blob ─────────────────────────────── +describe('CasService – verifyIntegrity', () => { + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + }); - describe('verifyIntegrity', () => { - it('returns false (not throws) when blob data is corrupted', async () => { - const originalData = 'original-content'; - const correctDigest = sha256(originalData); + it('returns false (not throws) when blob data is corrupted', async () => { + const originalData = 'original-content'; + const correctDigest = sha256(originalData); - // readBlob returns corrupted data that does not match the digest - mockPersistence.readBlob = vi - .fn() - .mockResolvedValue(Buffer.from('corrupted-content')); + // readBlob returns corrupted data that does not match the digest + mockPersistence.readBlob = vi + .fn() + .mockResolvedValue(Buffer.from('corrupted-content')); - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); - const manifest = new Manifest({ - slug: 'integrity-test', - filename: 'file.bin', - size: originalData.length, - chunks: [ - { - index: 0, - size: originalData.length, - blob: 'blob-oid-1', - digest: correctDigest, - }, - ], - }); - - const result = await service.verifyIntegrity(manifest); - expect(result).toBe(false); + const manifest = new Manifest({ + slug: 'integrity-test', + filename: 'file.bin', + size: originalData.length, + chunks: [ + { + index: 0, + size: originalData.length, + blob: 'blob-oid-1', + digest: correctDigest, + }, + ], }); + + const result = await service.verifyIntegrity(manifest); + expect(result).toBe(false); }); +}); - // ─── createTree – invalid manifest ────────────────────────────────── - - describe('createTree', () => { - it('throws when manifest is not a valid Manifest object', async () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - - // A plain object that lacks .toJSON() and .chunks - await expect( - service.createTree({ manifest: {} }), - ).rejects.toThrow(); - }); +describe('CasService – createTree', () => { + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + }); - it('throws when manifest.toJSON is not a function', async () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + it('throws when manifest is not a valid Manifest object', async () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); - const badManifest = { toJSON: 'not-a-function', chunks: [] }; + // A plain object that lacks .toJSON() and .chunks + await expect( + service.createTree({ manifest: {} }), + ).rejects.toThrow(); + }); - await expect( - service.createTree({ manifest: badManifest }), - ).rejects.toThrow(); + it('throws when manifest.toJSON is not a function', async () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); + + const badManifest = { toJSON: 'not-a-function', chunks: [] }; + + await expect( + service.createTree({ manifest: badManifest }), + ).rejects.toThrow(); }); }); diff --git a/test/unit/domain/services/CasService.key-validation.test.js b/test/unit/domain/services/CasService.key-validation.test.js index 190f80d..b63c2bb 100644 --- a/test/unit/domain/services/CasService.key-validation.test.js +++ b/test/unit/domain/services/CasService.key-validation.test.js @@ -8,191 +8,229 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import CasError from '../../../../src/domain/errors/CasError.js'; -describe('CasService key validation', () => { +function createService(mockPersistence) { + return new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); +} + +function createMockPersistence() { + return { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; +} + +describe('CasService key validation – encrypt() valid keys', () => { let service; - let mockPersistence; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - }); - - describe('encrypt() key validation', () => { - const plaintext = Buffer.from('hello world'); - - it('accepts a 32-byte Buffer key', () => { - const key = Buffer.alloc(32, 0xaa); - expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); - }); - - it('accepts crypto.randomBytes(32)', () => { - const key = randomBytes(32); - expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); - }); - - it('throws INVALID_KEY_LENGTH for a 16-byte key', () => { - const key = Buffer.alloc(16); - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_LENGTH'); - expect(err.message).toContain('32 bytes'); - expect(err.meta).toEqual({ expected: 32, actual: 16 }); - } - }); - - it('throws INVALID_KEY_LENGTH for a 64-byte key', () => { - const key = Buffer.alloc(64); - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_LENGTH'); - expect(err.message).toContain('32 bytes'); - expect(err.meta).toEqual({ expected: 32, actual: 64 }); - } - }); - - it('throws INVALID_KEY_LENGTH for an empty Buffer', () => { - const key = Buffer.alloc(0); - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_LENGTH'); - expect(err.meta).toEqual({ expected: 32, actual: 0 }); - } - }); - - it('throws INVALID_KEY_TYPE for a string key', () => { - const key = 'not-a-buffer-key-string-value!!!'; - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_TYPE'); - expect(err.message).toContain('must be a Buffer'); - } - }); - - it('throws INVALID_KEY_TYPE for a number key', () => { - const key = 12345; - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_TYPE'); - expect(err.message).toContain('must be a Buffer'); - } - }); - - it('throws INVALID_KEY_TYPE for null key', () => { - const key = null; - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_TYPE'); - expect(err.message).toContain('must be a Buffer'); - } - }); - }); - - describe('store() key validation', () => { - let tempDir; - let filePath; - - beforeEach(() => { - tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-key-test-')); - filePath = path.join(tempDir, 'test.txt'); - writeFileSync(filePath, 'test content'); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - /** Dummy async iterable that yields nothing — avoids opening a real fd. */ - async function* emptySource() {} - - it('accepts a 32-byte Buffer encryptionKey', async () => { - const key = Buffer.alloc(32, 0xbb); - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).resolves.toBeDefined(); - }); - - it('accepts crypto.randomBytes(32) as encryptionKey', async () => { - const key = randomBytes(32); - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).resolves.toBeDefined(); - }); - - it('stores without error when no encryptionKey is provided', async () => { - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt' }), - ).resolves.toBeDefined(); - }); - - it('throws INVALID_KEY_LENGTH for a 16-byte encryptionKey', async () => { - const key = Buffer.alloc(16); - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('throws INVALID_KEY_LENGTH for a 64-byte encryptionKey', async () => { - const key = Buffer.alloc(64); - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('throws INVALID_KEY_TYPE for a string encryptionKey', async () => { - const key = 'string-key'; - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('throws INVALID_KEY_TYPE for a number encryptionKey', async () => { - const key = 42; - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('does not throw for null encryptionKey (treated as no key)', async () => { - // null is falsy, so store skips validation entirely - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: null }), - ).resolves.toBeDefined(); - }); - }); - - describe('fuzz: key lengths 0..128', () => { - const plaintext = Buffer.from('fuzz test data'); - - it('only length 32 passes for encrypt()', () => { - for (let len = 0; len <= 128; len++) { - const key = Buffer.alloc(len, 0xff); - if (len === 32) { - expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); - } else { - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - } + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('hello world'); + + it('accepts a 32-byte Buffer key', () => { + const key = Buffer.alloc(32, 0xaa); + expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); + }); + + it('accepts crypto.randomBytes(32)', () => { + const key = randomBytes(32); + expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); + }); +}); + +describe('CasService key validation – encrypt() invalid key length', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('hello world'); + + it('throws INVALID_KEY_LENGTH for a 16-byte key', () => { + const key = Buffer.alloc(16); + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_LENGTH'); + expect(err.message).toContain('32 bytes'); + expect(err.meta).toEqual({ expected: 32, actual: 16 }); + } + }); + + it('throws INVALID_KEY_LENGTH for a 64-byte key', () => { + const key = Buffer.alloc(64); + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_LENGTH'); + expect(err.message).toContain('32 bytes'); + expect(err.meta).toEqual({ expected: 32, actual: 64 }); + } + }); + + it('throws INVALID_KEY_LENGTH for an empty Buffer', () => { + const key = Buffer.alloc(0); + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_LENGTH'); + expect(err.meta).toEqual({ expected: 32, actual: 0 }); + } + }); +}); + +describe('CasService key validation – encrypt() invalid key type', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('hello world'); + + it('throws INVALID_KEY_TYPE for a string key', () => { + const key = 'not-a-buffer-key-string-value!!!'; + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_TYPE'); + expect(err.message).toContain('must be a Buffer'); + } + }); + + it('throws INVALID_KEY_TYPE for a number key', () => { + const key = 12345; + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_TYPE'); + expect(err.message).toContain('must be a Buffer'); + } + }); + + it('throws INVALID_KEY_TYPE for null key', () => { + const key = null; + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_TYPE'); + expect(err.message).toContain('must be a Buffer'); + } + }); +}); + +describe('CasService key validation – store() valid keys', () => { + let service; + let tempDir; + let filePath; + + beforeEach(() => { + service = createService(createMockPersistence()); + tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-key-test-')); + filePath = path.join(tempDir, 'test.txt'); + writeFileSync(filePath, 'test content'); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('accepts a 32-byte Buffer encryptionKey', async () => { + const key = Buffer.alloc(32, 0xbb); + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).resolves.toBeDefined(); + }); + + it('accepts crypto.randomBytes(32) as encryptionKey', async () => { + const key = randomBytes(32); + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).resolves.toBeDefined(); + }); + + it('stores without error when no encryptionKey is provided', async () => { + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt' }), + ).resolves.toBeDefined(); + }); + + it('does not throw for null encryptionKey (treated as no key)', async () => { + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: null }), + ).resolves.toBeDefined(); + }); +}); + +describe('CasService key validation – store() invalid keys', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + async function* emptySource() {} + + it('throws INVALID_KEY_LENGTH for a 16-byte encryptionKey', async () => { + const key = Buffer.alloc(16); + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_LENGTH for a 64-byte encryptionKey', async () => { + const key = Buffer.alloc(64); + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for a string encryptionKey', async () => { + const key = 'string-key'; + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for a number encryptionKey', async () => { + const key = 42; + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); +}); + +describe('CasService key validation – fuzz: key lengths 0..128', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('fuzz test data'); + + it('only length 32 passes for encrypt()', () => { + for (let len = 0; len <= 128; len++) { + const key = Buffer.alloc(len, 0xff); + if (len === 32) { + expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); + } else { + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); } - }); + } }); }); diff --git a/test/unit/domain/services/CasService.restore.test.js b/test/unit/domain/services/CasService.restore.test.js index 9f0dd8f..b217de4 100644 --- a/test/unit/domain/services/CasService.restore.test.js +++ b/test/unit/domain/services/CasService.restore.test.js @@ -6,252 +6,292 @@ import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import Manifest from '../../../../src/domain/value-objects/Manifest.js'; import CasError from '../../../../src/domain/errors/CasError.js'; -describe('CasService.restore()', () => { +// --------------------------------------------------------------------------- +// Module-level helper: store content via async iterable, return manifest +// --------------------------------------------------------------------------- +async function storeBuffer(svc, buf, opts = {}) { + async function* source() { yield buf; } + return svc.store({ + source: source(), + slug: opts.slug || 'test', + filename: opts.filename || 'test.bin', + encryptionKey: opts.encryptionKey, + }); +} + +/** + * Shared factory: builds the standard test fixtures (crypto, blobStore, + * mockPersistence, service) used by every describe block. + */ +function setup() { + const crypto = new NodeCryptoAdapter(); + const blobStore = new Map(); + + const mockPersistence = { + writeBlob: vi.fn().mockImplementation(async (content) => { + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); + const oid = crypto.sha256(buf); + blobStore.set(oid, buf); + return oid; + }), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockImplementation(async (oid) => { + const buf = blobStore.get(oid); + if (!buf) { throw new Error(`Blob not found: ${oid}`); } + return buf; + }), + }; + + const service = new CasService({ + persistence: mockPersistence, + crypto, + codec: new JsonCodec(), + chunkSize: 1024, + }); + + return { crypto, blobStore, mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// Plaintext round-trip +// --------------------------------------------------------------------------- +describe('CasService.restore() – plaintext round-trip', () => { let service; - let crypto; - let mockPersistence; - /** Map simulating Git ODB */ - let blobStore; beforeEach(() => { - crypto = new NodeCryptoAdapter(); - blobStore = new Map(); - - mockPersistence = { - writeBlob: vi.fn().mockImplementation(async (content) => { - const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); - const oid = crypto.sha256(buf); - blobStore.set(oid, buf); - return oid; - }), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockImplementation(async (oid) => { - const buf = blobStore.get(oid); - if (!buf) throw new Error(`Blob not found: ${oid}`); - return buf; - }), - }; - - service = new CasService({ - persistence: mockPersistence, - crypto, - codec: new JsonCodec(), - chunkSize: 1024, - }); + ({ service } = setup()); }); - // --------------------------------------------------------------------------- - // Helper: store content via async iterable, return manifest - // --------------------------------------------------------------------------- - async function storeBuffer(svc, buf, opts = {}) { - async function* source() { yield buf; } - return svc.store({ - source: source(), - slug: opts.slug || 'test', - filename: opts.filename || 'test.bin', - encryptionKey: opts.encryptionKey, - }); - } + it('restores a single-chunk file', async () => { + const original = Buffer.from('hello world'); + const manifest = await storeBuffer(service, original); - // --------------------------------------------------------------------------- - // Golden path — plaintext - // --------------------------------------------------------------------------- - describe('plaintext round-trip', () => { - it('restores a single-chunk file', async () => { - const original = Buffer.from('hello world'); - const manifest = await storeBuffer(service, original); + const { buffer, bytesWritten } = await service.restore({ manifest }); - const { buffer, bytesWritten } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); - expect(buffer.equals(original)).toBe(true); - expect(bytesWritten).toBe(original.length); - }); + it('restores a multi-chunk file', async () => { + const original = randomBytes(3 * 1024); // 3 chunks at 1024 + const manifest = await storeBuffer(service, original); + expect(manifest.chunks.length).toBe(3); - it('restores a multi-chunk file', async () => { - const original = randomBytes(3 * 1024); // 3 chunks at 1024 - const manifest = await storeBuffer(service, original); - expect(manifest.chunks.length).toBe(3); + const { buffer, bytesWritten } = await service.restore({ manifest }); - const { buffer, bytesWritten } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); - expect(buffer.equals(original)).toBe(true); - expect(bytesWritten).toBe(original.length); - }); + it('restores a file that is exact multiple of chunkSize', async () => { + const original = randomBytes(2 * 1024); + const manifest = await storeBuffer(service, original); + expect(manifest.chunks.length).toBe(2); - it('restores a file that is exact multiple of chunkSize', async () => { - const original = randomBytes(2 * 1024); - const manifest = await storeBuffer(service, original); - expect(manifest.chunks.length).toBe(2); + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); +}); - const { buffer } = await service.restore({ manifest }); - expect(buffer.equals(original)).toBe(true); +// --------------------------------------------------------------------------- +// Encrypted round-trip +// --------------------------------------------------------------------------- +describe('CasService.restore() – encrypted round-trip', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('restores encrypted content with correct key', async () => { + const key = randomBytes(32); + const original = Buffer.from('secret data here'); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + + expect(manifest.encryption).toBeDefined(); + expect(manifest.encryption.encrypted).toBe(true); + + const { buffer, bytesWritten } = await service.restore({ + manifest, + encryptionKey: key, }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); }); - // --------------------------------------------------------------------------- - // Golden path — encrypted - // --------------------------------------------------------------------------- - describe('encrypted round-trip', () => { - it('restores encrypted content with correct key', async () => { - const key = randomBytes(32); - const original = Buffer.from('secret data here'); - const manifest = await storeBuffer(service, original, { encryptionKey: key }); + it('restores multi-chunk encrypted content', async () => { + const key = randomBytes(32); + const original = randomBytes(3 * 1024); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); +}); - expect(manifest.encryption).toBeDefined(); - expect(manifest.encryption.encrypted).toBe(true); +// --------------------------------------------------------------------------- +// Empty manifest +// --------------------------------------------------------------------------- +describe('CasService.restore() – empty manifest', () => { + let service; - const { buffer, bytesWritten } = await service.restore({ - manifest, - encryptionKey: key, - }); + beforeEach(() => { + ({ service } = setup()); + }); - expect(buffer.equals(original)).toBe(true); - expect(bytesWritten).toBe(original.length); + it('returns 0-byte buffer for empty manifest', async () => { + const manifest = new Manifest({ + slug: 'empty', + filename: 'empty.bin', + size: 0, + chunks: [], }); - it('restores multi-chunk encrypted content', async () => { - const key = randomBytes(32); - const original = randomBytes(3 * 1024); - const manifest = await storeBuffer(service, original, { encryptionKey: key }); + const { buffer, bytesWritten } = await service.restore({ manifest }); - const { buffer } = await service.restore({ manifest, encryptionKey: key }); - expect(buffer.equals(original)).toBe(true); - }); + expect(buffer.length).toBe(0); + expect(bytesWritten).toBe(0); }); +}); - // --------------------------------------------------------------------------- - // Empty manifest - // --------------------------------------------------------------------------- - describe('empty manifest', () => { - it('returns 0-byte buffer for empty manifest', async () => { - const manifest = new Manifest({ - slug: 'empty', - filename: 'empty.bin', - size: 0, - chunks: [], - }); - - const { buffer, bytesWritten } = await service.restore({ manifest }); - - expect(buffer.length).toBe(0); - expect(bytesWritten).toBe(0); - }); +// --------------------------------------------------------------------------- +// Wrong key +// --------------------------------------------------------------------------- +describe('CasService.restore() – wrong key', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); }); - // --------------------------------------------------------------------------- - // Wrong key - // --------------------------------------------------------------------------- - describe('wrong key', () => { - it('throws INTEGRITY_ERROR with wrong decryption key', async () => { - const keyA = randomBytes(32); - const keyB = randomBytes(32); - const original = Buffer.from('encrypted payload'); - const manifest = await storeBuffer(service, original, { encryptionKey: keyA }); - - await expect( - service.restore({ manifest, encryptionKey: keyB }), - ).rejects.toThrow(CasError); - - try { - await service.restore({ manifest, encryptionKey: keyB }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } - }); + it('throws INTEGRITY_ERROR with wrong decryption key', async () => { + const keyA = randomBytes(32); + const keyB = randomBytes(32); + const original = Buffer.from('encrypted payload'); + const manifest = await storeBuffer(service, original, { encryptionKey: keyA }); + + await expect( + service.restore({ manifest, encryptionKey: keyB }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest, encryptionKey: keyB }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } }); +}); - // --------------------------------------------------------------------------- - // Corrupted chunk - // --------------------------------------------------------------------------- - describe('corrupted chunk', () => { - it('throws INTEGRITY_ERROR when chunk data is corrupted', async () => { - const original = Buffer.from('some content to store'); - const manifest = await storeBuffer(service, original); +// --------------------------------------------------------------------------- +// Corrupted chunk +// --------------------------------------------------------------------------- +describe('CasService.restore() – corrupted chunk', () => { + let service; + let blobStore; - // Corrupt the blob in the store - const firstChunk = manifest.chunks[0]; - const corruptBuf = Buffer.from(blobStore.get(firstChunk.blob)); - corruptBuf[0] ^= 0x01; - blobStore.set(firstChunk.blob, corruptBuf); - - // Overwrite readBlob to return corrupted data - // (the oid key still maps, but content is wrong) - await expect( - service.restore({ manifest }), - ).rejects.toThrow(CasError); - - try { - await service.restore({ manifest }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - expect(err.meta.chunkIndex).toBe(0); - } - }); + beforeEach(() => { + ({ service, blobStore } = setup()); + }); + + it('throws INTEGRITY_ERROR when chunk data is corrupted', async () => { + const original = Buffer.from('some content to store'); + const manifest = await storeBuffer(service, original); + + // Corrupt the blob in the store + const firstChunk = manifest.chunks[0]; + const corruptBuf = Buffer.from(blobStore.get(firstChunk.blob)); + corruptBuf[0] ^= 0x01; + blobStore.set(firstChunk.blob, corruptBuf); + + // Overwrite readBlob to return corrupted data + // (the oid key still maps, but content is wrong) + await expect( + service.restore({ manifest }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + expect(err.meta.chunkIndex).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Key validation +// --------------------------------------------------------------------------- +describe('CasService.restore() – key validation', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); }); - // --------------------------------------------------------------------------- - // Key validation - // --------------------------------------------------------------------------- - describe('key validation', () => { - it('throws INVALID_KEY_LENGTH for 16-byte key', async () => { - const manifest = new Manifest({ - slug: 'x', - filename: 'x.bin', - size: 0, - chunks: [], - encryption: { algorithm: 'aes-256-gcm', nonce: 'x', tag: 'x', encrypted: true }, - }); - - await expect( - service.restore({ manifest, encryptionKey: Buffer.alloc(16) }), - ).rejects.toThrow(CasError); + it('throws INVALID_KEY_LENGTH for 16-byte key', async () => { + const manifest = new Manifest({ + slug: 'x', + filename: 'x.bin', + size: 0, + chunks: [], + encryption: { algorithm: 'aes-256-gcm', nonce: 'x', tag: 'x', encrypted: true }, }); - it('throws INVALID_KEY_TYPE for string key', async () => { - const manifest = new Manifest({ - slug: 'x', - filename: 'x.bin', - size: 0, - chunks: [], - }); - - await expect( - service.restore({ manifest, encryptionKey: 'bad-key' }), - ).rejects.toThrow(CasError); + await expect( + service.restore({ manifest, encryptionKey: Buffer.alloc(16) }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for string key', async () => { + const manifest = new Manifest({ + slug: 'x', + filename: 'x.bin', + size: 0, + chunks: [], }); + + await expect( + service.restore({ manifest, encryptionKey: 'bad-key' }), + ).rejects.toThrow(CasError); }); +}); - // --------------------------------------------------------------------------- - // Fuzz round-trip - // --------------------------------------------------------------------------- - describe('fuzz round-trip', () => { - const key = randomBytes(32); +// --------------------------------------------------------------------------- +// Fuzz round-trip +// --------------------------------------------------------------------------- +describe('CasService.restore() – fuzz round-trip', () => { + let service; + const key = randomBytes(32); - for (let i = 0; i < 50; i++) { - // Sizes from 0 to 3*chunkSize spread across 50 iterations - const size = Math.floor((i / 49) * 3 * 1024); + beforeEach(() => { + ({ service } = setup()); + }); - it(`round-trips ${size} bytes (plaintext, iteration ${i})`, async () => { - const original = Buffer.alloc(size); - for (let b = 0; b < size; b++) original[b] = (i + b) & 0xff; + for (let i = 0; i < 50; i++) { + // Sizes from 0 to 3*chunkSize spread across 50 iterations + const size = Math.floor((i / 49) * 3 * 1024); - const manifest = await storeBuffer(service, original); - const { buffer } = await service.restore({ manifest }); - expect(buffer.equals(original)).toBe(true); - }); - } + it(`round-trips ${size} bytes (plaintext, iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) { original[b] = (i + b) & 0xff; } + + const manifest = await storeBuffer(service, original); + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); + } - for (let i = 0; i < 50; i++) { - const size = Math.floor((i / 49) * 3 * 1024); + for (let i = 0; i < 50; i++) { + const size = Math.floor((i / 49) * 3 * 1024); - it(`round-trips ${size} bytes (encrypted, iteration ${i})`, async () => { - const original = Buffer.alloc(size); - for (let b = 0; b < size; b++) original[b] = (i * 3 + b) & 0xff; + it(`round-trips ${size} bytes (encrypted, iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) { original[b] = (i * 3 + b) & 0xff; } - const manifest = await storeBuffer(service, original, { encryptionKey: key }); - const { buffer } = await service.restore({ manifest, encryptionKey: key }); - expect(buffer.equals(original)).toBe(true); - }); - } - }); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); + } }); diff --git a/test/unit/domain/services/CasService.stream-error.test.js b/test/unit/domain/services/CasService.stream-error.test.js index 0d5bb2e..fa7d900 100644 --- a/test/unit/domain/services/CasService.stream-error.test.js +++ b/test/unit/domain/services/CasService.stream-error.test.js @@ -4,48 +4,55 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import CasError from '../../../../src/domain/errors/CasError.js'; -describe('CasService – stream error recovery (STREAM_ERROR)', () => { +/** + * Creates an async iterable that yields `n` chunks of `chunkSize` bytes + * then throws an error. + */ +function failingSource(chunksBeforeError, chunkSize = 1024) { + let yielded = 0; + return { + [Symbol.asyncIterator]() { + return { + async next() { + if (yielded >= chunksBeforeError) { + throw new Error('simulated stream failure'); + } + yielded++; + return { value: Buffer.alloc(chunkSize, 0xaa), done: false }; + }, + }; + }, + }; +} + +/** + * Shared factory: builds the standard test fixtures used by every block. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + return { mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// STREAM_ERROR after 3 chunks +// --------------------------------------------------------------------------- +describe('CasService stream error – STREAM_ERROR after 3 chunks', () => { let service; - let mockPersistence; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + ({ service } = setup()); }); - /** - * Creates an async iterable that yields `n` chunks of `chunkSize` bytes - * then throws an error. - */ - function failingSource(chunksBeforeError, chunkSize = 1024) { - let yielded = 0; - return { - [Symbol.asyncIterator]() { - return { - async next() { - if (yielded >= chunksBeforeError) { - throw new Error('simulated stream failure'); - } - yielded++; - return { value: Buffer.alloc(chunkSize, 0xaa), done: false }; - }, - }; - }, - }; - } - - // --------------------------------------------------------------------------- - // Stream error after N chunks - // --------------------------------------------------------------------------- it('throws STREAM_ERROR when stream fails after 3 chunks', async () => { await expect( service.store({ @@ -67,6 +74,17 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { expect(err.message).toContain('simulated stream failure'); } }); +}); + +// --------------------------------------------------------------------------- +// STREAM_ERROR immediate failure +// --------------------------------------------------------------------------- +describe('CasService stream error – STREAM_ERROR immediate failure', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); it('throws STREAM_ERROR with chunksWritten=0 when stream fails immediately', async () => { await expect( @@ -88,6 +106,17 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { expect(err.meta.chunksWritten).toBe(0); } }); +}); + +// --------------------------------------------------------------------------- +// No manifest on error +// --------------------------------------------------------------------------- +describe('CasService stream error – no manifest on error', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); it('does not return a manifest on stream error', async () => { let manifest; @@ -102,10 +131,18 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { } expect(manifest).toBeUndefined(); }); +}); + +// --------------------------------------------------------------------------- +// Successful stores still work +// --------------------------------------------------------------------------- +describe('CasService stream error – successful stores', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); - // --------------------------------------------------------------------------- - // Successful stores still work - // --------------------------------------------------------------------------- it('succeeds when stream completes normally', async () => { async function* goodSource() { yield Buffer.alloc(512, 0xbb); @@ -120,10 +157,18 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { expect(manifest).toBeDefined(); expect(manifest.slug).toBe('ok'); }); +}); + +// --------------------------------------------------------------------------- +// CasError passthrough (not double-wrapped) +// --------------------------------------------------------------------------- +describe('CasService stream error – CasError passthrough', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); - // --------------------------------------------------------------------------- - // CasError passthrough (not double-wrapped) - // --------------------------------------------------------------------------- it('does not wrap CasError as STREAM_ERROR', async () => { const casErr = new CasError('custom error', 'CUSTOM_CODE'); const badSource = { @@ -148,34 +193,40 @@ describe('CasService – stream error recovery (STREAM_ERROR)', () => { expect(err.code).toBe('CUSTOM_CODE'); } }); +}); - // --------------------------------------------------------------------------- - // Fuzz: randomized failure points - // --------------------------------------------------------------------------- - describe('fuzz: randomized failure points', () => { - for (let i = 0; i < 20; i++) { - const failAfter = i; - - it(`STREAM_ERROR with chunksWritten=${failAfter} (iteration ${i})`, async () => { - await expect( - service.store({ - source: failingSource(failAfter), - slug: `fuzz-${i}`, - filename: 'fuzz.bin', - }), - ).rejects.toThrow(CasError); - - try { - await service.store({ - source: failingSource(failAfter), - slug: `fuzz-${i}`, - filename: 'fuzz.bin', - }); - } catch (err) { - expect(err.code).toBe('STREAM_ERROR'); - expect(err.meta.chunksWritten).toBe(failAfter); - } - }); - } +// --------------------------------------------------------------------------- +// Fuzz: randomized failure points +// --------------------------------------------------------------------------- +describe('CasService stream error – fuzz', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); }); + + for (let i = 0; i < 20; i++) { + const failAfter = i; + + it(`STREAM_ERROR with chunksWritten=${failAfter} (iteration ${i})`, async () => { + await expect( + service.store({ + source: failingSource(failAfter), + slug: `fuzz-${i}`, + filename: 'fuzz.bin', + }), + ).rejects.toThrow(CasError); + + try { + await service.store({ + source: failingSource(failAfter), + slug: `fuzz-${i}`, + filename: 'fuzz.bin', + }); + } catch (err) { + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(failAfter); + } + }); + } }); diff --git a/test/unit/domain/services/CasService.test.js b/test/unit/domain/services/CasService.test.js index 2d6fcbd..f2121dc 100644 --- a/test/unit/domain/services/CasService.test.js +++ b/test/unit/domain/services/CasService.test.js @@ -8,22 +8,33 @@ import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import Manifest from '../../../../src/domain/value-objects/Manifest.js'; import { digestOf } from '../../../helpers/crypto.js'; -describe('CasService', () => { +/** + * Shared factory: builds the standard test fixtures. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockImplementation((oid) => Promise.resolve(Buffer.from(oid === 'b1' ? 'chunk1' : 'chunk2'))), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + return { mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// store +// --------------------------------------------------------------------------- +describe('CasService – store', () => { let service; let mockPersistence; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockImplementation((oid) => Promise.resolve(Buffer.from(oid === 'b1' ? 'chunk1' : 'chunk2'))), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + ({ service, mockPersistence } = setup()); }); it('chunks a file and stores blobs', async () => { @@ -45,6 +56,18 @@ describe('CasService', () => { rmSync(tempDir, { recursive: true, force: true }); }); +}); + +// --------------------------------------------------------------------------- +// createTree +// --------------------------------------------------------------------------- +describe('CasService – createTree', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('creates a tree from manifest', async () => { const manifest = new Manifest({ @@ -67,6 +90,17 @@ describe('CasService', () => { expect.stringContaining(digestOf('chunk-b')) ])); }); +}); + +// --------------------------------------------------------------------------- +// verifyIntegrity +// --------------------------------------------------------------------------- +describe('CasService – verifyIntegrity', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); it('verifies integrity of chunks', async () => { // Helper to calc hash since service._sha256 is private diff --git a/test/unit/domain/value-objects/Chunk.test.js b/test/unit/domain/value-objects/Chunk.test.js index 4baead5..d21e38a 100644 --- a/test/unit/domain/value-objects/Chunk.test.js +++ b/test/unit/domain/value-objects/Chunk.test.js @@ -13,9 +13,10 @@ const validChunkData = () => ({ digest: sha256('test-chunk-0'), }); -describe('Chunk value-object', () => { - // ─── happy path ───────────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Creation (happy path) +// --------------------------------------------------------------------------- +describe('Chunk – creation', () => { it('creates a frozen object from valid data', () => { const c = new Chunk(validChunkData()); @@ -30,9 +31,12 @@ describe('Chunk value-object', () => { const c = new Chunk({ ...validChunkData(), index: 0 }); expect(c.index).toBe(0); }); +}); - // ─── index validation ────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Field validation +// --------------------------------------------------------------------------- +describe('Chunk – field validation', () => { it('throws when index is negative', () => { const data = { ...validChunkData(), index: -1 }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); @@ -43,8 +47,6 @@ describe('Chunk value-object', () => { expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); - // ─── size validation ─────────────────────────────────────────────── - it('throws when size is 0 (schema requires positive)', () => { const data = { ...validChunkData(), size: 0 }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); @@ -55,8 +57,6 @@ describe('Chunk value-object', () => { expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); - // ─── digest validation (must be exactly 64 hex chars) ────────────── - it('throws when digest is 63 characters', () => { const data = { ...validChunkData(), digest: 'a'.repeat(63) }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); @@ -72,15 +72,16 @@ describe('Chunk value-object', () => { expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); - // ─── blob validation ─────────────────────────────────────────────── - it('throws when blob is empty string', () => { const data = { ...validChunkData(), blob: '' }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); +}); - // ─── missing fields ──────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Missing fields +// --------------------------------------------------------------------------- +describe('Chunk – missing fields', () => { it('throws when index is missing', () => { const data = validChunkData(); delete data.index; diff --git a/test/unit/domain/value-objects/Manifest.test.js b/test/unit/domain/value-objects/Manifest.test.js index 0b7a57f..6b70c52 100644 --- a/test/unit/domain/value-objects/Manifest.test.js +++ b/test/unit/domain/value-objects/Manifest.test.js @@ -21,9 +21,10 @@ const validManifestData = () => ({ chunks: [validChunk(0)], }); -describe('Manifest value-object', () => { - // ─── happy path ───────────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Creation (happy path + toJSON) +// --------------------------------------------------------------------------- +describe('Manifest – creation', () => { it('creates a frozen object from valid data', () => { const m = new Manifest(validManifestData()); @@ -57,9 +58,12 @@ describe('Manifest value-object', () => { expect(json.size).toBe(data.size); expect(json.chunks).toHaveLength(data.chunks.length); }); +}); - // ─── missing / invalid slug ───────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Validation – slug and filename +// --------------------------------------------------------------------------- +describe('Manifest – validation (slug and filename)', () => { it('throws when slug is missing', () => { const data = validManifestData(); delete data.slug; @@ -71,16 +75,17 @@ describe('Manifest value-object', () => { expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); }); - // ─── missing / invalid filename ───────────────────────────────────── - it('throws when filename is missing', () => { const data = validManifestData(); delete data.filename; expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); }); +}); - // ─── size validation ──────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Validation – size and chunks +// --------------------------------------------------------------------------- +describe('Manifest – validation (size and chunks)', () => { it('throws when size is negative', () => { const data = { ...validManifestData(), size: -1 }; expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); @@ -92,8 +97,6 @@ describe('Manifest value-object', () => { expect(m.size).toBe(0); }); - // ─── chunks validation ────────────────────────────────────────────── - it('throws when chunks is not an array', () => { const data = { ...validManifestData(), chunks: 'not-an-array' }; expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); @@ -104,8 +107,6 @@ describe('Manifest value-object', () => { expect(() => new Manifest(data)).toThrow(); }); - // ─── missing required fields ──────────────────────────────────────── - it('throws when size field is missing entirely', () => { const data = validManifestData(); delete data.size; diff --git a/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js index af4a87b..cb3b223 100644 --- a/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js +++ b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js @@ -15,91 +15,56 @@ function mockPlumbing(output) { /** Stub policy that just runs the fn directly. */ const noPolicy = { execute: (fn) => fn() }; -describe('GitPersistenceAdapter.readTree()', () => { - // --------------------------------------------------------------------------- - // Golden path - // --------------------------------------------------------------------------- +/** Shorthand: create adapter whose plumbing returns `output`. */ +function adapterFor(output) { + return new GitPersistenceAdapter({ plumbing: mockPlumbing(output), policy: noPolicy }); +} + +/** Expected shape for every entry. */ +function entry(oid, name) { + return { mode: '100644', type: 'blob', oid, name }; +} + +// --------------------------------------------------------------------------- +// Parsing – golden path, empty tree, spaces +// --------------------------------------------------------------------------- +describe('GitPersistenceAdapter.readTree() – parsing', () => { it('parses a typical ls-tree output with manifest and chunks', async () => { const output = [ '100644 blob abc123def456\tmanifest.json', - '100644 blob deadbeef1234\t' + 'a'.repeat(64), - '100644 blob cafebabe5678\t' + 'b'.repeat(64), + `100644 blob deadbeef1234\t${'a'.repeat(64)}`, + `100644 blob cafebabe5678\t${'b'.repeat(64)}`, ].join('\0'); - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(output), - policy: noPolicy, - }); - - const entries = await adapter.readTree('some-tree-oid'); + const entries = await adapterFor(output).readTree('some-tree-oid'); expect(entries).toHaveLength(3); - expect(entries[0]).toEqual({ - mode: '100644', - type: 'blob', - oid: 'abc123def456', - name: 'manifest.json', - }); - expect(entries[1]).toEqual({ - mode: '100644', - type: 'blob', - oid: 'deadbeef1234', - name: 'a'.repeat(64), - }); - expect(entries[2]).toEqual({ - mode: '100644', - type: 'blob', - oid: 'cafebabe5678', - name: 'b'.repeat(64), - }); + expect(entries[0]).toEqual(entry('abc123def456', 'manifest.json')); + expect(entries[1]).toEqual(entry('deadbeef1234', 'a'.repeat(64))); + expect(entries[2]).toEqual(entry('cafebabe5678', 'b'.repeat(64))); }); - // --------------------------------------------------------------------------- - // Empty tree - // --------------------------------------------------------------------------- it('returns [] for empty output', async () => { - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(''), - policy: noPolicy, - }); - - const entries = await adapter.readTree('empty-tree'); - expect(entries).toEqual([]); + expect(await adapterFor('').readTree('empty-tree')).toEqual([]); }); it('returns [] for NUL-only output', async () => { - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing('\0'), - policy: noPolicy, - }); - - const entries = await adapter.readTree('empty-tree'); - expect(entries).toEqual([]); + expect(await adapterFor('\0').readTree('empty-tree')).toEqual([]); }); - // --------------------------------------------------------------------------- - // Filename with spaces (tab delimiter) - // --------------------------------------------------------------------------- it('handles filenames with spaces', async () => { const output = '100644 blob abc123\tfile with spaces.txt\0'; - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(output), - policy: noPolicy, - }); - - const entries = await adapter.readTree('tree-oid'); + const entries = await adapterFor(output).readTree('tree-oid'); expect(entries[0].name).toBe('file with spaces.txt'); }); +}); - // --------------------------------------------------------------------------- - // Malformed output - // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Errors – malformed output + plumbing error propagation +// --------------------------------------------------------------------------- +describe('GitPersistenceAdapter.readTree() – errors', () => { it('throws TREE_PARSE_ERROR when entry has no tab', async () => { - const output = '100644 blob abc123 no-tab-here\0'; - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(output), - policy: noPolicy, - }); + const adapter = adapterFor('100644 blob abc123 no-tab-here\0'); await expect(adapter.readTree('bad-tree')).rejects.toThrow(CasError); try { @@ -110,11 +75,7 @@ describe('GitPersistenceAdapter.readTree()', () => { }); it('throws TREE_PARSE_ERROR when metadata has wrong number of fields', async () => { - const output = '100644 blob\tmanifest.json\0'; // only 2 fields before tab - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(output), - policy: noPolicy, - }); + const adapter = adapterFor('100644 blob\tmanifest.json\0'); await expect(adapter.readTree('bad-tree')).rejects.toThrow(CasError); try { @@ -124,9 +85,21 @@ describe('GitPersistenceAdapter.readTree()', () => { } }); - // --------------------------------------------------------------------------- - // Fuzz: 1000 entries - // --------------------------------------------------------------------------- + it('propagates plumbing errors', async () => { + const plumbing = { + execute: vi.fn().mockRejectedValue(new Error('git failed')), + executeStream: vi.fn(), + }; + const adapter = new GitPersistenceAdapter({ plumbing, policy: noPolicy }); + + await expect(adapter.readTree('bad-oid')).rejects.toThrow('git failed'); + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz – 1000 synthetic entries +// --------------------------------------------------------------------------- +describe('GitPersistenceAdapter.readTree() – fuzz', () => { it('parses 1000 synthetic entries', async () => { const lines = []; for (let i = 0; i < 1000; i++) { @@ -134,31 +107,9 @@ describe('GitPersistenceAdapter.readTree()', () => { lines.push(`100644 blob ${oid}\tchunk-${i}`); } const output = lines.join('\0'); - - const adapter = new GitPersistenceAdapter({ - plumbing: mockPlumbing(output), - policy: noPolicy, - }); - - const entries = await adapter.readTree('big-tree'); + const entries = await adapterFor(output).readTree('big-tree'); expect(entries).toHaveLength(1000); expect(entries[0].name).toBe('chunk-0'); expect(entries[999].name).toBe('chunk-999'); }); - - // --------------------------------------------------------------------------- - // Plumbing error propagation - // --------------------------------------------------------------------------- - it('propagates plumbing errors', async () => { - const plumbing = { - execute: vi.fn().mockRejectedValue(new Error('git failed')), - executeStream: vi.fn(), - }; - const adapter = new GitPersistenceAdapter({ - plumbing, - policy: noPolicy, - }); - - await expect(adapter.readTree('bad-oid')).rejects.toThrow('git failed'); - }); }); From 5bc2c3df6727483dd5607b9b694f8a5448279bd8 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 22:08:30 -0800 Subject: [PATCH 08/10] fix: remove redundant encryptionKey guard in restore decrypt path The MISSING_KEY error is already thrown earlier when encrypted content has no key, making the && encryptionKey check unreachable and redundant. --- src/domain/services/CasService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index 1c84545..743467d 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -231,7 +231,7 @@ export default class CasService { const chunks = await this._readAndVerifyChunks(manifest.chunks); let buffer = Buffer.concat(chunks); - if (manifest.encryption?.encrypted && encryptionKey) { + if (manifest.encryption?.encrypted) { buffer = this.decrypt({ buffer, key: encryptionKey, From f3d7a5154898f3468fa5e1b4a6c3db8374f6cc35 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 22:13:37 -0800 Subject: [PATCH 09/10] fix: use default import for zod to fix Bun compatibility Zod 3.25.x re-exports z as a namespace object which Bun cannot resolve as a named import. Switch to default import which works across Node, Bun, and Deno. --- src/domain/schemas/ManifestSchema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/schemas/ManifestSchema.js b/src/domain/schemas/ManifestSchema.js index 771fe6c..5a24d56 100644 --- a/src/domain/schemas/ManifestSchema.js +++ b/src/domain/schemas/ManifestSchema.js @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; export const ChunkSchema = z.object({ index: z.number().int().min(0), From d7f8fcfcd6c448b105261d2b00f242a3798f1c10 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 22:26:44 -0800 Subject: [PATCH 10/10] fix: add nodejs to Deno Docker image for native package install scripts cbor-extract and esbuild require the node binary to run their install/postinstall scripts during deno install. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 550ee0f..d40a96f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ CMD ["bunx", "vitest", "run", "test/unit"] # --- Deno --- FROM denoland/deno:latest AS deno USER root -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y git nodejs npm && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY . . RUN deno install --allow-scripts