# Capability Spec Delta: storage-file (complete-storage-uri-adapters) **Package**: `@suaveplan/storage-file` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: `file://` URI plugin factory The package SHALL export `fileUriPlugin(options)` from `./uri-plugin/uri-plugin` returning a `UriPlugin` whose `scheme` is the lowercase string `"file"` and whose `adapter` is the caller-supplied `NodeFsStorage` instance. The factory MUST validate `options.rootDir` is a non-empty absolute path; failure raises `StorageFileUriError(STORAGE_FILE_URI.INVALID_URI)` synchronously. #### Scenario: factory returns a UriPlugin with scheme "file" - **WHEN** `fileUriPlugin({ adapter, rootDir: "/var/data" })` is called - **THEN** the returned plugin's `scheme === "file"`, `adapter` is the same reference, and `runUriPluginConformance(plugin, fixtures)` passes #### Scenario: empty rootDir is rejected - **WHEN** `fileUriPlugin({ adapter, rootDir: "" })` is called - **THEN** the call throws `StorageFileUriError` with `code === "STORAGE_FILE_URI.INVALID_URI"` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"exports a UriPlugin with scheme file"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects empty rootDir"` --- ### Requirement: RFC 8089 authority handling The plugin SHALL accept `file:///...` and `file://localhost/...` as equivalent absolute-path forms. Any other non-empty authority SHALL raise `STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY` so network-file-system URIs (`file://server/share/...`) do not silently degrade to local lookups. #### Scenario: empty authority resolves to local path - **WHEN** `plugin.parse("file:///etc/hosts")` is called - **THEN** the result has `key` equal to `"etc/hosts"` (or platform equivalent relative to `rootDir`) #### Scenario: localhost authority resolves identically to empty - **WHEN** `plugin.parse("file://localhost/etc/hosts")` is called - **THEN** the result matches the empty-authority form exactly #### Scenario: non-empty non-localhost authority is rejected - **WHEN** `plugin.parse("file://nas.example.com/share/x")` is called - **THEN** the call throws `StorageFileUriError` with `code === "STORAGE_FILE_URI.UNSUPPORTED_AUTHORITY"` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"resolves empty authority to local path"` AND `src/uri-plugin/uri-plugin.test.ts::"treats localhost authority as local"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects non-empty non-localhost authority"` --- ### Requirement: Windows drive-letter preservation The plugin SHALL detect Windows drive-letter form `file:///:/path` (the leading `/` followed by `:`) and preserve the drive letter through `parse` and `build`. Malformed drive-letter forms (e.g. `file:///C/path` or `file:///CC:/path`) SHALL raise `STORAGE_FILE_URI.WINDOWS_DRIVE_INVALID`. #### Scenario: well-formed drive-letter parses - **WHEN** `plugin.parse("file:///C:/Users/x.txt")` is called - **THEN** the parsed key preserves `C:/Users/x.txt` (verbatim, no percent-encoding of the colon) #### Scenario: malformed drive-letter is rejected - **WHEN** `plugin.parse("file:///CC:/x")` is called - **THEN** the call throws `StorageFileUriError` with `code === "STORAGE_FILE_URI.WINDOWS_DRIVE_INVALID"` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"preserves Windows drive letters"` AND `src/uri-plugin/uri-plugin.test.ts::"rejects malformed drive-letter forms"` --- ### Requirement: `..` traversal rejection The plugin SHALL reject any URI whose decoded path resolves outside `rootDir` after `..` normalisation. Detection runs BEFORE the call reaches the underlying adapter. #### Scenario: parent-directory escape is rejected - **WHEN** `plugin.parse("file:///../../etc/passwd")` is called against a plugin with `rootDir: "/var/data"` - **THEN** the call throws `StorageFileUriError` with `code === "STORAGE_FILE_URI.PATH_ESCAPE"` #### Scenario: same-directory descent succeeds - **WHEN** `plugin.parse("file:///a/b/../c.txt")` is called with the same rootDir - **THEN** the parse succeeds with the normalised key `a/c.txt` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"rejects path traversal escapes"` AND `src/uri-plugin/uri-plugin.test.ts::"normalises in-tree traversal"` --- ### Requirement: Permission-denied passthrough The plugin SHALL re-wrap permission-denied failures raised by the bound `NodeFsStorage` (`STORAGE_FILE.PERMISSION_DENIED` from `EACCES`/`EPERM`) as `StorageFileUriError(STORAGE_FILE_URI.PERMISSION_DENIED)` and MUST preserve the original on `cause`. #### Scenario: adapter EACCES becomes STORAGE_FILE_URI.PERMISSION_DENIED - **WHEN** the resolver routes `read("file:///protected/x")` and the underlying `adapter.get` throws `STORAGE_FILE.PERMISSION_DENIED` - **THEN** the rejection is `StorageFileUriError` with `code === "STORAGE_FILE_URI.PERMISSION_DENIED"` and `cause` set to the original `StorageFileError` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"wraps EACCES as STORAGE_FILE_URI.PERMISSION_DENIED"` --- ### Requirement: `build(key)` inverse with percent-encoding The plugin SHALL implement `build(key)` returning a `file://`-scheme URI that round-trips through `parse` for every key produced by the bound adapter's `list` operation. Path segments SHALL be percent-encoded per RFC 3986 ยง3.3; Windows drive letters SHALL be preserved verbatim. #### Scenario: POSIX key round-trips - **WHEN** `plugin.build("a/b%file.txt")` is called - **THEN** the result `parse`s back to a `ParsedUri` whose `key === "a/b%file.txt"` #### Scenario: Windows key round-trips with drive letter - **WHEN** `plugin.build("C:/Users/x.txt")` is called - **THEN** the result is `"file:///C:/Users/x.txt"` and `parse`s back **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"build round-trips POSIX keys"` AND `src/uri-plugin/uri-plugin.test.ts::"build round-trips Windows keys"` --- ### Requirement: `STORAGE_FILE_URI_CODES` registry The package SHALL export `STORAGE_FILE_URI_CODES` produced by `defineCodes("STORAGE_FILE_URI", [...] as const)` covering exactly 6 codes: `INVALID_URI`, `UNSUPPORTED_AUTHORITY`, `PATH_ESCAPE`, `PERMISSION_DENIED`, `WINDOWS_DRIVE_INVALID`, `KEY_TOO_LONG`. Every code SHALL be reachable from a real call site exercised by the test suite, and the structured error class `StorageFileUriError` SHALL extend `SuaveplanError` carrying optional `uri`/`scheme` discriminants. #### Scenario: every code is reachable - **WHEN** the test suite exercises every documented failure path - **THEN** every member of `STORAGE_FILE_URI_CODES` appears on at least one raised `StorageFileUriError` instance #### Scenario: error preserves cause - **WHEN** the plugin re-wraps an adapter `StorageFileError` - **THEN** the resulting `StorageFileUriError.cause` is the original adapter error instance **Verified by:** `src/uri-plugin/error-codes.test.ts::"exposes 6 codes all reachable"` AND `src/uri-plugin/error-codes.test.ts::"preserves cause when re-wrapping"` --- ### Requirement: AbortSignal threading The plugin SHALL forward an `AbortSignal` (received via the resolver's options bag) unchanged to every adapter call. A pre-aborted signal SHALL short-circuit with `STORAGE_FILE.ABORTED` (the adapter's existing code) without invoking the adapter's I/O methods. #### Scenario: pre-aborted signal short-circuits - **WHEN** the resolver routes `read("file:///x", AbortSignal.abort())` - **THEN** the rejection has `code === "STORAGE_FILE.ABORTED"` and the adapter's `get` is NEVER invoked #### Scenario: live signal is forwarded - **WHEN** `read("file:///x", signal)` is called with a live signal - **THEN** the adapter receives the same signal reference **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"short-circuits on pre-aborted signal"` AND `src/uri-plugin/uri-plugin.test.ts::"forwards live signal to adapter"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports for `"./uri-plugin/uri-plugin"` and `"./uri-plugin/error-codes"` in `package.json` so consumers can tree-shake the URI plugin independently. #### Scenario: subpath imports resolve - **WHEN** `import("@suaveplan/storage-file/uri-plugin/uri-plugin")` is executed - **THEN** the module resolves and re-exports `fileUriPlugin` **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"is importable via the documented subpath"` --- ### Requirement: Conformance harness pass `fileUriPlugin` SHALL pass `runUriPluginConformance` from `@suaveplan/storage-uri/conformance/conformance` with fixtures covering POSIX paths, Windows drive-letter paths, `localhost` authority, malformed inputs (empty path, non-localhost authority, traversal escape), and a non-empty `buildRoundTrip` set. #### Scenario: conformance fixtures pass - **WHEN** `runUriPluginConformance(fileUriPlugin(opts), fixtures)` runs with the documented fixture set - **THEN** the call returns without throwing **Verified by:** `src/uri-plugin/uri-plugin.test.ts::"passes runUriPluginConformance"`