Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
# @suaveplan/drive-storage-archive-core - Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode enforcement, error code registry, and a conformance harness consumed by every per-format drive-storage adapter.
Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode enforcement, error code registry, and a conformance harness consumed by every per-format `drive-storage-*` adapter (zip, tar, gz, tgz, rar, 7z).
This package is the FOUNDATION layer — it does not itself read any archive. The per-format adapters live in sibling packages and depend on this one for their cross-cutting concerns.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/error-codes.md) — Declares the `ARCHIVE_CODES` registry — ten stable string identifiers covering every failure mode the archive core surf…
- [error-codes](./src/errors/error-codes.md) — Declares the `ARCHIVE_CODES` registry — ten stable string identifiers covering every failure mode the archive core surf…
- [factory](./src/factory/open-archive-storage.md) — Implements the public `openArchiveStorage(source, options)` factory plus the test-injection seam `setDynamicImporter`.…
- [open-archive-storage](./src/factory/open-archive-storage.md) — Implements the public `openArchiveStorage(source, options)` factory plus the test-injection seam `setDynamicImporter`.…
- [pending-mutations](./src/modes/pending-mutations.md) — Provides the `pendingMutations<T>()` factory and the `PendingMutations<T>` overlay interface that per-format archive ad…
- [validate-mode](./src/modes/validate-mode.md) — Provides `validateMode(mode, capabilities)` and `assertNotReadOnly(mode)` — the two helpers every per-format archive ad…
- [password](./src/password/password-hook.md) — Implements the small set of helpers every per-format archive adapter calls when handling encrypted entries: `wrapPasswo…
- [password-hook](./src/password/password-hook.md) — Implements the small set of helpers every per-format archive adapter calls when handling encrypted entries: `wrapPasswo…
- [rar-sibling-resolver](./src/resolvers/rar-sibling-resolver.md) — Default `ResolveSibling` factory for split-rar archives. Handles BOTH split-naming conventions transparently — the mode…
- [seven-zip-sibling-resolver](./src/resolvers/seven-zip-sibling-resolver.md) — Default `ResolveSibling` factory for split-7z archives following the canonical `<basename>.7z.NNN` naming convention. U…
- [sibling-host](./src/resolvers/sibling-host.md) — Declares the minimal `SiblingHost` structural-view interface that the default sibling-volume resolvers (`zipSiblingReso…
- [zip-sibling-resolver](./src/resolvers/zip-sibling-resolver.md) — Default `ResolveSibling` factory for split-zip archives following the PKZIP App-Note §8.5.3 convention: intermediate vo…
- [sniff](./src/sniff/sniff-format.md) — Identifies an archive's container format by inspecting magic bytes (and, where appropriate, structural artefacts like t…
- [sniff-format](./src/sniff/sniff-format.md) — Identifies an archive's container format by inspecting magic bytes (and, where appropriate, structural artefacts like t…
- [testing](./src/testing/run-archive-conformance.md) — Implements `runArchiveConformance(adapterFactory, opts)` — the cross-format conformance harness every per-format archiv…
- [run-archive-conformance](./src/testing/run-archive-conformance.md) — Implements `runArchiveConformance(adapterFactory, opts)` — the cross-format conformance harness every per-format archiv…
- [archive-capabilities](./src/types/archive-capabilities.md) — Declares the `ArchiveCapabilities` record shape that every per-format archive adapter MUST expose so consumers (and the…
- [archive-codec](./src/types/archive-codec.md) — Declares the `ArchiveCodec` interface — the contract a single byte-level compression codec (gzip, bzip2, xz, zstd…) MUS…
- [archive-format](./src/types/archive-format.md) — Declares the canonical `ArchiveFormat` string-literal union that names every archive container format the Suaveplan dri…
- [archive-mode](./src/types/archive-mode.md) — Declares the `ArchiveMode` string-literal union (`read-only`, `copy-on-write`, `rewrite`) that every per-format archive…
- [entry-reader](./src/types/entry-reader.md) — Declares the `EntryReader` interface that every per-format archive adapter implements internally to surface a single en…
- [open-archive-storage-options](./src/types/open-archive-storage-options.md) — Declares the option bag passed to `openArchiveStorage(source, options)` plus the supporting types used by the dynamic-i…
- [password-hook](./src/types/password-hook.md) — Declares the `PasswordHook` callback signature used by every archive adapter to fetch passwords lazily for encrypted en…
- [resolve-sibling](./src/types/resolve-sibling.md) — Declares the `ResolveSibling` callback contract that per-format archive adapters use to fetch additional split-volume b…
<!-- end:auto-docs -->
## Quick start
```ts
import { openArchiveStorage } from "@suaveplan/drive-storage-archive-core";
const adapter = await openArchiveStorage(zipBytes, {
format: "auto",
mode: "read-only",
password: async () => "correct horse battery staple",
});
console.log(adapter.mode); // "read-only"
```
## Modules
- `types/` — `ArchiveFormat`, `ArchiveMode`, `ArchiveCodec`, `ArchiveCapabilities`, `EntryReader`, `PasswordHook`, `ResolveSibling`, `OpenArchiveStorageOptions`.
- `sniff/` — `sniffFormat` + `sniffFormatWithReason` magic-byte detector covering ZIP, RAR4, RAR5, 7z, GZIP, USTAR tar.
- `factory/` — `openArchiveStorage(source, options)` dispatcher + `setDynamicImporter` test seam.
- `errors/` — `ARCHIVE_CODES` registry (10 stable identifiers) + `ArchiveError` subclass.
- `resolvers/` — default `SiblingHost`-backed resolvers for split-zip / split-rar / split-7z.
- `modes/` — `validateMode`, `assertNotReadOnly`, `pendingMutations<T>()` overlay with `TOMBSTONE`.
- `password/` — `wrapPasswordHook`, `assertPasswordHook`, `consumePassword`, `withPassword`.
- `testing/` (subpath export `./testing`) — `runArchiveConformance(adapterFactory, opts)` harness.
## Error codes
Ten stable identifiers via `defineCodes("ARCHIVE", [...])`:
- `ARCHIVE.FORMAT_UNKNOWN` — magic bytes did not match any known signature.
- `ARCHIVE.FORMAT_MISMATCH` — caller's declared extension disagrees with the sniffed format (warning).
- `ARCHIVE.SPLIT_MISSING_VOLUME` — `resolveSibling` returned `null` for a required volume.
- `ARCHIVE.PASSWORD_REQUIRED` — encrypted entry encountered without a hook.
- `ARCHIVE.PASSWORD_INVALID` — hook returned a value that failed authenticated decryption.
- `ARCHIVE.MODE_READ_ONLY` — mutation attempted on a read-only adapter.
- `ARCHIVE.FLUSH_REQUIRED` — adapter disposed with pending overlay mutations.
- `ARCHIVE.CORRUPT` — structural validation failed.
- `ARCHIVE.UNSUPPORTED_FEATURE` — archive uses a feature the adapter does not implement.
- `ARCHIVE.CODEC_MISSING` — composed adapter constructed without the required codec slot OR per-format package not installed.
## Status
`0.1.0` — initial bottom-up landing.

View File

@@ -0,0 +1,87 @@
{
"name": "@suaveplan/drive-storage-archive-core",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode enforcement, error code registry, and a conformance harness consumed by every per-format drive-storage adapter.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./resolvers": {
"import": {
"types": "./dist/resolvers/index.d.ts",
"default": "./dist/resolvers/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,67 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the `ARCHIVE_CODES` registry — ten stable string identifiers covering every failure mode the archive core surfaces — and the `ArchiveError` class that wraps them. The codes are produced by `defineCodes("ARCHIVE", [...])` from `@suaveplan/error`, which concatenates the prefix and the member name into `"ARCHIVE.FORMAT_UNKNOWN"` etc. Each code is a non-reusable identifier: removing a member in a future release does NOT free its string for re-use because consumers may branch on the string value.
## Features
- Ten codes covering the entire core's failure surface (format detection, split volumes, password handling, mode enforcement, structural integrity, codec missing).
- `ArchiveError` subclass narrows `code` to `ArchiveErrorCode` via the generic type parameter on `SuaveplanError`.
- Public constructor so callers can construct errors directly (e.g. test fixtures, conformance harness).
## Usage: Basic Example
```ts
import { ARCHIVE_CODES, ArchiveError } from "@suaveplan/drive-storage-archive-core";
throw new ArchiveError(
ARCHIVE_CODES.MODE_READ_ONLY,
"archive opened in read-only mode; write rejected",
);
```
## Usage: Advanced Example
```ts
import {
ARCHIVE_CODES,
ArchiveError,
type ArchiveErrorCode,
} from "@suaveplan/drive-storage-archive-core";
function isRecoverable(err: unknown): err is ArchiveError {
if (!(err instanceof ArchiveError)) return false;
const recoverable: ArchiveErrorCode[] = [
ARCHIVE_CODES.PASSWORD_INVALID,
ARCHIVE_CODES.PASSWORD_REQUIRED,
];
return recoverable.includes(err.archiveCode);
}
try {
/* ... */
} catch (err) {
if (isRecoverable(err)) {
// prompt for a new password
} else {
throw err;
}
}
```
## API Reference
- `const ARCHIVE_CODES` — `defineCodes("ARCHIVE", [...])` registry of ten codes.
- `type ArchiveErrorCode` — union of every stable code value.
- `class ArchiveError extends SuaveplanError<ArchiveErrorCode>` — concrete throwable.
- `public readonly archiveCode: ArchiveErrorCode` — code mirror for inspection.
- `public constructor(code, message, options?)`.
## Implementation Notes
The redundant `archiveCode` field is intentional — it both mirrors `code` (which is typed as the union on `SuaveplanError<T>`) for ergonomic access in catch blocks AND gives the constructor body a non-trivial statement so lint rule `noUselessConstructor` does not fire. The pattern matches `SseError` and other newer Suaveplan error subclasses. Removing the field would require a `// biome-ignore` comment which CLAUDE.md rule 9 forbids in new code. The ten codes match the spec exactly — adding an eleventh requires both a spec change and a design proposal.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_CODES, ArchiveError } from "./error-codes.js";
describe("ARCHIVE_CODES registry", () => {
it("exposes 10 stable codes with ARCHIVE. prefix", {
timeout: 5000,
}, () => {
const values = Object.values(ARCHIVE_CODES);
expect(values).toHaveLength(10);
for (const v of values) {
expect(v.startsWith("ARCHIVE.")).toBe(true);
}
});
it("includes every code listed in the spec", { timeout: 5000 }, () => {
const expected = [
"FORMAT_UNKNOWN",
"FORMAT_MISMATCH",
"SPLIT_MISSING_VOLUME",
"PASSWORD_REQUIRED",
"PASSWORD_INVALID",
"MODE_READ_ONLY",
"FLUSH_REQUIRED",
"CORRUPT",
"UNSUPPORTED_FEATURE",
"CODEC_MISSING",
] as const;
for (const name of expected) {
expect(ARCHIVE_CODES[name]).toBe(`ARCHIVE.${name}`);
}
});
});
describe("ArchiveError", () => {
it("carries the code on both 'code' and 'archiveCode'", {
timeout: 5000,
}, () => {
const err = new ArchiveError(ARCHIVE_CODES.CORRUPT, "boom");
expect(err.code).toBe(ARCHIVE_CODES.CORRUPT);
expect(err.archiveCode).toBe(ARCHIVE_CODES.CORRUPT);
expect(err.message).toBe("boom");
});
it("carries a cause chain", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new ArchiveError(ARCHIVE_CODES.CORRUPT, "outer", { cause });
expect(err.cause).toBe(cause);
});
it("is an instanceof Error AND ArchiveError", { timeout: 5000 }, () => {
const err = new ArchiveError(ARCHIVE_CODES.MODE_READ_ONLY, "x");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ArchiveError);
});
});

View File

@@ -0,0 +1,68 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/drive-storage-archive-core`.
*
* Every failure path inside the core OR any per-format adapter that uses
* the core's contracts raises an {@link ArchiveError} carrying one of
* these ten `ARCHIVE_*` codes. The stable string identifier is the
* concat of the prefix and the member name (e.g.
* `"ARCHIVE.FORMAT_UNKNOWN"`), produced by `defineCodes`.
*
* - `FORMAT_UNKNOWN` — `sniffFormat` returned `null` and no explicit
* format was provided.
* - `FORMAT_MISMATCH` — caller-declared extension disagrees with the
* sniffed magic bytes. Always a warning, never fatal.
* - `SPLIT_MISSING_VOLUME` — `resolveSibling` returned `null` for a
* required split-archive volume. `context.index` carries the missing
* index; `context.basename` carries the primary's basename.
* - `PASSWORD_REQUIRED` — encrypted entry encountered without a
* `password` hook configured on the adapter.
* - `PASSWORD_INVALID` — `password` hook returned a value that failed
* authenticated decryption.
* - `MODE_READ_ONLY` — mutation attempted on an adapter constructed in
* `read-only` mode.
* - `FLUSH_REQUIRED` — adapter was disposed without a `flush()` call
* while pending mutations existed in the `copy-on-write` overlay.
* - `CORRUPT` — structural validation failed (EOCD missing, tar header
* checksum bad, gz trailer CRC mismatch).
* - `UNSUPPORTED_FEATURE` — archive uses a feature the adapter does not
* implement (e.g. unknown PAX header, BCJ filter chain in 7z).
* - `CODEC_MISSING` — composed adapter constructed without the required
* codec slot filled in.
*/
export const ARCHIVE_CODES = defineCodes("ARCHIVE", [
"FORMAT_UNKNOWN",
"FORMAT_MISMATCH",
"SPLIT_MISSING_VOLUME",
"PASSWORD_REQUIRED",
"PASSWORD_INVALID",
"MODE_READ_ONLY",
"FLUSH_REQUIRED",
"CORRUPT",
"UNSUPPORTED_FEATURE",
"CODEC_MISSING",
] as const);
export type ArchiveErrorCode =
(typeof ARCHIVE_CODES)[keyof typeof ARCHIVE_CODES];
/**
* Concrete error class. Extends {@link SuaveplanError} so callers can
* `instanceof ArchiveError` to narrow `code` to {@link ArchiveErrorCode}.
* Stores the code in a redundant readonly field so the constructor body
* is non-trivial (sidesteps lint rule `noUselessConstructor`).
*/
export class ArchiveError extends SuaveplanError<ArchiveErrorCode> {
public readonly archiveCode: ArchiveErrorCode;
public constructor(
code: ArchiveErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.archiveCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
ARCHIVE_CODES,
ArchiveError,
type ArchiveErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,71 @@
# open-archive-storage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the public `openArchiveStorage(source, options)` factory plus the test-injection seam `setDynamicImporter`. The factory is the single entry point for mounting any archive format: it sniffs (when `format === "auto"`), emits an `ARCHIVE_FORMAT_MISMATCH` warning if a declared extension disagrees with the detected format, dynamic-imports the matching `@suaveplan/drive-storage-${format}` package, and forwards `(source, resolvedOptions)` to the package's exported `archiveAdapterFactory`. The factory returns whatever the adapter returns — typically a `DriveStorage`-shaped object with `flush()` and `mode` properties.
## Features
- Magic-byte format detection via `sniffFormat` against the first 512 bytes of a `Uint8Array` source.
- Optional `ARCHIVE_FORMAT_MISMATCH` warning when the caller's `declaredExtension` disagrees with the sniffed format.
- Dynamic-import dispatch keyed by format name (`@suaveplan/drive-storage-zip`, etc.).
- Test-injection seams: `options.__formatRegistry` for inline fake adapters, `setDynamicImporter(fn)` for swapping the underlying `import()` call.
- Typed errors: `ARCHIVE_FORMAT_UNKNOWN` when sniff fails AND `CODEC_MISSING` when the per-format package is not installed.
## Usage: Basic Example
```ts
import { openArchiveStorage } from "@suaveplan/drive-storage-archive-core";
const adapter = await openArchiveStorage(zipBytes, {
format: "auto",
mode: "read-only",
});
console.log(adapter.mode);
```
## Usage: Advanced Example
```ts
import {
openArchiveStorage,
setDynamicImporter,
type ArchiveAdapterFormatRegistry,
} from "@suaveplan/drive-storage-archive-core";
const registry: ArchiveAdapterFormatRegistry = {
zip: async (_source, opts) => ({
mode: opts.mode,
async flush() {
/* test stub */
},
}),
};
const restore = setDynamicImporter(async (_spec) => {
throw new Error("real dynamic import disabled in this test");
});
try {
const adapter = await openArchiveStorage(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), {
format: "zip",
__formatRegistry: registry,
});
console.log(adapter.mode);
} finally {
restore();
}
```
## API Reference
- `openArchiveStorage(source, options?): Promise<ArchiveStorageHandle>` — main factory.
- `setDynamicImporter(impl): () => void` — replace the underlying `import()` implementation; returns a restorer.
- `type OpenArchiveSource` — `Uint8Array | ReadableStream<Uint8Array> | unknown`.
## Implementation Notes
The factory deliberately refuses to drain a `ReadableStream` for sniffing — `sniffFormat` is synchronous and accepts only `Uint8Array`. When the caller passes a stream AND `format === "auto"`, the factory raises `ARCHIVE_FORMAT_UNKNOWN` rather than buffer-the-whole-stream-then-sniff. Callers that own the underlying bytes should pass the buffer; callers that genuinely have a stream MUST pass an explicit `format`. The dispatch table is a static `Record<ArchiveFormat, string>` keyed by format → package specifier so the dynamic import call site is a single statement that a bundler can trace. When the per-format package is not installed, `await import(specifier)` rejects with `MODULE_NOT_FOUND`; the factory rewraps it as `ARCHIVE_CODEC_MISSING` so callers see a stable typed error.

View File

@@ -0,0 +1,281 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import type { ArchiveAdapterFormatRegistry } from "../types/open-archive-storage-options.js";
import {
openArchiveStorage,
setDynamicImporter,
} from "./open-archive-storage.js";
function zipMagic(): Uint8Array {
return Uint8Array.from([
0x50,
0x4b,
0x03,
0x04,
0,
0,
0,
0, // local header start
0x50,
0x4b,
0x05,
0x06, // EOCD
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
]);
}
function gzipMagic(): Uint8Array {
return Uint8Array.from([
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]);
}
describe("openArchiveStorage", () => {
it("auto-detects format from Uint8Array and dispatches via registry", {
timeout: 5000,
}, async () => {
const registry: ArchiveAdapterFormatRegistry = {
zip: async (_source, opts) => ({
mode: opts.mode,
async flush() {
/* no-op */
},
}),
};
const out = await openArchiveStorage(zipMagic(), {
format: "auto",
mode: "read-only",
__formatRegistry: registry,
});
expect(out.mode).toBe("read-only");
});
it("honours explicit format and skips sniff", {
timeout: 5000,
}, async () => {
const registry: ArchiveAdapterFormatRegistry = {
tar: async (_source, opts) => ({
mode: opts.mode,
async flush() {
/* no-op */
},
}),
};
const out = await openArchiveStorage(new Uint8Array([0, 0, 0, 0]), {
format: "tar",
__formatRegistry: registry,
});
expect(out.mode).toBe("read-only"); // default
});
it("logs ARCHIVE_FORMAT_MISMATCH when declaredExtension disagrees with sniff", {
timeout: 5000,
}, async () => {
const warned: Array<[string, unknown]> = [];
const logger = {
debug() {},
info() {},
warn(msg: string, ctx?: Readonly<Record<string, unknown>>) {
warned.push([msg, ctx]);
},
error() {},
};
const registry: ArchiveAdapterFormatRegistry = {
gz: async (_s, opts) => ({
mode: opts.mode,
async flush() {},
}),
};
await openArchiveStorage(gzipMagic(), {
format: "auto",
declaredExtension: "zip",
logger,
__formatRegistry: registry,
});
expect(warned.length).toBe(1);
expect(warned[0]![0]).toMatch(/ARCHIVE_FORMAT_MISMATCH/);
const ctx = warned[0]![1] as { code?: string };
expect(ctx.code).toBe(ARCHIVE_CODES.FORMAT_MISMATCH);
});
it("does NOT log a mismatch when declaredExtension agrees", {
timeout: 5000,
}, async () => {
const warned: string[] = [];
const logger = {
debug() {},
info() {},
warn(msg: string) {
warned.push(msg);
},
error() {},
};
const registry: ArchiveAdapterFormatRegistry = {
gz: async (_s, opts) => ({
mode: opts.mode,
async flush() {},
}),
};
await openArchiveStorage(gzipMagic(), {
format: "auto",
declaredExtension: "gz",
logger,
__formatRegistry: registry,
});
expect(warned).toEqual([]);
});
it("raises ARCHIVE_FORMAT_UNKNOWN when source is not Uint8Array and format=auto", {
timeout: 5000,
}, async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
});
await expect(
openArchiveStorage(stream, { format: "auto" })
).rejects.toBeInstanceOf(ArchiveError);
try {
await openArchiveStorage(stream, { format: "auto" });
} catch (err) {
expect(err).toBeInstanceOf(ArchiveError);
expect((err as ArchiveError).archiveCode).toBe(
ARCHIVE_CODES.FORMAT_UNKNOWN
);
}
});
it("raises ARCHIVE_FORMAT_UNKNOWN when sniff fails on bytes", {
timeout: 5000,
}, async () => {
await expect(
openArchiveStorage(new Uint8Array([0, 0, 0, 0]), {
format: "auto",
})
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.FORMAT_UNKNOWN,
});
});
it("raises ARCHIVE_CODEC_MISSING when dynamic import fails", {
timeout: 5000,
}, async () => {
const restore = setDynamicImporter(async () => {
throw new Error("MODULE_NOT_FOUND");
});
try {
await expect(
openArchiveStorage(zipMagic(), { format: "zip" })
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.CODEC_MISSING,
});
} finally {
restore();
}
});
it("raises ARCHIVE_CODEC_MISSING when imported module lacks archiveAdapterFactory", {
timeout: 5000,
}, async () => {
const restore = setDynamicImporter(async () => ({}));
try {
await expect(
openArchiveStorage(zipMagic(), { format: "zip" })
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.CODEC_MISSING,
});
} finally {
restore();
}
});
it("succeeds via real dynamic-import shim when adapter export is present", {
timeout: 5000,
}, async () => {
const restore = setDynamicImporter(async () => ({
archiveAdapterFactory: async (
_s: unknown,
opts: { mode: "read-only" | "copy-on-write" | "rewrite" }
) => ({
mode: opts.mode,
async flush() {},
}),
}));
try {
const out = await openArchiveStorage(zipMagic(), {
format: "zip",
mode: "copy-on-write",
});
expect(out.mode).toBe("copy-on-write");
} finally {
restore();
}
});
it("uses default options when none provided", {
timeout: 5000,
}, async () => {
await expect(
openArchiveStorage(new Uint8Array([0, 0, 0, 0]))
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.FORMAT_UNKNOWN,
});
});
it("uses the default dynamic importer when none is injected", {
timeout: 5000,
}, async () => {
// No registry, no setDynamicImporter override. The default
// importer will be invoked and will reject because the real
// per-format package is not yet installed; the factory must
// map that to CODEC_MISSING.
await expect(
openArchiveStorage(zipMagic(), { format: "zip" })
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.CODEC_MISSING,
});
});
it("treats empty declaredExtension as 'no declaration'", {
timeout: 5000,
}, async () => {
const warned: string[] = [];
const logger = {
debug() {},
info() {},
warn(msg: string) {
warned.push(msg);
},
error() {},
};
const registry: ArchiveAdapterFormatRegistry = {
gz: async (_s, opts) => ({ mode: opts.mode, async flush() {} }),
};
await openArchiveStorage(gzipMagic(), {
format: "auto",
declaredExtension: "",
logger,
__formatRegistry: registry,
});
expect(warned).toEqual([]);
});
});

View File

@@ -0,0 +1,148 @@
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import { sniffFormat } from "../sniff/sniff-format.js";
import type { ArchiveFormat } from "../types/archive-format.js";
import type {
ArchiveAdapterFactory,
ArchiveAdapterFormatRegistry,
ArchiveStorageHandle,
OpenArchiveStorageOptions,
ResolvedAdapterOptions,
} from "../types/open-archive-storage-options.js";
/**
* Source value accepted by `openArchiveStorage`. The factory itself
* does not consume the source — it forwards the value to the per-format
* adapter. The factory only inspects the leading 512 bytes when
* `format === "auto"` AND `source` is already a `Uint8Array`.
*/
export type OpenArchiveSource =
| Uint8Array
| ReadableStream<Uint8Array>
| unknown;
const PACKAGE_BY_FORMAT: Record<ArchiveFormat, string> = {
zip: "@suaveplan/drive-storage-zip",
tar: "@suaveplan/drive-storage-tar",
gz: "@suaveplan/drive-storage-gz",
tgz: "@suaveplan/drive-storage-tgz",
rar: "@suaveplan/drive-storage-rar",
"7z": "@suaveplan/drive-storage-7z",
};
type DynamicImporter = (specifier: string) => Promise<unknown>;
// Indirection layer the test suite can swap out via `setDynamicImporter`.
let dynamicImporter: DynamicImporter = (specifier) => import(specifier);
/**
* Replace the dynamic-import implementation used by the factory.
* Returns a disposer that restores the previous importer. Intended for
* tests that want to simulate a missing per-format package without
* touching the real module graph.
*/
export function setDynamicImporter(impl: DynamicImporter): () => void {
const previous = dynamicImporter;
dynamicImporter = impl;
return () => {
dynamicImporter = previous;
};
}
function sniffLeadingBytes(source: OpenArchiveSource): ArchiveFormat | null {
if (source instanceof Uint8Array) {
const head = source.subarray(0, Math.min(source.byteLength, 512));
return sniffFormat(head);
}
return null;
}
function buildResolvedOptions(
opts: OpenArchiveStorageOptions
): ResolvedAdapterOptions {
return {
mode: opts.mode ?? "read-only",
password: opts.password,
resolveSibling: opts.resolveSibling,
logger: opts.logger,
codec: opts.codec,
};
}
async function loadAdapterFactory(
format: ArchiveFormat,
registry: ArchiveAdapterFormatRegistry | undefined
): Promise<ArchiveAdapterFactory> {
const fromRegistry = registry?.[format];
if (fromRegistry !== undefined) return fromRegistry;
const specifier = PACKAGE_BY_FORMAT[format];
let mod: unknown;
try {
mod = await dynamicImporter(specifier);
} catch (cause) {
throw new ArchiveError(
ARCHIVE_CODES.CODEC_MISSING,
`archive codec package '${specifier}' not installed`,
{ cause }
);
}
const candidate = (mod as { archiveAdapterFactory?: ArchiveAdapterFactory })
.archiveAdapterFactory;
if (typeof candidate !== "function") {
throw new ArchiveError(
ARCHIVE_CODES.CODEC_MISSING,
`archive codec package '${specifier}' is missing an 'archiveAdapterFactory' export`
);
}
return candidate;
}
/**
* Open an archive byte source as a `DriveStorage`-shaped adapter.
*
* - When `format === "auto"` (default) and `source` is a `Uint8Array`,
* the factory runs `sniffFormat` against the first 512 bytes.
* - When `declaredExtension` is provided and differs from the sniffed
* format, an `ARCHIVE_FORMAT_MISMATCH` warning is logged via
* `options.logger?.warn` (the detected format wins).
* - When `source` is not a `Uint8Array` (e.g. a `ReadableStream`) and
* `format === "auto"`, the factory raises `ARCHIVE_FORMAT_UNKNOWN`
* because the sniffer is synchronous and refuses to drain a stream.
*
* The per-format adapter is loaded via `dynamicImporter` (which is
* overridable via `setDynamicImporter` for testing). The adapter MUST
* export an `archiveAdapterFactory` symbol of type
* {@link ArchiveAdapterFactory}.
*/
export async function openArchiveStorage(
source: OpenArchiveSource,
options: OpenArchiveStorageOptions = {}
): Promise<ArchiveStorageHandle> {
const declared = options.format ?? "auto";
let chosen: ArchiveFormat;
if (declared === "auto") {
const sniffed = sniffLeadingBytes(source);
if (sniffed === null) {
throw new ArchiveError(
ARCHIVE_CODES.FORMAT_UNKNOWN,
"openArchiveStorage: format is 'auto' but no magic bytes matched (or source is not a Uint8Array)"
);
}
chosen = sniffed;
const declaredExt = options.declaredExtension?.toLowerCase();
if (
declaredExt !== undefined &&
declaredExt !== "" &&
declaredExt !== chosen
) {
options.logger?.warn(
`ARCHIVE_FORMAT_MISMATCH: declared extension '${declaredExt}' but sniffed '${chosen}'`,
{ code: ARCHIVE_CODES.FORMAT_MISMATCH }
);
}
} else {
chosen = declared;
}
const factory = await loadAdapterFactory(chosen, options.__formatRegistry);
const resolved = buildResolvedOptions(options);
return factory(source, resolved);
}

View File

@@ -0,0 +1,65 @@
// Types
// Errors
export {
ARCHIVE_CODES,
ArchiveError,
type ArchiveErrorCode,
} from "./errors/error-codes.js";
// Factory
export {
type OpenArchiveSource,
openArchiveStorage,
setDynamicImporter,
} from "./factory/open-archive-storage.js";
export {
type PendingMutations,
pendingMutations,
TOMBSTONE,
} from "./modes/pending-mutations.js";
// Modes
export { assertNotReadOnly, validateMode } from "./modes/validate-mode.js";
// Password
export {
assertPasswordHook,
consumePassword,
type PasswordHandle,
withPassword,
wrapPasswordHook,
} from "./password/password-hook.js";
// Resolvers
export { rarSiblingResolver } from "./resolvers/rar-sibling-resolver.js";
export { sevenZipSiblingResolver } from "./resolvers/seven-zip-sibling-resolver.js";
export type { SiblingHost } from "./resolvers/sibling-host.js";
export {
stripTrailingExtension,
zipSiblingResolver,
} from "./resolvers/zip-sibling-resolver.js";
// Sniffing
export {
type SniffResult,
sniffFormat,
sniffFormatWithReason,
} from "./sniff/sniff-format.js";
export type { ArchiveCapabilities } from "./types/archive-capabilities.js";
export type { ArchiveCodec } from "./types/archive-codec.js";
export {
ARCHIVE_FORMATS,
type ArchiveFormat,
type ArchiveFormatOrAuto,
} from "./types/archive-format.js";
export {
ARCHIVE_MODES,
type ArchiveMode,
isArchiveMode,
} from "./types/archive-mode.js";
export type { EntryReader } from "./types/entry-reader.js";
export type {
ArchiveAdapterFactory,
ArchiveAdapterFormatRegistry,
ArchiveStorageHandle,
OpenArchiveStorageOptions,
ResolvedAdapterOptions,
} from "./types/open-archive-storage-options.js";
export type { PasswordHook } from "./types/password-hook.js";
export type { ResolveSibling } from "./types/resolve-sibling.js";

View File

@@ -0,0 +1,6 @@
export {
type PendingMutations,
pendingMutations,
TOMBSTONE,
} from "./pending-mutations.js";
export { assertNotReadOnly, validateMode } from "./validate-mode.js";

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import type { ArchiveCapabilities } from "../types/archive-capabilities.js";
import { pendingMutations, TOMBSTONE } from "./pending-mutations.js";
import { assertNotReadOnly, validateMode } from "./validate-mode.js";
const caps: ArchiveCapabilities = {
format: "zip",
modes: ["read-only", "copy-on-write"],
readsEncrypted: true,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
};
describe("validateMode", () => {
it("accepts a supported mode", { timeout: 5000 }, () => {
expect(() => validateMode("read-only", caps)).not.toThrow();
expect(() => validateMode("copy-on-write", caps)).not.toThrow();
});
it("throws ARCHIVE_UNSUPPORTED_FEATURE for an unsupported mode", {
timeout: 5000,
}, () => {
try {
validateMode("rewrite", caps);
throw new Error("should not reach here");
} catch (err) {
expect(err).toBeInstanceOf(ArchiveError);
expect((err as ArchiveError).archiveCode).toBe(
ARCHIVE_CODES.UNSUPPORTED_FEATURE
);
}
});
});
describe("assertNotReadOnly", () => {
it("passes for non read-only modes", { timeout: 5000 }, () => {
expect(() => assertNotReadOnly("copy-on-write")).not.toThrow();
expect(() => assertNotReadOnly("rewrite")).not.toThrow();
});
it("throws ARCHIVE_MODE_READ_ONLY for read-only", { timeout: 5000 }, () => {
try {
assertNotReadOnly("read-only");
throw new Error("should not reach here");
} catch (err) {
expect(err).toBeInstanceOf(ArchiveError);
expect((err as ArchiveError).archiveCode).toBe(
ARCHIVE_CODES.MODE_READ_ONLY
);
}
});
});
describe("pendingMutations", () => {
it("set + has + get round-trips", { timeout: 5000 }, () => {
const m = pendingMutations<Uint8Array>();
const bytes = new Uint8Array([1, 2, 3]);
m.set("a.txt", bytes);
expect(m.has("a.txt")).toBe(true);
expect(m.get("a.txt")).toBe(bytes);
expect(m.size).toBe(1);
});
it("delete inserts TOMBSTONE", { timeout: 5000 }, () => {
const m = pendingMutations();
m.delete("gone.bin");
expect(m.get("gone.bin")).toBe(TOMBSTONE);
});
it("entries iterates pairs", { timeout: 5000 }, () => {
const m = pendingMutations<Uint8Array>();
m.set("a", new Uint8Array([1]));
m.delete("b");
const out: Array<[string, unknown]> = [];
for (const [k, v] of m.entries()) out.push([k, v]);
expect(out).toEqual([
["a", expect.any(Uint8Array)],
["b", TOMBSTONE],
]);
});
it("clear empties the overlay", { timeout: 5000 }, () => {
const m = pendingMutations();
m.set("a", new Uint8Array());
m.clear();
expect(m.size).toBe(0);
});
it("assertEmpty is a no-op when empty", { timeout: 5000 }, () => {
const m = pendingMutations();
expect(() => m.assertEmpty()).not.toThrow();
});
it("assertEmpty throws ARCHIVE_FLUSH_REQUIRED when non-empty", {
timeout: 5000,
}, () => {
const m = pendingMutations();
m.set("a", new Uint8Array());
try {
m.assertEmpty();
throw new Error("should not reach here");
} catch (err) {
expect(err).toBeInstanceOf(ArchiveError);
expect((err as ArchiveError).archiveCode).toBe(
ARCHIVE_CODES.FLUSH_REQUIRED
);
}
});
it("returns undefined for unknown paths", { timeout: 5000 }, () => {
expect(pendingMutations().get("nope")).toBeUndefined();
});
});

View File

@@ -0,0 +1,81 @@
# pending-mutations
## Purpose
Provides the `pendingMutations<T>()` factory and the `PendingMutations<T>` overlay interface that per-format archive adapters use to buffer writes and deletes in `copy-on-write` mode. The overlay distinguishes a pending write (path → bytes) from a pending delete (path → `TOMBSTONE` sentinel) so the adapter's `flush()` path can rebuild the archive deterministically, applying additions and removals in a single pass. Also exports `TOMBSTONE`, the unique-symbol marker that signals deletion, and an `assertEmpty()` helper that raises `ARCHIVE_FLUSH_REQUIRED` when an adapter is disposed without flushing.
## Features
- Explicit deletion tracking via a unique-symbol tombstone — adapters cannot confuse "absent from overlay" with "deleted in overlay".
- O(1) `set`/`delete`/`has`/`get` backed by a Map.
- `assertEmpty` integration with the FLUSH_REQUIRED error code so disposal-without-flush is a typed error consumers can catch.
- Generic over the value type — defaults to `Uint8Array` but adapters that need a structured payload (e.g. tar's `{ bytes, mtime }`) can specialise.
## Usage: Basic Example
```ts
import { pendingMutations, TOMBSTONE } from "@suaveplan/drive-storage-archive-core";
const overlay = pendingMutations();
overlay.set("notes.txt", new TextEncoder().encode("hello"));
overlay.delete("old.bin");
console.log(overlay.size); // 2
const out = overlay.get("old.bin");
if (out === TOMBSTONE) console.log("deleted");
```
## Usage: Advanced Example
```ts
import {
pendingMutations,
TOMBSTONE,
type PendingMutations,
} from "@suaveplan/drive-storage-archive-core";
class CopyOnWriteAdapter {
readonly #pending: PendingMutations = pendingMutations();
async write(path: string, bytes: Uint8Array): Promise<void> {
this.#pending.set(path, bytes);
}
async delete(path: string): Promise<boolean> {
this.#pending.delete(path);
return true;
}
async flush(): Promise<void> {
for (const [path, value] of this.#pending.entries()) {
if (value === TOMBSTONE) {
/* drop entry in re-encoded archive */
} else {
/* write new entry */
}
}
this.#pending.clear();
}
async dispose(): Promise<void> {
this.#pending.assertEmpty(); // throws ARCHIVE_FLUSH_REQUIRED if anything pending
}
}
```
## API Reference
- `pendingMutations<T = Uint8Array>(): PendingMutations<T>` — factory.
- `interface PendingMutations<T>`
- `set(path: string, value: T): void`
- `delete(path: string): void`
- `has(path: string): boolean`
- `get(path: string): T | typeof TOMBSTONE | undefined`
- `readonly size: number`
- `entries(): IterableIterator<[string, T | typeof TOMBSTONE]>`
- `clear(): void`
- `assertEmpty(): void` — throws `ARCHIVE_FLUSH_REQUIRED` when overlay non-empty.
- `const TOMBSTONE: unique symbol` — sentinel for pending deletes.
## Implementation Notes
The overlay is intentionally a flat Map rather than a tree. Archive entry paths are stored verbatim as keys; adapters that need hierarchical introspection iterate `entries()` and split on `/`. The tombstone is a `unique symbol` (not a string) so it cannot collide with any user-supplied path even if a malicious archive contained a literal entry named `"TOMBSTONE"`. `assertEmpty` is intentionally idempotent — calling it twice on an empty overlay does not throw — so disposal paths that mix it with other cleanup steps stay simple.

View File

@@ -0,0 +1,75 @@
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
/**
* Tombstone sentinel inserted into the overlay when an entry is
* deleted in `copy-on-write` mode.
*/
export const TOMBSTONE: unique symbol = Symbol("archive.tombstone");
/**
* Overlay map exposed to per-format adapters in `copy-on-write` mode.
* Per-format adapters call `set(path, bytes)`, `delete(path)`, and
* iterate `entries()` on flush. The overlay distinguishes "added /
* modified" from "removed" so the adapter can rebuild the archive
* deterministically.
*/
export interface PendingMutations<T = Uint8Array> {
set(path: string, value: T): void;
delete(path: string): void;
has(path: string): boolean;
/**
* Returns the overlay value for `path`, or `undefined` when no
* mutation is pending. Returns `TOMBSTONE` when a delete is pending.
*/
get(path: string): T | typeof TOMBSTONE | undefined;
/** Number of pending mutations (writes + deletes). */
readonly size: number;
/** Iterator over `[path, value | TOMBSTONE]` pairs. */
entries(): IterableIterator<[string, T | typeof TOMBSTONE]>;
/** Clears every pending mutation (after a successful flush). */
clear(): void;
/**
* Throws `ARCHIVE_FLUSH_REQUIRED` if any mutations are pending.
* Adapters call this from their `dispose()` path when a write-mode
* adapter is being torn down without an explicit flush.
*/
assertEmpty(): void;
}
/**
* Construct an empty pending-mutations overlay.
*/
export function pendingMutations<T = Uint8Array>(): PendingMutations<T> {
const store = new Map<string, T | typeof TOMBSTONE>();
return {
set(path, value) {
store.set(path, value);
},
delete(path) {
store.set(path, TOMBSTONE);
},
has(path) {
return store.has(path);
},
get(path) {
return store.get(path);
},
get size() {
return store.size;
},
entries() {
return store.entries();
},
clear() {
store.clear();
},
assertEmpty() {
if (store.size > 0) {
throw new ArchiveError(
ARCHIVE_CODES.FLUSH_REQUIRED,
`dispose() called with ${store.size} pending mutation(s); call flush() first`
);
}
},
};
}

View File

@@ -0,0 +1,64 @@
# validate-mode
## Purpose
Provides `validateMode(mode, capabilities)` and `assertNotReadOnly(mode)` — the two helpers every per-format archive adapter calls at the boundary between user input and adapter state. `validateMode` checks that a requested mode is present in the adapter's static `ArchiveCapabilities.modes` array and throws `ARCHIVE_UNSUPPORTED_FEATURE` otherwise. `assertNotReadOnly` checks that the current mode is not `read-only` and throws `ARCHIVE_MODE_READ_ONLY` otherwise; adapters call this from every mutation entry point (`set`, `delete`, `setPartial`).
## Features
- Single source of truth for mode-validation error messages — every adapter throws the same typed error with the same shape.
- `validateMode` runs at construction time; `assertNotReadOnly` runs at mutation time. The two cover the full lifecycle.
- Error messages enumerate the supported modes so the user sees which mode they should have chosen.
## Usage: Basic Example
```ts
import { assertNotReadOnly, validateMode } from "@suaveplan/drive-storage-archive-core";
const caps = {
format: "zip" as const,
modes: ["read-only", "copy-on-write"] as const,
readsEncrypted: true,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
};
validateMode("copy-on-write", caps); // ok
```
## Usage: Advanced Example
```ts
import {
assertNotReadOnly,
validateMode,
type ArchiveMode,
} from "@suaveplan/drive-storage-archive-core";
class StubAdapter {
constructor(private readonly mode: ArchiveMode) {
validateMode(mode, {
format: "zip",
modes: ["read-only", "copy-on-write", "rewrite"],
readsEncrypted: false,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
});
}
write(_key: string, _value: Uint8Array): void {
assertNotReadOnly(this.mode);
// ... real write path ...
}
}
```
## API Reference
- `validateMode(mode: ArchiveMode, capabilities: ArchiveCapabilities): void` — throws `ARCHIVE_UNSUPPORTED_FEATURE` when `mode` is absent from `capabilities.modes`.
- `assertNotReadOnly(mode: ArchiveMode): void` — throws `ARCHIVE_MODE_READ_ONLY` when `mode === "read-only"`.
## Implementation Notes
`validateMode` and `assertNotReadOnly` are intentionally split because they fire at different lifecycle points: `validateMode` is a one-time check at adapter construction (a constant cost per adapter), while `assertNotReadOnly` runs on every mutation call (the cost adds up under load). Keeping them separate lets the v8 inliner specialise the hot mutation path without dragging in the capability-record lookup. Both throw `ArchiveError` instances with stable error codes so consumers can `instanceof ArchiveError` and branch on `archiveCode` — the codes ARE the API contract for these checks.

View File

@@ -0,0 +1,33 @@
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import type { ArchiveCapabilities } from "../types/archive-capabilities.js";
import type { ArchiveMode } from "../types/archive-mode.js";
/**
* Validates that a requested mode is supported by an adapter's
* capabilities. Throws `ARCHIVE_UNSUPPORTED_FEATURE` when the mode is
* absent from the capabilities.modes array.
*/
export function validateMode(
mode: ArchiveMode,
capabilities: ArchiveCapabilities
): void {
if (!capabilities.modes.includes(mode)) {
throw new ArchiveError(
ARCHIVE_CODES.UNSUPPORTED_FEATURE,
`mode '${mode}' is not supported by the '${capabilities.format}' adapter; supported modes: ${capabilities.modes.join(", ")}`
);
}
}
/**
* Throws `ARCHIVE_MODE_READ_ONLY` when called on a `read-only` mode.
* Adapters call this helper at the top of every mutation method.
*/
export function assertNotReadOnly(mode: ArchiveMode): void {
if (mode === "read-only") {
throw new ArchiveError(
ARCHIVE_CODES.MODE_READ_ONLY,
"archive opened in read-only mode; write/delete rejected"
);
}
}

View File

@@ -0,0 +1,72 @@
# password-hook (helpers)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the small set of helpers every per-format archive adapter calls when handling encrypted entries: `wrapPasswordHook(hook?)` (enforces a defensive NFC-normalised copy on every invocation so an adapter cannot accidentally share the host's string reference), `assertPasswordHook(hook?)` (throws `ARCHIVE_PASSWORD_REQUIRED` when an encrypted entry is encountered without a configured hook), `consumePassword(hook)` (yields a release-able `PasswordHandle` for a single decrypt operation), and `withPassword(hook, fn)` (the higher-level try/finally wrapper that pairs `consumePassword` with automatic release on every exit path).
## Features
- Defensive copy via `String.prototype.normalize("NFC")` on every wrapped-hook call — adapters cannot leak the host's string identity.
- Typed `ARCHIVE_PASSWORD_REQUIRED` / `ARCHIVE_PASSWORD_INVALID` errors so consumers branch on stable codes.
- `release()` drops the wrapper's reference to the password as soon as the active decrypt scope completes — the GC can then reclaim the original allocation.
- `withPassword` provides try/finally semantics so adapters do not have to write the boilerplate themselves.
## Usage: Basic Example
```ts
import {
assertPasswordHook,
withPassword,
} from "@suaveplan/drive-storage-archive-core";
async function decryptEntry(hook: (() => Promise<string>) | undefined): Promise<Uint8Array> {
assertPasswordHook(hook); // throws ARCHIVE_PASSWORD_REQUIRED if undefined
return withPassword(hook, async (password) => {
// ... derive key, decrypt, return bytes ...
return new TextEncoder().encode(`pw len: ${password.length}`);
});
}
```
## Usage: Advanced Example
```ts
import {
consumePassword,
wrapPasswordHook,
type PasswordHook,
} from "@suaveplan/drive-storage-archive-core";
const hostHook: PasswordHook = async () => "correct horse battery staple";
const wrapped = wrapPasswordHook(hostHook);
if (wrapped !== undefined) {
const handle = await consumePassword(wrapped);
try {
console.log(handle.value.length);
} finally {
handle.release();
}
// Subsequent access throws ARCHIVE_PASSWORD_INVALID.
try {
console.log(handle.value);
} catch (err) {
console.error("expected:", (err as Error).message);
}
}
```
## API Reference
- `wrapPasswordHook(hook: PasswordHook | undefined): PasswordHook | undefined` — defensive-copy wrapper.
- `assertPasswordHook(hook: PasswordHook | undefined): asserts hook is PasswordHook` — throws when undefined.
- `consumePassword(hook: PasswordHook): Promise<PasswordHandle>` — yield a single-use handle.
- `withPassword<T>(hook: PasswordHook, fn: (pw: string) => Promise<T>): Promise<T>` — scoped helper with automatic release.
- `interface PasswordHandle { readonly value: string; release(): void }`.
## Implementation Notes
JavaScript strings are immutable, so `release()` cannot actually overwrite the bytes of the resolved password in the engine's heap. The strongest scrub the language permits is dropping every reference and letting the GC reclaim the allocation — which is what `release()` does (it sets the closure-captured `stored` to `undefined`). The NFC normalisation in `wrapPasswordHook` is the cheapest way to force a new String allocation distinct from the host's input. `withPassword` is the recommended entry point for adapters because it makes the release path automatic; `consumePassword` exists for adapters that need to thread the handle across multiple async ops (e.g. interleaved decrypt + verify).

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import {
assertPasswordHook,
consumePassword,
withPassword,
wrapPasswordHook,
} from "./password-hook.js";
describe("wrapPasswordHook", () => {
it("returns undefined when input is undefined", { timeout: 5000 }, () => {
expect(wrapPasswordHook(undefined)).toBeUndefined();
});
it("returns a hook that yields the password value", {
timeout: 5000,
}, async () => {
const wrapped = wrapPasswordHook(async () => "letmein");
expect(wrapped).toBeDefined();
expect(await wrapped!()).toBe("letmein");
});
it("normalises the password to NFC", { timeout: 5000 }, async () => {
// 'é' as combining vs precomposed
const decomposed = "é";
const wrapped = wrapPasswordHook(async () => decomposed);
const out = await wrapped!();
expect(out).toBe(decomposed.normalize("NFC"));
});
});
describe("assertPasswordHook", () => {
it("throws ARCHIVE_PASSWORD_REQUIRED when undefined", {
timeout: 5000,
}, () => {
try {
assertPasswordHook(undefined);
throw new Error("should not reach here");
} catch (err) {
expect(err).toBeInstanceOf(ArchiveError);
expect((err as ArchiveError).archiveCode).toBe(
ARCHIVE_CODES.PASSWORD_REQUIRED
);
}
});
it("is a no-op when hook is defined", { timeout: 5000 }, () => {
expect(() => assertPasswordHook(async () => "x")).not.toThrow();
});
});
describe("consumePassword", () => {
it("yields a handle whose value is the password", {
timeout: 5000,
}, async () => {
const handle = await consumePassword(async () => "abc");
expect(handle.value).toBe("abc");
handle.release();
});
it("throws ARCHIVE_PASSWORD_INVALID after release", {
timeout: 5000,
}, async () => {
const handle = await consumePassword(async () => "abc");
handle.release();
try {
// Access after release.
void handle.value;
throw new Error("should not reach here");
} catch (err) {
expect(err).toBeInstanceOf(ArchiveError);
expect((err as ArchiveError).archiveCode).toBe(
ARCHIVE_CODES.PASSWORD_INVALID
);
}
});
it("release is idempotent", { timeout: 5000 }, async () => {
const handle = await consumePassword(async () => "x");
handle.release();
expect(() => handle.release()).not.toThrow();
});
});
describe("withPassword", () => {
it("invokes the callback with the password", {
timeout: 5000,
}, async () => {
const result = await withPassword(
async () => "secret",
async (pw) => `len=${pw.length}`
);
expect(result).toBe("len=6");
});
it("releases the handle on success", { timeout: 5000 }, async () => {
// We cannot directly observe disposal, but we can verify the helper
// does not retain a reference by re-running and confirming the hook
// is invoked again.
let invocations = 0;
const hook = async (): Promise<string> => {
invocations++;
return "pw";
};
await withPassword(hook, async () => "ok");
await withPassword(hook, async () => "ok2");
expect(invocations).toBe(2);
});
it("releases the handle on throw", { timeout: 5000 }, async () => {
let invocations = 0;
const hook = async (): Promise<string> => {
invocations++;
return "pw";
};
try {
await withPassword(hook, async () => {
throw new Error("boom");
});
} catch {
// expected
}
// Confirm we can re-enter immediately.
await withPassword(hook, async () => "ok");
expect(invocations).toBe(2);
});
});

View File

@@ -0,0 +1,98 @@
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import type { PasswordHook } from "../types/password-hook.js";
/**
* Handle yielded by `consumePassword` for the duration of a single
* decrypt operation. The handle exposes the password via `value` and
* an explicit `release()` method that drops the internal reference so
* the GC can reclaim the original string allocation. JavaScript strings
* are immutable, so this is not a cryptographic zeroize — it is the
* strongest in-process scrub the language permits.
*/
export interface PasswordHandle {
readonly value: string;
release(): void;
}
/**
* Wraps a host-supplied `PasswordHook` with retention-asserting
* semantics. The wrapper:
*
* 1. Awaits the host hook.
* 2. Defensive-copies the returned string via `String.prototype.normalize`
* so downstream adapters that accidentally cache the reference do
* not see the original captured value.
* 3. Returns the copy on every call — the original is dropped from the
* wrapper's closure as soon as the copy is produced.
*
* Returns `undefined` when the input hook is `undefined`.
*/
export function wrapPasswordHook(
hook: PasswordHook | undefined
): PasswordHook | undefined {
if (hook === undefined) return undefined;
return async () => {
const raw = await hook();
return raw.normalize("NFC");
};
}
/**
* Throws `ARCHIVE_PASSWORD_REQUIRED` if no password hook is configured.
* Adapters call this from every encrypted-entry read path before
* invoking `consumePassword`.
*/
export function assertPasswordHook(
hook: PasswordHook | undefined
): asserts hook is PasswordHook {
if (hook === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.PASSWORD_REQUIRED,
"encrypted entry encountered but no password hook configured"
);
}
}
/**
* Invokes `hook` and yields a `PasswordHandle` whose `value` is the
* resolved password. Callers MUST call `handle.release()` (typically
* in a `finally` block) so the wrapper drops its internal reference
* to the password as soon as the active decrypt operation completes.
*/
export async function consumePassword(
hook: PasswordHook
): Promise<PasswordHandle> {
const raw = await hook();
let stored: string | undefined = raw;
return {
get value(): string {
if (stored === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.PASSWORD_INVALID,
"password handle accessed after release()"
);
}
return stored;
},
release(): void {
stored = undefined;
},
};
}
/**
* Higher-level helper that pairs `consumePassword` with a try/finally
* scope. Pass a callback that receives the password string; the helper
* releases the handle on every exit path (success OR throw).
*/
export async function withPassword<T>(
hook: PasswordHook,
fn: (password: string) => Promise<T>
): Promise<T> {
const handle = await consumePassword(hook);
try {
return await fn(handle.value);
} finally {
handle.release();
}
}

View File

@@ -0,0 +1,7 @@
export { rarSiblingResolver } from "./rar-sibling-resolver.js";
export { sevenZipSiblingResolver } from "./seven-zip-sibling-resolver.js";
export type { SiblingHost } from "./sibling-host.js";
export {
stripTrailingExtension,
zipSiblingResolver,
} from "./zip-sibling-resolver.js";

View File

@@ -0,0 +1,44 @@
# rar-sibling-resolver
## Purpose
Default `ResolveSibling` factory for split-rar archives. Handles BOTH split-naming conventions transparently — the modern RAR5 / WinRAR ≥ 3 `<basename>.partN.rar` series AND the legacy RAR4 `<basename>.rar` + `<basename>.r00`..`.r99` series — by inspecting the primary-volume key to choose the scheme. RAR5 detection is purely syntactic (the key matches `\.partN\.rar$`); any other input is treated as a RAR4 primary. The resolver wraps a `SiblingHost` view of the underlying storage and returns a `ResolveSibling` closure that the per-format rar adapter calls with `(basename, index)`.
## Features
- RAR5: index 0 → `.part1.rar`, 1 → `.part2.rar`, ..., N → `.part(N+1).rar`.
- RAR4: index 0 → `.rar` (the primary itself), 1 → `.r00`, 2 → `.r01`, ..., 100 → `.r99`.
- Single resolver implementation; scheme is chosen once at factory time based on the primary key.
- Returns `null` for missing volumes per the `ResolveSibling` contract.
## Usage: Basic Example
```ts
import { rarSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const resolver = rarSiblingResolver(host, "movie.part1.rar");
const vol0 = await resolver("movie", 0); // → host.get("movie.part1.rar")
const vol1 = await resolver("movie", 1); // → host.get("movie.part2.rar")
```
## Usage: Advanced Example
```ts
import { rarSiblingResolver } from "@suaveplan/drive-storage-archive-core";
// Legacy RAR4 split with 5 volumes.
const legacy = rarSiblingResolver(host, "backup.rar");
const v0 = await legacy("backup", 0); // → host.get("backup.rar")
const v1 = await legacy("backup", 1); // → host.get("backup.r00")
const v2 = await legacy("backup", 2); // → host.get("backup.r01")
// ...
const v4 = await legacy("backup", 4); // → host.get("backup.r03")
```
## API Reference
- `rarSiblingResolver(host: SiblingHost, primaryKey: string): ResolveSibling` — returns a closure that maps a 0-based volume index to host-storage bytes.
## Implementation Notes
The RAR4 scheme is intentionally awkward — the primary volume is `.rar`, but every subsequent volume is `.r00`, `.r01`, etc. This means RAR4 volume `N` for `N > 0` corresponds to extension `.r{N-1:02}`. The resolver collapses that into one branch: `index === 0 ? '.rar' : '.r{NN}'`. The RAR5 detection regex anchors on `.partN.rar` (one or more digits between `.part` and `.rar`) so a file named `movie.party.rar` will NOT be misclassified as RAR5; it falls back to the RAR4 branch. The `stripTrailingExtension` helper is shared with the zip resolver so the basename normalisation is identical across formats.

View File

@@ -0,0 +1,35 @@
import type { ResolveSibling } from "../types/resolve-sibling.js";
import type { SiblingHost } from "./sibling-host.js";
import { stripTrailingExtension } from "./zip-sibling-resolver.js";
/**
* Default sibling-volume resolver for split-rar archives. Handles BOTH
* naming conventions transparently:
*
* - RAR5 / WinRAR ≥ 3 — `<basename>.part1.rar`, `.part2.rar`, ..., `.partN.rar`.
* - RAR4 / legacy — `<basename>.rar`, `<basename>.r00`, `.r01`, ..., `.r99`.
*
* The resolver inspects `primaryKey` to decide which scheme to follow:
* a key matching `\.partN\.rar$` uses the RAR5 scheme; otherwise the
* RAR4 scheme. Volume index 0 maps to `.part1.rar` (RAR5) or to the
* primary `.rar` itself (RAR4); index 1 maps to `.part2.rar` or `.r00`;
* etc.
*/
export function rarSiblingResolver(
host: SiblingHost,
primaryKey: string
): ResolveSibling {
const isPart = /\.part\d+\.rar$/i.test(primaryKey);
const basename = stripTrailingExtension(primaryKey);
return async (_basename, index) => {
if (index < 0) return null;
const key = isPart
? `${basename}.part${index + 1}.rar`
: index === 0
? `${basename}.rar`
: `${basename}.r${String(index - 1).padStart(2, "0")}`;
const bytes = await host.get(key);
if (bytes !== undefined) return bytes;
return null;
};
}

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { rarSiblingResolver } from "./rar-sibling-resolver.js";
import { sevenZipSiblingResolver } from "./seven-zip-sibling-resolver.js";
import type { SiblingHost } from "./sibling-host.js";
import {
stripTrailingExtension,
zipSiblingResolver,
} from "./zip-sibling-resolver.js";
function memHost(entries: Record<string, Uint8Array>): SiblingHost {
return {
async get(key) {
return entries[key];
},
async exists(key) {
return key in entries;
},
};
}
describe("stripTrailingExtension", () => {
const cases: [string, string][] = [
["archive.zip", "archive"],
["archive.z01", "archive"],
["archive.z99", "archive"],
["movie.part1.rar", "movie"],
["movie.part22.rar", "movie"],
["legacy.rar", "legacy"],
["legacy.r00", "legacy"],
["legacy.r42", "legacy"],
["data.7z", "data"],
["data.7z.001", "data"],
["data.7z.999", "data"],
["no-extension", "no-extension"],
["weird.bin", "weird.bin"],
];
for (const [input, expected] of cases) {
it(`strips '${input}' -> '${expected}'`, { timeout: 5000 }, () => {
expect(stripTrailingExtension(input)).toBe(expected);
});
}
});
describe("zipSiblingResolver", () => {
it("returns intermediate volume when present", {
timeout: 5000,
}, async () => {
const host = memHost({
"a.z01": new Uint8Array([1]),
"a.zip": new Uint8Array([99]),
});
const r = zipSiblingResolver(host, "a.zip");
expect(await r("a", 0)).toEqual(new Uint8Array([1]));
});
it("falls back to .zip terminal when intermediate absent", {
timeout: 5000,
}, async () => {
const host = memHost({ "a.zip": new Uint8Array([99]) });
const r = zipSiblingResolver(host, "a.zip");
expect(await r("a", 5)).toEqual(new Uint8Array([99]));
});
it("returns null when neither present", { timeout: 5000 }, async () => {
const r = zipSiblingResolver(memHost({}), "a.zip");
expect(await r("a", 3)).toBeNull();
});
it("returns null for negative index", { timeout: 5000 }, async () => {
const r = zipSiblingResolver(memHost({}), "a.zip");
expect(await r("a", -1)).toBeNull();
});
it("zero-pads volume numbers to 2 digits", { timeout: 5000 }, async () => {
const host = memHost({
"a.z01": new Uint8Array([1]),
"a.z10": new Uint8Array([10]),
});
const r = zipSiblingResolver(host, "a.zip");
expect(await r("a", 0)).toEqual(new Uint8Array([1]));
expect(await r("a", 9)).toEqual(new Uint8Array([10]));
});
});
describe("rarSiblingResolver", () => {
it("uses RAR5 partN.rar scheme when primaryKey matches", {
timeout: 5000,
}, async () => {
const host = memHost({
"m.part1.rar": new Uint8Array([1]),
"m.part2.rar": new Uint8Array([2]),
});
const r = rarSiblingResolver(host, "m.part1.rar");
expect(await r("m", 0)).toEqual(new Uint8Array([1]));
expect(await r("m", 1)).toEqual(new Uint8Array([2]));
});
it("uses RAR4 r00/r01 scheme for legacy primary", {
timeout: 5000,
}, async () => {
const host = memHost({
"b.rar": new Uint8Array([0]),
"b.r00": new Uint8Array([1]),
"b.r01": new Uint8Array([2]),
});
const r = rarSiblingResolver(host, "b.rar");
expect(await r("b", 0)).toEqual(new Uint8Array([0]));
expect(await r("b", 1)).toEqual(new Uint8Array([1]));
expect(await r("b", 2)).toEqual(new Uint8Array([2]));
});
it("returns null for missing volume", { timeout: 5000 }, async () => {
const r = rarSiblingResolver(memHost({}), "x.part1.rar");
expect(await r("x", 5)).toBeNull();
});
it("returns null for negative index", { timeout: 5000 }, async () => {
const r = rarSiblingResolver(memHost({}), "x.part1.rar");
expect(await r("x", -1)).toBeNull();
});
});
describe("sevenZipSiblingResolver", () => {
it("uses 3-digit padded scheme", { timeout: 5000 }, async () => {
const host = memHost({
"d.7z.001": new Uint8Array([1]),
"d.7z.002": new Uint8Array([2]),
"d.7z.123": new Uint8Array([123]),
});
const r = sevenZipSiblingResolver(host, "d.7z.001");
expect(await r("d", 0)).toEqual(new Uint8Array([1]));
expect(await r("d", 1)).toEqual(new Uint8Array([2]));
expect(await r("d", 122)).toEqual(new Uint8Array([123]));
});
it("returns null when missing", { timeout: 5000 }, async () => {
const r = sevenZipSiblingResolver(memHost({}), "d.7z.001");
expect(await r("d", 0)).toBeNull();
});
it("returns null for negative index", { timeout: 5000 }, async () => {
const r = sevenZipSiblingResolver(memHost({}), "d.7z.001");
expect(await r("d", -1)).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
# seven-zip-sibling-resolver
## Purpose
Default `ResolveSibling` factory for split-7z archives following the canonical `<basename>.7z.NNN` naming convention. Unlike split-zip (which has a special-cased `.zip` terminal) or split-rar (which has two competing schemes), 7z splits use a single, regular series: `.7z.001`, `.7z.002`, ..., `.7z.NNN` with 3-digit zero-padded numbering starting at `001`. The resolver wraps a `SiblingHost` view of the underlying storage and returns a closure the per-format 7z adapter calls with `(basename, index)`.
## Features
- Single regular naming convention — no terminal-vs-intermediate special case.
- 3-digit zero-padded numbering starting at `001`; volume index N maps to `.7z.{(N+1):03}`.
- Returns `null` for missing volumes per the `ResolveSibling` contract.
- Shares `stripTrailingExtension` with the zip and rar resolvers so basename normalisation is consistent across formats.
## Usage: Basic Example
```ts
import { sevenZipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const resolver = sevenZipSiblingResolver(host, "dataset.7z.001");
const v0 = await resolver("dataset", 0); // → host.get("dataset.7z.001")
const v1 = await resolver("dataset", 1); // → host.get("dataset.7z.002")
```
## Usage: Advanced Example
```ts
import { sevenZipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
async function collectAll(primaryKey: string): Promise<Uint8Array[]> {
const resolver = sevenZipSiblingResolver(host, primaryKey);
const volumes: Uint8Array[] = [];
for (let i = 0; ; i++) {
const bytes = await resolver("dataset", i);
if (bytes === null) break;
if (bytes instanceof Uint8Array) {
volumes.push(bytes);
}
}
return volumes;
}
```
## API Reference
- `sevenZipSiblingResolver(host: SiblingHost, primaryKey: string): ResolveSibling` — returns a closure mapping a 0-based volume index to host-storage bytes.
## Implementation Notes
The 7z split convention does NOT use the unsplit `.7z` filename as a terminal. A split archive `dataset.7z.001..003` does not have a `dataset.7z` companion; the first volume is `.7z.001` and the trailing header lives in the last `.7z.NNN`. That means the resolver does not need a fallback path the way the zip resolver does. The 3-digit padding lets a single archive grow to 999 volumes — beyond that, the 7z spec is silent and consumer tools (7-Zip, PeaZip) all roll over to 4-digit padding, which the resolver does NOT support; callers that need 1000+ volumes must provide a custom resolver. Returning `null` for missing volumes lets the adapter raise `ARCHIVE_SPLIT_MISSING_VOLUME` with the precise missing index.

View File

@@ -0,0 +1,23 @@
import type { ResolveSibling } from "../types/resolve-sibling.js";
import type { SiblingHost } from "./sibling-host.js";
import { stripTrailingExtension } from "./zip-sibling-resolver.js";
/**
* Default sibling-volume resolver for split-7z archives following the
* `.7z.NNN` convention: `<basename>.7z.001`, `.7z.002`, ..., `.7z.NNN`
* with 3-digit zero-padded numbering starting at `001`. The first
* volume IS `.7z.001` — there is no special-cased terminal volume.
*/
export function sevenZipSiblingResolver(
host: SiblingHost,
primaryKey: string
): ResolveSibling {
const basename = stripTrailingExtension(primaryKey);
return async (_basename, index) => {
if (index < 0) return null;
const key = `${basename}.7z.${String(index + 1).padStart(3, "0")}`;
const bytes = await host.get(key);
if (bytes !== undefined) return bytes;
return null;
};
}

View File

@@ -0,0 +1,57 @@
# sibling-host
## Purpose
Declares the minimal `SiblingHost` structural-view interface that the default sibling-volume resolvers (`zipSiblingResolver`, `rarSiblingResolver`, `sevenZipSiblingResolver`) consume to fetch additional split-archive volumes. The view is intentionally tiny — just `get(key)` and `exists(key)` — so the same resolver implementations work over any of the host shapes the drive ecosystem exposes: `AbstractStorage`, `DriveStorage`, a plain `Map`-backed test fixture, or an HTTP-backed adapter.
## Features
- Structural typing — any object with the two methods satisfies the contract; no inheritance, no module-name coupling.
- Only the methods the resolvers actually need — `list`/`stat`/`delete` are deliberately absent so test doubles stay small.
- Works for sync OR async backends because both methods return Promises.
## Usage: Basic Example
```ts
import type { SiblingHost } from "@suaveplan/drive-storage-archive-core";
const store = new Map<string, Uint8Array>();
const host: SiblingHost = {
async get(key) {
return store.get(key);
},
async exists(key) {
return store.has(key);
},
};
```
## Usage: Advanced Example
```ts
import type { SiblingHost } from "@suaveplan/drive-storage-archive-core";
function httpHost(baseUrl: string): SiblingHost {
return {
async get(key) {
const res = await fetch(`${baseUrl}/${key}`);
if (res.status === 404) return undefined;
return new Uint8Array(await res.arrayBuffer());
},
async exists(key) {
const res = await fetch(`${baseUrl}/${key}`, { method: "HEAD" });
return res.ok;
},
};
}
```
## API Reference
- `interface SiblingHost`
- `get(key: string): Promise<Uint8Array | undefined>` — returns volume bytes or `undefined` when absent.
- `exists(key: string): Promise<boolean>` — fast presence check (resolvers call this before `get` for non-final volumes).
## Implementation Notes
The view returns `undefined` (not `null`) from `get` to match `AbstractStorage.get`'s existing contract — that lets a `DriveStorage` value pass directly as a `SiblingHost` without an adapter wrapper. The resolvers translate `undefined` into the `null` value that the `ResolveSibling` contract expects, so callers of the resolver are insulated from the `undefined`/`null` distinction. `exists` is separate from `get` so HTTP-backed hosts can use a HEAD probe instead of fetching a multi-MiB volume just to discover it is missing — that matters for split-archive resolution where 6 of 7 volumes are present and one is not.

View File

@@ -0,0 +1,10 @@
/**
* Structural view of the host storage that the default sibling
* resolvers consume. We accept any object exposing `get(key)` and
* `exists(key)` so the resolvers work over `AbstractStorage`,
* `DriveStorage`, a raw `Map`, an HTTP-backed shim, etc.
*/
export interface SiblingHost {
get(key: string): Promise<Uint8Array | undefined>;
exists(key: string): Promise<boolean>;
}

View File

@@ -0,0 +1,56 @@
# zip-sibling-resolver
## Purpose
Default `ResolveSibling` factory for split-zip archives following the PKZIP App-Note §8.5.3 convention: intermediate volumes are `<basename>.z01`, `.z02`, ..., `.zNN` and the terminal volume (the one holding the End-of-Central-Directory record) is `<basename>.zip`. The resolver wraps a `SiblingHost` view of the underlying storage and returns a closure that the per-format zip adapter calls with `(basename, index)`. Also exports `stripTrailingExtension` — a small helper that normalises a primary-volume key to its basename by stripping any known archive extension (zip / rar / 7z variants).
## Features
- PKZIP-conventional naming: intermediate volumes use `.zNN` with 2-digit zero-padded numbering starting at `01`, terminal uses `.zip`.
- Falls back to the `.zip` terminal when an intermediate key is absent — the adapter discovers the final volume on its own request without an explicit signal.
- Returns `null` (per `ResolveSibling` contract) when neither volume exists so the adapter can raise `ARCHIVE_SPLIT_MISSING_VOLUME` with the missing index.
- `stripTrailingExtension` covers every extension the archive family understands (zip / partN.rar / r00 / rar / 7z / 7z.NNN) so a single helper handles all three resolvers.
## Usage: Basic Example
```ts
import { zipSiblingResolver } from "@suaveplan/drive-storage-archive-core";
const host = {
async get(key: string) {
return store.get(key);
},
async exists(key: string) {
return store.has(key);
},
};
const resolver = zipSiblingResolver(host, "bundle.zip");
const volume0 = await resolver("bundle", 0); // → store.get("bundle.z01")
```
## Usage: Advanced Example
```ts
import {
stripTrailingExtension,
zipSiblingResolver,
} from "@suaveplan/drive-storage-archive-core";
const primary = "shared/archive-2026.zip";
const basename = stripTrailingExtension(primary); // "shared/archive-2026"
const resolver = zipSiblingResolver(host, primary);
for (let i = 0; ; i++) {
const bytes = await resolver(basename, i);
if (bytes === null) break;
// ... feed to the zip adapter ...
}
```
## API Reference
- `zipSiblingResolver(host: SiblingHost, primaryKey: string): ResolveSibling` — returns a sibling-resolver closure for split-zip archives.
- `stripTrailingExtension(key: string): string` — strips any known archive extension from a host-storage key.
## Implementation Notes
The fallback to `.zip` after a missing `.zNN` is intentional and matches how every real-world split-zip implementation lays out files: zip64 spec puts the EOCD in the final volume and that volume is always `.zip`. The two-step lookup keeps the resolver stateless — it does not pre-compute the volume count from the primary's EOCD because the resolver is invoked BEFORE the adapter has parsed the EOCD. The `stripTrailingExtension` helper uses ordered alternatives because `.partN.rar` and `.rar` both end with `.rar`; the longer pattern is checked first to avoid a false positive that would leave `.partN` in the basename.

View File

@@ -0,0 +1,51 @@
import type { ResolveSibling } from "../types/resolve-sibling.js";
import type { SiblingHost } from "./sibling-host.js";
/**
* Default sibling-volume resolver for split-zip archives.
*
* Convention (PKZIP App-Note §8.5.3):
* - Volume `n` (for n >= 0) is `<basename>.z{NN}` where `NN` is
* `(n+1)` zero-padded to 2 digits, e.g. `.z01`, `.z02`, `.z10`.
* - The terminal volume holding the EOCD is `<basename>.zip` (NOT `.z00`).
*
* The resolver tries the intermediate volume first; if absent, falls
* back to the terminal `.zip`. Returns `null` when neither exists.
*
* The `primaryKey` argument is the host-key of the user-facing primary
* volume — extension is stripped before sibling lookup.
*/
export function zipSiblingResolver(
host: SiblingHost,
primaryKey: string
): ResolveSibling {
const basename = stripTrailingExtension(primaryKey);
return async (_requestedBasename, index) => {
if (index < 0) return null;
const intermediateKey = `${basename}.z${String(index + 1).padStart(2, "0")}`;
const intermediate = await host.get(intermediateKey);
if (intermediate !== undefined) return intermediate;
const terminal = `${basename}.zip`;
const final = await host.get(terminal);
if (final !== undefined) return final;
return null;
};
}
/**
* Strips a known trailing extension (`.zip`, `.z01`..`.z99`,
* `.partN.rar`, `.rar`, `.r00`..`.r99`, `.7z`, `.7z.001`..`.7z.999`)
* from a primary-volume key. Exposed for the resolver tests.
*/
export function stripTrailingExtension(key: string): string {
if (/\.zip$/i.test(key)) return key.slice(0, -4);
if (/\.z\d{2}$/i.test(key)) return key.slice(0, -4);
const sevenZipSplit = /\.7z\.\d{3}$/i.exec(key);
if (sevenZipSplit !== null) return key.slice(0, sevenZipSplit.index);
if (/\.7z$/i.test(key)) return key.slice(0, -3);
const partRar = /\.part\d+\.rar$/i.exec(key);
if (partRar !== null) return key.slice(0, partRar.index);
if (/\.r\d{2}$/i.test(key)) return key.slice(0, -4);
if (/\.rar$/i.test(key)) return key.slice(0, -4);
return key;
}

View File

@@ -0,0 +1,52 @@
# sniff-format
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Identifies an archive's container format by inspecting magic bytes (and, where appropriate, structural artefacts like the ZIP End-of-Central-Directory record or the tar header checksum) in the first ~512 bytes of a buffer. Returns `null` when nothing matches so the caller can fall back to caller-declared format or surface `ARCHIVE_FORMAT_UNKNOWN`. The accompanying `sniffFormatWithReason` variant returns the same identification PLUS a short human-readable description of which check succeeded — useful for debug logging and the `ARCHIVE_FORMAT_MISMATCH` warning emitted by `openArchiveStorage`.
## Features
- Six formats: ZIP, RAR4, RAR5, 7z, GZIP, POSIX/USTAR tar.
- Magic-byte match plus structural validation (ZIP requires EOCD locatable in the trailing 64 KiB; tar requires the USTAR magic at offset 257 AND the octal checksum at bytes 148-156 to match the block sum).
- ZIP detection accepts both the local-file-header signature `PK\x03\x04` and the split-marker `PK\x07\x08` so a split-zip's first volume is identified correctly even without an EOCD.
- RAR5 is tested before RAR4 because the 5-byte prefix is shared.
## Usage: Basic Example
```ts
import { sniffFormat } from "@suaveplan/drive-storage-archive-core";
const fmt = sniffFormat(bytes);
if (fmt === null) {
console.log("unknown format");
} else {
console.log(`detected ${fmt}`);
}
```
## Usage: Advanced Example
```ts
import { sniffFormatWithReason } from "@suaveplan/drive-storage-archive-core";
const { format, reason } = sniffFormatWithReason(bytes);
if (format === null) {
console.warn(`sniff failed: ${reason}`);
} else {
console.log(`format=${format} via ${reason}`);
}
```
## API Reference
- `sniffFormat(bytes: Uint8Array): ArchiveFormat | null` — returns the detected format or `null`.
- `sniffFormatWithReason(bytes: Uint8Array): SniffResult` — `{ format, reason }`.
- `interface SniffResult { readonly format: ArchiveFormat | null; readonly reason: string }`.
## Implementation Notes
ZIP detection runs a backward scan from end-of-buffer for the 4-byte EOCD signature `PK\x05\x06`, bounded at 65 557 bytes (22-byte EOCD record + max 0xFFFF comment length). When the magic matches but the EOCD is not located, the sniff still returns `"zip"` with a reason indicating the buffer is likely a non-final split volume — the per-format adapter will reject it with a clearer error if it cannot reconstruct the central directory. The tar checksum routine sums every byte of the 512-byte block treating bytes 148-156 as ASCII spaces (0x20) per RFC. The function MUST NOT mutate the input buffer — callers pass `subarray(0, 512)` views routinely. Empty buffers short-circuit to `{ format: null, reason: "empty buffer" }` so test harnesses can probe the negative path without allocating a one-byte sentinel.

View File

@@ -0,0 +1,342 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { sniffFormat, sniffFormatWithReason } from "./sniff-format.js";
function makeMinimalZip(): Uint8Array {
// Local file header (PK\x03\x04) + minimal central directory + EOCD.
// Layout for a single entry named "a" with no data.
const out: number[] = [];
// Local file header
out.push(0x50, 0x4b, 0x03, 0x04);
out.push(0x14, 0x00); // version
out.push(0x00, 0x00); // flags
out.push(0x00, 0x00); // method = stored
out.push(0x00, 0x00, 0x00, 0x00); // time/date
out.push(0x00, 0x00, 0x00, 0x00); // crc
out.push(0x00, 0x00, 0x00, 0x00); // compressed size
out.push(0x00, 0x00, 0x00, 0x00); // uncompressed size
out.push(0x01, 0x00); // file name length = 1
out.push(0x00, 0x00); // extra length
out.push(0x61); // 'a'
const cdOffset = out.length;
// Central directory
out.push(0x50, 0x4b, 0x01, 0x02);
out.push(0x14, 0x00, 0x14, 0x00);
out.push(0x00, 0x00, 0x00, 0x00);
out.push(0x00, 0x00, 0x00, 0x00);
out.push(0x00, 0x00, 0x00, 0x00);
out.push(0x00, 0x00, 0x00, 0x00);
out.push(0x00, 0x00, 0x00, 0x00);
out.push(0x01, 0x00); // name length
out.push(0x00, 0x00); // extra
out.push(0x00, 0x00); // comment
out.push(0x00, 0x00); // disk
out.push(0x00, 0x00); // internal attrs
out.push(0x00, 0x00, 0x00, 0x00); // external attrs
out.push(0x00, 0x00, 0x00, 0x00); // local header offset
out.push(0x61); // 'a'
const cdSize = out.length - cdOffset;
// EOCD
out.push(0x50, 0x4b, 0x05, 0x06);
out.push(0x00, 0x00); // disk
out.push(0x00, 0x00); // disk w/ CD
out.push(0x01, 0x00); // entries on this disk
out.push(0x01, 0x00); // total entries
out.push(cdSize & 0xff, (cdSize >> 8) & 0xff, 0x00, 0x00); // CD size
out.push(cdOffset & 0xff, (cdOffset >> 8) & 0xff, 0x00, 0x00); // CD offset
out.push(0x00, 0x00); // comment length
return Uint8Array.from(out);
}
function makeMinimalGzip(): Uint8Array {
// Magic + method (8=deflate) + flags + mtime + xfl + os
// + empty deflate block + CRC32 + ISIZE.
return Uint8Array.from([
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]);
}
function makeMinimalTar(): Uint8Array {
const block = new Uint8Array(512);
const name = "hello.txt";
for (let i = 0; i < name.length; i++) block[i] = name.charCodeAt(i);
// mode "000644 \0" at offset 100
const mode = "000644 ";
for (let i = 0; i < mode.length; i++) block[100 + i] = mode.charCodeAt(i);
block[107] = 0;
// size 0 octal "0000000000000 "
const size = "00000000000 ";
for (let i = 0; i < size.length; i++) block[124 + i] = size.charCodeAt(i);
block[136] = 0;
// mtime " "
for (let i = 0; i < 11; i++) block[136 + i] = 0x30;
block[147] = 0x20;
// ustar magic at 257
const ustar = "ustar";
for (let i = 0; i < ustar.length; i++) block[257 + i] = ustar.charCodeAt(i);
block[262] = 0;
// typeflag 0
block[156] = 0x30;
// Initialise checksum field as spaces
for (let i = 148; i < 156; i++) block[i] = 0x20;
let sum = 0;
for (let i = 0; i < 512; i++) sum += block[i] ?? 0;
// Write octal checksum
const cs = sum.toString(8).padStart(6, "0");
for (let i = 0; i < cs.length; i++) block[148 + i] = cs.charCodeAt(i);
block[148 + cs.length] = 0;
block[148 + cs.length + 1] = 0x20;
return block;
}
function make7zHeader(): Uint8Array {
const buf = new Uint8Array(32);
buf[0] = 0x37;
buf[1] = 0x7a;
buf[2] = 0xbc;
buf[3] = 0xaf;
buf[4] = 0x27;
buf[5] = 0x1c;
// version major/minor
buf[6] = 0x00;
buf[7] = 0x04;
return buf;
}
function makeRar4Header(): Uint8Array {
return Uint8Array.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00, 0x00]);
}
function makeRar5Header(): Uint8Array {
return Uint8Array.from([
0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00, 0x00,
]);
}
describe("sniffFormat", () => {
it("returns 'zip' for a minimal zip", { timeout: 5000 }, () => {
expect(sniffFormat(makeMinimalZip())).toBe("zip");
});
it("returns 'gz' for a minimal gzip", { timeout: 5000 }, () => {
expect(sniffFormat(makeMinimalGzip())).toBe("gz");
});
it("returns 'tar' for a minimal ustar block", { timeout: 5000 }, () => {
expect(sniffFormat(makeMinimalTar())).toBe("tar");
});
it("returns '7z' for the 7z magic", { timeout: 5000 }, () => {
expect(sniffFormat(make7zHeader())).toBe("7z");
});
it("returns 'rar' for RAR4 magic", { timeout: 5000 }, () => {
expect(sniffFormat(makeRar4Header())).toBe("rar");
});
it("returns 'rar' for RAR5 magic", { timeout: 5000 }, () => {
expect(sniffFormat(makeRar5Header())).toBe("rar");
});
it("returns null for empty buffer", { timeout: 5000 }, () => {
expect(sniffFormat(new Uint8Array())).toBeNull();
});
it("returns null for random non-archive bytes", { timeout: 5000 }, () => {
const rnd = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
expect(sniffFormat(rnd)).toBeNull();
});
it("returns null for truncated gzip (no trailer)", {
timeout: 5000,
}, () => {
expect(sniffFormat(Uint8Array.from([0x1f, 0x8b, 0x08]))).toBeNull();
});
it("returns null for truncated 7z header", { timeout: 5000 }, () => {
expect(
sniffFormat(
Uint8Array.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00])
)
).toBeNull();
});
it("returns 'zip' even without EOCD (split volume case)", {
timeout: 5000,
}, () => {
const buf = Uint8Array.from([0x50, 0x4b, 0x07, 0x08, 0, 0, 0, 0]);
expect(sniffFormat(buf)).toBe("zip");
});
it("returns 'zip' for a long zip-magic buffer with no EOCD anywhere", {
timeout: 5000,
}, () => {
// 64 bytes starting with PK\x03\x04 then arbitrary non-EOCD bytes.
const buf = new Uint8Array(64);
buf[0] = 0x50;
buf[1] = 0x4b;
buf[2] = 0x03;
buf[3] = 0x04;
for (let i = 4; i < 64; i++) buf[i] = 0xff;
expect(sniffFormat(buf)).toBe("zip");
});
it("rejects tar block whose checksum field is all spaces (no digits)", {
timeout: 5000,
}, () => {
const tar = makeMinimalTar();
for (let i = 148; i < 156; i++) tar[i] = 0x20;
expect(sniffFormat(tar)).toBeNull();
});
it("rejects 512-byte block with non-ustar magic at offset 257", {
timeout: 5000,
}, () => {
const block = new Uint8Array(512);
// Bytes 257..262 are zero, not "ustar".
expect(sniffFormat(block)).toBeNull();
});
it("accepts checksum field that begins with a space then digits", {
timeout: 5000,
}, () => {
const tar = makeMinimalTar();
// Recompute checksum from scratch and prepend with a leading space.
// Initialise checksum bytes as 0x20.
for (let i = 148; i < 156; i++) tar[i] = 0x20;
let sum = 0;
for (let i = 0; i < 512; i++) sum += tar[i] ?? 0;
const cs = sum.toString(8).padStart(6, "0");
// Layout: ' ' then octal digits then nul/space.
tar[148] = 0x20; // leading space (covers continue branch)
for (let i = 0; i < cs.length; i++) {
tar[149 + i] = cs.charCodeAt(i);
}
tar[149 + cs.length] = 0;
expect(sniffFormat(tar)).toBe("tar");
});
it("rejects bytes that match ustar offset but bad checksum", {
timeout: 5000,
}, () => {
const tar = makeMinimalTar();
// Corrupt the checksum
tar[148] = 0x39; // overwrite first octal digit with garbage that does not match
tar[149] = 0x39;
tar[150] = 0x39;
tar[151] = 0x39;
tar[152] = 0x39;
tar[153] = 0x39;
expect(sniffFormat(tar)).toBeNull();
});
it("rejects ustar magic when checksum field contains a non-octal char", {
timeout: 5000,
}, () => {
const tar = makeMinimalTar();
tar[148] = 0x39; // '9' is not a valid octal digit
expect(sniffFormat(tar)).toBeNull();
});
it("rejects tar when shorter than block", { timeout: 5000 }, () => {
expect(sniffFormat(new Uint8Array(100))).toBeNull();
});
it("rejects tar when shorter than ustar offset", { timeout: 5000 }, () => {
// 512 bytes but cut off before 257 + 5 -- impossible, both checks coexist.
// Construct a 200-byte buffer that doesn't satisfy the block-size check.
expect(sniffFormat(new Uint8Array(200))).toBeNull();
});
it("returns reason via sniffFormatWithReason for zip", {
timeout: 5000,
}, () => {
const out = sniffFormatWithReason(makeMinimalZip());
expect(out.format).toBe("zip");
expect(out.reason).toMatch(/EOCD/);
});
it("returns reason='empty buffer' for empty input", {
timeout: 5000,
}, () => {
expect(sniffFormatWithReason(new Uint8Array())).toEqual({
format: null,
reason: "empty buffer",
});
});
it("returns reason for truncated gzip", { timeout: 5000 }, () => {
const out = sniffFormatWithReason(Uint8Array.from([0x1f, 0x8b, 0x08]));
expect(out.format).toBeNull();
expect(out.reason).toMatch(/trailer truncated/);
});
it("returns reason for truncated 7z", { timeout: 5000 }, () => {
const out = sniffFormatWithReason(
Uint8Array.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00])
);
expect(out.format).toBeNull();
expect(out.reason).toMatch(/truncated/);
});
it("returns reason for zip without EOCD", { timeout: 5000 }, () => {
const out = sniffFormatWithReason(
Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0])
);
expect(out.format).toBe("zip");
expect(out.reason).toMatch(/without EOCD/);
});
it("property: random buffers with no known magic never sniff as a format", {
timeout: 10000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 256 }),
(bytes) => {
const arr = Array.from(bytes);
// Ensure the buffer doesn't accidentally start with a known magic.
const known = [
[0x50, 0x4b, 0x03, 0x04],
[0x50, 0x4b, 0x07, 0x08],
[0x1f, 0x8b],
[0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c],
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07],
];
for (const sig of known) {
let match = true;
for (let i = 0; i < sig.length; i++) {
if (arr[i] !== sig[i]) {
match = false;
break;
}
}
if (match) return true; // skip — this is intentional
}
// No known magic AND no chance of accidental ustar (very unlikely
// for a < 256 byte buffer).
if (bytes.length < 257 + 5) {
return sniffFormat(bytes) === null;
}
return true;
}
),
{ numRuns: 200 }
);
});
it("property: every minimal-encoded format round-trips through sniff", {
timeout: 10000,
}, () => {
const fixtures: [string, Uint8Array][] = [
["zip", makeMinimalZip()],
["gz", makeMinimalGzip()],
["tar", makeMinimalTar()],
["7z", make7zHeader()],
];
for (const [name, bytes] of fixtures) {
expect(sniffFormat(bytes)).toBe(name);
}
});
});

View File

@@ -0,0 +1,149 @@
import type { ArchiveFormat } from "../types/archive-format.js";
/**
* Result of `sniffFormatWithReason`. `format` is the detected format
* (or `null` if nothing matched); `reason` is a short human-readable
* description of the structural check that succeeded or failed.
*/
export interface SniffResult {
readonly format: ArchiveFormat | null;
readonly reason: string;
}
const ZIP_LOCAL_FILE_HEADER = [0x50, 0x4b, 0x03, 0x04] as const;
const ZIP_EOCD_SIGNATURE = [0x50, 0x4b, 0x05, 0x06] as const;
const ZIP_SPLIT_MARKER = [0x50, 0x4b, 0x07, 0x08] as const;
const GZIP_HEADER = [0x1f, 0x8b] as const;
const SEVENZ_HEADER = [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c] as const;
const RAR4_HEADER = [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00] as const;
const RAR5_HEADER = [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00] as const;
const USTAR_MAGIC = [0x75, 0x73, 0x74, 0x61, 0x72] as const; // "ustar"
const TAR_BLOCK = 512;
const EOCD_MAX_SCAN = 65_557; // 22 + 0xFFFF comment
function startsWith(bytes: Uint8Array, sig: readonly number[]): boolean {
if (bytes.length < sig.length) return false;
for (let i = 0; i < sig.length; i++) {
if (bytes[i] !== sig[i]) return false;
}
return true;
}
function locateEocd(bytes: Uint8Array): number {
// Backward scan from end-of-buffer up to EOCD_MAX_SCAN bytes.
const maxScan = Math.min(bytes.length, EOCD_MAX_SCAN);
const start = bytes.length - maxScan;
for (let i = bytes.length - 22; i >= start; i--) {
if (
bytes[i] === ZIP_EOCD_SIGNATURE[0] &&
bytes[i + 1] === ZIP_EOCD_SIGNATURE[1] &&
bytes[i + 2] === ZIP_EOCD_SIGNATURE[2] &&
bytes[i + 3] === ZIP_EOCD_SIGNATURE[3]
) {
return i;
}
}
return -1;
}
function tarHeaderChecksumOk(bytes: Uint8Array): boolean {
if (bytes.length < TAR_BLOCK) return false;
// TAR_BLOCK (512) > 257 + 5 so the USTAR offsets are always in range.
// USTAR magic at offset 257.
for (let i = 0; i < USTAR_MAGIC.length; i++) {
if (bytes[257 + i] !== USTAR_MAGIC[i]) return false;
}
// Checksum: bytes 148..156 hold an ASCII octal value; treat the
// checksum field itself as ASCII spaces (0x20) when summing.
let sum = 0;
for (let i = 0; i < TAR_BLOCK; i++) {
if (i >= 148 && i < 156) {
sum += 0x20;
continue;
}
// Safe: i < TAR_BLOCK <= bytes.length per the guard above.
sum += bytes[i] as number;
}
// Parse the stored checksum.
let stored = 0;
let parsedAny = false;
for (let i = 148; i < 156; i++) {
const b = bytes[i] as number;
if (b === 0 || b === 0x20) {
if (parsedAny) break;
continue;
}
if (b < 0x30 || b > 0x37) return false;
stored = stored * 8 + (b - 0x30);
parsedAny = true;
}
if (!parsedAny) return false;
return sum === stored;
}
/**
* Inspect the first ~512 bytes (and, for ZIP, the trailing 64 KiB) of
* a buffer to identify the archive format. Returns `null` if no magic
* byte signature matches AND no structural fallback succeeds. The
* caller-supplied filename / extension is not consulted.
*
* Detection order matters: ZIP local-file-header is `PK\x03\x04`, which
* cannot collide with any other format's magic; ZIP_SPLIT_MARKER
* (`PK\x07\x08`) also leads a split-zip's first volume. RAR5 must be
* tested BEFORE RAR4 because the 5-byte signature `Rar!\x1A\x07` is
* shared between them and the 7th byte differs.
*/
export function sniffFormatWithReason(bytes: Uint8Array): SniffResult {
if (bytes.length === 0) {
return { format: null, reason: "empty buffer" };
}
// ZIP — local file header OR split marker, with structural EOCD check.
if (
startsWith(bytes, ZIP_LOCAL_FILE_HEADER) ||
startsWith(bytes, ZIP_SPLIT_MARKER)
) {
if (locateEocd(bytes) !== -1) {
return { format: "zip", reason: "zip magic + EOCD located" };
}
// No EOCD — may be a split-zip volume that is not the final part.
return {
format: "zip",
reason: "zip magic without EOCD (split volume?)",
};
}
// GZIP — 2-byte magic + structural ISIZE trailer.
if (startsWith(bytes, GZIP_HEADER)) {
if (bytes.length >= 18) {
return { format: "gz", reason: "gzip magic + trailer present" };
}
return { format: null, reason: "gzip magic but trailer truncated" };
}
// 7z.
if (startsWith(bytes, SEVENZ_HEADER)) {
if (bytes.length >= 32) {
return { format: "7z", reason: "7z magic + start header present" };
}
return { format: null, reason: "7z magic but start header truncated" };
}
// RAR5 (must precede RAR4 because RAR5 sig is longer).
if (startsWith(bytes, RAR5_HEADER)) {
return { format: "rar", reason: "rar5 magic" };
}
if (startsWith(bytes, RAR4_HEADER)) {
return { format: "rar", reason: "rar4 magic" };
}
// TAR — content-based; USTAR magic at byte 257 + header checksum.
if (tarHeaderChecksumOk(bytes)) {
return { format: "tar", reason: "tar ustar magic + checksum ok" };
}
return { format: null, reason: "no known magic / structural match" };
}
/**
* Convenience wrapper around {@link sniffFormatWithReason} returning
* only the format. Returns `null` when nothing matched.
*/
export function sniffFormat(bytes: Uint8Array): ArchiveFormat | null {
return sniffFormatWithReason(bytes).format;
}

View File

@@ -0,0 +1,10 @@
export {
ARCHIVE_CODES,
ArchiveError,
type ConformanceAdapter,
type ConformanceAdapterFactory,
type ConformanceFixtures,
type ConformanceWritableFactory,
type RunArchiveConformanceOptions,
runArchiveConformance,
} from "./run-archive-conformance.js";

View File

@@ -0,0 +1,84 @@
# run-archive-conformance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `runArchiveConformance(adapterFactory, opts)` — the cross-format conformance harness every per-format archive adapter MUST pass. The harness exercises the behaviours every adapter shares: open + list + stat + read of a plaintext entry, mode enforcement (`read-only` rejects every mutation with `ARCHIVE_MODE_READ_ONLY`), optional password failure path (`ARCHIVE_PASSWORD_REQUIRED` when no hook), optional split-volume reassembly, and an optional round-trip of one entry through write + flush + read for writable adapters. The harness imports `describe`/`it`/`expect` from `@suaveplan/testing/runner` only (rule 11).
## Features
- Per-format packages call the harness from INSIDE their own `describe` block so the behaviours appear as nested `it`s under the caller's description — the harness does NOT call `describe` at the top level.
- Optional fixtures (`encrypted`, `split`, `writable`) let formats opt-in to the surfaces they actually support. Tar can skip `encrypted`; gz can skip `split`; rar can declare `readOnlyOnly: true`.
- Asserts against the public typed errors (`ARCHIVE_CODES.MODE_READ_ONLY`, `ARCHIVE_CODES.PASSWORD_REQUIRED`) so adapter implementations stay decoupled from the harness internals.
## Usage: Basic Example
```ts
import { describe } from "@suaveplan/testing/runner";
import { runArchiveConformance } from "@suaveplan/drive-storage-archive-core/testing";
describe("ZipArchiveStorage conformance", () => {
runArchiveConformance(
async (fixture, mode) => {
// construct a real ZipArchiveStorage(...)
return adapter;
},
{
plaintext: {
bytes: zipBytes,
entryPath: "hello.txt",
entryBytes: new TextEncoder().encode("hello"),
},
},
);
});
```
## Usage: Advanced Example
```ts
import { describe } from "@suaveplan/testing/runner";
import { runArchiveConformance } from "@suaveplan/drive-storage-archive-core/testing";
describe("RarArchiveStorage conformance", () => {
runArchiveConformance(
async (fixture, mode) => new RarArchiveStorage(fixture.bytes, { mode }),
{
plaintext: {
bytes: rarBytes,
entryPath: "data/file.bin",
entryBytes: blob,
},
encrypted: {
bytes: rarBytesEncrypted,
entryPath: "secret.txt",
correctPassword: "correct horse battery staple",
},
split: {
primary: part1,
siblings: new Map([["movie.part2.rar", part2]]),
basename: "movie",
entryPath: "movie.mp4",
entryBytes: movieBlob,
},
readOnlyOnly: true, // RAR is read-only
},
);
});
```
## API Reference
- `runArchiveConformance(adapterFactory, opts): void` — main entry point.
- `interface ConformanceAdapter` — structural minimum surface every adapter satisfies.
- `type ConformanceAdapterFactory = (fixture, mode) => Promise<ConformanceAdapter>`.
- `interface ConformanceFixtures` — `plaintext` required; `encrypted`, `split`, `writable` optional.
- `interface RunArchiveConformanceOptions extends ConformanceFixtures` — adds `writableFactory` and `readOnlyOnly`.
- Re-exports `ARCHIVE_CODES` and `ArchiveError` for caller convenience.
## Implementation Notes
The harness imports `describe`/`it`/`expect` from `@suaveplan/testing/runner` exclusively (CLAUDE.md rule 11). It deliberately avoids `vi.mock`/`jest.mock`/etc. — all adapters under test are real implementations exercising real bytes (rule 2). Per-format suites can layer their own format-specific `describe` blocks above the call to `runArchiveConformance`; the harness's own `describe("archive conformance")` nests cleanly inside. The optional fixtures are checked with `if (opts.encrypted !== undefined)` rather than truthy-check so a fixture with explicit `bytes: new Uint8Array(0)` is still honoured.

View File

@@ -0,0 +1,192 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import type { ArchiveMode } from "../types/archive-mode.js";
import {
type ConformanceAdapter,
runArchiveConformance,
} from "./run-archive-conformance.js";
/**
* Minimal in-memory archive-adapter stand-in used to self-test the
* harness. It satisfies the `ConformanceAdapter` structural shape and
* implements the mode-enforcement semantics correctly.
*/
class FakeAdapter implements ConformanceAdapter {
readonly mode: ArchiveMode;
readonly #entries: Map<string, Uint8Array>;
readonly #encryptedPath: string | undefined;
readonly #passwordHook: (() => Promise<string>) | undefined;
constructor(
mode: ArchiveMode,
entries: Map<string, Uint8Array>,
opts: {
encryptedPath?: string;
passwordHook?: () => Promise<string>;
} = {}
) {
this.mode = mode;
this.#entries = entries;
this.#encryptedPath = opts.encryptedPath;
this.#passwordHook = opts.passwordHook;
}
async list(): Promise<{ keys: string[] }> {
return { keys: [...this.#entries.keys()] };
}
async exists(key: string): Promise<boolean> {
return this.#entries.has(key);
}
async get(key: string): Promise<Uint8Array | undefined> {
if (this.#encryptedPath === key && this.#passwordHook === undefined) {
throw new ArchiveError(
ARCHIVE_CODES.PASSWORD_REQUIRED,
"encrypted entry but no password hook"
);
}
return this.#entries.get(key);
}
async set(key: string, value: Uint8Array): Promise<void> {
if (this.mode === "read-only") {
throw new ArchiveError(
ARCHIVE_CODES.MODE_READ_ONLY,
"read-only adapter rejects set"
);
}
this.#entries.set(key, value);
}
async delete(key: string): Promise<boolean> {
if (this.mode === "read-only") {
throw new ArchiveError(
ARCHIVE_CODES.MODE_READ_ONLY,
"read-only adapter rejects delete"
);
}
return this.#entries.delete(key);
}
async flush(): Promise<void> {
// no-op; mutations are already in the map.
}
}
describe("runArchiveConformance harness self-test", () => {
const plaintextBytes = new TextEncoder().encode("hello world");
const fixturePlaintext = {
bytes: new Uint8Array([0x46, 0x41, 0x4b, 0x45]), // "FAKE"
entryPath: "greeting.txt",
entryBytes: plaintextBytes,
};
runArchiveConformance(
async (fixture, mode) => {
const entries = new Map<string, Uint8Array>();
entries.set(fixture.entryPath, fixture.entryBytes);
return new FakeAdapter(mode, entries);
},
{
plaintext: fixturePlaintext,
}
);
it("self-test sanity: harness ran and the FakeAdapter passes plaintext path", {
timeout: 5000,
}, () => {
// If we got here, the nested describe's `it`s executed and passed.
expect(true).toBe(true);
});
});
describe("runArchiveConformance with optional fixtures", () => {
const plaintext = {
bytes: new Uint8Array([0x46, 0x41, 0x4b, 0x45]),
entryPath: "x.txt",
entryBytes: new TextEncoder().encode("x"),
};
runArchiveConformance(
async (fixture, mode) => {
const entries = new Map<string, Uint8Array>();
entries.set(fixture.entryPath, fixture.entryBytes);
return new FakeAdapter(mode, entries, {
encryptedPath: "secret.bin",
});
},
{
plaintext,
encrypted: {
bytes: new Uint8Array(),
entryPath: "secret.bin",
correctPassword: "pw",
},
split: {
primary: new Uint8Array(),
siblings: new Map(),
basename: "x",
entryPath: "x.txt",
entryBytes: new TextEncoder().encode("x"),
},
writable: true,
writableFactory: {
async create(_seed, mode) {
return new FakeAdapter(mode, new Map());
},
},
}
);
});
describe("runArchiveConformance with minimal adapter (no set/delete)", () => {
const plaintext = {
bytes: new Uint8Array([0x46]),
entryPath: "z.txt",
entryBytes: new TextEncoder().encode("z"),
};
runArchiveConformance(
async (fixture, _mode) => {
const entries = new Map<string, Uint8Array>();
entries.set(fixture.entryPath, fixture.entryBytes);
return {
mode: "read-only",
async list() {
return { keys: [...entries.keys()] };
},
async exists(k) {
return entries.has(k);
},
async get(k) {
return entries.get(k);
},
};
},
{ plaintext }
);
});
describe("runArchiveConformance with readOnlyOnly: true", () => {
const plaintext = {
bytes: new Uint8Array([0x46]),
entryPath: "y.txt",
entryBytes: new TextEncoder().encode("y"),
};
runArchiveConformance(
async (fixture, mode) => {
const entries = new Map<string, Uint8Array>();
entries.set(fixture.entryPath, fixture.entryBytes);
return new FakeAdapter(mode, entries);
},
{
plaintext,
writable: true,
writableFactory: {
async create(_seed, mode) {
return new FakeAdapter(mode, new Map());
},
},
readOnlyOnly: true,
}
);
});

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_CODES, ArchiveError } from "../errors/error-codes.js";
import type { ArchiveMode } from "../types/archive-mode.js";
/**
* Minimum surface every per-format adapter MUST expose to the harness.
* It is structural — any object satisfying the shape passes.
*/
export interface ConformanceAdapter {
readonly mode: ArchiveMode;
list(): Promise<{ keys: string[] }>;
exists(key: string): Promise<boolean>;
get(key: string): Promise<Uint8Array | undefined>;
set?(key: string, value: Uint8Array): Promise<void>;
delete?(key: string): Promise<boolean>;
flush?(): Promise<void>;
}
/** Optional fixture set the harness uses to exercise extra surfaces. */
export interface ConformanceFixtures {
/**
* Bytes representing an archive carrying ONE plaintext entry at a
* known path. The harness reads + lists + stats this entry.
*/
readonly plaintext: {
readonly bytes: Uint8Array;
readonly entryPath: string;
readonly entryBytes: Uint8Array;
};
/**
* Optional: bytes of an archive whose single entry is encrypted.
* The harness verifies that a missing/wrong password produces the
* documented typed errors.
*/
readonly encrypted?: {
readonly bytes: Uint8Array;
readonly entryPath: string;
readonly correctPassword: string;
};
/**
* Optional: primary-volume bytes plus sibling map for a split
* archive. The harness asserts the assembled entry round-trips.
*/
readonly split?: {
readonly primary: Uint8Array;
readonly siblings: ReadonlyMap<string, Uint8Array>;
readonly basename: string;
readonly entryPath: string;
readonly entryBytes: Uint8Array;
};
/** True if the adapter advertises a writable mode. */
readonly writable?: boolean;
}
/** Factory passed by the per-format package's test suite. */
export type ConformanceAdapterFactory = (
fixture: ConformanceFixtures["plaintext"],
mode: ArchiveMode
) => Promise<ConformanceAdapter>;
/** Writable-mode factory; called only when `fixtures.writable === true`. */
export interface ConformanceWritableFactory {
create(seed: Uint8Array, mode: ArchiveMode): Promise<ConformanceAdapter>;
}
export interface RunArchiveConformanceOptions extends ConformanceFixtures {
readonly writableFactory?: ConformanceWritableFactory;
/** Read-only enforcement subset: when true, the harness exercises only the read-only paths. */
readonly readOnlyOnly?: boolean;
}
/**
* Runs the cross-format archive-adapter conformance suite. Per-format
* packages call this from INSIDE their own `describe` block so the
* harness behaviours appear as nested `it`s under the caller's
* description. The harness never calls `describe` at the top level of
* the suite — that would prevent per-format tests from supplying
* format-specific setup/teardown around it.
*/
export function runArchiveConformance(
adapterFactory: ConformanceAdapterFactory,
opts: RunArchiveConformanceOptions
): void {
describe("archive conformance", () => {
it("open + list + stat + read plaintext entry", {
timeout: 5000,
}, async () => {
const adapter = await adapterFactory(opts.plaintext, "read-only");
const listed = await adapter.list();
expect(listed.keys).toContain(opts.plaintext.entryPath);
expect(await adapter.exists(opts.plaintext.entryPath)).toBe(true);
const bytes = await adapter.get(opts.plaintext.entryPath);
expect(bytes).toBeDefined();
expect(bytes).toEqual(opts.plaintext.entryBytes);
});
it("read-only mode rejects every mutation", {
timeout: 5000,
}, async () => {
const adapter = await adapterFactory(opts.plaintext, "read-only");
if (adapter.set !== undefined) {
await expect(
adapter.set("intruder.bin", new Uint8Array([1, 2, 3]))
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.MODE_READ_ONLY,
});
}
if (adapter.delete !== undefined) {
await expect(
adapter.delete(opts.plaintext.entryPath)
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.MODE_READ_ONLY,
});
}
});
if (opts.encrypted !== undefined) {
const encryptedFixture = opts.encrypted;
it("encrypted entry without password raises PASSWORD_REQUIRED", {
timeout: 5000,
}, async () => {
const adapter = await adapterFactory(
{
bytes: encryptedFixture.bytes,
entryPath: encryptedFixture.entryPath,
entryBytes: new Uint8Array(),
},
"read-only"
);
await expect(
adapter.get(encryptedFixture.entryPath)
).rejects.toMatchObject({
archiveCode: ARCHIVE_CODES.PASSWORD_REQUIRED,
});
});
}
if (opts.split !== undefined) {
const splitFixture = opts.split;
it("split archive reassembles via resolveSibling", {
timeout: 5000,
}, async () => {
const adapter = await adapterFactory(
{
bytes: splitFixture.primary,
entryPath: splitFixture.entryPath,
entryBytes: splitFixture.entryBytes,
},
"read-only"
);
const bytes = await adapter.get(splitFixture.entryPath);
expect(bytes).toEqual(splitFixture.entryBytes);
});
}
if (
opts.writable === true &&
opts.writableFactory !== undefined &&
opts.readOnlyOnly !== true
) {
const wf = opts.writableFactory;
it("round-trips a write/flush/read in copy-on-write mode", {
timeout: 5000,
}, async () => {
const adapter = await wf.create(
opts.plaintext.bytes,
"copy-on-write"
);
expect(adapter.set).toBeDefined();
expect(adapter.flush).toBeDefined();
const payload = new TextEncoder().encode("hello");
await adapter.set!("greet.txt", payload);
await adapter.flush!();
const got = await adapter.get("greet.txt");
expect(got).toEqual(payload);
});
}
});
}
/**
* Re-export for adapters that want to assert against the typed error
* directly without importing from `../errors`.
*/
export { ARCHIVE_CODES, ArchiveError };

View File

@@ -0,0 +1,61 @@
# archive-capabilities
## Purpose
Declares the `ArchiveCapabilities` record shape that every per-format archive adapter MUST expose so consumers (and the core's mode-validator) can introspect what an adapter is willing to do BEFORE attempting to construct it or call an operation that would throw. This is the static-discovery counterpart to runtime mode enforcement: an adapter that refuses `rewrite` mode declares `modes: ["read-only", "copy-on-write"]` here, and `validateMode` short-circuits with a typed error instead of letting the adapter throw deep inside its write path.
## Features
- Static discovery: read once at adapter construction, not on every call.
- Includes a `format` field so a flat list of capabilities can be filtered by format without re-keying the structure.
- Splits read-encrypted from write-encrypted so AES-read / AES-write-deferred adapters (the ZIP plan) can model the asymmetry honestly.
## Usage: Basic Example
```ts
import type { ArchiveCapabilities } from "@suaveplan/drive-storage-archive-core";
const zipCaps: ArchiveCapabilities = {
format: "zip",
modes: ["read-only", "copy-on-write", "rewrite"],
readsEncrypted: true,
writesEncrypted: false,
supportsSplitVolumes: true,
streaming: true,
};
```
## Usage: Advanced Example
```ts
import {
type ArchiveCapabilities,
type ArchiveMode,
validateMode,
} from "@suaveplan/drive-storage-archive-core";
function pickMode(caps: ArchiveCapabilities, desired: ArchiveMode): ArchiveMode {
try {
validateMode(desired, caps);
return desired;
} catch {
// Fall back to the strongest mode the adapter supports.
if (caps.modes.includes("copy-on-write")) return "copy-on-write";
return "read-only";
}
}
```
## API Reference
- `interface ArchiveCapabilities`
- `readonly format: ArchiveFormat`
- `readonly modes: ReadonlyArray<ArchiveMode>`
- `readonly readsEncrypted: boolean`
- `readonly writesEncrypted: boolean`
- `readonly supportsSplitVolumes: boolean`
- `readonly streaming: boolean`
## Implementation Notes
Capabilities are static for the life of an adapter instance — they do not vary by entry. An adapter that supports `readsEncrypted` MUST honour any encryption surface its format defines even if a particular archive instance has none. The `streaming` flag is true if the adapter overrides `createReadStream` with a true streaming implementation; for adapters that buffer the full entry then enqueue once, `streaming` is false. The `modes` array MUST be ordered with `read-only` first to match `ARCHIVE_MODES`. The `format` field is duplicated from the package name to let callers carry a single capability record across packages without an external lookup table.

View File

@@ -0,0 +1,23 @@
import type { ArchiveFormat } from "./archive-format.js";
import type { ArchiveMode } from "./archive-mode.js";
/**
* Capability declaration exposed by each per-format adapter. Consumers
* read this BEFORE attempting to construct an adapter to discover
* supported modes, encryption surface, and split-volume support without
* trapping a typed error.
*/
export interface ArchiveCapabilities {
/** The format this adapter mounts. */
readonly format: ArchiveFormat;
/** Modes this adapter supports. Always includes `"read-only"`. */
readonly modes: ReadonlyArray<ArchiveMode>;
/** True if the adapter can read encrypted entries with a password hook. */
readonly readsEncrypted: boolean;
/** True if the adapter can WRITE encrypted entries. */
readonly writesEncrypted: boolean;
/** True if the adapter supports split-volume reassembly via resolveSibling. */
readonly supportsSplitVolumes: boolean;
/** True if the adapter exposes streaming reads over Web Streams. */
readonly streaming: boolean;
}

View File

@@ -0,0 +1,61 @@
# archive-codec
## Purpose
Declares the `ArchiveCodec` interface — the contract a single byte-level compression codec (gzip, bzip2, xz, zstd…) MUST satisfy when plugged into a composed archive adapter such as `drive-storage-tgz`. The codec is intentionally minimal — a stable string identifier, a decode function, and an encode function — so individual format packages can ship their own codec exports (`gzipCodec`, `xzCodec`) without dragging the core into a codec implementation. The composed adapter is unaware of the underlying compression algorithm; it only knows how to call `decode(buf)` and `encode(buf)`.
## Features
- Algorithm-agnostic — works for gzip, bzip2, xz, zstd, lz4, or any future codec.
- Accepts either synchronous or asynchronous codecs (the consumer awaits the result either way).
- Carries a stable identifier so error messages and registry lookups can name the codec without exposing its internals.
## Usage: Basic Example
```ts
import type { ArchiveCodec } from "@suaveplan/drive-storage-archive-core";
const passthroughCodec: ArchiveCodec = {
id: "passthrough",
decode: (buf) => buf,
encode: (buf) => buf,
};
const out = await Promise.resolve(passthroughCodec.decode(new Uint8Array([1, 2, 3])));
console.log(out.byteLength);
```
## Usage: Advanced Example
```ts
import type { ArchiveCodec } from "@suaveplan/drive-storage-archive-core";
function wrapWithLogging(inner: ArchiveCodec, log: (msg: string) => void): ArchiveCodec {
return {
id: `${inner.id}+log`,
decode: async (buf) => {
const start = performance.now();
const out = await inner.decode(buf);
log(`decode ${buf.byteLength}->${out.byteLength} in ${performance.now() - start}ms`);
return out;
},
encode: async (buf) => {
const start = performance.now();
const out = await inner.encode(buf);
log(`encode ${buf.byteLength}->${out.byteLength} in ${performance.now() - start}ms`);
return out;
},
};
}
```
## API Reference
- `interface ArchiveCodec`
- `readonly id: string` — stable identifier (e.g. `"gzip"`).
- `decode(input: Uint8Array): Promise<Uint8Array> | Uint8Array` — decompress.
- `encode(input: Uint8Array): Promise<Uint8Array> | Uint8Array` — compress.
## Implementation Notes
Codecs MUST be pure functions over their input — no captured mutable state, no I/O. Statefulness would break the rewrite-on-flush invariant in `drive-storage-tgz`, which assumes that re-encoding the same bytes produces identical output. The signature accepts `Promise<Uint8Array> | Uint8Array` so a wasm-backed gzip implementation can return synchronously while a node-streams-backed xz codec can return a promise — consumers await either form uniformly. The codec's `id` is consumed by `ARCHIVE_CODEC_MISSING` error messages emitted by the factory when a composed adapter is constructed without a codec slot filled in.

View File

@@ -0,0 +1,19 @@
/**
* Pluggable byte-level codec used by composed adapters like
* `drive-storage-tgz` (tar over gz/bz2/xz/zstd). The codec abstracts a
* single stream-compression layer; the composed adapter wraps an
* uncompressed inner format around the codec's encode/decode pair.
*
* Each codec MUST be a pure function over `Uint8Array` — no I/O, no
* captured state outside the closure that defines it. The codec ID is
* the public name (`"gzip"`, `"xz"`, `"zstd"`, etc.) used in error
* messages and codec-registry lookups.
*/
export interface ArchiveCodec {
/** Stable identifier, e.g. `"gzip"`. */
readonly id: string;
/** Decode compressed bytes into the inner uncompressed stream. */
decode(input: Uint8Array): Promise<Uint8Array> | Uint8Array;
/** Encode the inner uncompressed stream into compressed bytes. */
encode(input: Uint8Array): Promise<Uint8Array> | Uint8Array;
}

View File

@@ -0,0 +1,53 @@
# archive-format
## Purpose
Declares the canonical `ArchiveFormat` string-literal union that names every archive container format the Suaveplan drive can mount: `zip`, `tar`, `gz`, `tgz`, `rar`, and `7z`. Also declares the `ArchiveFormatOrAuto` extension which adds the `"auto"` sentinel consumed by `openArchiveStorage` to request magic-byte detection. The frozen `ARCHIVE_FORMATS` constant exposes the same set as a runtime array so call sites can iterate or validate against it without re-declaring the list.
## Features
- Single source of truth for valid archive-format identifiers across the per-format packages.
- Exhaustive: every adapter package's name suffix matches one of these strings (`drive-storage-zip` ↔ `"zip"`, etc.) so dynamic dispatch in `openArchiveStorage` is a string concat away.
- Includes the `auto` sentinel only on the option-input type, never on adapter-side enums, so adapters can never be constructed for a non-existent codec.
## Usage: Basic Example
```ts
import { ARCHIVE_FORMATS, type ArchiveFormat } from "@suaveplan/drive-storage-archive-core";
function describe(fmt: ArchiveFormat): string {
return `mounted as ${fmt}`;
}
for (const fmt of ARCHIVE_FORMATS) {
console.log(describe(fmt));
}
```
## Usage: Advanced Example
```ts
import type {
ArchiveFormat,
ArchiveFormatOrAuto,
} from "@suaveplan/drive-storage-archive-core";
function isConcrete(value: ArchiveFormatOrAuto): value is ArchiveFormat {
return value !== "auto";
}
function pickAdapter(fmt: ArchiveFormatOrAuto): ArchiveFormat | "detect" {
if (isConcrete(fmt)) return fmt;
return "detect";
}
```
## API Reference
- `type ArchiveFormat` — `"zip" | "tar" | "gz" | "tgz" | "rar" | "7z"`.
- `type ArchiveFormatOrAuto` — `ArchiveFormat | "auto"`.
- `const ARCHIVE_FORMATS: readonly ArchiveFormat[]` — frozen iteration order.
## Implementation Notes
The `auto` sentinel is intentionally NOT a member of `ArchiveFormat`. Adapter constructors accept only concrete formats — letting `"auto"` leak into them would force every adapter to handle an impossible value. The factory resolves `"auto"` via `sniffFormat` and constructs the adapter with the concrete result. The frozen array uses `Object.freeze` so an accidental mutation throws under strict mode rather than silently corrupting downstream iteration. The order matches the package directory order under `packages/drive/drive-storage-*` for predictable docs and test layout.

View File

@@ -0,0 +1,25 @@
/**
* String literal union enumerating every archive format that the
* `drive-storage-archive-*` family knows how to mount. Used by
* `sniffFormat`, `openArchiveStorage`, and every per-format adapter's
* capabilities declaration.
*/
export type ArchiveFormat = "zip" | "tar" | "gz" | "tgz" | "rar" | "7z";
/**
* `"auto"` is the sentinel value passed to {@link OpenArchiveStorageOptions}
* to request magic-byte detection. It is NOT a member of {@link ArchiveFormat}
* because no adapter exists for `"auto"` — the factory resolves it to a
* concrete format before constructing the adapter.
*/
export type ArchiveFormatOrAuto = ArchiveFormat | "auto";
/** Ordered list of every concrete archive format. */
export const ARCHIVE_FORMATS: readonly ArchiveFormat[] = Object.freeze([
"zip",
"tar",
"gz",
"tgz",
"rar",
"7z",
]);

View File

@@ -0,0 +1,49 @@
# archive-mode
## Purpose
Declares the `ArchiveMode` string-literal union (`read-only`, `copy-on-write`, `rewrite`) that every per-format archive-storage adapter accepts at construction. The mode governs how mutations are buffered, materialised, and validated, and it is the single contract every adapter shares for write semantics. Mode enforcement is mandatory: a `read-only` adapter MUST throw `ARCHIVE_MODE_READ_ONLY` on any mutation; a `copy-on-write` adapter MUST defer materialisation until `flush()`; a `rewrite` adapter MUST re-encode synchronously.
## Features
- Single shared mode vocabulary so callers can swap one adapter for another with no semantic surprise.
- Exposes a runtime constant `ARCHIVE_MODES` for iteration / validation in tests.
- Ships a type guard `isArchiveMode` for input validation at adapter boundaries.
## Usage: Basic Example
```ts
import { isArchiveMode, type ArchiveMode } from "@suaveplan/drive-storage-archive-core";
const mode: ArchiveMode = "copy-on-write";
if (!isArchiveMode(mode)) throw new Error("invalid mode");
```
## Usage: Advanced Example
```ts
import {
ARCHIVE_MODES,
isArchiveMode,
} from "@suaveplan/drive-storage-archive-core";
function parseMode(input: string): ArchiveMode {
if (isArchiveMode(input)) return input;
const allowed = ARCHIVE_MODES.join(", ");
throw new Error(`unknown mode '${input}'; expected one of ${allowed}`);
}
for (const m of ARCHIVE_MODES) {
parseMode(m); // round-trip valid for every member
}
```
## API Reference
- `type ArchiveMode` — `"read-only" | "copy-on-write" | "rewrite"`.
- `const ARCHIVE_MODES: readonly ArchiveMode[]` — frozen iteration order.
- `isArchiveMode(value): value is ArchiveMode` — type guard.
## Implementation Notes
The order in `ARCHIVE_MODES` ascends in mutability: read-only is the safest default, copy-on-write trades a memory overlay for batchable mutations, rewrite trades CPU/IO cost for in-place semantics. Adapters that cannot safely implement a stronger mode (e.g. RAR is patent-encumbered for writes) MUST reject the constructor argument at type level or via an explicit `ARCHIVE_UNSUPPORTED_FEATURE` throw. The guard is intentionally a plain equality check rather than a Set lookup — the union is small enough that v8's branch predictor outperforms a Set probe.

View File

@@ -0,0 +1,29 @@
/**
* Mutation modes recognised by every archive-storage adapter.
*
* - `"read-only"` — every mutation throws `ARCHIVE_MODE_READ_ONLY`. Default
* for adapters whose format is not safely writable (RAR) or whose host
* bytes the caller does not own.
* - `"copy-on-write"` — mutations land in an in-memory overlay. The
* underlying host bytes are unchanged until `flush()` materialises them.
* - `"rewrite"` — every mutation re-encodes the archive into host storage
* immediately. O(archive size) per mutation; suitable for batch sizes
* of one.
*/
export type ArchiveMode = "read-only" | "copy-on-write" | "rewrite";
/** Ordered list of every mode for iteration / validation. */
export const ARCHIVE_MODES: readonly ArchiveMode[] = Object.freeze([
"read-only",
"copy-on-write",
"rewrite",
]);
/** Type guard. */
export function isArchiveMode(value: unknown): value is ArchiveMode {
return (
value === "read-only" ||
value === "copy-on-write" ||
value === "rewrite"
);
}

View File

@@ -0,0 +1,61 @@
# entry-reader
## Purpose
Declares the `EntryReader` interface that every per-format archive adapter implements internally to surface a single entry's bytes. The reader is the streaming-friendly counterpart to `DriveStorage.get` — adapters expose `EntryReader.open()` so callers (including the conformance harness and the chunks plugin) can pipeline decode + decrypt + CRC-verify without first materialising the entry in memory. `readAll()` is the convenience wrapper that consumes the stream and concatenates into a `Uint8Array`.
## Features
- Streaming-first: `open()` returns a Web `ReadableStream<Uint8Array>` so multi-GB entries never need to fit in memory.
- Convenience wrapper `readAll()` for callers that genuinely need the bytes contiguously.
- Carries the entry path and (optional) size so callers can pre-allocate buffers when the size is known.
## Usage: Basic Example
```ts
import type { EntryReader } from "@suaveplan/drive-storage-archive-core";
async function dump(reader: EntryReader): Promise<void> {
const bytes = await reader.readAll();
console.log(`${reader.path}: ${bytes.byteLength} bytes`);
}
```
## Usage: Advanced Example
```ts
import type { EntryReader } from "@suaveplan/drive-storage-archive-core";
async function hash(reader: EntryReader, algorithm: "SHA-256"): Promise<ArrayBuffer> {
// Stream-hash without ever buffering the full entry.
const stream = reader.open();
const chunks: Uint8Array[] = [];
const writer = new WritableStream<Uint8Array>({
write(chunk) {
chunks.push(chunk);
},
});
await stream.pipeTo(writer);
let total = 0;
for (const c of chunks) total += c.byteLength;
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
merged.set(c, off);
off += c.byteLength;
}
return crypto.subtle.digest(algorithm, merged);
}
```
## API Reference
- `interface EntryReader`
- `readonly path: string` — entry path inside the archive.
- `readonly size: number | null` — uncompressed byte length, or `null` when unknown (tar streaming, gz with no ISIZE trailer).
- `open(): ReadableStream<Uint8Array>` — open a Web Stream.
- `readAll(): Promise<Uint8Array>` — read fully into memory.
## Implementation Notes
`size` is `number | null` because tar entries are encountered sequentially and the size in the header is the only source of truth; some legacy tar variants pad the size field with whitespace and the reader cannot trust it without re-reading the data. `null` lets the adapter signal "I don't know yet" without lying. The `open()` method MUST be callable more than once if the underlying format supports random access (zip / 7z with a known central directory); for sequential formats (tar / gz) the adapter MAY raise on the second open. `readAll()` is the default fallback path for `DriveStorage.get` and MUST be implemented as `await new Response(this.open()).arrayBuffer()` or equivalent to keep the streaming code path the single source of truth.

View File

@@ -0,0 +1,18 @@
/**
* Per-entry read surface every per-format adapter exposes internally
* (and surfaces through `DriveStorage.createReadStream` / `get`).
*
* `EntryReader` is the streaming primitive — the core's harness uses it
* to drive conformance tests, and adapters use it to compose decode
* pipelines (decompress, then decrypt, then verify CRC).
*/
export interface EntryReader {
/** The entry's logical path inside the archive. */
readonly path: string;
/** Uncompressed byte length, or `null` if unknown (streaming tar entries). */
readonly size: number | null;
/** Open a Web Stream over the decoded entry contents. */
open(): ReadableStream<Uint8Array>;
/** Read the entire entry into memory. */
readAll(): Promise<Uint8Array>;
}

View File

@@ -0,0 +1,22 @@
export type { ArchiveCapabilities } from "./archive-capabilities.js";
export type { ArchiveCodec } from "./archive-codec.js";
export {
ARCHIVE_FORMATS,
type ArchiveFormat,
type ArchiveFormatOrAuto,
} from "./archive-format.js";
export {
ARCHIVE_MODES,
type ArchiveMode,
isArchiveMode,
} from "./archive-mode.js";
export type { EntryReader } from "./entry-reader.js";
export type {
ArchiveAdapterFactory,
ArchiveAdapterFormatRegistry,
ArchiveStorageHandle,
OpenArchiveStorageOptions,
ResolvedAdapterOptions,
} from "./open-archive-storage-options.js";
export type { PasswordHook } from "./password-hook.js";
export type { ResolveSibling } from "./resolve-sibling.js";

View File

@@ -0,0 +1,60 @@
# open-archive-storage-options
## Purpose
Declares the option bag passed to `openArchiveStorage(source, options)` plus the supporting types used by the dynamic-import dispatch path: `ArchiveAdapterFormatRegistry` (used by tests to inject a synchronous registry instead of loading real per-format packages), `ArchiveAdapterFactory` (the signature each per-format package exports), `ResolvedAdapterOptions` (the subset of options the factory hands to a concrete adapter once `auto` has been resolved), and `ArchiveStorageHandle` (the minimum surface every adapter MUST expose back to the factory).
## Features
- Every option is optional — sensible defaults (`format: "auto"`, `mode: "read-only"`) cover the common case.
- `declaredExtension` lets callers feed the factory the original filename's extension so the `ARCHIVE_FORMAT_MISMATCH` warning can fire WITHOUT making the factory parse filenames.
- `__formatRegistry` is an explicit test-injection seam — production code never sets it; conformance tests use it to supply fake adapters.
- `codec` slot exists at the core's option layer so composed adapters (tgz over gzipCodec) inherit the option shape directly.
## Usage: Basic Example
```ts
import type { OpenArchiveStorageOptions } from "@suaveplan/drive-storage-archive-core";
const opts: OpenArchiveStorageOptions = {
format: "auto",
mode: "read-only",
};
```
## Usage: Advanced Example
```ts
import type {
ArchiveAdapterFormatRegistry,
OpenArchiveStorageOptions,
} from "@suaveplan/drive-storage-archive-core";
const registry: ArchiveAdapterFormatRegistry = {
zip: async (_source, opts) => ({
mode: opts.mode,
async flush() {
/* test stub */
},
}),
};
const opts: OpenArchiveStorageOptions = {
format: "zip",
mode: "copy-on-write",
declaredExtension: "zip",
__formatRegistry: registry,
};
```
## API Reference
- `interface OpenArchiveStorageOptions` — factory option bag.
- `type ArchiveAdapterFormatRegistry` — `Partial<Record<ArchiveFormat, ArchiveAdapterFactory>>` for test injection.
- `type ArchiveAdapterFactory` — `(source, ResolvedAdapterOptions) => Promise<ArchiveStorageHandle>`.
- `interface ResolvedAdapterOptions` — the post-resolve subset (no `format`, no `declaredExtension`).
- `interface ArchiveStorageHandle` — `{ mode, flush() }`.
## Implementation Notes
The `__formatRegistry` name uses a leading double-underscore to signal "private / test-only" without forcing a TypeScript private modifier (which would make it unusable from a test file via type narrowing). The factory must check the registry FIRST and only dynamic-import when the slot is unset, so test runs that inject a registry never touch the real per-format packages — that matters because the real ZIP / TAR / 7z packages do not yet exist in this project when this package's tests run. `ResolvedAdapterOptions` deliberately omits `format` because the factory has already chosen the adapter by the time it builds the resolved options — leaving `format` in would invite an adapter to second-guess the dispatcher.

View File

@@ -0,0 +1,78 @@
import type { LoggerLike } from "@suaveplan/types";
import type { ArchiveCodec } from "./archive-codec.js";
import type { ArchiveFormatOrAuto } from "./archive-format.js";
import type { ArchiveMode } from "./archive-mode.js";
import type { PasswordHook } from "./password-hook.js";
import type { ResolveSibling } from "./resolve-sibling.js";
/**
* Options accepted by `openArchiveStorage`.
*/
export interface OpenArchiveStorageOptions {
/** Format to load. Defaults to `"auto"` (magic-byte detection). */
readonly format?: ArchiveFormatOrAuto;
/** Mutation mode. Defaults to `"read-only"`. */
readonly mode?: ArchiveMode;
/** Lazy password supplier for encrypted entries. */
readonly password?: PasswordHook;
/** Sibling-volume resolver for split archives. */
readonly resolveSibling?: ResolveSibling;
/**
* Filename extension declared by the caller (e.g. `"zip"`). When
* provided AND `format === "auto"` AND the sniffed format differs,
* the factory logs `ARCHIVE_FORMAT_MISMATCH` via `options.logger`
* and proceeds with the sniffed format.
*/
readonly declaredExtension?: string;
/** Optional logger. Used for `ARCHIVE_FORMAT_MISMATCH` warnings. */
readonly logger?: LoggerLike;
/** Codec slot for composed adapters (tgz family). */
readonly codec?: ArchiveCodec;
/**
* Adapter registry used by the dynamic-import dispatcher. Tests may
* inject a static registry to avoid loading real per-format packages.
* Production callers leave this unset; the factory dynamic-imports
* `@suaveplan/drive-storage-${format}`.
*/
readonly __formatRegistry?: ArchiveAdapterFormatRegistry;
}
/**
* Input to {@link OpenArchiveStorageOptions.__formatRegistry}. Tests
* provide a synchronous map keyed by format → factory.
*/
export type ArchiveAdapterFormatRegistry = Partial<
Record<import("./archive-format.js").ArchiveFormat, ArchiveAdapterFactory>
>;
/**
* Factory function each per-format package exports. The core's
* `openArchiveStorage` invokes it with the resolved options.
*/
export type ArchiveAdapterFactory = (
source: Uint8Array | ReadableStream<Uint8Array> | unknown,
options: ResolvedAdapterOptions
) => Promise<ArchiveStorageHandle>;
/**
* Subset of options handed to a per-format adapter factory. `format` is
* concrete here ("auto" has already been resolved).
*/
export interface ResolvedAdapterOptions {
readonly mode: ArchiveMode;
readonly password: PasswordHook | undefined;
readonly resolveSibling: ResolveSibling | undefined;
readonly logger: LoggerLike | undefined;
readonly codec: ArchiveCodec | undefined;
}
/**
* Minimum surface every adapter exposes back to the factory. The factory
* does not introspect the full `DriveStorage` surface — it only needs
* `flush()` and `mode` to satisfy its own return contract; the
* `DriveStorage` portion is structurally typed at the call site.
*/
export interface ArchiveStorageHandle {
readonly mode: ArchiveMode;
flush(): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
# password-hook
## Purpose
Declares the `PasswordHook` callback signature used by every archive adapter to fetch passwords lazily for encrypted entries. The hook is invoked only when an encrypted entry is actually read — listing or stat operations against encrypted archives never trigger a password prompt. The contract intentionally treats the password as a transient secret: adapters MUST NOT cache the returned value on the adapter instance, and the `wrapPasswordHook` helper enforces this by holding the value only inside a using-disposable scope that zeroizes on disposal.
## Features
- Lazy: hook is never called on non-encrypted reads.
- Async: lets the host prompt a user, fetch from a vault, or call a remote KMS.
- Tied to a single decrypt operation; the password is dropped as soon as the active read completes.
## Usage: Basic Example
```ts
import type { PasswordHook } from "@suaveplan/drive-storage-archive-core";
const hook: PasswordHook = async () => process.env.ARCHIVE_PW ?? "";
const pw = await hook();
console.log(pw.length, "chars");
```
## Usage: Advanced Example
```ts
import type { PasswordHook } from "@suaveplan/drive-storage-archive-core";
function fromVault(unlock: () => Promise<string>): PasswordHook {
let inflight: Promise<string> | undefined;
return async () => {
// Coalesce concurrent reads of encrypted entries onto one unlock.
if (inflight !== undefined) return inflight;
inflight = unlock().finally(() => {
// Discard the cached Promise after settlement so the next encrypted
// read triggers a fresh unlock.
inflight = undefined;
});
return inflight;
};
}
```
## API Reference
- `type PasswordHook = () => Promise<string>` — host callback returning a password.
## Implementation Notes
The hook returns a `Promise<string>` rather than `string | Promise<string>` to keep the calling site's await contract uniform — adapters never need to detect sync-vs-async and shape their decryption pipeline accordingly. Adapters MUST treat the returned string as untrusted input: they pass it directly to the format-specific KDF (PBKDF2 for ZIP AES, AES-CTR-derived key for 7zAES, RAR4's modified SHA-1 pipeline) and discard the original string immediately after derivation. The accompanying `wrapPasswordHook` utility in `src/password/` enforces no-retention by holding the value only inside a `using` block, then overwriting the string-backed buffer on disposal so a heap snapshot cannot recover the plaintext.

View File

@@ -0,0 +1,9 @@
/**
* Lazy password supplier for encrypted archive entries.
*
* Invoked only when an encrypted entry is encountered. Passwords MUST NOT
* be retained on the adapter beyond the lifetime of the active decrypt
* operation. The hook returns a Promise so the host may prompt a user,
* fetch from a keychain, or unlock a vault.
*/
export type PasswordHook = () => Promise<string>;

View File

@@ -0,0 +1,49 @@
# resolve-sibling
## Purpose
Declares the `ResolveSibling` callback contract that per-format archive adapters use to fetch additional split-volume bytes from the host application. The host is the single source of truth for sibling discovery — adapters never assume a filesystem layout, an HTTP URL pattern, or an IndexedDB row scheme. This contract lets the same adapter mount a split archive whether the volumes live on disk, on a CDN, in a P2P drive, or inside a single ZIP container nested in another.
## Features
- Host-owned discovery: the adapter passes a basename + zero-based index and the host returns bytes.
- Stream-friendly: returns `Uint8Array` or a `ReadableStream<Uint8Array>` so the host can lazy-fetch multi-GB volumes.
- Explicit absence: returning `null` triggers `ARCHIVE_SPLIT_MISSING_VOLUME`, distinguishing "this volume is missing" from "this volume is empty".
## Usage: Basic Example
```ts
import type { ResolveSibling } from "@suaveplan/drive-storage-archive-core";
const resolver: ResolveSibling = async (basename, index) => {
const filename = `${basename}.z${String(index + 1).padStart(2, "0")}`;
const res = await fetch(`/archives/${filename}`);
if (res.status === 404) return null;
return new Uint8Array(await res.arrayBuffer());
};
```
## Usage: Advanced Example
```ts
import type { ResolveSibling } from "@suaveplan/drive-storage-archive-core";
function cachedResolver(inner: ResolveSibling): ResolveSibling {
const cache = new Map<string, Uint8Array | ReadableStream<Uint8Array> | null>();
return async (basename, index) => {
const key = `${basename}#${index}`;
if (cache.has(key)) return cache.get(key) ?? null;
const out = await inner(basename, index);
cache.set(key, out);
return out;
};
}
```
## API Reference
- `type ResolveSibling = (basename: string, index: number) => Promise<Uint8Array | ReadableStream<Uint8Array> | null>` — host callback for sibling-volume fetch.
## Implementation Notes
The callback returns a Promise so the host can do async I/O without blocking the adapter. The `Uint8Array | ReadableStream` union is intentional — small volumes (e.g. `.z01` of 1 MiB) are cheaper to return as a fully-realised buffer, while multi-GB final volumes benefit from streaming. The basename is the primary volume's filename WITHOUT the trailing extension; the adapter reconstructs the per-format naming convention (`name.z01..zNN`, `name.partN.rar`, `name.7z.NNN`) from `basename` + `index`. Adapters MUST validate the returned bytes' volume header matches the expected `index` and raise `ARCHIVE_SPLIT_MISSING_VOLUME` on mismatch so partial fetches can't masquerade as the wrong volume.

View File

@@ -0,0 +1,13 @@
/**
* Callback used by every per-format adapter to resolve additional
* volumes of a split archive. The adapter passes its primary-volume
* basename and a zero-based volume index, and the host returns the
* sibling's bytes (or a stream over them).
*
* Returning `null` MUST be interpreted as "no such volume" and triggers
* `ARCHIVE_SPLIT_MISSING_VOLUME`.
*/
export type ResolveSibling = (
basename: string,
index: number
) => Promise<Uint8Array | ReadableStream<Uint8Array> | null>;

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ARCHIVE_FORMATS } from "./archive-format.js";
import { ARCHIVE_MODES, isArchiveMode } from "./archive-mode.js";
describe("ARCHIVE_FORMATS", () => {
it("lists six formats", { timeout: 5000 }, () => {
expect(ARCHIVE_FORMATS).toEqual([
"zip",
"tar",
"gz",
"tgz",
"rar",
"7z",
]);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(ARCHIVE_FORMATS)).toBe(true);
});
});
describe("ARCHIVE_MODES + isArchiveMode", () => {
it("lists three modes in stability order", { timeout: 5000 }, () => {
expect(ARCHIVE_MODES).toEqual([
"read-only",
"copy-on-write",
"rewrite",
]);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(ARCHIVE_MODES)).toBe(true);
});
it("guards every valid mode", { timeout: 5000 }, () => {
for (const m of ARCHIVE_MODES) {
expect(isArchiveMode(m)).toBe(true);
}
});
it("rejects unknown values", { timeout: 5000 }, () => {
for (const v of ["", "rw", null, undefined, 42, {}]) {
expect(isArchiveMode(v)).toBe(false);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,49 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/drive-storage-archive-core",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"types/index": "src/types/index.ts",
"errors/index": "src/errors/index.ts",
"resolvers/index": "src/resolvers/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/storage",
"@suaveplan/storage/abstract",
"@suaveplan/types",
"@suaveplan/testing",
"@suaveplan/testing/runner",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();