diff --git a/crates/spt-daemon/src/webserve.rs b/crates/spt-daemon/src/webserve.rs index 7e9c9bd5..25815c7e 100644 --- a/crates/spt-daemon/src/webserve.rs +++ b/crates/spt-daemon/src/webserve.rs @@ -1,11 +1,24 @@ -//! Node-prefixed W0 HTTP routing over the serving registry. +//! Node-prefixed HTTP routing over the serving registry (W0), and the +//! resolve-before-bytes split the cross-node proxy rides (W1). //! //! The listener remains in `docshost`; docs bytes and directory containment use -//! its existing sanitizer and serving implementation. Registry state is loaded -//! from the explicitly supplied home on every index/file request. +//! its existing sanitizer and containment gates. Registry state is loaded from +//! the explicitly supplied home on every index/file request. +//! +//! ## Resolve, then materialize (W1) +//! +//! [`resolve_path`] decides WHAT a request names without moving a byte: a +//! finished response ([`Resolved::Ready`] — redirects, errors, the index, the +//! docs compatibility surface), a file on this node ([`Resolved::File`]), or a +//! resource a KNOWN subnet peer owns ([`Resolved::Proxy`]). The local listener +//! materializes the first two itself and hands the third to the daemon's +//! proxy; the OWNING node's dispatcher runs the same `resolve_path` for a +//! request that arrived over the wire and streams a `File` plan back in +//! chunks. One router, both sides — which is what keeps a served name +//! meaning the same thing whichever box the URL was pasted into (ADR-0056). use std::fmt::Write as _; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use http_body_util::Full; use hyper::body::Bytes; @@ -13,7 +26,212 @@ use hyper::{Response, StatusCode}; use serde::Serialize; use spt_store::serving::{alias_url, encode_url_segment, entry_url, ServedEntry, ServedKind, ServingRegistry}; -use crate::docshost::{content_type_for, sanitize_request_path, serve_path}; +use crate::docshost::{content_type_for, locate_path, sanitize_request_path, serve_path}; + +/// What one GET/HEAD path names, decided before any bytes move. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub enum Resolved { + /// A complete response: redirects, refusals, the index page, the docs + /// compatibility surface and its 404s. + Ready(Response>), + /// A file on THIS node — read whole by the local listener, streamed in + /// bounded chunks by the owner-side proxy serve. + File { + path: PathBuf, + content_type: &'static str, + }, + /// A resource a KNOWN subnet peer owns (`node_hex` is its Ed25519 node id, + /// `node_label` the label the URL used): the daemon proxies the request. + Proxy { + node_label: String, + node_hex: String, + }, +} + +/// How a `Range` header applies to a body of `len` bytes. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RangePlan { + /// No usable range: answer the whole body as 200 (a malformed or + /// multi-part range is ignored, which HTTP permits). + Full, + /// Bytes `start..=end` as 206 with `Content-Range: bytes start-end/len`. + Partial { start: u64, end: u64 }, + /// The range starts past the end: 416 with `Content-Range: bytes */len`. + Unsatisfiable, +} + +/// ONE range grammar for the loopback listener and the cross-node owner +/// (ADR-0056: the same URL answers the same way on every box): `bytes=a-b`, +/// `bytes=a-` and the suffix form `bytes=-n`. A closed end past the last byte +/// is clamped; a start past the last byte is unsatisfiable; anything else +/// (no header, another unit, several ranges, a reversed range, garbage) is +/// ignored and the whole body is served as 200. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn apply_range(len: u64, range: Option<&str>) -> RangePlan { + let Some(spec) = range.and_then(|r| r.trim().strip_prefix("bytes=")) else { + return RangePlan::Full; + }; + let spec = spec.trim(); + if spec.contains(',') { + return RangePlan::Full; + } + let Some((first, last)) = spec.split_once('-') else { + return RangePlan::Full; + }; + let (first, last) = (first.trim(), last.trim()); + if first.is_empty() { + // Suffix form: the last `n` bytes. + let Ok(n) = last.parse::() else { + return RangePlan::Full; + }; + if n == 0 || len == 0 { + return RangePlan::Unsatisfiable; + } + let start = len.saturating_sub(n); + return RangePlan::Partial { start, end: len - 1 }; + } + let Ok(start) = first.parse::() else { + return RangePlan::Full; + }; + if start >= len { + return RangePlan::Unsatisfiable; + } + let end = if last.is_empty() { + len - 1 + } else { + let Ok(end) = last.parse::() else { + return RangePlan::Full; + }; + if end < start { + return RangePlan::Full; + } + end.min(len - 1) + }; + RangePlan::Partial { start, end } +} + +/// The head of a file response after the range is applied: what to say and +/// which bytes to send. Shared by the local read and the owner-side stream. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilePlan { + pub status: StatusCode, + pub content_type: &'static str, + /// Byte count of the body to send (0 for a 416). + pub content_length: u64, + /// The `Content-Range` header value, when one applies. + pub content_range: Option, + /// First byte to send. + pub start: u64, +} + +impl FilePlan { + /// The response headers, in wire form (also the shape the owner carries + /// back across the stream verbatim). + pub fn headers(&self) -> Vec<(String, String)> { + let mut headers = vec![ + ("content-type".to_owned(), self.content_type.to_owned()), + ("content-length".to_owned(), self.content_length.to_string()), + ]; + if let Some(range) = &self.content_range { + headers.push(("content-range".to_owned(), range.clone())); + } + headers + } +} + +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn plan_file(len: u64, content_type: &'static str, range: Option<&str>) -> FilePlan { + match apply_range(len, range) { + RangePlan::Full => FilePlan { + status: StatusCode::OK, + content_type, + content_length: len, + content_range: None, + start: 0, + }, + RangePlan::Partial { start, end } => FilePlan { + status: StatusCode::PARTIAL_CONTENT, + content_type, + content_length: end - start + 1, + content_range: Some(format!("bytes {start}-{end}/{len}")), + start, + }, + RangePlan::Unsatisfiable => FilePlan { + status: StatusCode::RANGE_NOT_SATISFIABLE, + content_type: "text/plain; charset=utf-8", + content_length: 0, + content_range: Some(format!("bytes */{len}")), + start: 0, + }, + } +} + +/// The 502 every unreachable-owner path answers with: it names the NODE, +/// never a surface (the surface belongs to the owner's 403), and says why. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn node_unavailable(node_label: &str, why: &str, head_only: bool) -> Response> { + text(StatusCode::BAD_GATEWAY, format!("NODE_UNAVAILABLE: {node_label}: {why}\n"), head_only) +} + +/// Read a resolved file plan into a complete response (the local listener's +/// leg; the owner-side proxy streams the same plan instead). A file that +/// vanished between resolve and read is the 404 its name deserves. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +fn serve_file(path: &Path, content_type: &'static str, head_only: bool, range: Option<&str>) -> Response> { + use std::io::{Read as _, Seek as _, SeekFrom}; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let mut file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only) + } + Err(error) => return text(StatusCode::INTERNAL_SERVER_ERROR, format!("READ_FAIL: {error}\n"), head_only), + }; + let len = match file.metadata() { + Ok(meta) => meta.len(), + Err(error) => return text(StatusCode::INTERNAL_SERVER_ERROR, format!("READ_FAIL: {error}\n"), head_only), + }; + let plan = plan_file(len, content_type, range); + let mut builder = Response::builder().status(plan.status); + for (name, value) in plan.headers() { + builder = builder.header(name, value); + } + let body = if head_only || plan.content_length == 0 { + Vec::new() + } else { + let mut bytes = vec![0u8; plan.content_length as usize]; + if let Err(error) = file.seek(SeekFrom::Start(plan.start)).and_then(|_| file.read_exact(&mut bytes)) { + return text(StatusCode::INTERNAL_SERVER_ERROR, format!("READ_FAIL: {error}\n"), head_only); + } + bytes + }; + builder.body(Full::new(Bytes::from(body))).expect("file response builds") +} + +/// Turn a resolution into the local listener's response. `Proxy` here means +/// the caller has no daemon to proxy through (a bare listener, the unit +/// tests): the honest answer is the 502 naming the node. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn materialize(resolved: Resolved, head_only: bool, range: Option<&str>) -> Response> { + match resolved { + Resolved::Ready(response) => response, + Resolved::File { path, content_type } => serve_file(&path, content_type, head_only, range), + Resolved::Proxy { node_label, .. } => { + node_unavailable(&node_label, "this listener has no daemon to proxy through", head_only) + } + } +} + +/// A docs-root-contained file as a plan, or the sanitizer's/containment's +/// finished refusal. +fn planned(root: &Path, uri_path: &str, head_only: bool) -> Resolved { + match locate_path(root, uri_path, head_only) { + Ok((path, content_type)) => Resolved::File { path, content_type }, + Err(response) => Resolved::Ready(response), + } +} fn response( status: StatusCode, @@ -73,24 +291,28 @@ fn route_segment(raw: &str) -> Option { Some(segment.to_owned()) } -/// Match node labels, not endpoint IDs. Membership is loaded for this home on +/// Match node labels, not endpoint IDs, and answer the member's node id (the +/// Ed25519 pubkey hex the proxy dials). Membership is loaded for this home on /// every request so joining/leaving a subnet changes only the compatibility -/// alias's shadow, never the canonical local docs URL. -fn is_known_subnet_node(home: &Path, label: &str) -> bool { +/// alias's shadow, never the canonical local docs URL. A registry-advertised +/// label wins over the roster's; two members carrying one label resolve to +/// the first match in subnet-then-roster order. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn known_subnet_node(home: &Path, label: &str) -> Option { let identity = home.join("identity"); let subnets = spt_store::subnet::SubnetStore::load_from(&identity.join("subnet.json")); let roster = spt_store::roster::RosterStore::load_from(&identity.join("roster.json")); - subnets.subnets.iter().any(|subnet| { + subnets.subnets.iter().find_map(|subnet| { let path = crate::registryhost::RegistryHost::snapshot_path(&identity.join("registry"), &subnet.name); let registry: spt_net::net::registry::SubnetRegistry = std::fs::read(path) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default(); - roster.members_in(&subnet.name).any(|member| { + roster.members_in(&subnet.name).find_map(|member| { let advertised = registry.node_labels() .find_map(|(node, label)| (node == member.pubkey_hex).then_some(label)) .unwrap_or(&member.label); - advertised.eq_ignore_ascii_case(label) + advertised.eq_ignore_ascii_case(label).then(|| member.pubkey_hex.clone()) }) }) } @@ -179,15 +401,15 @@ fn reference( uri_path: &str, query: Option<&str>, head_only: bool, -) -> Response> { +) -> Resolved { let (raw_name, subpath) = remainder.split_once('/').unwrap_or((remainder, "")); let Some(name) = route_segment(raw_name) else { - return text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one served-name segment\n".to_owned(), head_only); + return Resolved::Ready(text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one served-name segment\n".to_owned(), head_only)); }; // HTTP names are names, not management IDs. An ID accepted by `serve rm` // must not turn into an unlisted second URL (or shadow another entry). let Some(entry) = registry.entries().find(|entry| entry.served_name == name) else { - return text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only); + return Resolved::Ready(text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only)); }; serve_entry(home, entry, subpath, uri_path, query, head_only) } @@ -200,12 +422,12 @@ fn serve_entry( uri_path: &str, query: Option<&str>, head_only: bool, -) -> Response> { +) -> Resolved { let name = &entry.served_name; if let Some(adapter) = &entry.adapter { let valid_root = spt_store::perch::validate_adapter_web_dir_in(home, adapter); if !matches!((valid_root, entry.path.canonicalize()), (Ok(expected), Ok(actual)) if expected == actual) { - return text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only); + return Resolved::Ready(text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only)); } } match entry.kind { @@ -213,22 +435,19 @@ fn serve_entry( // The root index must be addressed as a directory so relative links // stay below the served root. Docs compatibility paths are untouched. if subpath.is_empty() && !uri_path.ends_with('/') { - return redirect_directory(uri_path, query); - } - serve_path(&entry.path, subpath, head_only) - } - ServedKind::File if subpath.is_empty() && entry.path.is_file() => { - match std::fs::read(&entry.path) { - Ok(bytes) => response(StatusCode::OK, content_type_for(&entry.path), bytes, head_only), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only) - } - Err(error) => text(StatusCode::INTERNAL_SERVER_ERROR, format!("READ_FAIL: {error}\n"), head_only), + return Resolved::Ready(redirect_directory(uri_path, query)); } + planned(&entry.path, subpath, head_only) } + // Resolved at request time (ADR-0057): the plan names the CURRENT + // file, so an edit is visible and a deletion is the 404 below. + ServedKind::File if subpath.is_empty() && entry.path.is_file() => Resolved::File { + path: entry.path.clone(), + content_type: content_type_for(&entry.path), + }, // Attachment byte serving belongs to W2, even if an entry of that // kind already exists in the forward-compatible registry format. - _ => text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only), + _ => Resolved::Ready(text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only)), } } @@ -240,15 +459,76 @@ fn adapter( uri_path: &str, query: Option<&str>, head_only: bool, -) -> Response> { +) -> Resolved { let (raw_adapter, subpath) = remainder.split_once('/').unwrap_or((remainder, "")); let Some(name) = route_segment(raw_adapter) else { - return text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one adapter segment\n".to_owned(), head_only); + return Resolved::Ready(text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one adapter segment\n".to_owned(), head_only)); }; match registry.entries().find(|entry| entry.adapter.as_deref() == Some(name.as_str())) { Some(entry) => serve_entry(home, entry, subpath, uri_path, query, head_only), - None => text(StatusCode::NOT_FOUND, format!("NOT_FOUND: facet a adapter {name}\n"), head_only), + None => Resolved::Ready(text(StatusCode::NOT_FOUND, format!("NOT_FOUND: facet a adapter {name}\n"), head_only)), + } +} + +/// The peer arm of the router: which facets of `//...` open a stream to +/// the owner, and which the requester answers itself. Reserved facets stay +/// router-first on BOTH boxes (W0 reserved them before any registry lookup), +/// so an unbuilt facet is refused here without a hop, and a bare `f`/`a` +/// (no name) is the same local FACET_NOT_FOUND the local node answers. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +fn peer_arm(node_label: &str, node_hex: String, remainder: &str, head_only: bool) -> Resolved { + let proxy = || Resolved::Proxy { node_label: node_label.to_owned(), node_hex: node_hex.clone() }; + if remainder.is_empty() { + return proxy(); // the peer's index page (and its ?json twin) } + let (raw_facet, rest) = remainder.split_once('/').unwrap_or((remainder, "")); + let Some(facet) = route_segment(raw_facet) else { + return Resolved::Ready(text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one facet segment\n".to_owned(), head_only)); + }; + match facet.as_str() { + "docs" => proxy(), + "f" | "a" if !rest.is_empty() => proxy(), + "m" | "bin" | "install" => Resolved::Ready(text(StatusCode::NOT_FOUND, format!("FACET_UNAVAILABLE: {facet}\n"), head_only)), + "a" | "f" => Resolved::Ready(text(StatusCode::NOT_FOUND, format!("FACET_NOT_FOUND: {facet}\n"), head_only)), + _ => proxy(), // a short alias only the owner's registry can resolve + } +} + +/// The registering endpoint of the served entry a node-prefixed path names on +/// THIS node — the WEB gate's subject on the owner side. `None` when the path +/// names no registry entry (the index, the docs facet, a miss): the gate then +/// runs at node scope with an empty subject. +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn served_subject(home: &Path, local_node: &str, uri_path: &str) -> Option { + let relative = sanitize_request_path(uri_path)?; + let mut components = relative.components(); + let Component::Normal(first) = components.next()? else { + return None; + }; + if !first.to_str()?.eq_ignore_ascii_case(local_node) { + return None; + } + let Component::Normal(facet) = components.next()? else { + return None; + }; + let facet = facet.to_str()?; + let registry = registry_at(home).ok()?; + let entry = match facet { + "docs" | "m" | "bin" | "install" => return None, + "f" | "a" => { + let Component::Normal(name) = components.next()? else { + return None; + }; + let name = name.to_str()?; + if facet == "f" { + registry.entries().find(|entry| entry.served_name == name)? + } else { + registry.entries().find(|entry| entry.adapter.as_deref() == Some(name))? + } + } + alias => registry.entries().find(|entry| entry.short_alias && entry.served_name == alias)?, + }; + entry.origin.clone() } /// Resolve one GET/HEAD path. The production listener owns method selection; @@ -263,8 +543,27 @@ pub fn handle_path( query: Option<&str>, head_only: bool, ) -> Response> { + materialize(resolve_path(home, docs_root, local_node, port, uri_path, query, head_only), head_only, None) +} + +/// Decide what one GET/HEAD path names, without moving a byte (module docs). +/// The docs compatibility surface — every first segment that is neither the +/// local node nor a known peer, and every reserved facet at the root — stays +/// byte-for-byte the W0 `serve_path` answer, its 404 included. +// [impl->REQ-WEB-URL-NODE-PREFIX] +// [impl->REQ-WEB-CROSS-NODE-PROXY] +pub fn resolve_path( + home: &Path, + docs_root: &Path, + local_node: &str, + port: u16, + uri_path: &str, + query: Option<&str>, + head_only: bool, +) -> Resolved { + let ready = Resolved::Ready; if uri_path == "/" { - return redirect_node(local_node, query); + return ready(redirect_node(local_node, query)); } let Some(relative) = sanitize_request_path(uri_path) else { let raw_first = uri_path.trim_start_matches('/').split('/').next().unwrap_or(""); @@ -272,11 +571,11 @@ pub fn handle_path( first.eq_ignore_ascii_case(local_node) && !spt_store::hostlabel::is_reserved_web_facet(&first) }) { - return text(StatusCode::BAD_REQUEST, "BAD_PATH: rejected by the docs-root sanitizer\n".to_owned(), head_only); + return ready(text(StatusCode::BAD_REQUEST, "BAD_PATH: rejected by the docs-root sanitizer\n".to_owned(), head_only)); } // Preserve the docs-less compatibility response as well as the // installed bundle's sanitizer status; no bytes bypass containment. - return serve_path(docs_root, uri_path, head_only); + return ready(serve_path(docs_root, uri_path, head_only)); }; // ADR-0056 Amendment 1: local node, exact docs root-file leaf, known peer, // then the unchanged docs compatibility surface (including its docs 404). @@ -285,10 +584,10 @@ pub fn handle_path( _ => None, }); let Some(first) = first else { - return serve_path(docs_root, uri_path, head_only); + return ready(serve_path(docs_root, uri_path, head_only)); }; if spt_store::hostlabel::is_reserved_web_facet(first) { - return serve_path(docs_root, uri_path, head_only); + return ready(serve_path(docs_root, uri_path, head_only)); } let path = uri_path.trim_start_matches('/'); let bare_node = !path.contains('/') && route_segment(path).is_some(); @@ -296,59 +595,62 @@ pub fn handle_path( // Rule 2.5 protects root files by URL shape, not hostname policy. // A trailing slash or child segment always keeps the node grammar. if bare_node && docs_root.join(first).is_file() { - return serve_path(docs_root, uri_path, head_only); + return ready(serve_path(docs_root, uri_path, head_only)); } - if is_known_subnet_node(home, first) { + if let Some(node_hex) = known_subnet_node(home, first) { if bare_node { - return redirect_node(first, query); + return ready(redirect_node(first, query)); } - return text(StatusCode::BAD_GATEWAY, format!("NODE_UNAVAILABLE: {first}: cross-node serving is not available yet\n"), head_only); + let remainder = path.split_once('/').map(|(_, rest)| rest).unwrap_or(""); + return peer_arm(first, node_hex, remainder, head_only); } - return serve_path(docs_root, uri_path, head_only); + // An UNKNOWN label is the docs compatibility surface, its 404 + // included — never a 502 (ruled 2026-09-07: 502 names a KNOWN peer). + return ready(serve_path(docs_root, uri_path, head_only)); } if bare_node { - return redirect_node(local_node, query); + return ready(redirect_node(local_node, query)); } let (raw_node, remainder) = path.split_once('/').unwrap_or((path, "")); let Some(_) = route_segment(raw_node) else { - return text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one node segment\n".to_owned(), head_only); + return ready(text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one node segment\n".to_owned(), head_only)); }; if remainder.is_empty() { - return match registry_at(home) { + return ready(match registry_at(home) { Ok(registry) => index(®istry, local_node, port, query, head_only), Err(error) => text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only), - }; + }); } let (raw_facet, remainder) = remainder.split_once('/').unwrap_or((remainder, "")); let Some(facet) = route_segment(raw_facet) else { - return text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one facet segment\n".to_owned(), head_only); + return ready(text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one facet segment\n".to_owned(), head_only)); }; match facet.as_str() { "docs" => { if remainder.is_empty() && !uri_path.ends_with('/') { - return redirect_directory(uri_path, query); + return ready(redirect_directory(uri_path, query)); } - serve_path(docs_root, remainder, head_only) + planned(docs_root, remainder, head_only) } "f" if !remainder.is_empty() => match registry_at(home) { Ok(registry) => reference(home, ®istry, remainder, uri_path, query, head_only), - Err(error) => text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only), + Err(error) => ready(text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only)), }, "a" if !remainder.is_empty() => match registry_at(home) { Ok(registry) => adapter(home, ®istry, remainder, uri_path, query, head_only), - Err(error) => text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only), + Err(error) => ready(text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only)), }, - "m" | "bin" | "install" => text(StatusCode::NOT_FOUND, format!("FACET_UNAVAILABLE: {facet}\n"), head_only), - "a" | "f" => text(StatusCode::NOT_FOUND, format!("FACET_NOT_FOUND: {facet}\n"), head_only), + "m" | "bin" | "install" => ready(text(StatusCode::NOT_FOUND, format!("FACET_UNAVAILABLE: {facet}\n"), head_only)), + "a" | "f" => ready(text(StatusCode::NOT_FOUND, format!("FACET_NOT_FOUND: {facet}\n"), head_only)), _ => match registry_at(home) { Ok(registry) => { let entry = registry.entries().find(|entry| entry.short_alias && entry.served_name == facet); match entry { Some(entry) => serve_entry(home, entry, remainder, uri_path, query, head_only), - None => text(StatusCode::NOT_FOUND, format!("FACET_NOT_FOUND: {facet}\n"), head_only), + None => ready(text(StatusCode::NOT_FOUND, format!("FACET_NOT_FOUND: {facet}\n"), head_only)), } } - Err(error) => text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only), + Err(error) => ready(text(StatusCode::INTERNAL_SERVER_ERROR, error, head_only)), }, } } @@ -626,4 +928,149 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!(String::from_utf8(body(response)).unwrap().contains("spt update fetch --apply")); } + + // [unit->REQ-WEB-CROSS-NODE-PROXY] the one Range grammar: closed, open- + // ended and suffix forms; a closed end past the last byte clamps; a start + // past the last byte is unsatisfiable; every other shape is ignored (200). + #[test] + fn range_grammar_partial_suffix_clamp_and_unsatisfiable() { + use RangePlan::*; + assert_eq!(apply_range(10, None), Full); + assert_eq!(apply_range(10, Some("bytes=0-3")), Partial { start: 0, end: 3 }); + assert_eq!(apply_range(10, Some("bytes=4-")), Partial { start: 4, end: 9 }); + assert_eq!(apply_range(10, Some("bytes=-3")), Partial { start: 7, end: 9 }); + assert_eq!(apply_range(10, Some("bytes=-30")), Partial { start: 0, end: 9 }, "a long suffix is the whole body, as 206"); + assert_eq!(apply_range(10, Some("bytes=2-99")), Partial { start: 2, end: 9 }, "a closed end clamps"); + assert_eq!(apply_range(10, Some("bytes=10-")), Unsatisfiable); + assert_eq!(apply_range(10, Some("bytes=-0")), Unsatisfiable); + assert_eq!(apply_range(0, Some("bytes=0-")), Unsatisfiable, "an empty body satisfies no range"); + for ignored in ["items=0-3", "bytes=3-1", "bytes=0-1,4-5", "bytes=a-b", "bytes=", "0-3"] { + assert_eq!(apply_range(10, Some(ignored)), Full, "{ignored:?} is ignored, not refused"); + } + let plan = plan_file(10, "text/plain; charset=utf-8", Some("bytes=0-3")); + assert_eq!(plan.status, StatusCode::PARTIAL_CONTENT); + assert_eq!(plan.content_length, 4); + assert_eq!(plan.content_range.as_deref(), Some("bytes 0-3/10")); + let plan = plan_file(10, "text/plain; charset=utf-8", Some("bytes=10-")); + assert_eq!(plan.status, StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(plan.content_length, 0); + assert_eq!(plan.content_range.as_deref(), Some("bytes */10")); + } + + // [unit->REQ-WEB-CROSS-NODE-PROXY] a file plan materializes as the + // range says: 206 with Content-Range and exactly the window's bytes, + // HEAD keeps the head and drops the body, 416 names the length, and a + // file that vanished between resolve and read is a 404. + #[test] + fn materialize_reads_a_file_plan_through_the_range() { + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("ten.txt"); + std::fs::write(&source, b"0123456789").unwrap(); + let plan = || Resolved::File { path: source.clone(), content_type: "text/plain; charset=utf-8" }; + let full = materialize(plan(), false, None); + assert_eq!(full.status(), StatusCode::OK); + assert_eq!(full.headers()["content-length"], "10"); + assert_eq!(body(full), b"0123456789"); + let part = materialize(plan(), false, Some("bytes=2-5")); + assert_eq!(part.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(part.headers()["content-range"], "bytes 2-5/10"); + assert_eq!(part.headers()["content-length"], "4"); + assert_eq!(body(part), b"2345"); + let head = materialize(plan(), true, Some("bytes=2-5")); + assert_eq!(head.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(head.headers()["content-length"], "4"); + assert!(body(head).is_empty()); + let none = materialize(plan(), false, Some("bytes=10-")); + assert_eq!(none.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(none.headers()["content-range"], "bytes */10"); + std::fs::remove_file(&source).unwrap(); + let gone = materialize(plan(), false, None); + assert_eq!(gone.status(), StatusCode::NOT_FOUND); + assert!(String::from_utf8(body(gone)).unwrap().contains("ten.txt")); + let proxy = materialize(Resolved::Proxy { node_label: "peer-x".into(), node_hex: "aa".into() }, false, None); + assert_eq!(proxy.status(), StatusCode::BAD_GATEWAY); + assert!(String::from_utf8(body(proxy)).unwrap().starts_with("NODE_UNAVAILABLE: peer-x:")); + } + + fn seed_peer(home: &Path, label: &str, hex: &str) { + use spt_store::roster::{RosterEntry, RosterStore}; + use spt_store::subnet::SubnetStore; + let identity = home.join("identity"); + let mut subnets = SubnetStore::load_from(&identity.join("subnet.json")); + if subnets.subnets.is_empty() { + subnets.create_subnet("home", spt_store::access::Mode::Open).unwrap(); + subnets.save_to(&identity.join("subnet.json")).unwrap(); + } + let mut roster = RosterStore::load_from(&identity.join("roster.json")); + roster.merge_entry(RosterEntry { + pubkey_hex: hex.to_owned(), + subnet: "home".to_owned(), + label: label.to_owned(), + machine_id: format!("machine-{hex}"), + address: None, + last_seen: "1".to_owned(), + lease_epoch: 1, + }); + roster.save_to(&identity.join("roster.json")).unwrap(); + } + + // [unit->REQ-WEB-CROSS-NODE-PROXY] the peer arm's proxy-or-local + // decision per facet: the index, docs, a named f/ or a/ resource and a + // short alias open a stream; the bare node still redirects; reserved + // and unbuilt facets are answered locally without a hop; an UNKNOWN + // label is the docs compatibility 404, unchanged and never a 502. + #[test] + fn peer_paths_proxy_by_facet_and_unknown_labels_stay_docs_404() { + let home = tempfile::tempdir().unwrap(); + seed_peer(home.path(), "Peer-B", "bb22"); + assert_eq!(known_subnet_node(home.path(), "peer-b").as_deref(), Some("bb22")); + assert_eq!(known_subnet_node(home.path(), "nobody"), None); + let resolve = |path: &str| resolve_path(home.path(), &home.path().join("docs"), "LOCAL", 5474, path, None, false); + for path in ["/peer-b/", "/Peer-B/docs/", "/peer-b/docs/cli/reference.md", "/peer-b/f/report.md", "/peer-b/a/example/out.txt", "/peer-b/short/", "/peer-b/f/dir/child.txt"] { + match resolve(path) { + Resolved::Proxy { node_label, node_hex } => { + assert_eq!(node_hex, "bb22", "{path}"); + assert!(node_label.eq_ignore_ascii_case("peer-b"), "{path}: {node_label}"); + } + _ => panic!("{path} must proxy to the owner"), + } + } + let bare = materialize(resolve("/peer-b"), false, None); + assert_eq!(bare.status(), StatusCode::FOUND); + assert_eq!(bare.headers()["location"], "/peer-b/"); + for (path, marker) in [("/peer-b/m/x", "FACET_UNAVAILABLE: m"), ("/peer-b/bin/spt", "FACET_UNAVAILABLE: bin"), ("/peer-b/install", "FACET_UNAVAILABLE: install"), ("/peer-b/f/", "FACET_NOT_FOUND: f"), ("/peer-b/f", "FACET_NOT_FOUND: f"), ("/peer-b/a/", "FACET_NOT_FOUND: a")] { + let response = match resolve(path) { + Resolved::Ready(response) => response, + _ => panic!("{path} is answered locally, never proxied"), + }; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + assert!(String::from_utf8(body(response)).unwrap().starts_with(marker), "{path}"); + } + let unknown = materialize(resolve("/nobody/f/report.md"), false, None); + let docs = serve_path(&home.path().join("docs"), "/nobody/f/report.md", false); + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + assert_eq!(body(unknown), body(docs), "the docs compatibility 404, byte for byte"); + } + + // [unit->REQ-WEB-CROSS-NODE-PROXY] the owner's gate subject is the + // served entry's registering endpoint, resolved through the same registry + // the local facet reads; paths that name no entry gate at node scope. + #[test] + fn served_subject_is_the_entrys_origin_or_none() { + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("report.md"); + std::fs::write(&source, b"x").unwrap(); + let registry_path = spt_store::perch::serving_registry_file_in(home.path()); + let mut registry = ServingRegistry::load_at(®istry_path).unwrap(); + registry.add_reference(&source, Some("report.md"), Some("ling"), 1).unwrap(); + registry.save_at(®istry_path).unwrap(); + let entry = add_adapter(home.path(), "example", Some("short")); + assert_eq!(served_subject(home.path(), "LOCAL", "/local/f/report.md").as_deref(), Some("ling")); + assert_eq!(served_subject(home.path(), "LOCAL", "/LOCAL/f/report.md").as_deref(), Some("ling")); + assert_eq!(served_subject(home.path(), "LOCAL", "/local/a/example/out.txt"), entry.origin); + assert_eq!(served_subject(home.path(), "LOCAL", "/local/short/out.txt"), entry.origin); + for path in ["/local/", "/local/docs/index.html", "/local/f/missing.md", "/other/f/report.md", "/local/m/x"] { + assert_eq!(served_subject(home.path(), "LOCAL", path), None, "{path}"); + } + } }