first commit
This commit is contained in:
78
docs/networking/drive/drive-storage-archive-core/README.md
Normal file
78
docs/networking/drive/drive-storage-archive-core/README.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user