Files
suaveplan/openspec/changes/complete-storage-uri-adapters/specs/storage-file/spec.md
2026-05-27 23:29:53 +01:00

9.0 KiB

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 parses 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 parses 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"