Initial Commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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:///<drive>:/path` (the leading `/` followed by `<ALPHA>:`) 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<StorageFileUriErrorCode>` 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"`
|
||||
Reference in New Issue
Block a user