Files
Hare a7186173b4 feat(fs): bind external scopes to current execution lineage
Add fail-closed Composition lifetime attestation for non-root scopes; preserve pinned deployment-root semantics and principal visibility. Fetch and dispatch bind optional published scope refs inside the refreshed backend boundary.

Validation: cargo test -p wip-fs-server (25 unit, 8 HTTP, 31 extension tests and 1 doc-test); Nix clippy for FS and public example with -D warnings; git diff --check.
2026-10-08 03:56:17 +09:00

310 lines
17 KiB
Markdown

# wip-fs-server
`wip-fs-server` projects one configured Linux filesystem directory onto WIP
Worldspace `/` at request time. It does not register the filesystem contents or
keep one object instance per entry. It uses `wip-host` for publication and typed
operation dispatch and uses the standard `wip-http` routes, JSON codecs,
envelopes, and HTTP status mapping.
## Library assembly and migration
`ServerConfig`, `WipFsServer::new`, `handle_http` and CLI flags retain their
assembly shape. The scoped Host/Protocol API is a breaking Rust/wire migration:
Interface references are `{scope, name}` values, registration takes a `Scope`
and local name, and calls use `interface.reference` plus optional `scope_ref`.
Fetch returns the same structured reference and current scope Object's optional
ref. This is the standard WIP wire format, not an FS-specific profile.
Use `default_projection(root, access, limits)` to obtain the default Linux engine
without starting a listener. `projection.view(&principal)` is the effective view;
it owns canonical `observe` and `fetch_interface` and implements the standard
`wip_host::ObjectProvider` typed `call_operation` API. `DefaultPolicy` has the unit
principal `()`; default engines also offer unit-principal convenience methods.
For an external backend/composition:
1. Implement `BackendEntry` on a local, cloneable, root-confined stable capability.
Its `WipObject` metadata is public-only; its validator binds identity and the
protected state. Implement `FilesystemBackend` to resolve one connected
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
bounded children, and execute against that already-resolved lineage.
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
register generated definitions with `registry.register(&scope, name,
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
optionally supplies reusable built-in tokens named `file.read`,
`symlink.inspect`, and `file.write`; tokens do not force publication. For a
non-root scope, create a handle at its canonical WIP path and implement
`Composition::scope(path, &entry, context)` to attest that handle only for
its current Object lifetime. Local names are exact names, not parsed `::`/`#`
display strings; the same local name can be registered at different scopes.
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
and implements both `WipObject` and the chosen generated traits, avoiding orphan
rules. `PublishedObject::expose` proves each implementation at compile time.
Publication can add, replace, reorder or omit built-ins based on entry metadata
and actual backend capabilities, not just entry kind. `LinuxEntry` implements
bounded `FileRead`, `FileWrite` and `SymlinkInspect` for safe delegation; never
advertise write in read-only mode or link operations on a non-link capability.
4. Assemble `Projection::new(backend, registry, composition, policy, limits)`.
`ProjectionPolicy` supplies an opaque associated `Principal`, hides Objects and
entire hidden subtrees, selects an ordered subset of implemented interfaces,
controls descriptor/operation visibility, and permits or denies execution.
5. An authenticated deployment chooses the principal and `ExecutionContext`. To
use HTTP, construct `HttpAdapter::new(&view, &endpoint, limits)` and invoke
`handle_http_with_context`. This uses the standard `wip-http` binding, not a
filesystem wire profile. Enforce body collection, header, concurrency and
deadline bounds in the deployment's listener; the adapter owns none of these
intake resources. The bundled loopback listener remains a thin default user.
A complete external-style memory backend, local Object with multiple custom
Operations, typed registration, principal policy and HTTP flow is exercised in
[`tests/extensions.rs`](tests/extensions.rs). It uses no Linux filesystem for its
backend. The public library APIs do not require private types, a fork, an HTTP
listener, a global object registry, or dynamic plugin loading.
### Extension security contract
Backend implementations and Operation authors are trusted code, not sandboxed
plugins. `FilesystemBackend::resolve` returns `ResolvedEntry::new(ancestors, target)`
from one connected backend traversal/snapshot, not independent path-prefix
resolutions. A root target has no ancestors; other targets retain the root first,
then intermediate parent containers. This is backend-private context, never wire
metadata. Backend `resolve` and `execute` signatures both carry this type;
compositions receive exact retained entries, including publication of the root
scope Object while another target is executing, never independent raw-path
lookups. Root publication must be deterministic and side-effect-free.
Every lineage must retain the same deployment-pinned root Object. Replacing that
Object requires a new projection and fresh `Scope`, not reuse of registrations
at the same path. The engine constructs `ScopeObservation` from the published
root Object's metadata using the refreshed retained root capability **inside**
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
It never independently looks up the raw configured root path. Root Objects need
not expose any interfaces. Calls check both the private lifetime and optional
`scope_ref`; normal root state updates do not retire the lifetime.
`Composition::scope` is the trusted non-root lifetime attestation hook. It runs
only for registered scope paths in the refreshed, principal-visible execution
lineage, never through independent scope-path resolution. Return `Some(scope)`
only when the retained entry is still the current Object published at that path;
use retained backend identity and application publication-lifecycle state under
the backend boundary, not ref or validator equality. Deletion, replacement or
movement needs a fresh handle and registration, even if a ref is absent or reused.
Ordinary state changes retain the handle. A different returned path is an internal
publication failure; `None` or a fresh unregistered lifetime hides old artifacts.
The default hook returns `None`, so existing compositions fail closed outside `/`.
Self-scope needs special care: a backend may allow ordinary execution against an
unlinked/replaced pinned leaf, but that does not establish that leaf as the current
scope Object. The hook must attest current membership as well as retained identity
within the same boundary. The default Linux composition does not attest non-root
lifetimes or invent inode-based Object refs. External compositions must provide
that guarantee themselves, including their documented external-writer limits.
Scope metadata is obtained by publishing that retained entry (reusing the target
publication for self-scope); scope Objects need not expose interfaces. Optional
refs are taken from the published Object, not from a raw backend entry. Calls
without `scope_ref` remain valid when the lifetime is current; supplied stale refs
reject with `InterfaceMismatch` after Object validator checks.
`with_object` (including fetch) and individual tree Object publications also
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt
as an adapter to the existing execution API; no typed operation runs and no
commit is implied. Tree observations are bounded traversals, not one global tree
transaction. Fetch resolves the visible scope Object through the provider and
returns its ref, Descriptor and Interface validator together; retained registry
artifacts alone never establish publication.
`FilesystemBackend::execute` must validate ancestor membership/identity and
refresh **all** lineage state/validators inside its transaction or lock before
calling the engine callback, retaining the same target and connected ancestry
through validation and dispatch. Replaced/detached ancestors must return
`NotFound` without callback invocation, even when the replacement is visible.
The engine evaluates Object visibility for every refreshed ancestor and target
inside that same boundary; a hidden ancestor rejects the entire subtree before
publication/typed dispatch. Never fix a changed lineage by resolving the raw path
again and substituting capabilities. Participating policy-relevant backend state
changes must be serialized by this boundary, not just target-content writes.
Return the committed post-operation validator. Retained ancestry is subject to
handle/work ceilings; resource exhaustion is an error, not missing ancestry.
Confirm rollback only after rollback succeeds. Pre-dispatch failures
must never commit; failed started operations without a non-commit guarantee remain
`OperationOutcomeUnknown`. Object implementations must delegate validators to
that entry and declare requirements per `(InterfaceRef, operation)`, rather than
classify all operations by a read/write name. Operations use the scoped entry and
propagated context, not a fresh raw-path resolution; they must check deadlines,
cancellation and resource budgets throughout backend work and report effects
truthfully. POSIX symlinks, identity, atomic mutations and locking cannot be assumed
for other backends.
The default Linux backend acquires advisory exclusive locks in root-to-parent
order and then on the lockable target (directory or regular file). It compares
retained ancestor identity to no-follow descriptor-relative lookup results,
refreshes lineage snapshots after acquiring the locks, and holds the guards
through post-dispatch state observation. The configured root remains its pinned
capability even if its textual path is renamed; replaced intermediate ancestors
are rejected. Leaf path replacement never redirects execution to a new inode.
This serializes cooperating library calls; it does **not** promise transactional
isolation from unrelated processes ignoring `flock`. Linux capabilities only
expose bounded methods, not raw descriptors or backend paths.
A policy must be stable/deterministic within one effective Worldspace view.
Interface/operation visibility is uniform per registered reference within that
view, so observation, fetch and dispatch use the identical filtered descriptor.
For entry-specific operation sets, register distinct interface definitions and
select them by entry. Different principals may have different effective views.
Changing a policy must establish a new view/reference lifetime (e.g. rebuild the
registry/engine); do not keep validator-free references while changing their
meaning. Execution permission may deny a visible Operation without hiding it.
A published interface is always fetchable by that principal; hidden operations
are omitted (along with their unreachable named types and interface-wide docs)
and guessed calls return `OperationNotFound`, whereas visible denied
operations return `PermissionDenied`. Hidden Objects and descendants return
`NotFound` and are absent from parent observations. Work spent scanning hidden
entries still counts against traversal bounds; resource limits are not a
constant-time/side-channel guarantee.
Never put absolute paths, hidden metadata, credentials or policy internals in
public `WipObject` metadata, descriptions, validators or normal logs. Validators
must be opaque public tokens, not confidential content. Engine/HTTP failures use
bounded generic diagnostics; arbitrary trusted implementation metadata/logging
cannot be sanitized automatically. Do not log credentials or sensitive panic
payloads from extensions.
## Security model
- The effective root is opened once as a directory capability. Root path
components must be real directories (symlink components are rejected), and the
displayed effective root is read back from that same pinned descriptor. Every
request is resolved segment-by-segment with directory-relative, `O_NOFOLLOW`
handles.
- Symlinks are projected as links and are never followed. Their stored target is
available only through the `SymlinkInspect.read_link` application operation.
- Regular files, directories, symlinks, and unsupported entry types have distinct
public descriptions and capability sets.
- WIP names are Unicode. Linux names that are not valid UTF-8 are deliberately
not published; valid UTF-8 names map byte-for-byte and therefore cannot collide.
Unicode paths containing backend-invalid NUL bytes are likewise treated as
unpublished rather than as backend failures.
- No object `ref` is issued. This avoids claiming representation identity for
hard links or path-specific names. Regular files and symlinks receive an opaque
validator containing filesystem identity plus protected metadata; replacing a
path therefore does not preserve its validator.
- Protocol metadata and errors never contain the backend absolute path. Startup
prints only the explicitly configured effective root and security-sensitive
modes.
- The built-in HTTP listener is loopback-only. TLS termination, certificates,
authentication, an externally visible URL scheme, and proxy routing are
deployment concerns rather than separate WIP transport profiles.
The implementation currently targets Linux because it uses `O_PATH`,
directory-relative descriptor traversal, and `/proc/self/fd` to reopen the same
pinned backend object without resolving the untrusted Worldspace path twice.
## Run
Run the loopback listener in read-only mode:
```console
cargo run -p wip-fs-server -- \
--root /srv/public-files \
--listen 127.0.0.1:8080 \
--base-path /wip
```
Run with the write capability deliberately enabled:
```console
cargo run -p wip-fs-server -- \
--root ./example-files \
--listen 127.0.0.1:8080 \
--base-path /wip \
--read-write
```
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, endpoint,
and actual listen address. It does not enumerate the root.
## Standard interactions
The following examples use the built-in HTTP listener. The JSON follows the
standard WIP over HTTP binding; validators and operation `Bytes` are base64 body
fields, never `ETag` or `If-Match` headers.
Observe only the root Object:
```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"path":"/","depth":0}'
```
Observe the root and two levels of indexable children:
```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/observe \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"path":"/","depth":2}'
```
A regular-file observation returns a structured read interface reference and an
object validator. Built-ins explicitly use scope `/` and the local names
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
binds fetch and call to the pinned deployment root. Copy the complete reference
from the observation to retrieve that descriptor:
```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{"interface":{"scope":"/","name":"file.read"}}'
```
Then call a bounded range read:
```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
"interface":{"reference":{"scope":"/","name":"file.read"}},
"operation":"read",
"arguments":{"offset":0,"length":4096}
}'
```
In `--read-write` mode a second regular-file interface declares `replace`.
Writes require the current object validator and return the post-write validator:
```console
curl -sS -X POST http://127.0.0.1:8080/wip/v1/call_operation \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
--data '{
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
"interface":{"reference":{"scope":"/","name":"file.write"}},
"operation":"replace",
"arguments":{"contents":"dXBkYXRlZA=="}
}'
```
## Resource and failure behavior
`ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON
nesting, file read/write bytes, tree depth/node count, pinned handles, and active
connections/requests. One absolute request deadline covers header and
body intake, dispatch, cooperative filesystem work, and response transmission;
stalled clients are closed and cannot occupy a worker beyond that bound. Workers
accept connections directly, so the listener never moves more than the configured
concurrency into active userspace service. Advisory file-lock acquisition uses a
nonblocking loop and checks the same deadline/cancellation rather than allowing a
contended lock to stall a worker. Object observation recursively opens only the
requested depth. A successful response is complete through that depth; if the
configured projected-node budget would be exceeded, the interaction fails with
`ResourceLimitExceeded` instead of returning a partial observation. I/O failure
and cancellation remain errors.
Whole-file replacement compares the request validator against the identity and
state of the same pinned file handle used for the write. Failures before mutation
carry a `NotCommitted` guarantee and map to `Internal` or
`ResourceLimitExceeded`; a failure after truncation/write begins maps to
`OperationOutcomeUnknown`. The server and Clients do not automatically retry
side-effecting operations.