Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1998fb8fd | ||
|
|
b4e38fc2c2 | ||
|
|
f207b71f83 | ||
|
|
e5162339e9 | ||
|
|
20b3986ab6 | ||
|
|
2da2a28a67 |
Generated
+1
-95
@@ -8,24 +8,12 @@ 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"
|
||||||
@@ -43,22 +31,6 @@ 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"
|
||||||
@@ -68,17 +40,6 @@ 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"
|
||||||
@@ -221,18 +182,6 @@ 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"
|
||||||
@@ -245,12 +194,6 @@ 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"
|
||||||
@@ -284,25 +227,6 @@ 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"
|
||||||
@@ -405,19 +329,6 @@ 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"
|
||||||
@@ -546,15 +457,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-fs-server"
|
name = "wip-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http",
|
|
||||||
"rustix",
|
|
||||||
"tempfile",
|
|
||||||
"wip-host",
|
|
||||||
"wip-https",
|
"wip-https",
|
||||||
"wip-interface",
|
|
||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,20 @@ 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 stateful
|
||||||
interface-authoring facade, a request-scoped host runtime, a bounded Linux
|
presentation-independent client runtime, a Rust interface-authoring facade, a
|
||||||
filesystem projection server, and a private public-API conformance consumer. The
|
request-scoped host runtime, and a private public-API conformance consumer. The
|
||||||
repository root is not itself a package.
|
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
|
||||||
│ └── wip-protocol
|
│ └── wip-protocol
|
||||||
|
├── wip-client stateful Known Space, lazy tree, observation, and call runtime
|
||||||
|
│ ├── wip-https
|
||||||
|
│ └── wip-protocol
|
||||||
├── wip-interface application DTO codecs and interface authoring facade
|
├── wip-interface application DTO codecs and interface authoring facade
|
||||||
│ ├── wip-protocol
|
│ ├── wip-protocol
|
||||||
│ └── wip-interface-macros proc-macro implementation detail
|
│ └── wip-interface-macros proc-macro implementation detail
|
||||||
@@ -33,10 +30,10 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
|
||||||
| `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-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, lazy entry trees, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. |
|
||||||
| `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, 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-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-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
|
||||||
@@ -245,17 +242,6 @@ 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,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "wip-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Presentation-independent stateful WIP client runtime"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wip-https = { path = "../wip-https" }
|
||||||
|
wip-protocol = { path = "../wip-protocol" }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use wip_client::{
|
||||||
|
CallOutcome, Client, ClientErrorKind, ClientLimits, Completion, DiagnosticKind,
|
||||||
|
ObservationState, OutcomeUnknownReason, SecurityContext,
|
||||||
|
};
|
||||||
|
use wip_https::http::{Response, StatusCode};
|
||||||
|
use wip_https::{
|
||||||
|
Limits, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response,
|
||||||
|
};
|
||||||
|
use wip_protocol::{
|
||||||
|
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
|
||||||
|
InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError,
|
||||||
|
ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn limits() -> Limits {
|
||||||
|
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup() -> (Client, wip_client::SessionId) {
|
||||||
|
let mut client = Client::new(ClientLimits::new(2, 16, 16, 16, 16).unwrap(), limits());
|
||||||
|
let session = client
|
||||||
|
.open_session("https://example.test/wip", SecurityContext::new("subject"))
|
||||||
|
.unwrap();
|
||||||
|
(client, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("content-type", "application/json; charset=utf-8")
|
||||||
|
.body(body.to_vec())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object() -> Object {
|
||||||
|
Object {
|
||||||
|
name: "item".into(),
|
||||||
|
description: None,
|
||||||
|
interfaces: vec!["math".into()],
|
||||||
|
r#ref: None,
|
||||||
|
validator: Some(b"object-v1".to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor(format: &str) -> InterfaceDescriptor {
|
||||||
|
InterfaceDescriptor {
|
||||||
|
format: format.into(),
|
||||||
|
documentation: None,
|
||||||
|
types: vec![],
|
||||||
|
operations: vec![OperationDeclaration {
|
||||||
|
name: "calculate".into(),
|
||||||
|
documentation: None,
|
||||||
|
parameters: vec![ParameterDeclaration {
|
||||||
|
name: "value".into(),
|
||||||
|
required: true,
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Integer,
|
||||||
|
}],
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Integer,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
|
||||||
|
let cases: &[&[u8]] = &[
|
||||||
|
b"{not json",
|
||||||
|
br#"{"name":"item","interfaces":[],"unknown":true}"#,
|
||||||
|
br#"{"name":"item","interfaces":["math","math"]}"#,
|
||||||
|
br#"{"name":"wrong","interfaces":[]}"#,
|
||||||
|
];
|
||||||
|
for body in cases {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||||
|
let error = client
|
||||||
|
.complete(request.id, response(StatusCode::OK, body))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
|
||||||
|
assert!(matches!(
|
||||||
|
client.object(&session, "/item").unwrap().state,
|
||||||
|
ObservationState::Error(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
br#"{"error":{"code":"interface_not_found","message":"wrong"}}"#.as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
br#"{"error":{"code":"not_found","message":"wrong status"}}"#.as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StatusCode::CREATED,
|
||||||
|
br#"{"name":"item","interfaces":[]}"#.as_slice(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (status, body) in cases {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.complete(request.id, response(status, body))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind,
|
||||||
|
ClientErrorKind::InvalidResponse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||||
|
let error = ProtocolError {
|
||||||
|
code: ProtocolErrorCode::NotFound,
|
||||||
|
message: "missing".into(),
|
||||||
|
};
|
||||||
|
let mut encoded =
|
||||||
|
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
|
||||||
|
*encoded.status_mut() = StatusCode::IM_A_TEAPOT;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client.complete(request.id, encoded).unwrap(),
|
||||||
|
Completion::ProtocolFailure(error)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/item").unwrap().state,
|
||||||
|
ObservationState::Stale
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.diagnostics(&session).unwrap().back().unwrap().kind,
|
||||||
|
DiagnosticKind::HttpStatusMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_success_without_valid_envelope_remains_transport_failure() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.prepare_fetch(&session, "/item").unwrap();
|
||||||
|
let error = client
|
||||||
|
.complete(
|
||||||
|
request.id,
|
||||||
|
response(StatusCode::BAD_GATEWAY, b"gateway html"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind, ClientErrorKind::Transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let request = client.prepare_interface(&session, "math").unwrap();
|
||||||
|
let logical = FetchInterfaceRequest {
|
||||||
|
interface: "math".into(),
|
||||||
|
};
|
||||||
|
let value = FetchInterfaceResponse {
|
||||||
|
interface: "math".into(),
|
||||||
|
descriptor: descriptor("future-format/2"),
|
||||||
|
validator: Some(b"v2".to_vec()),
|
||||||
|
};
|
||||||
|
let encoded = encode_fetch_interface_response(&logical, &value, limits()).unwrap();
|
||||||
|
let error = client.complete(request.id, encoded).unwrap_err();
|
||||||
|
assert_eq!(error.kind, ClientErrorKind::UnsupportedDescriptorFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
|
||||||
|
let (mut client, session) = setup();
|
||||||
|
let fetch = client.prepare_fetch(&session, "/item").unwrap();
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
fetch.id,
|
||||||
|
encode_fetch_response(
|
||||||
|
&FetchRequest {
|
||||||
|
path: "/item".into(),
|
||||||
|
},
|
||||||
|
&object(),
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let interface = client.prepare_interface(&session, "math").unwrap();
|
||||||
|
client
|
||||||
|
.complete(
|
||||||
|
interface.id,
|
||||||
|
encode_fetch_interface_response(
|
||||||
|
&FetchInterfaceRequest {
|
||||||
|
interface: "math".into(),
|
||||||
|
},
|
||||||
|
&FetchInterfaceResponse {
|
||||||
|
interface: "math".into(),
|
||||||
|
descriptor: descriptor(INTERFACE_FORMAT_V1),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
limits(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let call = client
|
||||||
|
.prepare_call(
|
||||||
|
&session,
|
||||||
|
"/item",
|
||||||
|
"math",
|
||||||
|
"calculate",
|
||||||
|
BTreeMap::from([("value".into(), Value::Integer(2))]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
client.mark_dispatched(call.id).unwrap();
|
||||||
|
|
||||||
|
let completion = client
|
||||||
|
.complete(
|
||||||
|
call.id,
|
||||||
|
response(
|
||||||
|
StatusCode::OK,
|
||||||
|
br#"{"result":"not an integer","unknown":true}"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
completion,
|
||||||
|
Completion::Call(record)
|
||||||
|
if matches!(
|
||||||
|
&record.outcome,
|
||||||
|
CallOutcome::Unknown {
|
||||||
|
reason: OutcomeUnknownReason::ResponseDecode,
|
||||||
|
failure: Some(failure),
|
||||||
|
} if failure.kind == ClientErrorKind::InvalidResponse
|
||||||
|
)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
client.object(&session, "/item").unwrap().state,
|
||||||
|
ObservationState::Stale
|
||||||
|
);
|
||||||
|
assert_eq!(client.in_flight_len(), 0);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
//! 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<()>;
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
//! 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('/'))
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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
@@ -1,747 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
+18
-105
@@ -535,36 +535,11 @@ 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) = target.validator.as_deref()
|
if let Some(supplied) = request.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(
|
||||||
@@ -573,11 +548,9 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(bound) = self
|
let Some(bound) = self.interfaces.iter().find(|bound| {
|
||||||
.interfaces
|
bound.artifact.reference.as_str() == request.interface.reference.as_str()
|
||||||
.iter()
|
}) else {
|
||||||
.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",
|
||||||
@@ -586,7 +559,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
|
|
||||||
match (
|
match (
|
||||||
bound.artifact.validator.as_ref(),
|
bound.artifact.validator.as_ref(),
|
||||||
interface.validator.as_deref(),
|
request.interface.validator.as_deref(),
|
||||||
) {
|
) {
|
||||||
(Some(_), None) => {
|
(Some(_), None) => {
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
@@ -608,18 +581,18 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
.descriptor
|
.descriptor
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
.any(|declared| declared.name == operation)
|
.any(|operation| operation.name == request.operation)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::OperationNotFound,
|
CallErrorKind::OperationNotFound,
|
||||||
&format!("operation `{operation}` is not declared"),
|
&format!("operation `{}` is not declared", request.operation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if target.validator.is_none()
|
if request.target.validator.is_none()
|
||||||
&& self
|
&& self
|
||||||
.implementation
|
.implementation
|
||||||
.requires_validator(&bound.artifact.reference, &operation)
|
.requires_validator(&bound.artifact.reference, &request.operation)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(
|
return pre_dispatch_failure(
|
||||||
CallErrorKind::ValidatorRequired,
|
CallErrorKind::ValidatorRequired,
|
||||||
@@ -627,24 +600,19 @@ 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(&operation, &arguments)
|
.validate_arguments(&request.operation, &request.arguments)
|
||||||
{
|
{
|
||||||
return pre_dispatch_failure(CallErrorKind::InvalidArguments, &error.to_string());
|
return pre_dispatch_failure(CallErrorKind::InvalidArguments, &error.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authorize(&self.implementation, &bound.artifact.reference, &operation) {
|
if !authorize(
|
||||||
|
&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",
|
||||||
@@ -655,7 +623,8 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
return OperationAttempt::NotStarted(error);
|
return OperationAttempt::NotStarted(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let value = match (bound.dispatch)(&self.implementation, &operation, arguments) {
|
let value =
|
||||||
|
match (bound.dispatch)(&self.implementation, &request.operation, request.arguments) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(
|
Err(
|
||||||
error @ (DispatchError::UnknownOperation(_)
|
error @ (DispatchError::UnknownOperation(_)
|
||||||
@@ -670,7 +639,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
if bound
|
if bound
|
||||||
.artifact
|
.artifact
|
||||||
.descriptor
|
.descriptor
|
||||||
.validate_result(&operation, &value)
|
.validate_result(&request.operation, &value)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return OperationAttempt::Started(Err(OperationFailure::internal(
|
return OperationAttempt::Started(Err(OperationFailure::internal(
|
||||||
@@ -953,62 +922,6 @@ 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,89 +741,6 @@ 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user