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.
310 lines
17 KiB
Markdown
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.
|