Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
780150d7ae | ||
|
|
f5034c0e4b | ||
|
|
df51da3d03 | ||
|
|
fdfaa3a685 | ||
|
|
96db3dd6a8 | ||
|
|
625f4c148d |
Generated
+102
@@ -8,12 +8,24 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
@@ -31,6 +43,22 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -40,6 +68,17 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -182,6 +221,18 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -194,6 +245,12 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -227,6 +284,25 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -329,6 +405,19 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f"
|
checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termcolor"
|
name = "termcolor"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@@ -456,6 +545,19 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wip-fs-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"rustix",
|
||||||
|
"tempfile",
|
||||||
|
"wip-host",
|
||||||
|
"wip-https",
|
||||||
|
"wip-interface",
|
||||||
|
"wip-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-host"
|
name = "wip-host"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ Rust building blocks for the Web Interface Protocol (WIP).
|
|||||||
The repository is a resolver-3 virtual Cargo workspace whose packages all live
|
The repository is a resolver-3 virtual Cargo workspace whose packages all live
|
||||||
under `crates/*`. It provides a transport-independent protocol model, the
|
under `crates/*`. It provides a transport-independent protocol model, the
|
||||||
standard framework-independent WIP over HTTPS v1 binding, a Rust
|
standard framework-independent WIP over HTTPS v1 binding, a Rust
|
||||||
interface-authoring facade, a request-scoped host runtime, and a private
|
interface-authoring facade, a request-scoped host runtime, a bounded Linux
|
||||||
public-API conformance consumer. The repository root is not itself a package.
|
filesystem projection server, and a private public-API conformance consumer. The
|
||||||
|
repository root is not itself a package.
|
||||||
|
|
||||||
## Crate graph and responsibilities
|
## Crate graph and responsibilities
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
wip-fs-server (request-time filesystem projection and loopback listener)
|
||||||
|
├── wip-https standard routes/codecs/envelopes/batching
|
||||||
|
├── wip-host request-scoped publication and dispatch
|
||||||
|
└── wip-interface macro-generated filesystem capabilities
|
||||||
|
|
||||||
wip-public-api-example (workspace-only executable, integration tests, doctest)
|
wip-public-api-example (workspace-only executable, integration tests, doctest)
|
||||||
├── wip-protocol logical declarations, values, calls, and validation
|
├── wip-protocol logical declarations, values, calls, and validation
|
||||||
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching
|
├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching
|
||||||
@@ -29,7 +35,8 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
|
|||||||
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. |
|
||||||
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
|
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
|
||||||
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
|
||||||
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, and interface-qualified dispatch against one host-owned instance. | Backend atomicity, global object lookup, or network serving. |
|
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. |
|
||||||
|
| `wip-fs-server` | Linux directory-handle-confined request-time projection, bounded BFS trees and file operations, standard `wip-https` dispatch/batch handling, and a loopback HTTP binary for TLS-proxy or explicit development use. | A new wire profile, implicit symlink following, non-loopback cleartext, authentication policy, or filesystem-wide preregistration. |
|
||||||
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
|
| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
|
||||||
|
|
||||||
The boundary is intentional: protocol types describe what can cross a
|
The boundary is intentional: protocol types describe what can cross a
|
||||||
@@ -238,6 +245,17 @@ rolled back. Without that proof, post-invocation failures remain
|
|||||||
comparison and backend commit atomic and return a validator describing the state
|
comparison and backend commit atomic and return a validator describing the state
|
||||||
that becomes committed.
|
that becomes committed.
|
||||||
|
|
||||||
|
## Filesystem projection server
|
||||||
|
|
||||||
|
[`crates/wip-fs-server`](crates/wip-fs-server) contains the practical server
|
||||||
|
binary and its full security model, configuration, curl walkthrough, and
|
||||||
|
resource/failure semantics. It resolves every object from current filesystem
|
||||||
|
state, uses pinned directory-relative handles without following symlinks, omits
|
||||||
|
non-UTF-8 backend names, performs bounded breadth-first tree discovery, and
|
||||||
|
publishes validator-protected replacement only in explicit read-write mode.
|
||||||
|
The built-in cleartext listener is loopback-only: production HTTPS is explicitly
|
||||||
|
delegated to a reverse proxy, while local plain HTTP requires an opt-in flag.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
Enter the dev shell and run the acceptance commands exactly:
|
Enter the dev shell and run the acceptance commands exactly:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "wip-fs-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Request-time filesystem projection server for WIP over HTTPS"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
readme = "README.md"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
http = "1"
|
||||||
|
rustix = { version = "1.1", features = ["fs"] }
|
||||||
|
wip-host = { path = "../wip-host" }
|
||||||
|
wip-https = { path = "../wip-https" }
|
||||||
|
wip-interface = { path = "../wip-interface" }
|
||||||
|
wip-protocol = { path = "../wip-protocol" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 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-https` routes, JSON codecs,
|
||||||
|
envelopes, batching, and HTTP status mapping.
|
||||||
|
|
||||||
|
## 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 listener is cleartext and loopback-only. Production deployments
|
||||||
|
must pass `--behind-tls-proxy` and terminate HTTPS at a trusted reverse proxy.
|
||||||
|
Plain development HTTP requires the explicit `--development-http` flag.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Read-only behind a TLS-terminating reverse proxy:
|
||||||
|
|
||||||
|
```console
|
||||||
|
cargo run -p wip-fs-server -- \
|
||||||
|
--root /srv/public-files \
|
||||||
|
--listen 127.0.0.1:8443 \
|
||||||
|
--base-path /wip \
|
||||||
|
--behind-tls-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Local development 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 \
|
||||||
|
--development-http \
|
||||||
|
--read-write
|
||||||
|
```
|
||||||
|
|
||||||
|
The server reports the effective root, `ReadOnly`/`ReadWrite` mode, transport
|
||||||
|
mode, endpoint, and actual listen address. It does not enumerate the root.
|
||||||
|
|
||||||
|
## Standard interactions
|
||||||
|
|
||||||
|
The following examples use development HTTP for readability. The JSON is the
|
||||||
|
standard `wip-https` profile; validators and operation `Bytes` are base64 body
|
||||||
|
fields, never `ETag` or `If-Match` headers.
|
||||||
|
|
||||||
|
Fetch root:
|
||||||
|
|
||||||
|
```console
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
--data '{"path":"/"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetch a bounded tree (the root counts toward `limit`):
|
||||||
|
|
||||||
|
```console
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_tree \
|
||||||
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
|
--data '{"path":"/","depth":2,"limit":100}'
|
||||||
|
```
|
||||||
|
|
||||||
|
A regular-file fetch returns the opaque read interface reference and an object
|
||||||
|
validator. Retrieve that descriptor with:
|
||||||
|
|
||||||
|
```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":"<read-interface-from-fetch>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
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":"<read-interface>"},
|
||||||
|
"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":"<write-interface>"},
|
||||||
|
"operation":"replace",
|
||||||
|
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`fetch`, `fetch_tree`, and `fetch_interface` can also be combined through the
|
||||||
|
standard `POST /wip/v1/batch` route. `call_operation` is never batchable.
|
||||||
|
|
||||||
|
## Resource and failure behavior
|
||||||
|
|
||||||
|
`ResourceLimits` bounds request/response bytes, HTTP request-head bytes, JSON
|
||||||
|
nesting, batch items, 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. Tree traversal is breadth-first and opens only
|
||||||
|
requested entries. Once a limit or depth boundary is reached it reads only far
|
||||||
|
enough to
|
||||||
|
prove whether an additional visible child exists, so `truncated` is true only
|
||||||
|
when something was actually omitted. I/O failure and cancellation remain errors,
|
||||||
|
not clean exhaustion.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Filesystem capability interfaces published by the projection.
|
||||||
|
|
||||||
|
use wip_interface as wip;
|
||||||
|
|
||||||
|
/// Bounded reads from a regular file.
|
||||||
|
#[wip::interface]
|
||||||
|
pub trait FileRead {
|
||||||
|
/// Reads bytes from the current regular file.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// At most the configured read limit, beginning at `offset`.
|
||||||
|
fn read(
|
||||||
|
&self,
|
||||||
|
/// Byte offset; omitted means zero.
|
||||||
|
offset: Option<u64>,
|
||||||
|
/// Requested byte count; omitted means the rest of the file.
|
||||||
|
length: Option<u64>,
|
||||||
|
) -> wip::OperationResult<wip::Bytes>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspection operations for a symbolic link itself.
|
||||||
|
#[wip::interface]
|
||||||
|
pub trait SymlinkInspect {
|
||||||
|
/// Reads the link payload without following it.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// The platform path bytes stored in the symbolic link.
|
||||||
|
fn read_link(&self) -> wip::OperationResult<wip::Bytes>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly enabled mutation of a regular file.
|
||||||
|
#[wip::interface]
|
||||||
|
pub trait FileWrite {
|
||||||
|
/// Replaces the current regular file contents through its pinned handle.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Unit after the new bytes have been written and synchronized.
|
||||||
|
fn replace(
|
||||||
|
&self,
|
||||||
|
/// Complete replacement contents.
|
||||||
|
contents: wip::Bytes,
|
||||||
|
) -> wip::OperationResult<()>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! Request-time filesystem projection served through the standard WIP HTTPS binding.
|
||||||
|
//!
|
||||||
|
//! This crate deliberately keeps the boundaries visible:
|
||||||
|
//!
|
||||||
|
//! - [`wip_protocol`] owns logical requests and responses;
|
||||||
|
//! - [`wip_host`] owns object publication, validators, and typed dispatch;
|
||||||
|
//! - [`wip_https`] owns routes, JSON, envelopes, batching, and status mapping;
|
||||||
|
//! - this crate owns the filesystem capability boundary and the real HTTP listener.
|
||||||
|
//!
|
||||||
|
//! TLS is terminated by an explicitly configured reverse proxy. The built-in
|
||||||
|
//! listener accepts cleartext only on loopback, either behind that proxy or when
|
||||||
|
//! [`TransportSecurity::DevelopmentHttp`] is deliberately selected.
|
||||||
|
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod interfaces;
|
||||||
|
mod projection;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub use interfaces::{FileRead, FileWrite, SymlinkInspect};
|
||||||
|
pub use server::{BoundServer, ServerBuildError, WipFsServer};
|
||||||
|
|
||||||
|
/// Filesystem capabilities published by regular files.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AccessMode {
|
||||||
|
/// Publish bounded reads only.
|
||||||
|
ReadOnly,
|
||||||
|
/// Also publish validator-protected whole-file replacement.
|
||||||
|
ReadWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How HTTPS is provided for the endpoint.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TransportSecurity {
|
||||||
|
/// A trusted reverse proxy terminates TLS and forwards to the loopback listener.
|
||||||
|
ReverseProxyTls,
|
||||||
|
/// Cleartext HTTP is deliberately enabled for local development only.
|
||||||
|
DevelopmentHttp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded resource policy applied by the HTTP adapter and filesystem provider.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ResourceLimits {
|
||||||
|
/// Standard JSON, body-size, nesting, and batch limits.
|
||||||
|
pub wire: wip_https::Limits,
|
||||||
|
/// Maximum bytes returned by one file read.
|
||||||
|
pub max_file_read_bytes: usize,
|
||||||
|
/// Maximum bytes accepted by one file replacement.
|
||||||
|
pub max_file_write_bytes: usize,
|
||||||
|
/// Maximum accepted `fetch_tree` depth.
|
||||||
|
pub max_tree_depth: u32,
|
||||||
|
/// Maximum accepted `fetch_tree` node count, including its root.
|
||||||
|
pub max_tree_nodes: u32,
|
||||||
|
/// Maximum simultaneously pinned projected filesystem handles.
|
||||||
|
pub max_open_handles: usize,
|
||||||
|
/// Maximum bytes accepted for one HTTP request head, including its terminator.
|
||||||
|
pub max_http_header_bytes: usize,
|
||||||
|
/// Maximum requests executing concurrently.
|
||||||
|
pub max_concurrent_requests: usize,
|
||||||
|
/// End-to-end deadline for HTTP intake, dispatch, and response transmission.
|
||||||
|
pub request_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResourceLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
wire: wip_https::Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64)
|
||||||
|
.expect("default wire limits are nonzero"),
|
||||||
|
max_file_read_bytes: 1024 * 1024,
|
||||||
|
max_file_write_bytes: 1024 * 1024,
|
||||||
|
max_tree_depth: 32,
|
||||||
|
max_tree_nodes: 1024,
|
||||||
|
max_open_handles: 2048,
|
||||||
|
max_http_header_bytes: 16 * 1024,
|
||||||
|
max_concurrent_requests: 32,
|
||||||
|
request_timeout: Duration::from_secs(30),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete listener and projection configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Filesystem directory projected as Worldspace `/`.
|
||||||
|
pub root: PathBuf,
|
||||||
|
/// Loopback socket used by the built-in cleartext listener.
|
||||||
|
pub listen: SocketAddr,
|
||||||
|
/// URL path prefix below which standard `v1/*` routes are mounted.
|
||||||
|
pub endpoint_base_path: String,
|
||||||
|
/// Read-only or explicit read-write publication mode.
|
||||||
|
pub access: AccessMode,
|
||||||
|
/// TLS termination or explicit development HTTP policy.
|
||||||
|
pub transport_security: TransportSecurity,
|
||||||
|
/// Request, operation, traversal, and concurrency bounds.
|
||||||
|
pub limits: ResourceLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
/// Builds a secure-by-default configuration for `root`.
|
||||||
|
///
|
||||||
|
/// The listener defaults to an ephemeral loopback port, read-only access,
|
||||||
|
/// and TLS termination delegated to a reverse proxy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn for_root(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: root.into(),
|
||||||
|
listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||||
|
endpoint_base_path: "/wip".into(),
|
||||||
|
access: AccessMode::ReadOnly,
|
||||||
|
transport_security: TransportSecurity::ReverseProxyTls,
|
||||||
|
limits: ResourceLimits::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if !self.listen.ip().is_loopback() {
|
||||||
|
return Err(ConfigError::NonLoopbackCleartext);
|
||||||
|
}
|
||||||
|
if !valid_base_path(&self.endpoint_base_path) {
|
||||||
|
return Err(ConfigError::InvalidBasePath);
|
||||||
|
}
|
||||||
|
if self.limits.max_file_read_bytes == 0
|
||||||
|
|| self.limits.max_file_write_bytes == 0
|
||||||
|
|| self.limits.max_tree_nodes == 0
|
||||||
|
|| self.limits.max_open_handles == 0
|
||||||
|
|| self.limits.max_http_header_bytes == 0
|
||||||
|
|| self.limits.max_concurrent_requests == 0
|
||||||
|
|| self.limits.request_timeout.is_zero()
|
||||||
|
{
|
||||||
|
return Err(ConfigError::ZeroLimit);
|
||||||
|
}
|
||||||
|
if self.limits.max_open_handles < self.limits.max_tree_nodes as usize {
|
||||||
|
return Err(ConfigError::InsufficientHandles);
|
||||||
|
}
|
||||||
|
if self.limits.wire.max_response_bytes() < 512 {
|
||||||
|
return Err(ConfigError::ResponseLimitTooSmall);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn endpoint(&self) -> Result<wip_https::Endpoint, ConfigError> {
|
||||||
|
let endpoint = format!("https://localhost{}", self.endpoint_base_path);
|
||||||
|
wip_https::Endpoint::parse(&endpoint).map_err(|_| ConfigError::InvalidBasePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalid server configuration.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
/// The cleartext built-in listener was exposed beyond loopback.
|
||||||
|
NonLoopbackCleartext,
|
||||||
|
/// The endpoint prefix is not an absolute canonical URL path.
|
||||||
|
InvalidBasePath,
|
||||||
|
/// A resource limit is zero.
|
||||||
|
ZeroLimit,
|
||||||
|
/// Tree nodes could exhaust the configured handle ceiling by construction.
|
||||||
|
InsufficientHandles,
|
||||||
|
/// The response limit cannot reliably carry a bounded protocol error envelope.
|
||||||
|
ResponseLimitTooSmall,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(match self {
|
||||||
|
Self::NonLoopbackCleartext => "the built-in cleartext listener must bind to loopback",
|
||||||
|
Self::InvalidBasePath => {
|
||||||
|
"endpoint base path must be absolute, canonical, and query-free"
|
||||||
|
}
|
||||||
|
Self::ZeroLimit => "all resource limits and the request timeout must be nonzero",
|
||||||
|
Self::InsufficientHandles => "max_open_handles must cover max_tree_nodes",
|
||||||
|
Self::ResponseLimitTooSmall => "max_response_bytes must be at least 512",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
|
fn valid_base_path(path: &str) -> bool {
|
||||||
|
path.starts_with('/')
|
||||||
|
&& !path.contains('?')
|
||||||
|
&& !path.contains('#')
|
||||||
|
&& !path.contains("//")
|
||||||
|
&& !path
|
||||||
|
.split('/')
|
||||||
|
.any(|segment| segment == "." || segment == "..")
|
||||||
|
&& (path == "/" || !path.ends_with('/'))
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut root: Option<PathBuf> = None;
|
||||||
|
let mut listen: SocketAddr = "127.0.0.1:8443".parse()?;
|
||||||
|
let mut base_path = "/wip".to_owned();
|
||||||
|
let mut access = AccessMode::ReadOnly;
|
||||||
|
let mut security = None;
|
||||||
|
let mut arguments = std::env::args().skip(1);
|
||||||
|
while let Some(argument) = arguments.next() {
|
||||||
|
match argument.as_str() {
|
||||||
|
"--root" => root = Some(PathBuf::from(required_value(&mut arguments, "--root")?)),
|
||||||
|
"--listen" => listen = required_value(&mut arguments, "--listen")?.parse()?,
|
||||||
|
"--base-path" => base_path = required_value(&mut arguments, "--base-path")?,
|
||||||
|
"--read-write" => access = AccessMode::ReadWrite,
|
||||||
|
"--development-http" => {
|
||||||
|
set_security(&mut security, TransportSecurity::DevelopmentHttp)?
|
||||||
|
}
|
||||||
|
"--behind-tls-proxy" => {
|
||||||
|
set_security(&mut security, TransportSecurity::ReverseProxyTls)?
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
unknown => return Err(format!("unknown argument `{unknown}`; use --help").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = root.ok_or("--root is required")?;
|
||||||
|
let transport_security =
|
||||||
|
security.ok_or("select exactly one of --behind-tls-proxy or --development-http")?;
|
||||||
|
let mut config = ServerConfig::for_root(root);
|
||||||
|
config.listen = listen;
|
||||||
|
config.endpoint_base_path = base_path;
|
||||||
|
config.access = access;
|
||||||
|
config.transport_security = transport_security;
|
||||||
|
|
||||||
|
let server = WipFsServer::new(config)?;
|
||||||
|
let effective_root = server.effective_root().to_owned();
|
||||||
|
let endpoint = server.endpoint().clone();
|
||||||
|
let bound = server.bind()?;
|
||||||
|
eprintln!(
|
||||||
|
"wip-fs-server root={} access={access:?} transport={transport_security:?} endpoint={} listen={}",
|
||||||
|
effective_root.display(),
|
||||||
|
endpoint,
|
||||||
|
bound.local_addr()
|
||||||
|
);
|
||||||
|
bound.serve()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_value(
|
||||||
|
arguments: &mut impl Iterator<Item = String>,
|
||||||
|
option: &str,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
arguments
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("{option} requires a value").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_security(
|
||||||
|
security: &mut Option<TransportSecurity>,
|
||||||
|
value: TransportSecurity,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
if security.replace(value).is_some() {
|
||||||
|
Err("select only one transport-security mode".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
println!(
|
||||||
|
"Usage: wip-fs-server --root PATH [--listen 127.0.0.1:8443] \\\n [--base-path /wip] [--read-write] \\\n (--behind-tls-proxy | --development-http)\n\n\
|
||||||
|
The built-in listener is cleartext and loopback-only. In production, \
|
||||||
|
select --behind-tls-proxy and terminate HTTPS at a trusted reverse proxy."
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,747 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use http::{Request, Response, StatusCode};
|
||||||
|
use wip_host::{ExecutionContext, ObjectProvider};
|
||||||
|
use wip_https::{
|
||||||
|
BatchRequestKind, BatchResponse, BatchResponseItem, BatchSuccess, CodecError, EncodeHttpError,
|
||||||
|
Endpoint, RequestMetadataError, ResourceLimitKind, Route, decode_batch_request,
|
||||||
|
decode_call_operation_metadata, decode_fetch_interface_request, decode_fetch_request,
|
||||||
|
decode_fetch_tree_request, encode_batch_error_response,
|
||||||
|
encode_batch_resource_limit_error_response, encode_batch_response,
|
||||||
|
encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response,
|
||||||
|
encode_fetch_tree_response, encode_protocol_error_response,
|
||||||
|
encode_resource_limit_error_response, validate_http_request,
|
||||||
|
};
|
||||||
|
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||||
|
|
||||||
|
use crate::projection::Projection;
|
||||||
|
use crate::{ConfigError, ServerConfig};
|
||||||
|
|
||||||
|
/// Filesystem projection and framework-neutral standard WIP request adapter.
|
||||||
|
pub struct WipFsServer {
|
||||||
|
config: ServerConfig,
|
||||||
|
endpoint: Endpoint,
|
||||||
|
effective_root: std::path::PathBuf,
|
||||||
|
projection: Arc<Projection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WipFsServer {
|
||||||
|
/// Validates configuration and opens the configured directory capability.
|
||||||
|
pub fn new(config: ServerConfig) -> Result<Self, ServerBuildError> {
|
||||||
|
config.validate()?;
|
||||||
|
let endpoint = config.endpoint()?;
|
||||||
|
let projection = Projection::open(&config.root, config.access, config.limits)?;
|
||||||
|
let effective_root = projection.effective_root()?;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
endpoint,
|
||||||
|
effective_root,
|
||||||
|
projection: Arc::new(projection),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical effective filesystem root opened by the server.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_root(&self) -> &std::path::Path {
|
||||||
|
&self.effective_root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured transport endpoint prefix.
|
||||||
|
#[must_use]
|
||||||
|
pub fn endpoint(&self) -> &Endpoint {
|
||||||
|
&self.endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles one already-buffered HTTP request with the standard `wip-https` codecs.
|
||||||
|
///
|
||||||
|
/// Listener integrations must enforce the same body limit while collecting bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn handle_http(&self, request: Request<Vec<u8>>) -> Response<Vec<u8>> {
|
||||||
|
self.handle_http_with_deadline(request, Instant::now() + self.config.limits.request_timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_http_with_deadline(
|
||||||
|
&self,
|
||||||
|
request: Request<Vec<u8>>,
|
||||||
|
deadline: Instant,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
let Some(route) = self.route(request.uri().path()) else {
|
||||||
|
return empty_response(StatusCode::NOT_FOUND);
|
||||||
|
};
|
||||||
|
if let Err(error) = validate_http_request(&self.endpoint, route, &request) {
|
||||||
|
return self.metadata_error(route, error);
|
||||||
|
}
|
||||||
|
let context = ExecutionContext::new()
|
||||||
|
.with_deadline(deadline)
|
||||||
|
.with_resource_budget(u64::from(self.config.limits.max_tree_nodes));
|
||||||
|
match route {
|
||||||
|
Route::Fetch => self.fetch(request.body(), &context),
|
||||||
|
Route::FetchTree => self.fetch_tree(request.body(), &context),
|
||||||
|
Route::FetchInterface => self.fetch_interface(request.body()),
|
||||||
|
Route::CallOperation => self.call_operation(request.body(), &context),
|
||||||
|
Route::Batch => self.batch(request.body(), &context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds the built-in bounded loopback HTTP listener.
|
||||||
|
///
|
||||||
|
/// The listener is cleartext by design. Configuration must explicitly state
|
||||||
|
/// whether TLS is terminated by a reverse proxy or development HTTP is in use.
|
||||||
|
pub fn bind(self) -> Result<BoundServer, ServerBuildError> {
|
||||||
|
let listener = TcpListener::bind(self.config.listen)
|
||||||
|
.map_err(|error| ServerBuildError::Listener(error.to_string()))?;
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.map_err(|error| ServerBuildError::Listener(error.to_string()))?;
|
||||||
|
let local_addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|error| ServerBuildError::Listener(error.to_string()))?;
|
||||||
|
Ok(BoundServer {
|
||||||
|
local_addr,
|
||||||
|
service: Arc::new(self),
|
||||||
|
listener: Arc::new(listener),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(&self, path: &str) -> Option<Route> {
|
||||||
|
[
|
||||||
|
Route::Fetch,
|
||||||
|
Route::FetchTree,
|
||||||
|
Route::FetchInterface,
|
||||||
|
Route::CallOperation,
|
||||||
|
Route::Batch,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|route| self.endpoint.route(*route).path() == path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||||
|
let request = match decode_fetch_request(body, self.config.limits.wire) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::Fetch, error),
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.projection
|
||||||
|
.fetch_with_context(context, &request.path)
|
||||||
|
.map_err(|error| error.protocol_error())
|
||||||
|
{
|
||||||
|
Ok(object) => encode_fetch_response(&request, &object, self.config.limits.wire)
|
||||||
|
.unwrap_or_else(|error| self.read_encode_error(ProtocolInteraction::Fetch, error)),
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::Fetch, &error, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_tree(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||||
|
let request = match decode_fetch_tree_request(body, self.config.limits.wire) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::FetchTree, error),
|
||||||
|
};
|
||||||
|
match self.projection.fetch_tree(context, &request) {
|
||||||
|
Ok(tree) => encode_fetch_tree_response(&request, &tree, self.config.limits.wire)
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
self.read_encode_error(ProtocolInteraction::FetchTree, error)
|
||||||
|
}),
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::FetchTree, &error, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_interface(&self, body: &[u8]) -> Response<Vec<u8>> {
|
||||||
|
let request = match decode_fetch_interface_request(body, self.config.limits.wire) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::FetchInterface, error),
|
||||||
|
};
|
||||||
|
match self.projection.fetch_interface(&request) {
|
||||||
|
Ok(interface) => {
|
||||||
|
encode_fetch_interface_response(&request, &interface, self.config.limits.wire)
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
self.read_encode_error(ProtocolInteraction::FetchInterface, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::FetchInterface, &error, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_operation(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||||
|
let metadata = match decode_call_operation_metadata(body, self.config.limits.wire) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) => return self.codec_error(ProtocolInteraction::CallOperation, error),
|
||||||
|
};
|
||||||
|
let may_commit = metadata.operation == "replace";
|
||||||
|
let decoded = std::sync::Mutex::new(None);
|
||||||
|
let execution = self.projection.call_operation_with_context_and_decoder(
|
||||||
|
context,
|
||||||
|
metadata.target.clone(),
|
||||||
|
metadata.interface.clone(),
|
||||||
|
metadata.operation.clone(),
|
||||||
|
|descriptor| {
|
||||||
|
let request = metadata.decode_request(body, descriptor, self.config.limits.wire)?;
|
||||||
|
let arguments = request.arguments.clone();
|
||||||
|
*decoded.lock().unwrap_or_else(|error| error.into_inner()) =
|
||||||
|
Some((request, descriptor.clone()));
|
||||||
|
Ok::<_, CodecError>(arguments)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match execution.map_err(|error| error.protocol_error()) {
|
||||||
|
Ok(result) => {
|
||||||
|
let Some((request, descriptor)) = decoded
|
||||||
|
.into_inner()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
else {
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: if may_commit {
|
||||||
|
ProtocolErrorCode::OperationOutcomeUnknown
|
||||||
|
} else {
|
||||||
|
ProtocolErrorCode::Internal
|
||||||
|
},
|
||||||
|
message: "operation completed without response metadata".into(),
|
||||||
|
};
|
||||||
|
return self.protocol_error(ProtocolInteraction::CallOperation, &error, None);
|
||||||
|
};
|
||||||
|
encode_call_operation_response(
|
||||||
|
&request,
|
||||||
|
&descriptor,
|
||||||
|
&result,
|
||||||
|
self.config.limits.wire,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|encode_error| {
|
||||||
|
if !may_commit {
|
||||||
|
return self
|
||||||
|
.read_encode_error(ProtocolInteraction::CallOperation, encode_error);
|
||||||
|
}
|
||||||
|
// Replacement may already be committed, so a response
|
||||||
|
// encoding failure cannot carry a non-commit guarantee.
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::OperationOutcomeUnknown,
|
||||||
|
message: "operation completed but its response could not be encoded".into(),
|
||||||
|
};
|
||||||
|
self.protocol_error(ProtocolInteraction::CallOperation, &error, None)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => self.protocol_error(ProtocolInteraction::CallOperation, &error, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch(&self, body: &[u8], context: &ExecutionContext) -> Response<Vec<u8>> {
|
||||||
|
let request = match decode_batch_request(body, self.config.limits.wire) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(error) => return self.batch_codec_error(error),
|
||||||
|
};
|
||||||
|
let responses = request
|
||||||
|
.requests
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let outcome = match &item.request {
|
||||||
|
BatchRequestKind::Fetch(request) => self
|
||||||
|
.projection
|
||||||
|
.fetch_with_context(context, &request.path)
|
||||||
|
.map(BatchSuccess::Fetch)
|
||||||
|
.map_err(|error| error.protocol_error()),
|
||||||
|
BatchRequestKind::FetchTree(request) => self
|
||||||
|
.projection
|
||||||
|
.fetch_tree(context, request)
|
||||||
|
.map(BatchSuccess::FetchTree),
|
||||||
|
BatchRequestKind::FetchInterface(request) => self
|
||||||
|
.projection
|
||||||
|
.fetch_interface(request)
|
||||||
|
.map(BatchSuccess::FetchInterface),
|
||||||
|
BatchRequestKind::Invalid { error, .. } => Err(error.clone()),
|
||||||
|
};
|
||||||
|
BatchResponseItem {
|
||||||
|
id: item.id.clone(),
|
||||||
|
outcome,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let response = BatchResponse { responses };
|
||||||
|
encode_batch_response(&request, &response, self.config.limits.wire).unwrap_or_else(
|
||||||
|
|error| match error {
|
||||||
|
EncodeHttpError::Codec(codec) => self.batch_codec_error(codec),
|
||||||
|
EncodeHttpError::Http(_) => self.batch_top_error(&internal_error(), None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_error(&self, route: Route, error: RequestMetadataError) -> Response<Vec<u8>> {
|
||||||
|
match error {
|
||||||
|
RequestMetadataError::InvalidRequest(_) => self.protocol_error(
|
||||||
|
interaction(route),
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: "invalid HTTP request metadata".into(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
RequestMetadataError::UnsupportedMediaType => {
|
||||||
|
empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
}
|
||||||
|
RequestMetadataError::NotAcceptable => empty_response(StatusCode::NOT_ACCEPTABLE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_error(
|
||||||
|
&self,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: CodecError,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
if let Some(kind) = error.resource_limit_kind() {
|
||||||
|
self.protocol_error(interaction, &resource_error(), Some(kind))
|
||||||
|
} else {
|
||||||
|
self.protocol_error(
|
||||||
|
interaction,
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: "malformed WIP request".into(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_codec_error(&self, error: CodecError) -> Response<Vec<u8>> {
|
||||||
|
if let Some(kind) = error.resource_limit_kind() {
|
||||||
|
self.batch_top_error(&resource_error(), Some(kind))
|
||||||
|
} else {
|
||||||
|
self.batch_top_error(
|
||||||
|
&ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: "malformed WIP batch request".into(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_encode_error(
|
||||||
|
&self,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: EncodeHttpError,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
match error {
|
||||||
|
EncodeHttpError::Codec(codec) if codec.resource_limit_kind().is_some() => {
|
||||||
|
self.protocol_error(interaction, &resource_error(), codec.resource_limit_kind())
|
||||||
|
}
|
||||||
|
_ => self.protocol_error(interaction, &internal_error(), None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_error(
|
||||||
|
&self,
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: &ProtocolError,
|
||||||
|
resource_kind: Option<ResourceLimitKind>,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
let encoded = match resource_kind {
|
||||||
|
Some(kind) => encode_resource_limit_error_response(
|
||||||
|
interaction,
|
||||||
|
error,
|
||||||
|
kind,
|
||||||
|
self.config.limits.wire,
|
||||||
|
),
|
||||||
|
None => encode_protocol_error_response(interaction, error, self.config.limits.wire),
|
||||||
|
};
|
||||||
|
encoded.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_top_error(
|
||||||
|
&self,
|
||||||
|
error: &ProtocolError,
|
||||||
|
resource_kind: Option<ResourceLimitKind>,
|
||||||
|
) -> Response<Vec<u8>> {
|
||||||
|
let encoded = match resource_kind {
|
||||||
|
Some(kind) => {
|
||||||
|
encode_batch_resource_limit_error_response(error, kind, self.config.limits.wire)
|
||||||
|
}
|
||||||
|
None => encode_batch_error_response(error, self.config.limits.wire),
|
||||||
|
};
|
||||||
|
encoded.unwrap_or_else(|_| empty_response(StatusCode::INTERNAL_SERVER_ERROR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound real HTTP listener for a [`WipFsServer`].
|
||||||
|
pub struct BoundServer {
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
service: Arc<WipFsServer>,
|
||||||
|
listener: Arc<TcpListener>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundServer {
|
||||||
|
/// Returns the actual bound loopback address, including an assigned port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn local_addr(&self) -> SocketAddr {
|
||||||
|
self.local_addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves requests with a fixed worker count until `shutdown` becomes true.
|
||||||
|
///
|
||||||
|
/// Workers accept connections directly, so at most
|
||||||
|
/// `max_concurrent_requests` sockets are active in userspace. Each connection
|
||||||
|
/// is closed after one request and shares one absolute deadline across HTTP
|
||||||
|
/// intake, dispatch, and response transmission.
|
||||||
|
pub fn serve_until(&self, shutdown: Arc<AtomicBool>) -> Result<(), std::io::Error> {
|
||||||
|
let mut workers = Vec::with_capacity(self.service.config.limits.max_concurrent_requests);
|
||||||
|
for _ in 0..self.service.config.limits.max_concurrent_requests {
|
||||||
|
let listener = Arc::clone(&self.listener);
|
||||||
|
let service = Arc::clone(&self.service);
|
||||||
|
let shutdown = Arc::clone(&shutdown);
|
||||||
|
workers.push(thread::spawn(move || {
|
||||||
|
while !shutdown.load(Ordering::Acquire) {
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((stream, _)) => serve_connection(&service, stream),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for worker in workers {
|
||||||
|
worker
|
||||||
|
.join()
|
||||||
|
.map_err(|_| std::io::Error::other("HTTP worker panicked"))??;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves until the process is terminated.
|
||||||
|
pub fn serve(&self) -> Result<(), std::io::Error> {
|
||||||
|
self.serve_until(Arc::new(AtomicBool::new(false)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ParsedRequestHead {
|
||||||
|
method: http::Method,
|
||||||
|
target: String,
|
||||||
|
headers: Vec<(http::header::HeaderName, http::HeaderValue)>,
|
||||||
|
content_length: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum IntakeError {
|
||||||
|
Timeout,
|
||||||
|
HeaderTooLarge,
|
||||||
|
PayloadTooLarge { target: String },
|
||||||
|
BadRequest,
|
||||||
|
Socket,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_connection(service: &WipFsServer, mut stream: TcpStream) {
|
||||||
|
let _ = stream.set_nonblocking(false);
|
||||||
|
let started = Instant::now();
|
||||||
|
let deadline = started
|
||||||
|
.checked_add(service.config.limits.request_timeout)
|
||||||
|
.unwrap_or(started);
|
||||||
|
let response = match read_http_request(
|
||||||
|
&mut stream,
|
||||||
|
service.config.limits.max_http_header_bytes,
|
||||||
|
service.config.limits.wire.max_request_bytes(),
|
||||||
|
deadline,
|
||||||
|
) {
|
||||||
|
Ok(request) => Some(service.handle_http_with_deadline(request, deadline)),
|
||||||
|
Err(IntakeError::HeaderTooLarge) => {
|
||||||
|
Some(empty_response(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE))
|
||||||
|
}
|
||||||
|
Err(IntakeError::PayloadTooLarge { target }) => {
|
||||||
|
Some(payload_too_large_response(service, &target))
|
||||||
|
}
|
||||||
|
Err(IntakeError::BadRequest) => Some(empty_response(StatusCode::BAD_REQUEST)),
|
||||||
|
Err(IntakeError::Timeout | IntakeError::Socket) => None,
|
||||||
|
};
|
||||||
|
if let Some(response) = response {
|
||||||
|
let _ = write_http_response(&mut stream, &response, deadline);
|
||||||
|
}
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_http_request(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
maximum_header: usize,
|
||||||
|
maximum_body: usize,
|
||||||
|
deadline: Instant,
|
||||||
|
) -> Result<Request<Vec<u8>>, IntakeError> {
|
||||||
|
let mut received = Vec::with_capacity(maximum_header.min(8 * 1024));
|
||||||
|
let header_end = loop {
|
||||||
|
if let Some(position) = received.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||||
|
let end = position + 4;
|
||||||
|
if end > maximum_header {
|
||||||
|
return Err(IntakeError::HeaderTooLarge);
|
||||||
|
}
|
||||||
|
break end;
|
||||||
|
}
|
||||||
|
if received.len() >= maximum_header {
|
||||||
|
return Err(IntakeError::HeaderTooLarge);
|
||||||
|
}
|
||||||
|
let mut chunk = [0_u8; 4 * 1024];
|
||||||
|
let available = (maximum_header - received.len()).min(chunk.len());
|
||||||
|
let count = read_with_deadline(stream, &mut chunk[..available], deadline)?;
|
||||||
|
if count == 0 {
|
||||||
|
return Err(IntakeError::BadRequest);
|
||||||
|
}
|
||||||
|
received.extend_from_slice(&chunk[..count]);
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed = parse_request_head(&received[..header_end])?;
|
||||||
|
if parsed.content_length > maximum_body {
|
||||||
|
return Err(IntakeError::PayloadTooLarge {
|
||||||
|
target: parsed.target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut body = Vec::with_capacity(parsed.content_length);
|
||||||
|
let buffered = &received[header_end..];
|
||||||
|
body.extend_from_slice(&buffered[..buffered.len().min(parsed.content_length)]);
|
||||||
|
while body.len() < parsed.content_length {
|
||||||
|
let mut chunk = [0_u8; 8 * 1024];
|
||||||
|
let wanted = (parsed.content_length - body.len()).min(chunk.len());
|
||||||
|
let count = read_with_deadline(stream, &mut chunk[..wanted], deadline)?;
|
||||||
|
if count == 0 {
|
||||||
|
return Err(IntakeError::BadRequest);
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk[..count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = Request::builder().method(parsed.method).uri(parsed.target);
|
||||||
|
for (name, value) in parsed.headers {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
}
|
||||||
|
builder.body(body).map_err(|_| IntakeError::BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_request_head(bytes: &[u8]) -> Result<ParsedRequestHead, IntakeError> {
|
||||||
|
let text = std::str::from_utf8(bytes).map_err(|_| IntakeError::BadRequest)?;
|
||||||
|
let mut lines = text
|
||||||
|
.strip_suffix("\r\n\r\n")
|
||||||
|
.ok_or(IntakeError::BadRequest)?
|
||||||
|
.split("\r\n");
|
||||||
|
let mut request_line = lines
|
||||||
|
.next()
|
||||||
|
.ok_or(IntakeError::BadRequest)?
|
||||||
|
.split_ascii_whitespace();
|
||||||
|
let method = request_line.next().ok_or(IntakeError::BadRequest)?;
|
||||||
|
let target = request_line.next().ok_or(IntakeError::BadRequest)?;
|
||||||
|
let version = request_line.next().ok_or(IntakeError::BadRequest)?;
|
||||||
|
if request_line.next().is_some()
|
||||||
|
|| !matches!(version, "HTTP/1.0" | "HTTP/1.1")
|
||||||
|
|| !target.starts_with('/')
|
||||||
|
{
|
||||||
|
return Err(IntakeError::BadRequest);
|
||||||
|
}
|
||||||
|
let method =
|
||||||
|
http::Method::from_bytes(method.as_bytes()).map_err(|_| IntakeError::BadRequest)?;
|
||||||
|
let _: http::Uri = target.parse().map_err(|_| IntakeError::BadRequest)?;
|
||||||
|
|
||||||
|
let mut headers = Vec::new();
|
||||||
|
let mut content_length = None;
|
||||||
|
for line in lines {
|
||||||
|
if line.is_empty() || line.starts_with([' ', '\t']) {
|
||||||
|
return Err(IntakeError::BadRequest);
|
||||||
|
}
|
||||||
|
let (name, value) = line.split_once(':').ok_or(IntakeError::BadRequest)?;
|
||||||
|
let name = http::header::HeaderName::from_bytes(name.as_bytes())
|
||||||
|
.map_err(|_| IntakeError::BadRequest)?;
|
||||||
|
let value =
|
||||||
|
http::HeaderValue::from_str(value.trim()).map_err(|_| IntakeError::BadRequest)?;
|
||||||
|
if name == http::header::CONTENT_LENGTH {
|
||||||
|
if content_length.is_some() {
|
||||||
|
return Err(IntakeError::BadRequest);
|
||||||
|
}
|
||||||
|
content_length = Some(
|
||||||
|
value
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
|
.ok_or(IntakeError::BadRequest)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if name == http::header::TRANSFER_ENCODING {
|
||||||
|
return Err(IntakeError::BadRequest);
|
||||||
|
}
|
||||||
|
headers.push((name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedRequestHead {
|
||||||
|
method,
|
||||||
|
target: target.to_owned(),
|
||||||
|
headers,
|
||||||
|
content_length: content_length.unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_with_deadline(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
buffer: &mut [u8],
|
||||||
|
deadline: Instant,
|
||||||
|
) -> Result<usize, IntakeError> {
|
||||||
|
let remaining = deadline
|
||||||
|
.checked_duration_since(Instant::now())
|
||||||
|
.ok_or(IntakeError::Timeout)?;
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(remaining.max(Duration::from_millis(1))))
|
||||||
|
.map_err(|_| IntakeError::Socket)?;
|
||||||
|
stream.read(buffer).map_err(|error| match error.kind() {
|
||||||
|
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => IntakeError::Timeout,
|
||||||
|
_ => IntakeError::Socket,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_too_large_response(service: &WipFsServer, target: &str) -> Response<Vec<u8>> {
|
||||||
|
let route = service.route(target.split('?').next().unwrap_or_default());
|
||||||
|
route.map_or_else(
|
||||||
|
|| empty_response(StatusCode::PAYLOAD_TOO_LARGE),
|
||||||
|
|route| {
|
||||||
|
if route == Route::Batch {
|
||||||
|
service.batch_top_error(
|
||||||
|
&resource_error(),
|
||||||
|
Some(ResourceLimitKind::RepresentationBytes),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
service.protocol_error(
|
||||||
|
interaction(route),
|
||||||
|
&resource_error(),
|
||||||
|
Some(ResourceLimitKind::RepresentationBytes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_http_response(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
response: &Response<Vec<u8>>,
|
||||||
|
deadline: Instant,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
write!(
|
||||||
|
encoded,
|
||||||
|
"HTTP/1.1 {} {}\r\n",
|
||||||
|
response.status().as_u16(),
|
||||||
|
response.status().canonical_reason().unwrap_or("")
|
||||||
|
)?;
|
||||||
|
for (name, value) in response.headers() {
|
||||||
|
if name == http::header::CONTENT_LENGTH
|
||||||
|
|| name == http::header::CONNECTION
|
||||||
|
|| name == http::header::TRANSFER_ENCODING
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
encoded.extend_from_slice(name.as_str().as_bytes());
|
||||||
|
encoded.extend_from_slice(b": ");
|
||||||
|
encoded.extend_from_slice(value.as_bytes());
|
||||||
|
encoded.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
write!(
|
||||||
|
encoded,
|
||||||
|
"Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
response.body().len()
|
||||||
|
)?;
|
||||||
|
encoded.extend_from_slice(response.body());
|
||||||
|
write_all_with_deadline(stream, &encoded, deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_all_with_deadline(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
mut bytes: &[u8],
|
||||||
|
deadline: Instant,
|
||||||
|
) -> Result<(), std::io::Error> {
|
||||||
|
while !bytes.is_empty() {
|
||||||
|
let remaining = deadline
|
||||||
|
.checked_duration_since(Instant::now())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::TimedOut, "request timed out")
|
||||||
|
})?;
|
||||||
|
stream.set_write_timeout(Some(remaining.max(Duration::from_millis(1))))?;
|
||||||
|
match stream.write(bytes) {
|
||||||
|
Ok(0) => {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WriteZero,
|
||||||
|
"socket closed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(count) => bytes = &bytes[count..],
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interaction(route: Route) -> ProtocolInteraction {
|
||||||
|
match route {
|
||||||
|
Route::Fetch => ProtocolInteraction::Fetch,
|
||||||
|
Route::FetchTree => ProtocolInteraction::FetchTree,
|
||||||
|
Route::FetchInterface => ProtocolInteraction::FetchInterface,
|
||||||
|
Route::CallOperation => ProtocolInteraction::CallOperation,
|
||||||
|
Route::Batch => ProtocolInteraction::Fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_error() -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
message: "configured resource limit exceeded".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error() -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "filesystem server failed".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_response(status: StatusCode) -> Response<Vec<u8>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(Vec::new())
|
||||||
|
.expect("static status builds an HTTP response")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure while validating, opening, or binding the server.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ServerBuildError {
|
||||||
|
/// Configuration is unsafe or unbounded.
|
||||||
|
Config(ConfigError),
|
||||||
|
/// The root capability could not be opened.
|
||||||
|
Io(std::io::Error),
|
||||||
|
/// The listener could not be bound.
|
||||||
|
Listener(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ServerBuildError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Config(error) => write!(formatter, "invalid server configuration: {error}"),
|
||||||
|
Self::Io(error) => write!(formatter, "filesystem root could not be opened: {error}"),
|
||||||
|
Self::Listener(error) => write!(formatter, "HTTP listener could not be bound: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ServerBuildError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Config(error) => Some(error),
|
||||||
|
Self::Io(error) => Some(error),
|
||||||
|
Self::Listener(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConfigError> for ServerBuildError {
|
||||||
|
fn from(value: ConfigError) -> Self {
|
||||||
|
Self::Config(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ServerBuildError {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
|
||||||
|
use wip_https::{
|
||||||
|
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchSuccess, DecodedResponse,
|
||||||
|
Endpoint, JSON_CONTENT_TYPE, Limits, Route, decode_batch_request, decode_batch_response,
|
||||||
|
decode_call_operation_response, decode_fetch_interface_response, decode_fetch_response,
|
||||||
|
decode_fetch_tree_response, encode_batch_request, encode_call_operation_request,
|
||||||
|
encode_fetch_interface_request, encode_fetch_request, encode_fetch_tree_request,
|
||||||
|
};
|
||||||
|
use wip_protocol::{
|
||||||
|
CallOperationRequest, FetchInterfaceRequest, FetchRequest, FetchTreeRequest, InterfaceTarget,
|
||||||
|
ProtocolErrorCode, Target, Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_listener_serves_four_core_interactions_batch_and_write() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file");
|
||||||
|
std::fs::create_dir(temporary.path().join("nested")).expect("create directory");
|
||||||
|
|
||||||
|
let mut config = ServerConfig::for_root(temporary.path());
|
||||||
|
config.access = AccessMode::ReadWrite;
|
||||||
|
config.transport_security = TransportSecurity::DevelopmentHttp;
|
||||||
|
let limits = config.limits.wire;
|
||||||
|
let bound = Arc::new(
|
||||||
|
WipFsServer::new(config)
|
||||||
|
.expect("build server")
|
||||||
|
.bind()
|
||||||
|
.expect("bind"),
|
||||||
|
);
|
||||||
|
let address = bound.local_addr();
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let worker = {
|
||||||
|
let bound = Arc::clone(&bound);
|
||||||
|
let shutdown = Arc::clone(&shutdown);
|
||||||
|
thread::spawn(move || bound.serve_until(shutdown))
|
||||||
|
};
|
||||||
|
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
|
||||||
|
.expect("client endpoint");
|
||||||
|
|
||||||
|
let file_request = FetchRequest {
|
||||||
|
path: "/hello.txt".into(),
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_fetch_request(&endpoint, &file_request, limits).expect("encode fetch"),
|
||||||
|
);
|
||||||
|
let file =
|
||||||
|
success(decode_fetch_response(&file_request, &response, limits).expect("decode fetch"));
|
||||||
|
assert_eq!(file.name, "hello.txt");
|
||||||
|
assert_eq!(file.interfaces.len(), 2);
|
||||||
|
assert!(file.validator.is_some());
|
||||||
|
|
||||||
|
let tree_request = FetchTreeRequest {
|
||||||
|
path: "/".into(),
|
||||||
|
depth: 1,
|
||||||
|
limit: 10,
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_fetch_tree_request(&endpoint, &tree_request, limits).expect("encode tree"),
|
||||||
|
);
|
||||||
|
let tree =
|
||||||
|
success(decode_fetch_tree_response(&tree_request, &response, limits).expect("decode tree"));
|
||||||
|
assert!(!tree.truncated);
|
||||||
|
assert_eq!(tree.root.children.len(), 2);
|
||||||
|
|
||||||
|
let read_interface_request = FetchInterfaceRequest {
|
||||||
|
interface: file.interfaces[0].clone(),
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_fetch_interface_request(&endpoint, &read_interface_request, limits)
|
||||||
|
.expect("encode interface fetch"),
|
||||||
|
);
|
||||||
|
let read_interface = success(
|
||||||
|
decode_fetch_interface_response(&read_interface_request, &response, limits)
|
||||||
|
.expect("decode interface"),
|
||||||
|
);
|
||||||
|
assert_eq!(read_interface.descriptor.operations[0].name, "read");
|
||||||
|
|
||||||
|
let read_request = CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/hello.txt".into(),
|
||||||
|
validator: file.validator.clone(),
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference: file.interfaces[0].clone(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::from([
|
||||||
|
("offset".into(), Value::Integer(6)),
|
||||||
|
("length".into(), Value::Integer(5)),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_call_operation_request(&endpoint, &read_request, &read_interface.descriptor, limits)
|
||||||
|
.expect("encode read call"),
|
||||||
|
);
|
||||||
|
let read = success(
|
||||||
|
decode_call_operation_response(
|
||||||
|
&read_request,
|
||||||
|
&read_interface.descriptor,
|
||||||
|
&response,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
.expect("decode read call"),
|
||||||
|
);
|
||||||
|
assert_eq!(read.result, Value::Bytes(b"world".to_vec()));
|
||||||
|
|
||||||
|
let batch_request = BatchRequest {
|
||||||
|
requests: vec![
|
||||||
|
BatchRequestItem {
|
||||||
|
id: "root".into(),
|
||||||
|
request: BatchRequestKind::Fetch(FetchRequest { path: "/".into() }),
|
||||||
|
},
|
||||||
|
BatchRequestItem {
|
||||||
|
id: "tree".into(),
|
||||||
|
request: BatchRequestKind::FetchTree(FetchTreeRequest {
|
||||||
|
path: "/nested".into(),
|
||||||
|
depth: 1,
|
||||||
|
limit: 4,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
BatchRequestItem {
|
||||||
|
id: "interface".into(),
|
||||||
|
request: BatchRequestKind::FetchInterface(read_interface_request.clone()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_batch_request(&endpoint, &batch_request, limits).expect("encode batch"),
|
||||||
|
);
|
||||||
|
let batch =
|
||||||
|
success(decode_batch_response(&batch_request, &response, limits).expect("decode batch"));
|
||||||
|
assert_batch_successes(&batch);
|
||||||
|
|
||||||
|
let write_interface_request = FetchInterfaceRequest {
|
||||||
|
interface: file.interfaces[1].clone(),
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_fetch_interface_request(&endpoint, &write_interface_request, limits)
|
||||||
|
.expect("encode write interface fetch"),
|
||||||
|
);
|
||||||
|
let write_interface = success(
|
||||||
|
decode_fetch_interface_response(&write_interface_request, &response, limits)
|
||||||
|
.expect("decode write interface"),
|
||||||
|
);
|
||||||
|
let write_request = CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/hello.txt".into(),
|
||||||
|
validator: file.validator,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference: file.interfaces[1].clone(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "replace".into(),
|
||||||
|
arguments: BTreeMap::from([("contents".into(), Value::Bytes(b"updated".to_vec()))]),
|
||||||
|
};
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_call_operation_request(
|
||||||
|
&endpoint,
|
||||||
|
&write_request,
|
||||||
|
&write_interface.descriptor,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
.expect("encode write call"),
|
||||||
|
);
|
||||||
|
let write = success(
|
||||||
|
decode_call_operation_response(
|
||||||
|
&write_request,
|
||||||
|
&write_interface.descriptor,
|
||||||
|
&response,
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
.expect("decode write call"),
|
||||||
|
);
|
||||||
|
assert!(write.validator.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(temporary.path().join("hello.txt")).expect("read updated file"),
|
||||||
|
b"updated"
|
||||||
|
);
|
||||||
|
|
||||||
|
shutdown.store(true, Ordering::Release);
|
||||||
|
worker
|
||||||
|
.join()
|
||||||
|
.expect("join listener")
|
||||||
|
.expect("listener exits cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stalled_request_body_times_out_and_releases_the_only_worker() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
let mut config = ServerConfig::for_root(temporary.path());
|
||||||
|
config.transport_security = TransportSecurity::DevelopmentHttp;
|
||||||
|
config.limits.max_concurrent_requests = 1;
|
||||||
|
config.limits.request_timeout = Duration::from_millis(200);
|
||||||
|
let limits = config.limits.wire;
|
||||||
|
let bound = Arc::new(
|
||||||
|
WipFsServer::new(config)
|
||||||
|
.expect("build server")
|
||||||
|
.bind()
|
||||||
|
.expect("bind"),
|
||||||
|
);
|
||||||
|
let address = bound.local_addr();
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let worker = {
|
||||||
|
let bound = Arc::clone(&bound);
|
||||||
|
let shutdown = Arc::clone(&shutdown);
|
||||||
|
thread::spawn(move || bound.serve_until(shutdown))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stalled = TcpStream::connect(address).expect("connect stalled client");
|
||||||
|
stalled
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||||
|
.expect("set stalled read timeout");
|
||||||
|
write!(
|
||||||
|
stalled,
|
||||||
|
"POST /wip/v1/fetch HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: 100\r\n\r\n{{"
|
||||||
|
)
|
||||||
|
.expect("write partial request");
|
||||||
|
stalled.flush().expect("flush partial request");
|
||||||
|
thread::sleep(Duration::from_millis(25));
|
||||||
|
|
||||||
|
let endpoint = Endpoint::parse(&format!("https://localhost:{}/wip", address.port()))
|
||||||
|
.expect("client endpoint");
|
||||||
|
let fetch = FetchRequest { path: "/".into() };
|
||||||
|
let started = Instant::now();
|
||||||
|
let response = exchange(
|
||||||
|
address,
|
||||||
|
encode_fetch_request(&endpoint, &fetch, limits).expect("encode fetch"),
|
||||||
|
);
|
||||||
|
let _root = success(decode_fetch_response(&fetch, &response, limits).expect("decode fetch"));
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(1),
|
||||||
|
"the valid request remained starved after the intake deadline"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut byte = [0_u8; 1];
|
||||||
|
match stalled.read(&mut byte) {
|
||||||
|
Ok(0) => {}
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
error.kind(),
|
||||||
|
std::io::ErrorKind::ConnectionReset
|
||||||
|
| std::io::ErrorKind::ConnectionAborted
|
||||||
|
| std::io::ErrorKind::BrokenPipe
|
||||||
|
) => {}
|
||||||
|
outcome => panic!("stalled connection was not closed after its deadline: {outcome:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown.store(true, Ordering::Release);
|
||||||
|
worker
|
||||||
|
.join()
|
||||||
|
.expect("join listener")
|
||||||
|
.expect("listener exits cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_keeps_known_malformed_arguments_item_local() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
let config = ServerConfig::for_root(temporary.path());
|
||||||
|
let limits = config.limits.wire;
|
||||||
|
let server = WipFsServer::new(config).expect("build server");
|
||||||
|
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||||
|
let body = br#"{"requests":[{"id":"ok","method":"fetch","arguments":{"path":"/"}},{"id":"bad","method":"fetch_tree","arguments":{"path":"/","depth":"bad","limit":1}}]}"#;
|
||||||
|
let logical = decode_batch_request(body, limits).expect("decode item-local invalid request");
|
||||||
|
assert!(matches!(
|
||||||
|
logical.requests[1].request,
|
||||||
|
BatchRequestKind::Invalid { .. }
|
||||||
|
));
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(http::Method::POST)
|
||||||
|
.uri(endpoint.route(Route::Batch).as_str())
|
||||||
|
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||||
|
.header(http::header::ACCEPT, "application/json")
|
||||||
|
.body(body.to_vec())
|
||||||
|
.expect("build request");
|
||||||
|
let response = server.handle_http(request);
|
||||||
|
let batch = success(
|
||||||
|
decode_batch_response(&logical, &response, limits).expect("decode mixed batch response"),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
batch.responses[0].outcome,
|
||||||
|
Ok(BatchSuccess::Fetch(_))
|
||||||
|
));
|
||||||
|
assert!(batch.responses[1].outcome.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_argument_decoding_does_not_mask_missing_target() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
let config = ServerConfig::for_root(temporary.path());
|
||||||
|
let server = WipFsServer::new(config).expect("build server");
|
||||||
|
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||||
|
let body = br#"{"target":{"path":"/missing"},"interface":{"reference":"unknown"},"operation":"missing","arguments":{"bytes":"not-base64"}}"#;
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(http::Method::POST)
|
||||||
|
.uri(endpoint.route(Route::CallOperation).as_str())
|
||||||
|
.header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||||
|
.header(http::header::ACCEPT, "application/json")
|
||||||
|
.body(body.to_vec())
|
||||||
|
.expect("build request");
|
||||||
|
let response = server.handle_http(request);
|
||||||
|
assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
|
||||||
|
assert!(
|
||||||
|
std::str::from_utf8(response.body())
|
||||||
|
.expect("UTF-8 error")
|
||||||
|
.contains("\"not_found\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_invalid_unicode_path_is_not_found_not_internal() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
let config = ServerConfig::for_root(temporary.path());
|
||||||
|
let limits = config.limits.wire;
|
||||||
|
let server = WipFsServer::new(config).expect("build server");
|
||||||
|
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||||
|
let request = FetchRequest {
|
||||||
|
path: "/bad\0name".into(),
|
||||||
|
};
|
||||||
|
let response = server
|
||||||
|
.handle_http(encode_fetch_request(&endpoint, &request, limits).expect("encode fetch"));
|
||||||
|
assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
|
||||||
|
match decode_fetch_response(&request, &response, limits).expect("decode missing response") {
|
||||||
|
DecodedResponse::ProtocolFailure { error, .. } => {
|
||||||
|
assert_eq!(error.code, ProtocolErrorCode::NotFound);
|
||||||
|
}
|
||||||
|
DecodedResponse::Success(_) => panic!("backend-invalid path must not resolve"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_response_size_failure_preserves_non_commit_guarantee() {
|
||||||
|
let temporary = tempdir().expect("temporary root");
|
||||||
|
std::fs::write(temporary.path().join("large"), vec![b'x'; 2_000]).expect("write file");
|
||||||
|
let mut config = ServerConfig::for_root(temporary.path());
|
||||||
|
config.limits.wire = Limits::new(4_096, 1_024, 64, 64).expect("wire limits");
|
||||||
|
let limits = config.limits.wire;
|
||||||
|
let server = WipFsServer::new(config).expect("build server");
|
||||||
|
let endpoint = Endpoint::parse("https://localhost/wip").expect("endpoint");
|
||||||
|
|
||||||
|
let fetch_request = FetchRequest {
|
||||||
|
path: "/large".into(),
|
||||||
|
};
|
||||||
|
let response = server.handle_http(
|
||||||
|
encode_fetch_request(&endpoint, &fetch_request, limits).expect("encode fetch"),
|
||||||
|
);
|
||||||
|
let file =
|
||||||
|
success(decode_fetch_response(&fetch_request, &response, limits).expect("decode fetch"));
|
||||||
|
let interface_request = FetchInterfaceRequest {
|
||||||
|
interface: file.interfaces[0].clone(),
|
||||||
|
};
|
||||||
|
let response = server.handle_http(
|
||||||
|
encode_fetch_interface_request(&endpoint, &interface_request, limits)
|
||||||
|
.expect("encode interface fetch"),
|
||||||
|
);
|
||||||
|
let interface = success(
|
||||||
|
decode_fetch_interface_response(&interface_request, &response, limits)
|
||||||
|
.expect("decode interface"),
|
||||||
|
);
|
||||||
|
let call = CallOperationRequest {
|
||||||
|
target: Target {
|
||||||
|
path: "/large".into(),
|
||||||
|
validator: file.validator,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference: file.interfaces[0].clone(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::from([("length".into(), Value::Integer(1_500))]),
|
||||||
|
};
|
||||||
|
let response = server.handle_http(
|
||||||
|
encode_call_operation_request(&endpoint, &call, &interface.descriptor, limits)
|
||||||
|
.expect("encode read call"),
|
||||||
|
);
|
||||||
|
assert_eq!(response.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
|
||||||
|
match decode_call_operation_response(&call, &interface.descriptor, &response, limits)
|
||||||
|
.expect("decode resource failure")
|
||||||
|
{
|
||||||
|
DecodedResponse::ProtocolFailure { error, .. } => {
|
||||||
|
assert_eq!(error.code, ProtocolErrorCode::ResourceLimitExceeded);
|
||||||
|
}
|
||||||
|
DecodedResponse::Success(_) => panic!("oversized read response must fail"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_batch_successes(batch: &BatchResponse) {
|
||||||
|
assert_eq!(batch.responses.len(), 3);
|
||||||
|
assert!(matches!(
|
||||||
|
batch.responses[0].outcome,
|
||||||
|
Ok(BatchSuccess::Fetch(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
batch.responses[1].outcome,
|
||||||
|
Ok(BatchSuccess::FetchTree(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
batch.responses[2].outcome,
|
||||||
|
Ok(BatchSuccess::FetchInterface(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn success<T>(response: DecodedResponse<T>) -> T {
|
||||||
|
match response {
|
||||||
|
DecodedResponse::Success(value) => value,
|
||||||
|
DecodedResponse::ProtocolFailure { error, .. } => panic!("protocol failure: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange(address: SocketAddr, request: http::Request<Vec<u8>>) -> http::Response<Vec<u8>> {
|
||||||
|
let mut stream = TcpStream::connect(address).expect("connect listener");
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.expect("set read timeout");
|
||||||
|
let path = request
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map_or("/", |value| value.as_str());
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"{} {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n",
|
||||||
|
request.method(),
|
||||||
|
path
|
||||||
|
)
|
||||||
|
.expect("write request line");
|
||||||
|
for (name, value) in request.headers() {
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"{}: {}\r\n",
|
||||||
|
name,
|
||||||
|
value.to_str().expect("ASCII request header")
|
||||||
|
)
|
||||||
|
.expect("write header");
|
||||||
|
}
|
||||||
|
write!(stream, "Content-Length: {}\r\n\r\n", request.body().len())
|
||||||
|
.expect("write content length");
|
||||||
|
stream.write_all(request.body()).expect("write body");
|
||||||
|
stream.flush().expect("flush request");
|
||||||
|
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
stream.read_to_end(&mut raw).expect("read response");
|
||||||
|
let split = raw
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.expect("HTTP response header");
|
||||||
|
let head = std::str::from_utf8(&raw[..split]).expect("UTF-8 response head");
|
||||||
|
let mut lines = head.split("\r\n");
|
||||||
|
let status = lines
|
||||||
|
.next()
|
||||||
|
.and_then(|line| line.split_whitespace().nth(1))
|
||||||
|
.and_then(|status| status.parse::<u16>().ok())
|
||||||
|
.expect("response status");
|
||||||
|
let mut builder = http::Response::builder().status(status);
|
||||||
|
for line in lines {
|
||||||
|
let (name, value) = line.split_once(':').expect("response header");
|
||||||
|
builder = builder.header(name, value.trim());
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.body(raw[split + 4..].to_vec())
|
||||||
|
.expect("build response")
|
||||||
|
}
|
||||||
+115
-28
@@ -535,11 +535,36 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
request: CallRequest,
|
request: CallRequest,
|
||||||
authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool,
|
authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool,
|
||||||
|
) -> OperationAttempt {
|
||||||
|
let CallRequest {
|
||||||
|
target,
|
||||||
|
interface,
|
||||||
|
operation,
|
||||||
|
arguments,
|
||||||
|
} = request;
|
||||||
|
self.dispatch_resolved_with_decoder(
|
||||||
|
context,
|
||||||
|
target,
|
||||||
|
interface,
|
||||||
|
operation,
|
||||||
|
|_| Ok::<_, std::convert::Infallible>(arguments),
|
||||||
|
authorize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_resolved_with_decoder<E>(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
target: Target,
|
||||||
|
interface: InterfaceTarget,
|
||||||
|
operation: String,
|
||||||
|
decode_arguments: impl FnOnce(&InterfaceDescriptor) -> Result<BTreeMap<String, Value>, E>,
|
||||||
|
authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool,
|
||||||
) -> OperationAttempt {
|
) -> OperationAttempt {
|
||||||
let pre_dispatch_failure =
|
let pre_dispatch_failure =
|
||||||
|kind, message: &str| OperationAttempt::NotStarted(CallError::new(kind, message));
|
|kind, message: &str| OperationAttempt::NotStarted(CallError::new(kind, message));
|
||||||
let current_object_validator = self.implementation.validator();
|
let current_object_validator = self.implementation.validator();
|
||||||
if let Some(supplied) = request.target.validator.as_deref()
|
if let Some(supplied) = target.validator.as_deref()
|
||||||
&& current_object_validator.map(Validator::as_bytes) != Some(supplied)
|
&& current_object_validator.map(Validator::as_bytes) != Some(supplied)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
@@ -548,9 +573,11 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(bound) = self.interfaces.iter().find(|bound| {
|
let Some(bound) = self
|
||||||
bound.artifact.reference.as_str() == request.interface.reference.as_str()
|
.interfaces
|
||||||
}) else {
|
.iter()
|
||||||
|
.find(|bound| bound.artifact.reference.as_str() == interface.reference.as_str())
|
||||||
|
else {
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::InterfaceMismatch,
|
CallErrorKind::InterfaceMismatch,
|
||||||
"interface is not a member of the current object publication",
|
"interface is not a member of the current object publication",
|
||||||
@@ -559,7 +586,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
|
|
||||||
match (
|
match (
|
||||||
bound.artifact.validator.as_ref(),
|
bound.artifact.validator.as_ref(),
|
||||||
request.interface.validator.as_deref(),
|
interface.validator.as_deref(),
|
||||||
) {
|
) {
|
||||||
(Some(_), None) => {
|
(Some(_), None) => {
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
@@ -581,18 +608,18 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
.descriptor
|
.descriptor
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
.any(|operation| operation.name == request.operation)
|
.any(|declared| declared.name == operation)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::OperationNotFound,
|
CallErrorKind::OperationNotFound,
|
||||||
&format!("operation `{}` is not declared", request.operation),
|
&format!("operation `{operation}` is not declared"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.target.validator.is_none()
|
if target.validator.is_none()
|
||||||
&& self
|
&& self
|
||||||
.implementation
|
.implementation
|
||||||
.requires_validator(&bound.artifact.reference, &request.operation)
|
.requires_validator(&bound.artifact.reference, &operation)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::ValidatorRequired,
|
CallErrorKind::ValidatorRequired,
|
||||||
@@ -600,19 +627,24 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let arguments = match decode_arguments(&bound.artifact.descriptor) {
|
||||||
|
Ok(arguments) => arguments,
|
||||||
|
Err(_) => {
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InvalidArguments,
|
||||||
|
"operation arguments could not be decoded",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Err(error) = bound
|
if let Err(error) = bound
|
||||||
.artifact
|
.artifact
|
||||||
.descriptor
|
.descriptor
|
||||||
.validate_arguments(&request.operation, &request.arguments)
|
.validate_arguments(&operation, &arguments)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(CallErrorKind::InvalidArguments, &error.to_string());
|
return pre_dispatch_failure(CallErrorKind::InvalidArguments, &error.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authorize(
|
if !authorize(&self.implementation, &bound.artifact.reference, &operation) {
|
||||||
&self.implementation,
|
|
||||||
&bound.artifact.reference,
|
|
||||||
&request.operation,
|
|
||||||
) {
|
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::PermissionDenied,
|
CallErrorKind::PermissionDenied,
|
||||||
"operation is not permitted for this caller",
|
"operation is not permitted for this caller",
|
||||||
@@ -623,23 +655,22 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
return OperationAttempt::NotStarted(error);
|
return OperationAttempt::NotStarted(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let value =
|
let value = match (bound.dispatch)(&self.implementation, &operation, arguments) {
|
||||||
match (bound.dispatch)(&self.implementation, &request.operation, request.arguments) {
|
Ok(value) => value,
|
||||||
Ok(value) => value,
|
Err(
|
||||||
Err(
|
error @ (DispatchError::UnknownOperation(_)
|
||||||
error @ (DispatchError::UnknownOperation(_)
|
| DispatchError::InvalidArguments(_)
|
||||||
| DispatchError::InvalidArguments(_)
|
| DispatchError::Decode(_)),
|
||||||
| DispatchError::Decode(_)),
|
) => return OperationAttempt::NotStarted(CallError::from_pre_dispatch(error)),
|
||||||
) => return OperationAttempt::NotStarted(CallError::from_pre_dispatch(error)),
|
Err(error) => {
|
||||||
Err(error) => {
|
return OperationAttempt::Started(Err(OperationFailure::from_dispatch(error)));
|
||||||
return OperationAttempt::Started(Err(OperationFailure::from_dispatch(error)));
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if bound
|
if bound
|
||||||
.artifact
|
.artifact
|
||||||
.descriptor
|
.descriptor
|
||||||
.validate_result(&request.operation, &value)
|
.validate_result(&operation, &value)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return OperationAttempt::Started(Err(OperationFailure::internal(
|
return OperationAttempt::Started(Err(OperationFailure::internal(
|
||||||
@@ -922,6 +953,62 @@ pub trait ObjectProvider {
|
|||||||
ProviderResult::Failure(error) => Err(CallError::from_host_failure(error)),
|
ProviderResult::Failure(error) => Err(CallError::from_host_failure(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves one operation target and decodes its arguments only after all
|
||||||
|
/// descriptor-independent canonical preconditions have succeeded.
|
||||||
|
///
|
||||||
|
/// Transport bindings whose value representation depends on the selected
|
||||||
|
/// interface descriptor should use this method instead of resolving a
|
||||||
|
/// descriptor before [`ObjectProvider::call_operation_with_context`]. The
|
||||||
|
/// callback executes inside the same exact-once provider boundary after
|
||||||
|
/// object/interface validators, membership, operation lookup, and required
|
||||||
|
/// object-validator checks. A callback error becomes `InvalidArguments` and
|
||||||
|
/// typed implementation code is not invoked.
|
||||||
|
fn call_operation_with_context_and_decoder<E>(
|
||||||
|
&self,
|
||||||
|
context: &ExecutionContext,
|
||||||
|
target: Target,
|
||||||
|
interface: InterfaceTarget,
|
||||||
|
operation: String,
|
||||||
|
decode_arguments: impl FnOnce(&InterfaceDescriptor) -> Result<BTreeMap<String, Value>, E>,
|
||||||
|
) -> Result<CallResult, CallError> {
|
||||||
|
wip_protocol::validate_path(&target.path)
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
let path = target.path.clone();
|
||||||
|
let execution = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
self.execute_operation(context, &path, |publication| {
|
||||||
|
publication.dispatch_resolved_with_decoder(
|
||||||
|
context,
|
||||||
|
target,
|
||||||
|
interface,
|
||||||
|
operation,
|
||||||
|
decode_arguments,
|
||||||
|
|object, interface, operation| {
|
||||||
|
self.authorize(&path, object, interface, operation)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
let execution = execution.map_err(|_| {
|
||||||
|
CallError::from_operation_failure(OperationFailure::internal(
|
||||||
|
EffectGuarantee::Unknown,
|
||||||
|
"provider execution panicked",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
match execution {
|
||||||
|
ProviderResult::Found(OperationAttempt::NotStarted(error)) => Err(error),
|
||||||
|
ProviderResult::Found(OperationAttempt::Started(Ok(result))) => Ok(result),
|
||||||
|
ProviderResult::Found(OperationAttempt::Started(Err(error))) => {
|
||||||
|
Err(CallError::from_operation_failure(error))
|
||||||
|
}
|
||||||
|
ProviderResult::NotFound => Err(CallError::new(
|
||||||
|
CallErrorKind::NotFound,
|
||||||
|
"target path is not published",
|
||||||
|
)),
|
||||||
|
ProviderResult::Failure(error) => Err(CallError::from_host_failure(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure to construct an explicit object publication.
|
/// Failure to construct an explicit object publication.
|
||||||
|
|||||||
@@ -741,6 +741,89 @@ fn call_failures_follow_canonical_precedence_without_execution() {
|
|||||||
assert_eq!(provider.calls.get(), 0);
|
assert_eq!(provider.calls.get(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptor_guided_decoder_runs_inside_canonical_resolution_boundary() {
|
||||||
|
let (_, provider, hidden) = registry_and_provider();
|
||||||
|
let decodes = Cell::new(0_u32);
|
||||||
|
let decode_failure = || {
|
||||||
|
decodes.set(decodes.get() + 1);
|
||||||
|
Err::<BTreeMap<String, Value>, _>(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let missing = provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
Target {
|
||||||
|
path: "/missing".into(),
|
||||||
|
validator: Some(b"stale".to_vec()),
|
||||||
|
},
|
||||||
|
InterfaceTarget {
|
||||||
|
reference: hidden.reference().as_str().into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
"missing".into(),
|
||||||
|
|_| decode_failure(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(missing.kind(), CallErrorKind::NotFound);
|
||||||
|
assert_eq!(decodes.get(), 0);
|
||||||
|
|
||||||
|
let stale = provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
Target {
|
||||||
|
path: "/document".into(),
|
||||||
|
validator: Some(b"stale".to_vec()),
|
||||||
|
},
|
||||||
|
InterfaceTarget {
|
||||||
|
reference: hidden.reference().as_str().into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
"hidden".into(),
|
||||||
|
|_| decode_failure(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(stale.kind(), CallErrorKind::ValidatorMismatch);
|
||||||
|
assert_eq!(decodes.get(), 0);
|
||||||
|
|
||||||
|
let mismatch = provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
Target {
|
||||||
|
path: "/document".into(),
|
||||||
|
validator: Some(b"object-v1".to_vec()),
|
||||||
|
},
|
||||||
|
InterfaceTarget {
|
||||||
|
reference: hidden.reference().as_str().into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
"hidden".into(),
|
||||||
|
|_| decode_failure(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(mismatch.kind(), CallErrorKind::InterfaceMismatch);
|
||||||
|
assert_eq!(decodes.get(), 0);
|
||||||
|
|
||||||
|
let invalid_arguments = provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
Target {
|
||||||
|
path: "/document".into(),
|
||||||
|
validator: Some(b"object-v1".to_vec()),
|
||||||
|
},
|
||||||
|
InterfaceTarget {
|
||||||
|
reference: provider.reader.reference().as_str().into(),
|
||||||
|
validator: Some(b"schema-v1".to_vec()),
|
||||||
|
},
|
||||||
|
"add".into(),
|
||||||
|
|_| decode_failure(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(invalid_arguments.kind(), CallErrorKind::InvalidArguments);
|
||||||
|
assert_eq!(decodes.get(), 1);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn immutable_interface_rejects_a_supplied_validator() {
|
fn immutable_interface_rejects_a_supplied_validator() {
|
||||||
let (_, provider, _) = registry_and_provider();
|
let (_, provider, _) = registry_and_provider();
|
||||||
|
|||||||
+133
-44
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use http::header::CONTENT_TYPE;
|
use http::header::{ACCEPT, CONTENT_TYPE};
|
||||||
use http::{Method, Request, Response, StatusCode};
|
use http::{Method, Request, Response, StatusCode};
|
||||||
use serde_json::{Map, Value as Json};
|
use serde_json::{Map, Value as Json};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
@@ -11,9 +11,38 @@ use wip_protocol::{
|
|||||||
use crate::response::{self, DecodedResponse};
|
use crate::response::{self, DecodedResponse};
|
||||||
use crate::{
|
use crate::{
|
||||||
BodyKind, ClientResponseError, CodecError, EncodeHttpError, Endpoint, InvalidResponseKind,
|
BodyKind, ClientResponseError, CodecError, EncodeHttpError, Endpoint, InvalidResponseKind,
|
||||||
JSON_CONTENT_TYPE, Limits, Route, is_json_content_type, json,
|
JSON_CONTENT_TYPE, Limits, ResourceLimitKind, Route, is_json_content_type, json,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A retrieval method accepted by the standard v1 batch endpoint.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BatchMethod {
|
||||||
|
/// Fetch one object.
|
||||||
|
Fetch,
|
||||||
|
/// Fetch one bounded tree.
|
||||||
|
FetchTree,
|
||||||
|
/// Fetch one interface descriptor.
|
||||||
|
FetchInterface,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BatchMethod {
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Fetch => "fetch",
|
||||||
|
Self::FetchTree => "fetch_tree",
|
||||||
|
Self::FetchInterface => "fetch_interface",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interaction(self) -> ProtocolInteraction {
|
||||||
|
match self {
|
||||||
|
Self::Fetch => ProtocolInteraction::Fetch,
|
||||||
|
Self::FetchTree => ProtocolInteraction::FetchTree,
|
||||||
|
Self::FetchInterface => ProtocolInteraction::FetchInterface,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One retrieval interaction accepted by the standard v1 batch endpoint.
|
/// One retrieval interaction accepted by the standard v1 batch endpoint.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum BatchRequestKind {
|
pub enum BatchRequestKind {
|
||||||
@@ -23,6 +52,13 @@ pub enum BatchRequestKind {
|
|||||||
FetchTree(FetchTreeRequest),
|
FetchTree(FetchTreeRequest),
|
||||||
/// Fetch one interface descriptor.
|
/// Fetch one interface descriptor.
|
||||||
FetchInterface(FetchInterfaceRequest),
|
FetchInterface(FetchInterfaceRequest),
|
||||||
|
/// A known method whose `arguments` are not a valid logical request.
|
||||||
|
Invalid {
|
||||||
|
/// Method that was successfully identified from the item envelope.
|
||||||
|
method: BatchMethod,
|
||||||
|
/// Item-local `invalid_request` to return without rejecting other items.
|
||||||
|
error: ProtocolError,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BatchRequestKind {
|
impl BatchRequestKind {
|
||||||
@@ -31,6 +67,7 @@ impl BatchRequestKind {
|
|||||||
Self::Fetch(_) => ProtocolInteraction::Fetch,
|
Self::Fetch(_) => ProtocolInteraction::Fetch,
|
||||||
Self::FetchTree(_) => ProtocolInteraction::FetchTree,
|
Self::FetchTree(_) => ProtocolInteraction::FetchTree,
|
||||||
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
|
Self::FetchInterface(_) => ProtocolInteraction::FetchInterface,
|
||||||
|
Self::Invalid { method, .. } => method.interaction(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +133,7 @@ fn validate_ids<'a>(ids: impl Iterator<Item = &'a str>, maximum: usize) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_item_to_json(item: &BatchRequestItem) -> Json {
|
fn request_item_to_json(item: &BatchRequestItem) -> Result<Json, CodecError> {
|
||||||
let (method, arguments) = match &item.request {
|
let (method, arguments) = match &item.request {
|
||||||
BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)),
|
BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)),
|
||||||
BatchRequestKind::FetchTree(value) => {
|
BatchRequestKind::FetchTree(value) => {
|
||||||
@@ -106,8 +143,14 @@ fn request_item_to_json(item: &BatchRequestItem) -> Json {
|
|||||||
"fetch_interface",
|
"fetch_interface",
|
||||||
json::fetch_interface_request_to_json(value),
|
json::fetch_interface_request_to_json(value),
|
||||||
),
|
),
|
||||||
|
BatchRequestKind::Invalid { .. } => {
|
||||||
|
return Err(CodecError::InvalidField {
|
||||||
|
field: "requests".into(),
|
||||||
|
reason: "item-local invalid requests cannot be encoded as client requests".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
serde_json::json!({"id": item.id, "method": method, "arguments": arguments})
|
Ok(serde_json::json!({"id": item.id, "method": method, "arguments": arguments}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, CodecError> {
|
fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, CodecError> {
|
||||||
@@ -119,12 +162,15 @@ fn request_to_json(request: &BatchRequest, limits: Limits) -> Result<Json, Codec
|
|||||||
match &item.request {
|
match &item.request {
|
||||||
BatchRequestKind::Fetch(value) => value.validate()?,
|
BatchRequestKind::Fetch(value) => value.validate()?,
|
||||||
BatchRequestKind::FetchTree(value) => value.validate()?,
|
BatchRequestKind::FetchTree(value) => value.validate()?,
|
||||||
BatchRequestKind::FetchInterface(_) => {}
|
BatchRequestKind::FetchInterface(_) | BatchRequestKind::Invalid { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(serde_json::json!({
|
let requests = request
|
||||||
"requests": request.requests.iter().map(request_item_to_json).collect::<Vec<_>>()
|
.requests
|
||||||
}))
|
.iter()
|
||||||
|
.map(request_item_to_json)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(serde_json::json!({"requests": requests}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, CodecError> {
|
fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, CodecError> {
|
||||||
@@ -141,16 +187,12 @@ fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, Codec
|
|||||||
let field = format!("requests[{index}]");
|
let field = format!("requests[{index}]");
|
||||||
let fields = exact_object(value, &field, &["id", "method", "arguments"])?;
|
let fields = exact_object(value, &field, &["id", "method", "arguments"])?;
|
||||||
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
|
let id = string(required(fields, "id")?, &format!("{field}.id"))?;
|
||||||
let method = string(required(fields, "method")?, &format!("{field}.method"))?;
|
let method_name = string(required(fields, "method")?, &format!("{field}.method"))?;
|
||||||
let arguments = required(fields, "arguments")?;
|
let arguments = required(fields, "arguments")?;
|
||||||
let request = match method.as_str() {
|
let method = match method_name.as_str() {
|
||||||
"fetch" => BatchRequestKind::Fetch(json::fetch_request_from_json(arguments)?),
|
"fetch" => BatchMethod::Fetch,
|
||||||
"fetch_tree" => {
|
"fetch_tree" => BatchMethod::FetchTree,
|
||||||
BatchRequestKind::FetchTree(json::fetch_tree_request_from_json(arguments)?)
|
"fetch_interface" => BatchMethod::FetchInterface,
|
||||||
}
|
|
||||||
"fetch_interface" => BatchRequestKind::FetchInterface(
|
|
||||||
json::fetch_interface_request_from_json(arguments)?,
|
|
||||||
),
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CodecError::InvalidField {
|
return Err(CodecError::InvalidField {
|
||||||
field: format!("{field}.method"),
|
field: format!("{field}.method"),
|
||||||
@@ -158,6 +200,23 @@ fn request_from_json(value: &Json, limits: Limits) -> Result<BatchRequest, Codec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let decoded = match method {
|
||||||
|
BatchMethod::Fetch => {
|
||||||
|
json::fetch_request_from_json(arguments).map(BatchRequestKind::Fetch)
|
||||||
|
}
|
||||||
|
BatchMethod::FetchTree => {
|
||||||
|
json::fetch_tree_request_from_json(arguments).map(BatchRequestKind::FetchTree)
|
||||||
|
}
|
||||||
|
BatchMethod::FetchInterface => json::fetch_interface_request_from_json(arguments)
|
||||||
|
.map(BatchRequestKind::FetchInterface),
|
||||||
|
};
|
||||||
|
let request = decoded.unwrap_or_else(|error| BatchRequestKind::Invalid {
|
||||||
|
method,
|
||||||
|
error: ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: format!("invalid {} arguments: {error}", method.name()),
|
||||||
|
},
|
||||||
|
});
|
||||||
requests.push(BatchRequestItem { id, request });
|
requests.push(BatchRequestItem { id, request });
|
||||||
}
|
}
|
||||||
validate_ids(
|
validate_ids(
|
||||||
@@ -178,6 +237,7 @@ pub fn encode_batch_request(
|
|||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.uri(endpoint.route(Route::Batch).as_str())
|
.uri(endpoint.route(Route::Batch).as_str())
|
||||||
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
.body(body)?)
|
.body(body)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,33 +298,29 @@ fn correlate(
|
|||||||
id: item.id.clone(),
|
id: item.id.clone(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
match &item.outcome {
|
match (&item.outcome, *kind) {
|
||||||
Ok(BatchSuccess::Fetch(value)) if matches!(kind, BatchRequestKind::Fetch(_)) => {
|
(Ok(BatchSuccess::Fetch(value)), BatchRequestKind::Fetch(request)) => {
|
||||||
if let BatchRequestKind::Fetch(request) = kind {
|
request.validate_response(value)?;
|
||||||
request.validate_response(value)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(BatchSuccess::FetchTree(value))
|
(Ok(BatchSuccess::FetchTree(value)), BatchRequestKind::FetchTree(request)) => {
|
||||||
if matches!(kind, BatchRequestKind::FetchTree(_)) =>
|
value.validate_for(request)?;
|
||||||
{
|
|
||||||
if let BatchRequestKind::FetchTree(request) = kind {
|
|
||||||
value.validate_for(request)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(BatchSuccess::FetchInterface(value))
|
(
|
||||||
if matches!(kind, BatchRequestKind::FetchInterface(_)) =>
|
Ok(BatchSuccess::FetchInterface(value)),
|
||||||
{
|
BatchRequestKind::FetchInterface(request),
|
||||||
if let BatchRequestKind::FetchInterface(request) = kind {
|
) => {
|
||||||
value.validate_for(request)?;
|
value.validate_for(request)?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
(
|
||||||
return Err(CodecError::BatchInteractionMismatch {
|
Err(error),
|
||||||
id: item.id.clone(),
|
BatchRequestKind::Invalid {
|
||||||
});
|
error: expected, ..
|
||||||
}
|
},
|
||||||
Err(error) if error.code.is_allowed_for(kind.interaction()) => {}
|
) if error == expected => {}
|
||||||
Err(_) => {
|
(Err(error), kind)
|
||||||
|
if !matches!(kind, BatchRequestKind::Invalid { .. })
|
||||||
|
&& error.code.is_allowed_for(kind.interaction()) => {}
|
||||||
|
_ => {
|
||||||
return Err(CodecError::BatchInteractionMismatch {
|
return Err(CodecError::BatchInteractionMismatch {
|
||||||
id: item.id.clone(),
|
id: item.id.clone(),
|
||||||
});
|
});
|
||||||
@@ -301,7 +357,10 @@ pub fn encode_batch_response(
|
|||||||
/// Encodes a top-level batch-envelope failure.
|
/// Encodes a top-level batch-envelope failure.
|
||||||
///
|
///
|
||||||
/// Item-local failures belong in [`BatchResponseItem`]. Only failures that can
|
/// Item-local failures belong in [`BatchResponseItem`]. Only failures that can
|
||||||
/// reject the batch envelope before item execution are accepted here.
|
/// reject the batch envelope before item execution are accepted here. A
|
||||||
|
/// resource-limit failure defaults to the logical-limit status; use
|
||||||
|
/// [`encode_batch_resource_limit_error_response`] when the concrete class is
|
||||||
|
/// known.
|
||||||
pub fn encode_batch_error_response(
|
pub fn encode_batch_error_response(
|
||||||
error: &ProtocolError,
|
error: &ProtocolError,
|
||||||
limits: Limits,
|
limits: Limits,
|
||||||
@@ -325,6 +384,26 @@ pub fn encode_batch_error_response(
|
|||||||
.body(body)?)
|
.body(body)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes a class-specific top-level batch `resource_limit_exceeded` failure.
|
||||||
|
pub fn encode_batch_resource_limit_error_response(
|
||||||
|
error: &ProtocolError,
|
||||||
|
kind: ResourceLimitKind,
|
||||||
|
limits: Limits,
|
||||||
|
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||||
|
if error.code != ProtocolErrorCode::ResourceLimitExceeded {
|
||||||
|
return Err(CodecError::InvalidField {
|
||||||
|
field: "error.code".into(),
|
||||||
|
reason: "resource-limit encoder requires resource_limit_exceeded".into(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(response::status_for_resource_limit(kind))
|
||||||
|
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||||
|
.body(body)?)
|
||||||
|
}
|
||||||
|
|
||||||
fn response_from_json(
|
fn response_from_json(
|
||||||
value: &Json,
|
value: &Json,
|
||||||
request: &BatchRequest,
|
request: &BatchRequest,
|
||||||
@@ -367,8 +446,15 @@ fn response_from_json(
|
|||||||
}
|
}
|
||||||
(None, Some(error)) => {
|
(None, Some(error)) => {
|
||||||
let error = response::item_error_from_json(error)?;
|
let error = response::item_error_from_json(error)?;
|
||||||
if !error.code.is_allowed_for(kind.interaction()) {
|
match kind {
|
||||||
return Err(CodecError::BatchInteractionMismatch { id });
|
BatchRequestKind::Invalid {
|
||||||
|
error: expected, ..
|
||||||
|
} if error == *expected => {}
|
||||||
|
BatchRequestKind::Invalid { .. } => {
|
||||||
|
return Err(CodecError::BatchInteractionMismatch { id });
|
||||||
|
}
|
||||||
|
kind if error.code.is_allowed_for(kind.interaction()) => {}
|
||||||
|
_ => return Err(CodecError::BatchInteractionMismatch { id }),
|
||||||
}
|
}
|
||||||
Err(error)
|
Err(error)
|
||||||
}
|
}
|
||||||
@@ -388,6 +474,9 @@ fn response_from_json(
|
|||||||
value.validate_for(request)?;
|
value.validate_for(request)?;
|
||||||
BatchSuccess::FetchInterface(value)
|
BatchSuccess::FetchInterface(value)
|
||||||
}
|
}
|
||||||
|
BatchRequestKind::Invalid { .. } => {
|
||||||
|
return Err(CodecError::BatchInteractionMismatch { id });
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
responses.push(BatchResponseItem { id, outcome });
|
responses.push(BatchResponseItem { id, outcome });
|
||||||
|
|||||||
+225
-34
@@ -1,5 +1,8 @@
|
|||||||
use http::header::CONTENT_TYPE;
|
use std::error::Error;
|
||||||
use http::{Method, Request, Response, StatusCode};
|
use std::fmt::{self, Display, Formatter};
|
||||||
|
|
||||||
|
use http::header::{ACCEPT, CONTENT_TYPE};
|
||||||
|
use http::{HeaderMap, Method, Request, Response, StatusCode};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
|
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
|
||||||
FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1,
|
FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1,
|
||||||
@@ -9,7 +12,7 @@ use wip_protocol::{
|
|||||||
use crate::response::{self, DecodedResponse};
|
use crate::response::{self, DecodedResponse};
|
||||||
use crate::{
|
use crate::{
|
||||||
BodyKind, ClientResponseError, CodecError, Endpoint, InvalidResponseKind, JSON_CONTENT_TYPE,
|
BodyKind, ClientResponseError, CodecError, Endpoint, InvalidResponseKind, JSON_CONTENT_TYPE,
|
||||||
Limits, Route, json,
|
Limits, ResourceLimitKind, Route, json,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn request(
|
fn request(
|
||||||
@@ -21,6 +24,7 @@ fn request(
|
|||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.uri(endpoint.route(route).as_str())
|
.uri(endpoint.route(route).as_str())
|
||||||
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
.header(CONTENT_TYPE, JSON_CONTENT_TYPE)
|
||||||
|
.header(ACCEPT, "application/json")
|
||||||
.body(body)
|
.body(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,15 @@ fn require_response_content_type<B>(response: &Response<B>) -> Result<(), Client
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_utf8_charset(value: &str) -> bool {
|
||||||
|
let value = value.trim();
|
||||||
|
value.eq_ignore_ascii_case("utf-8")
|
||||||
|
|| value
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|value| value.strip_suffix('"'))
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case("utf-8"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether a Content-Type value denotes UTF-8 `application/json`.
|
/// Returns whether a Content-Type value denotes UTF-8 `application/json`.
|
||||||
///
|
///
|
||||||
/// The charset parameter is optional because JSON exchanged by this binding is
|
/// The charset parameter is optional because JSON exchanged by this binding is
|
||||||
@@ -68,13 +81,7 @@ pub fn is_json_content_type(value: &str) -> bool {
|
|||||||
let Some((name, value)) = parameter.trim().split_once('=') else {
|
let Some((name, value)) = parameter.trim().split_once('=') else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let charset = value.trim();
|
if !name.trim().eq_ignore_ascii_case("charset") || !is_utf8_charset(value) || saw_charset {
|
||||||
let charset_is_utf8 = charset.eq_ignore_ascii_case("utf-8")
|
|
||||||
|| charset
|
|
||||||
.strip_prefix('"')
|
|
||||||
.and_then(|value| value.strip_suffix('"'))
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("utf-8"));
|
|
||||||
if !name.trim().eq_ignore_ascii_case("charset") || !charset_is_utf8 || saw_charset {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
saw_charset = true;
|
saw_charset = true;
|
||||||
@@ -82,27 +89,166 @@ pub fn is_json_content_type(value: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn accepts_json(headers: &HeaderMap) -> bool {
|
||||||
|
let mut present = false;
|
||||||
|
let mut best_match: Option<((u8, u8), f32)> = None;
|
||||||
|
for value in headers.get_all(ACCEPT) {
|
||||||
|
present = true;
|
||||||
|
let Ok(value) = value.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for range in value.split(',') {
|
||||||
|
let mut parts = range.split(';');
|
||||||
|
let media_range = parts.next().unwrap_or_default().trim();
|
||||||
|
let mut quality = 1.0_f32;
|
||||||
|
let mut saw_quality = false;
|
||||||
|
let mut saw_charset = false;
|
||||||
|
let mut media_parameter_count = 0;
|
||||||
|
let mut valid = true;
|
||||||
|
for parameter in parts {
|
||||||
|
let parameter = parameter.trim();
|
||||||
|
let (name, value) = parameter
|
||||||
|
.split_once('=')
|
||||||
|
.map_or((parameter, None), |(name, value)| {
|
||||||
|
(name.trim(), Some(value))
|
||||||
|
});
|
||||||
|
if name.is_empty() {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if saw_quality {
|
||||||
|
// Parameters after q are Accept extensions and do not
|
||||||
|
// constrain the selected representation.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if name.eq_ignore_ascii_case("q") {
|
||||||
|
let Some(value) = value else {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
saw_quality = true;
|
||||||
|
match value.trim().parse::<f32>() {
|
||||||
|
Ok(value) if (0.0..=1.0).contains(&value) => quality = value,
|
||||||
|
_ => {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let Some(value) = value else {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !name.eq_ignore_ascii_case("charset")
|
||||||
|
|| !is_utf8_charset(value)
|
||||||
|
|| saw_charset
|
||||||
|
{
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
saw_charset = true;
|
||||||
|
media_parameter_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let media_specificity = if media_range.eq_ignore_ascii_case("application/json") {
|
||||||
|
Some(2)
|
||||||
|
} else if media_range.eq_ignore_ascii_case("application/*") {
|
||||||
|
Some(1)
|
||||||
|
} else if media_range == "*/*" {
|
||||||
|
Some(0)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if valid && let Some(media_specificity) = media_specificity {
|
||||||
|
let specificity = (media_specificity, media_parameter_count);
|
||||||
|
match best_match {
|
||||||
|
Some((best_specificity, _)) if best_specificity > specificity => {}
|
||||||
|
Some((best_specificity, best_quality)) if best_specificity == specificity => {
|
||||||
|
best_match = Some((specificity, best_quality.max(quality)));
|
||||||
|
}
|
||||||
|
_ => best_match = Some((specificity, quality)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if present {
|
||||||
|
best_match.is_some_and(|(_, quality)| quality > 0.0)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failure while validating inbound HTTP request metadata.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RequestMetadataError {
|
||||||
|
/// The method or route does not match the selected WIP interaction.
|
||||||
|
InvalidRequest(CodecError),
|
||||||
|
/// The request body does not use UTF-8 `application/json`.
|
||||||
|
UnsupportedMediaType,
|
||||||
|
/// The request's `Accept` metadata excludes `application/json`.
|
||||||
|
NotAcceptable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestMetadataError {
|
||||||
|
/// Returns the HTTP status for this metadata failure.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn status(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
Self::InvalidRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for RequestMetadataError {
|
||||||
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidRequest(error) => error.fmt(formatter),
|
||||||
|
Self::UnsupportedMediaType => {
|
||||||
|
formatter.write_str("expected UTF-8 application/json Content-Type")
|
||||||
|
}
|
||||||
|
Self::NotAcceptable => formatter.write_str("request does not accept application/json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for RequestMetadataError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::InvalidRequest(error) => Some(error),
|
||||||
|
Self::UnsupportedMediaType | Self::NotAcceptable => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates mandatory HTTP metadata for an inbound WIP request.
|
/// Validates mandatory HTTP metadata for an inbound WIP request.
|
||||||
///
|
///
|
||||||
/// Both absolute-form and origin-form request URIs are accepted, but the path
|
/// Both absolute-form and origin-form request URIs are accepted, but the path
|
||||||
/// must exactly equal the selected endpoint route and a query is forbidden.
|
/// must exactly equal the selected endpoint route and a query is forbidden.
|
||||||
|
/// Unsupported content negotiation is reported as a Transport Binding metadata
|
||||||
|
/// failure rather than a Core `invalid_request`.
|
||||||
pub fn validate_http_request<B>(
|
pub fn validate_http_request<B>(
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
route: Route,
|
route: Route,
|
||||||
value: &Request<B>,
|
value: &Request<B>,
|
||||||
) -> Result<(), CodecError> {
|
) -> Result<(), RequestMetadataError> {
|
||||||
if value.method() != Method::POST {
|
if value.method() != Method::POST {
|
||||||
return Err(CodecError::InvalidField {
|
return Err(RequestMetadataError::InvalidRequest(
|
||||||
field: "method".into(),
|
CodecError::InvalidField {
|
||||||
reason: "standard WIP routes require POST".into(),
|
field: "method".into(),
|
||||||
});
|
reason: "standard WIP routes require POST".into(),
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let expected = endpoint.route(route);
|
let expected = endpoint.route(route);
|
||||||
if value.uri().path() != expected.path() || value.uri().query().is_some() {
|
if value.uri().path() != expected.path() || value.uri().query().is_some() {
|
||||||
return Err(CodecError::InvalidField {
|
return Err(RequestMetadataError::InvalidRequest(
|
||||||
field: "route".into(),
|
CodecError::InvalidField {
|
||||||
reason: "request URI does not match the selected standard route".into(),
|
field: "route".into(),
|
||||||
});
|
reason: "request URI does not match the selected standard route".into(),
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let valid_content_type = value
|
let valid_content_type = value
|
||||||
.headers()
|
.headers()
|
||||||
@@ -110,10 +256,10 @@ pub fn validate_http_request<B>(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.is_some_and(is_json_content_type);
|
.is_some_and(is_json_content_type);
|
||||||
if !valid_content_type {
|
if !valid_content_type {
|
||||||
return Err(CodecError::InvalidField {
|
return Err(RequestMetadataError::UnsupportedMediaType);
|
||||||
field: "content-type".into(),
|
}
|
||||||
reason: "expected UTF-8 application/json".into(),
|
if !accepts_json(value.headers()) {
|
||||||
});
|
return Err(RequestMetadataError::NotAcceptable);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -405,17 +551,10 @@ pub fn decode_call_operation_response<B: AsRef<[u8]>>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes a canonical Host error envelope using its standard HTTP status.
|
fn validate_protocol_error(
|
||||||
///
|
|
||||||
/// The code must be allowed for `interaction`. This API accepts only
|
|
||||||
/// [`ProtocolError`], so client-local `InvalidResponse` and
|
|
||||||
/// `UnsupportedDescriptorFormat` classifications cannot be emitted as Host
|
|
||||||
/// error envelopes.
|
|
||||||
pub fn encode_protocol_error_response(
|
|
||||||
interaction: ProtocolInteraction,
|
interaction: ProtocolInteraction,
|
||||||
error: &ProtocolError,
|
error: &ProtocolError,
|
||||||
limits: Limits,
|
) -> Result<(), CodecError> {
|
||||||
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
|
||||||
if !error.code.is_allowed_for(interaction) {
|
if !error.code.is_allowed_for(interaction) {
|
||||||
return Err(CodecError::InvalidField {
|
return Err(CodecError::InvalidField {
|
||||||
field: "error.code".into(),
|
field: "error.code".into(),
|
||||||
@@ -423,11 +562,63 @@ pub fn encode_protocol_error_response(
|
|||||||
"code `{}` is not allowed for {interaction:?}",
|
"code `{}` is not allowed for {interaction:?}",
|
||||||
response::error_code_name(error.code)
|
response::error_code_name(error.code)
|
||||||
),
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_protocol_error_with_status(
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: &ProtocolError,
|
||||||
|
status: StatusCode,
|
||||||
|
limits: Limits,
|
||||||
|
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||||
|
validate_protocol_error(interaction, error)?;
|
||||||
|
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
|
||||||
|
Ok(response(status, body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a canonical Host error envelope using its standard HTTP status.
|
||||||
|
///
|
||||||
|
/// The code must be allowed for `interaction`. This API accepts only
|
||||||
|
/// [`ProtocolError`], so client-local `InvalidResponse` and
|
||||||
|
/// `UnsupportedDescriptorFormat` classifications cannot be emitted as Host
|
||||||
|
/// error envelopes. Resource-limit errors use the logical-limit status; callers
|
||||||
|
/// that know the concrete limit class should use
|
||||||
|
/// [`encode_resource_limit_error_response`].
|
||||||
|
pub fn encode_protocol_error_response(
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: &ProtocolError,
|
||||||
|
limits: Limits,
|
||||||
|
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||||
|
encode_protocol_error_with_status(
|
||||||
|
interaction,
|
||||||
|
error,
|
||||||
|
response::status_for_error(error.code),
|
||||||
|
limits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a `resource_limit_exceeded` envelope with its class-specific status.
|
||||||
|
pub fn encode_resource_limit_error_response(
|
||||||
|
interaction: ProtocolInteraction,
|
||||||
|
error: &ProtocolError,
|
||||||
|
kind: ResourceLimitKind,
|
||||||
|
limits: Limits,
|
||||||
|
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
|
||||||
|
if error.code != wip_protocol::ProtocolErrorCode::ResourceLimitExceeded {
|
||||||
|
return Err(CodecError::InvalidField {
|
||||||
|
field: "error.code".into(),
|
||||||
|
reason: "resource-limit encoder requires resource_limit_exceeded".into(),
|
||||||
}
|
}
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?;
|
encode_protocol_error_with_status(
|
||||||
Ok(response(response::status_for_error(error.code), body)?)
|
interaction,
|
||||||
|
error,
|
||||||
|
response::status_for_resource_limit(kind),
|
||||||
|
limits,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure while building an outbound framework-neutral HTTP message.
|
/// Failure while building an outbound framework-neutral HTTP message.
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ pub enum BodyKind {
|
|||||||
Response,
|
Response,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The class of a resource limit exceeded by a wire representation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResourceLimitKind {
|
||||||
|
/// A request or response representation exceeded its byte-size limit.
|
||||||
|
RepresentationBytes,
|
||||||
|
/// A logical limit such as nesting depth, batch count, or collection size was exceeded.
|
||||||
|
Logical,
|
||||||
|
}
|
||||||
|
|
||||||
/// A malformed or locally disallowed wire representation.
|
/// A malformed or locally disallowed wire representation.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CodecError {
|
pub enum CodecError {
|
||||||
@@ -177,6 +186,20 @@ pub enum CodecError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CodecError {
|
||||||
|
/// Classifies this failure when it represents a canonical resource limit.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn resource_limit_kind(&self) -> Option<ResourceLimitKind> {
|
||||||
|
match self {
|
||||||
|
Self::BodyTooLarge { .. } => Some(ResourceLimitKind::RepresentationBytes),
|
||||||
|
Self::NestingTooDeep { .. } | Self::TooManyBatchItems { .. } => {
|
||||||
|
Some(ResourceLimitKind::Logical)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Display for CodecError {
|
impl Display for CodecError {
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -36,25 +36,27 @@ mod json;
|
|||||||
mod response;
|
mod response;
|
||||||
|
|
||||||
pub use batch::{
|
pub use batch::{
|
||||||
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchResponseItem,
|
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
|
||||||
BatchSuccess, decode_batch_request, decode_batch_response, encode_batch_error_response,
|
BatchResponseItem, BatchSuccess, decode_batch_request, decode_batch_response,
|
||||||
encode_batch_request, encode_batch_response,
|
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
|
||||||
|
encode_batch_response,
|
||||||
};
|
};
|
||||||
pub use codec::{
|
pub use codec::{
|
||||||
CallOperationMetadata, EncodeHttpError, decode_call_operation_metadata,
|
CallOperationMetadata, EncodeHttpError, RequestMetadataError, decode_call_operation_metadata,
|
||||||
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
|
decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request,
|
||||||
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response,
|
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response,
|
||||||
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request,
|
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request,
|
||||||
encode_call_operation_response, encode_fetch_interface_request,
|
encode_call_operation_response, encode_fetch_interface_request,
|
||||||
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
|
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
|
||||||
encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response,
|
encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response,
|
||||||
is_json_content_type, validate_http_request,
|
encode_resource_limit_error_response, is_json_content_type, validate_http_request,
|
||||||
};
|
};
|
||||||
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
|
pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route};
|
||||||
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits};
|
pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits, ResourceLimitKind};
|
||||||
pub use response::{
|
pub use response::{
|
||||||
ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch,
|
ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch,
|
||||||
TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error,
|
TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error,
|
||||||
|
status_for_resource_limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Framework-neutral HTTP types used by this binding's public API.
|
/// Framework-neutral HTTP types used by this binding's public API.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use http::StatusCode;
|
|||||||
use serde_json::{Map, Value as Json};
|
use serde_json::{Map, Value as Json};
|
||||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||||
|
|
||||||
use crate::{BodyKind, CodecError, Limits, json};
|
use crate::{BodyKind, CodecError, Limits, ResourceLimitKind, json};
|
||||||
|
|
||||||
/// A successfully classified client response.
|
/// A successfully classified client response.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -59,6 +59,8 @@ pub enum InvalidResponseKind {
|
|||||||
SuccessBodyOnErrorStatus,
|
SuccessBodyOnErrorStatus,
|
||||||
/// A valid error envelope used a code forbidden for the interaction.
|
/// A valid error envelope used a code forbidden for the interaction.
|
||||||
DisallowedProtocolCode,
|
DisallowedProtocolCode,
|
||||||
|
/// A valid success body used a 2xx status other than canonical HTTP 200.
|
||||||
|
NonCanonicalSuccessStatus,
|
||||||
/// A 2xx body was malformed or semantically invalid.
|
/// A 2xx body was malformed or semantically invalid.
|
||||||
InvalidSuccessBody,
|
InvalidSuccessBody,
|
||||||
/// A batch response did not correlate exactly with its request.
|
/// A batch response did not correlate exactly with its request.
|
||||||
@@ -117,6 +119,10 @@ impl Display for ClientResponseError {
|
|||||||
impl Error for ClientResponseError {}
|
impl Error for ClientResponseError {}
|
||||||
|
|
||||||
/// Returns the canonical HTTP status for a wire-level protocol error.
|
/// Returns the canonical HTTP status for a wire-level protocol error.
|
||||||
|
///
|
||||||
|
/// [`ProtocolErrorCode::ResourceLimitExceeded`] defaults to the logical-limit
|
||||||
|
/// status. Use [`status_for_resource_limit`] when the concrete limit class is
|
||||||
|
/// known.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
|
pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
|
||||||
match code {
|
match code {
|
||||||
@@ -132,13 +138,31 @@ pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
|
|||||||
StatusCode::PRECONDITION_FAILED
|
StatusCode::PRECONDITION_FAILED
|
||||||
}
|
}
|
||||||
ProtocolErrorCode::PermissionDenied => StatusCode::FORBIDDEN,
|
ProtocolErrorCode::PermissionDenied => StatusCode::FORBIDDEN,
|
||||||
ProtocolErrorCode::ResourceLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
|
ProtocolErrorCode::ResourceLimitExceeded => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
ProtocolErrorCode::Internal | ProtocolErrorCode::OperationOutcomeUnknown => {
|
ProtocolErrorCode::Internal | ProtocolErrorCode::OperationOutcomeUnknown => {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical HTTP status for a concrete resource-limit class.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn status_for_resource_limit(kind: ResourceLimitKind) -> StatusCode {
|
||||||
|
match kind {
|
||||||
|
ResourceLimitKind::RepresentationBytes => StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
ResourceLimitKind::Logical => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_matches_error(status: StatusCode, code: ProtocolErrorCode) -> bool {
|
||||||
|
match code {
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded => {
|
||||||
|
status == StatusCode::PAYLOAD_TOO_LARGE || status == StatusCode::UNPROCESSABLE_ENTITY
|
||||||
|
}
|
||||||
|
_ => status == status_for_error(code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the stable snake-case wire spelling of a protocol code.
|
/// Returns the stable snake-case wire spelling of a protocol code.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn error_code_name(code: ProtocolErrorCode) -> &'static str {
|
pub const fn error_code_name(code: ProtocolErrorCode) -> &'static str {
|
||||||
@@ -288,12 +312,14 @@ pub(crate) fn decode<T>(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let expected = status_for_error(error.code);
|
let expected = status_for_error(error.code);
|
||||||
Ok(DecodedResponse::ProtocolFailure {
|
let status_mismatch =
|
||||||
error,
|
(!status_matches_error(status, error.code)).then_some(StatusMismatch {
|
||||||
status_mismatch: (status != expected).then_some(StatusMismatch {
|
|
||||||
actual: status,
|
actual: status,
|
||||||
expected,
|
expected,
|
||||||
}),
|
});
|
||||||
|
Ok(DecodedResponse::ProtocolFailure {
|
||||||
|
error,
|
||||||
|
status_mismatch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(_) if !status.is_success() => Err(transport_failure(status)),
|
Err(_) if !status.is_success() => Err(transport_failure(status)),
|
||||||
@@ -301,16 +327,24 @@ pub(crate) fn decode<T>(
|
|||||||
InvalidResponseKind::InvalidSuccessBody,
|
InvalidResponseKind::InvalidSuccessBody,
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
)),
|
)),
|
||||||
Ok(None) if status.is_success() => {
|
Ok(None) if status == StatusCode::OK => success(&value)
|
||||||
success(&value)
|
.map(DecodedResponse::Success)
|
||||||
.map(DecodedResponse::Success)
|
.map_err(|error| {
|
||||||
.map_err(|error| {
|
ClientResponseError::invalid(
|
||||||
ClientResponseError::invalid(
|
InvalidResponseKind::InvalidSuccessBody,
|
||||||
InvalidResponseKind::InvalidSuccessBody,
|
error.to_string(),
|
||||||
error.to_string(),
|
)
|
||||||
)
|
}),
|
||||||
})
|
Ok(None) if status.is_success() => match success(&value) {
|
||||||
}
|
Ok(_) => Err(ClientResponseError::invalid(
|
||||||
|
InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||||
|
format!("success body carried by non-canonical HTTP {status}"),
|
||||||
|
)),
|
||||||
|
Err(error) => Err(ClientResponseError::invalid(
|
||||||
|
InvalidResponseKind::InvalidSuccessBody,
|
||||||
|
error.to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
Ok(None) => match success(&value) {
|
Ok(None) => match success(&value) {
|
||||||
Ok(_) => Err(ClientResponseError::invalid(
|
Ok(_) => Err(ClientResponseError::invalid(
|
||||||
InvalidResponseKind::SuccessBodyOnErrorStatus,
|
InvalidResponseKind::SuccessBodyOnErrorStatus,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ use wip_https::wip_protocol::{
|
|||||||
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
|
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
|
||||||
};
|
};
|
||||||
use wip_https::{
|
use wip_https::{
|
||||||
BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchResponseItem,
|
BatchMethod, BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse,
|
||||||
BatchSuccess, ClientResponseError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
BatchResponseItem, BatchSuccess, ClientResponseError, DecodedResponse, Endpoint,
|
||||||
decode_batch_request, decode_batch_response, encode_batch_error_response, encode_batch_request,
|
InvalidResponseKind, Limits, ResourceLimitKind, decode_batch_request, decode_batch_response,
|
||||||
|
encode_batch_error_response, encode_batch_request, encode_batch_resource_limit_error_response,
|
||||||
encode_batch_response,
|
encode_batch_response,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ fn batch_round_trips_by_unique_id_with_item_failures_and_validators() {
|
|||||||
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
|
let endpoint = Endpoint::parse("https://example.test/wip").unwrap();
|
||||||
let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap();
|
let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap();
|
||||||
assert_eq!(encoded_request.uri().path(), "/wip/v1/batch");
|
assert_eq!(encoded_request.uri().path(), "/wip/v1/batch");
|
||||||
|
assert_eq!(encoded_request.headers()["accept"], "application/json");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_batch_request(encoded_request.body(), limits()).unwrap(),
|
decode_batch_request(encoded_request.body(), limits()).unwrap(),
|
||||||
request
|
request
|
||||||
@@ -115,8 +117,65 @@ fn batch_interface_validator_round_trips_in_item_body() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_known_method_arguments_are_item_local() {
|
||||||
|
let body = br#"{
|
||||||
|
"requests": [
|
||||||
|
{"id":"valid","method":"fetch","arguments":{"path":"/item"}},
|
||||||
|
{"id":"invalid","method":"fetch_tree","arguments":{"path":"/","depth":"deep","limit":1}}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let decoded = decode_batch_request(body, limits()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
decoded.requests[0].request,
|
||||||
|
BatchRequestKind::Fetch(_)
|
||||||
|
));
|
||||||
|
let invalid = match &decoded.requests[1].request {
|
||||||
|
BatchRequestKind::Invalid { method, error } => {
|
||||||
|
assert_eq!(*method, BatchMethod::FetchTree);
|
||||||
|
assert_eq!(error.code, ProtocolErrorCode::InvalidRequest);
|
||||||
|
error.clone()
|
||||||
|
}
|
||||||
|
other => panic!("expected item-local invalid request, got {other:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = BatchResponse {
|
||||||
|
responses: vec![
|
||||||
|
BatchResponseItem {
|
||||||
|
id: "valid".into(),
|
||||||
|
outcome: Ok(BatchSuccess::Fetch(Object {
|
||||||
|
name: "item".into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec![],
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
BatchResponseItem {
|
||||||
|
id: "invalid".into(),
|
||||||
|
outcome: Err(invalid),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let encoded = encode_batch_response(&decoded, &response, limits()).unwrap();
|
||||||
|
assert_eq!(encoded.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
decode_batch_response(&decoded, &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::Success(response)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_batch_envelopes_are_rejected_at_top_level() {
|
fn invalid_batch_envelopes_are_rejected_at_top_level() {
|
||||||
|
assert!(decode_batch_request(br#"{}"#, limits()).is_err());
|
||||||
|
|
||||||
|
let malformed_item = br#"{
|
||||||
|
"requests": [
|
||||||
|
{"id":"item","method":"fetch","arguments":{"path":"/"},"extra":true}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
assert!(decode_batch_request(malformed_item, limits()).is_err());
|
||||||
|
|
||||||
let duplicate = br#"{
|
let duplicate = br#"{
|
||||||
"requests": [
|
"requests": [
|
||||||
{"id":"same","method":"fetch","arguments":{"path":"/a"}},
|
{"id":"same","method":"fetch","arguments":{"path":"/a"}},
|
||||||
@@ -136,6 +195,67 @@ fn invalid_batch_envelopes_are_rejected_at_top_level() {
|
|||||||
assert!(decode_batch_request(duplicate, too_many).is_err());
|
assert!(decode_batch_request(duplicate, too_many).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_resource_limits_use_413_or_422_and_never_429() {
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
message: "bounded".into(),
|
||||||
|
};
|
||||||
|
for (kind, status) in [
|
||||||
|
(
|
||||||
|
ResourceLimitKind::RepresentationBytes,
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
),
|
||||||
|
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
] {
|
||||||
|
let encoded = encode_batch_resource_limit_error_response(&error, kind, limits()).unwrap();
|
||||||
|
assert_eq!(encoded.status(), status);
|
||||||
|
assert_ne!(encoded.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
assert_eq!(
|
||||||
|
decode_batch_response(&request(), &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error: error.clone(),
|
||||||
|
status_mismatch: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_success_body_requires_exactly_http_200() {
|
||||||
|
let request = request();
|
||||||
|
let response = BatchResponse {
|
||||||
|
responses: vec![
|
||||||
|
BatchResponseItem {
|
||||||
|
id: "object".into(),
|
||||||
|
outcome: Ok(BatchSuccess::Fetch(Object {
|
||||||
|
name: "item".into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec![],
|
||||||
|
r#ref: None,
|
||||||
|
validator: None,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
BatchResponseItem {
|
||||||
|
id: "interface".into(),
|
||||||
|
outcome: Err(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
|
message: "gone".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let encoded = encode_batch_response(&request, &response, limits()).unwrap();
|
||||||
|
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
|
||||||
|
let mut noncanonical = encoded.clone();
|
||||||
|
*noncanonical.status_mut() = status;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_batch_response(&request, &noncanonical, limits()).unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_level_batch_errors_use_the_batch_allowed_code_set() {
|
fn top_level_batch_errors_use_the_batch_allowed_code_set() {
|
||||||
let invalid = ProtocolError {
|
let invalid = ProtocolError {
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ use wip_https::wip_protocol::{
|
|||||||
TypeDeclaration, TypeExpr, UnionCase, Value,
|
TypeDeclaration, TypeExpr, UnionCase, Value,
|
||||||
};
|
};
|
||||||
use wip_https::{
|
use wip_https::{
|
||||||
ClientResponseError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
ClientResponseError, CodecError, DecodedResponse, Endpoint, InvalidResponseKind, Limits,
|
||||||
decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_response,
|
ResourceLimitKind, decode_call_operation_metadata, decode_call_operation_request,
|
||||||
decode_fetch_interface_request, decode_fetch_interface_response, decode_fetch_request,
|
decode_call_operation_response, decode_fetch_interface_request,
|
||||||
decode_fetch_response, decode_fetch_tree_request, decode_fetch_tree_response,
|
decode_fetch_interface_response, decode_fetch_request, decode_fetch_response,
|
||||||
encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request,
|
decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request,
|
||||||
|
encode_call_operation_response, encode_fetch_interface_request,
|
||||||
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
|
encode_fetch_interface_response, encode_fetch_request, encode_fetch_response,
|
||||||
encode_fetch_tree_request, encode_fetch_tree_response,
|
encode_fetch_tree_request, encode_fetch_tree_response,
|
||||||
};
|
};
|
||||||
@@ -193,6 +194,36 @@ fn call_request() -> CallOperationRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_single_interaction_request_accepts_json() {
|
||||||
|
let fetch =
|
||||||
|
encode_fetch_request(&endpoint(), &FetchRequest { path: "/".into() }, limits()).unwrap();
|
||||||
|
let fetch_tree = encode_fetch_tree_request(
|
||||||
|
&endpoint(),
|
||||||
|
&FetchTreeRequest {
|
||||||
|
path: "/".into(),
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let fetch_interface = encode_fetch_interface_request(
|
||||||
|
&endpoint(),
|
||||||
|
&FetchInterfaceRequest {
|
||||||
|
interface: "interface-v1".into(),
|
||||||
|
},
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let call = encode_call_operation_request(&endpoint(), &call_request(), &descriptor(), limits())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for request in [fetch, fetch_tree, fetch_interface, call] {
|
||||||
|
assert_eq!(request.headers()["accept"], "application/json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fetch_round_trips_direct_body_and_body_validator() {
|
fn fetch_round_trips_direct_body_and_body_validator() {
|
||||||
let request = FetchRequest {
|
let request = FetchRequest {
|
||||||
@@ -362,11 +393,21 @@ fn malformed_unknown_null_and_noncanonical_base64_are_rejected() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn caller_limits_bound_bytes_and_nesting() {
|
fn caller_limits_bound_bytes_and_nesting() {
|
||||||
let tiny = Limits::new(8, 8, 2, 1).unwrap();
|
let tiny = Limits::new(8, 8, 2, 1).unwrap();
|
||||||
assert!(decode_fetch_request(br#"{"path":"/long"}"#, tiny).is_err());
|
let too_large = decode_fetch_request(br#"{"path":"/long"}"#, tiny).unwrap_err();
|
||||||
|
assert!(matches!(&too_large, CodecError::BodyTooLarge { .. }));
|
||||||
|
assert_eq!(
|
||||||
|
too_large.resource_limit_kind(),
|
||||||
|
Some(ResourceLimitKind::RepresentationBytes)
|
||||||
|
);
|
||||||
|
|
||||||
let nested = br#"{"path":[[["/"]]]}"#;
|
let nested = br#"{"path":[[["/"]]]}"#;
|
||||||
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
|
let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap();
|
||||||
assert!(decode_fetch_request(nested, nesting_limited).is_err());
|
let too_deep = decode_fetch_request(nested, nesting_limited).unwrap_err();
|
||||||
|
assert!(matches!(&too_deep, CodecError::NestingTooDeep { .. }));
|
||||||
|
assert_eq!(
|
||||||
|
too_deep.resource_limit_kind(),
|
||||||
|
Some(ResourceLimitKind::Logical)
|
||||||
|
);
|
||||||
|
|
||||||
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap();
|
let outbound_limited = Limits::new(1_000_000, 1_000_000, 1, 10).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ use wip_https::wip_protocol::{
|
|||||||
FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction,
|
||||||
};
|
};
|
||||||
use wip_https::{
|
use wip_https::{
|
||||||
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, TransportFailureKind,
|
ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, ResourceLimitKind,
|
||||||
decode_fetch_response, encode_protocol_error_response, error_code_name, is_json_content_type,
|
TransportFailureKind, decode_fetch_response, encode_protocol_error_response,
|
||||||
status_for_error,
|
encode_resource_limit_error_response, error_code_name, is_json_content_type, status_for_error,
|
||||||
|
status_for_resource_limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn limits() -> Limits {
|
fn limits() -> Limits {
|
||||||
@@ -95,7 +96,7 @@ fn every_protocol_code_has_stable_name_and_status() {
|
|||||||
(
|
(
|
||||||
ProtocolErrorCode::ResourceLimitExceeded,
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
"resource_limit_exceeded",
|
"resource_limit_exceeded",
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
ProtocolErrorCode::Internal,
|
ProtocolErrorCode::Internal,
|
||||||
@@ -115,6 +116,73 @@ fn every_protocol_code_has_stable_name_and_status() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_limit_statuses_encode_and_decode_by_concrete_limit_class() {
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
message: "bounded".into(),
|
||||||
|
};
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
ResourceLimitKind::RepresentationBytes,
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
),
|
||||||
|
(ResourceLimitKind::Logical, StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
];
|
||||||
|
for (kind, status) in cases {
|
||||||
|
assert_eq!(status_for_resource_limit(kind), status);
|
||||||
|
let encoded = encode_resource_limit_error_response(
|
||||||
|
ProtocolInteraction::Fetch,
|
||||||
|
&error,
|
||||||
|
kind,
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(encoded.status(), status);
|
||||||
|
assert_eq!(
|
||||||
|
decode_fetch_response(&request(), &encoded, limits()).unwrap(),
|
||||||
|
DecodedResponse::ProtocolFailure {
|
||||||
|
error: error.clone(),
|
||||||
|
status_mismatch: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let default =
|
||||||
|
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
|
||||||
|
assert_eq!(default.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
assert_ne!(default.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_status_without_protocol_envelope_stays_transport_failure() {
|
||||||
|
let response = Response::builder()
|
||||||
|
.status(StatusCode::TOO_MANY_REQUESTS)
|
||||||
|
.body(b"rate limited".to_vec())
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_response(&request(), &response, limits()).unwrap_err(),
|
||||||
|
ClientResponseError::TransportBinding(failure)
|
||||||
|
if failure.status == StatusCode::TOO_MANY_REQUESTS
|
||||||
|
&& failure.kind == TransportFailureKind::HttpStatus
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_success_body_requires_exactly_http_200() {
|
||||||
|
let success_body = br#"{"name":"","interfaces":[]}"#;
|
||||||
|
for status in [StatusCode::CREATED, StatusCode::ACCEPTED] {
|
||||||
|
assert!(matches!(
|
||||||
|
decode_fetch_response(&request(), &response(status, success_body), limits())
|
||||||
|
.unwrap_err(),
|
||||||
|
ClientResponseError::InvalidResponse {
|
||||||
|
kind: InvalidResponseKind::NonCanonicalSuccessStatus,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_encoder_rejects_codes_disallowed_for_the_interaction() {
|
fn server_encoder_rejects_codes_disallowed_for_the_interaction() {
|
||||||
let error = ProtocolError {
|
let error = ProtocolError {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use wip_https::http::{Method, Request};
|
use wip_https::http::{Method, Request, StatusCode};
|
||||||
use wip_https::{Endpoint, Route, validate_http_request};
|
use wip_https::{Endpoint, RequestMetadataError, Route, validate_http_request};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn endpoint_is_https_absolute_queryless_and_canonical() {
|
fn endpoint_is_https_absolute_queryless_and_canonical() {
|
||||||
@@ -72,6 +72,50 @@ fn inbound_http_metadata_enforces_post_route_and_json() {
|
|||||||
.body(Vec::<u8>::new())
|
.body(Vec::<u8>::new())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err());
|
assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err());
|
||||||
|
|
||||||
|
for accept in [
|
||||||
|
"text/plain, */*;q=1, application/json;q=0",
|
||||||
|
"application/json; charset=utf-16",
|
||||||
|
"application/json; profile=future",
|
||||||
|
] {
|
||||||
|
let incompatible_accept = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/wip/v1/fetch")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("accept", accept)
|
||||||
|
.body(Vec::<u8>::new())
|
||||||
|
.unwrap();
|
||||||
|
let error =
|
||||||
|
validate_http_request(&endpoint, Route::Fetch, &incompatible_accept).unwrap_err();
|
||||||
|
assert!(matches!(&error, RequestMetadataError::NotAcceptable));
|
||||||
|
assert_eq!(error.status(), StatusCode::NOT_ACCEPTABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
for accept in [
|
||||||
|
"application/json; charset=UTF-8",
|
||||||
|
"application/json; q=1; profile=accept-extension",
|
||||||
|
] {
|
||||||
|
let compatible_accept = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/wip/v1/fetch")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("accept", accept)
|
||||||
|
.body(Vec::<u8>::new())
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_http_request(&endpoint, Route::Fetch, &compatible_accept).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsupported_media_type = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/wip/v1/fetch")
|
||||||
|
.header("content-type", "text/plain")
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.body(Vec::<u8>::new())
|
||||||
|
.unwrap();
|
||||||
|
let error =
|
||||||
|
validate_http_request(&endpoint, Route::Fetch, &unsupported_media_type).unwrap_err();
|
||||||
|
assert!(matches!(&error, RequestMetadataError::UnsupportedMediaType));
|
||||||
|
assert_eq!(error.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user