diff --git a/crates/spt-daemon/src/webserve.rs b/crates/spt-daemon/src/webserve.rs index 82ff0596..5d688697 100644 --- a/crates/spt-daemon/src/webserve.rs +++ b/crates/spt-daemon/src/webserve.rs @@ -424,6 +424,17 @@ fn serve_entry( head_only: bool, ) -> Resolved { let name = &entry.served_name; + // AN EXPIRED ENTRY IS ALREADY GONE, whether or not the reaper has run yet. + // The pulse reaps on its own schedule, so between expiry and the next tick + // there is a window in which the bytes are still on disk — serving them + // would make the ttl a suggestion. The refusal is the ordinary not-found: + // an expired link and a never-existing one are the same fact to a reader. + // [impl->REQ-WEB-ATTACHMENT-PULL] + if let Some(ttl) = entry.ttl_ms { + if entry.registered_at_ms.saturating_add(ttl) <= now_ms() { + return Resolved::Ready(text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only)); + } + } 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) { @@ -445,12 +456,123 @@ fn serve_entry( 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. + // An ATTACHMENT serves the SNAPSHOT taken at send time (ADR-0058): the + // one entry kind this registry does not resolve at request time, which + // is exactly what makes a message's attachment as immutable as the + // message. The path is checked against the node's own snapshot store + // first — an attachment entry naming a file outside it is not an + // attachment, whatever the registry says. + // [impl->REQ-WEB-ATTACHMENT-PULL] + ServedKind::Attachment if subpath.is_empty() && is_snapshot_of(home, &entry.path) => { + Resolved::File { + path: entry.path.clone(), + content_type: content_type_for(Path::new(&entry.served_name)), + } + } _ => Resolved::Ready(text(StatusCode::NOT_FOUND, format!("NOT_FOUND: served resource {name}\n"), head_only)), } } +/// Wall-clock milliseconds. A clock that has moved BACKWARDS keeps an entry +/// alive rather than expiring it early, for the reason the registry's own +/// `expired` states: serving something a moment too long is recoverable. +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_millis() as u64) + .unwrap_or(0) +} + +/// Is `path` a file inside this node's attachment snapshot store? +/// +/// The snapshot directory is the ONLY place an attachment's bytes may live, so +/// this is checked at serve time rather than trusted from the registry: a +/// registry that ever acquires an attachment entry pointing at an arbitrary +/// path must not thereby serve that path. +// [impl->REQ-WEB-ATTACHMENT-PULL] +fn is_snapshot_of(home: &Path, path: &Path) -> bool { + let store = spt_store::perch::serve_snapshots_dir_in(home); + match (store.canonicalize(), path.canonicalize()) { + (Ok(store), Ok(path)) => path.starts_with(&store) && path.is_file(), + _ => false, + } +} + +/// The `/m/` facet: one message, rendered (ADR-0061). +/// +/// It resolves through the SAME `spt_store::msgid::resolve_in` the CLI's +/// `spt msg show` uses, so a link and a command can never disagree about what a +/// message is. An id this node does not hold is a plain 404 — a link may name a +/// message that lives somewhere else, which is not an error. +// [impl->REQ-MSG-SHORT-ID] +fn message(home: &Path, remainder: &str, query: Option<&str>, head_only: bool) -> Response> { + let Some(id) = route_segment(remainder.split('/').next().unwrap_or(remainder)) else { + return text(StatusCode::BAD_REQUEST, "BAD_PATH: expected one message-id segment\n".to_owned(), head_only); + }; + if !spt_store::msgid::is_short_id(&id) { + return text(StatusCode::NOT_FOUND, format!("NOT_FOUND: message {id}\n"), head_only); + } + let resolved = match spt_store::msgid::resolve_detail_in(home, &id) { + Ok(Ok(message)) => message, + Ok(Err(miss)) => { + // The 404 BODY stays exactly what it was — a fetcher is a + // stranger, and the three counts describe THIS node's stores: + // operator detail, not a caller's business. The panel goes to + // the daemon's own stderr, where an operator diagnosing the + // miss is already looking. + spt_proto::emit_line_err!("MSG_FACET_MISS: {miss}"); + return text(StatusCode::NOT_FOUND, format!("NOT_FOUND: message {id}\n"), head_only); + } + Err(error) => return text(StatusCode::INTERNAL_SERVER_ERROR, format!("MESSAGE_READ_FAIL: {error}\n"), head_only), + }; + let body = spt_proto::event::parse_event(&resolved.body) + .map(|parsed| parsed.body.clone()) + .unwrap_or_else(|| resolved.body.clone()); + if query.is_some_and(|q| q.split('&').any(|part| part.split('=').next() == Some("json"))) { + let view = serde_json::json!({ + "short_id": resolved.short_id, + "owner": resolved.owner, + "from": resolved.from_id, + "created_at_ms": resolved.created_at, + "reply_to": resolved.reply_to, + "source": resolved.source, + "body": body, + "attachments": resolved.attachments, + }); + return match serde_json::to_vec(&view) { + Ok(bytes) => response(StatusCode::OK, "application/json", bytes, head_only), + Err(error) => text(StatusCode::INTERNAL_SERVER_ERROR, format!("MESSAGE_RENDER_FAIL: {error}\n"), head_only), + }; + } + let mut html = format!( + "Message {id}

Message {id}

from {} to {}

", + escape_html(&resolved.from_id), + escape_html(&resolved.owner), + ); + if let Some(parent) = &resolved.reply_to { + let parent = escape_html(parent); + write!(html, "

in reply to {parent}

") + .expect("writing to a String cannot fail"); + } + write!(html, "
{}
", escape_html(&body)).expect("writing to a String cannot fail"); + if !resolved.attachments.is_empty() { + html.push_str("

Attachments

"); + } + html.push_str("\n"); + response(StatusCode::OK, "text/html; charset=utf-8", html.into_bytes(), head_only) +} + // [impl->REQ-WEB-SERVING-REGISTRY] fn adapter( home: &Path, @@ -487,9 +609,12 @@ fn peer_arm(node_label: &str, node_hex: String, remainder: &str, head_only: bool }; 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)), + // [impl->REQ-MSG-SHORT-ID] A peer's message is resolved by ITS owner — + // the index is node-scoped, so the requester has nothing to resolve with + // and the hop is the only honest answer. + "f" | "a" | "m" if !rest.is_empty() => proxy(), + "bin" | "install" => Resolved::Ready(text(StatusCode::NOT_FOUND, format!("FACET_UNAVAILABLE: {facet}\n"), head_only)), + "a" | "f" | "m" => 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 } } @@ -500,6 +625,17 @@ fn peer_arm(node_label: &str, node_hex: String, remainder: &str, head_only: bool /// 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 { + served_entry(home, local_node, uri_path).and_then(|entry| entry.origin) +} + +/// The registry ENTRY a node-prefixed path names on this node. +/// +/// [`served_subject`] is this plus a field. Split out in W2 because the WEB gate +/// now needs a SECOND property of the same entry — its audience — and resolving +/// the path twice, in two functions, is how two answers about one URL start to +/// disagree. +// [impl->REQ-WEB-ENTRY-AUDIENCE] +pub fn served_entry(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 { @@ -528,7 +664,29 @@ pub fn served_subject(home: &Path, local_node: &str, uri_path: &str) -> Option registry.entries().find(|entry| entry.short_alias && entry.served_name == alias)?, }; - entry.origin.clone() + Some(entry.clone()) +} + +/// Which NODE hosts `endpoint_id`, according to this node's subnet registries. +/// +/// This is the bridge an audience check needs and does not have for free: the +/// cross-node handshake proves a NODE (REQ-HAZARD-WAN-ORIGIN-AUTH) while an +/// audience names an ENDPOINT, and WEB carries no daemon-stamped sender identity +/// yet (ADR-0060). An endpoint this node cannot place answers `None`, which the +/// caller must treat as a refusal rather than as an admission — an unplaceable +/// audience is exactly the case where admitting would erase the narrowing. +// [impl->REQ-WEB-ENTRY-AUDIENCE] +pub fn node_hosting_endpoint(home: &Path, endpoint_id: &str) -> Option { + let identity = home.join("identity"); + let subnets = spt_store::subnet::SubnetStore::load_from(&identity.join("subnet.json")); + 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(); + registry.instances(endpoint_id).first().map(|instance| instance.node.clone()) + }) } /// Resolve one GET/HEAD path. The production listener owns method selection; @@ -640,8 +798,11 @@ pub fn resolve_path( Ok(registry) => adapter(home, ®istry, remainder, uri_path, query, head_only), Err(error) => ready(text(StatusCode::INTERNAL_SERVER_ERROR, error, 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)), + // [impl->REQ-MSG-SHORT-ID] W0 reserved `m` as unavailable-by-name; W2 + // builds it. `bin` and `install` stay reserved. + "m" if !remainder.is_empty() => ready(message(home, remainder, query, head_only)), + "bin" | "install" => ready(text(StatusCode::NOT_FOUND, format!("FACET_UNAVAILABLE: {facet}\n"), head_only)), + "a" | "f" | "m" => 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); @@ -1026,7 +1187,7 @@ mod tests { 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"] { + 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", "/peer-b/m/BCDFGH23"] { match resolve(path) { Resolved::Proxy { node_label, node_hex } => { assert_eq!(node_hex, "bb22", "{path}"); @@ -1038,7 +1199,12 @@ mod tests { 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")] { + // [unit->REQ-MSG-SHORT-ID] REPINNED IN W2: `m` moved from + // reserved-and-answered-here to PROXIED, because a message id resolves + // only in the OWNER's node-scoped index — the requester has nothing to + // resolve it with. `bin` and `install` stay reserved, and a BARE `m` is + // still answered here, exactly like a bare `f`. + for (path, marker) in [("/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"), ("/peer-b/m", "FACET_NOT_FOUND: m"), ("/peer-b/m/", "FACET_NOT_FOUND: m")] { let response = match resolve(path) { Resolved::Ready(response) => response, _ => panic!("{path} is answered locally, never proxied"), diff --git a/crates/spt-store/src/serving.rs b/crates/spt-store/src/serving.rs index 77f232e0..232c6ad0 100644 --- a/crates/spt-store/src/serving.rs +++ b/crates/spt-store/src/serving.rs @@ -17,6 +17,68 @@ use crate::atomic::atomic_write_bytes_durable; const SCHEMA_VERSION: u32 = 1; +/// An attachment's lifetime when the sender names none: 30 days (ADR-0058, +/// operator-set). It is a CONSTANT rather than a config knob on purpose — the +/// figure is a published contract in the attachments page, and a per-send +/// `--ttl` already covers the case where a sender wants something else. +// [impl->REQ-WEB-ATTACHMENT-PULL] +pub const DEFAULT_ATTACHMENT_TTL_MS: u64 = 30 * 24 * 60 * 60 * 1_000; + +/// The lifetime a FILE_ACCESS_HELPER entry carries: 24 hours (ADR-0058 +/// Amendment 1, operator-directed). Shorter than an attachment's because the +/// user quoted a path in passing rather than sending a copy. +// [impl->REQ-NOW-SIGNAL-FILE-ACCESS-HELPER] +pub const HELPER_ENTRY_TTL_MS: u64 = 24 * 60 * 60 * 1_000; + +/// One attachment as the message envelope carries it: a name, the URL to pull +/// it from, and the size in bytes. +/// +/// The SIZE rides with the link because attachments are pull-model — a receiver +/// deciding whether to fetch has to be able to decide BEFORE it fetches, and a +/// link alone makes that impossible without a request. +// [impl->REQ-WEB-ATTACHMENT-PULL] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttachmentRef { + /// The SERVED name, which is what the URL ends in — never the source + /// basename, because the registry disambiguates a repeated name and the + /// receiver must be told the name it can actually address. + pub name: String, + pub url: String, + pub bytes: u64, +} + +/// Parse a `--ttl` specification into milliseconds. +/// +/// A UNIT IS REQUIRED. A bare number would have to mean seconds, or days, or +/// milliseconds, and every reader would pick a different one — a `--ttl 30` +/// that silently meant 30 seconds when the sender meant 30 days is a deleted +/// attachment, so the refusal is deliberate and names what it wanted. +// [impl->REQ-WEB-ATTACHMENT-PULL] +pub fn parse_ttl_ms(spec: &str) -> Result { + let trimmed = spec.trim(); + let (digits, unit) = trimmed.split_at( + trimmed + .find(|c: char| !c.is_ascii_digit()) + .ok_or_else(|| format!("ttl needs a unit (s, m, h, d): {trimmed}"))?, + ); + let count: u64 = digits + .parse() + .map_err(|_| format!("ttl must start with a count: {trimmed}"))?; + let unit_ms: u64 = match unit { + "s" => 1_000, + "m" => 60 * 1_000, + "h" => 60 * 60 * 1_000, + "d" => 24 * 60 * 60 * 1_000, + other => return Err(format!("unknown ttl unit {other:?} (want s, m, h, d)")), + }; + // A ttl that overflows is refused rather than saturated: saturating would + // hand back a lifetime the sender did not ask for and cannot see. + count + .checked_mul(unit_ms) + .filter(|ms| *ms > 0) + .ok_or_else(|| format!("ttl out of range: {trimmed}")) +} + /// How an entry's path is interpreted. W0 creates references only. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -230,6 +292,120 @@ impl ServingRegistry { Ok(entry) } + /// Register bytes ALREADY COPIED into the registry's own snapshot store. + /// The caller writes the snapshot first, so a failed copy never mints an + /// entry that serves nothing. Unlike a reference, an attachment is NEVER + /// deduplicated by source path: each send is its own message with its own + /// lifetime, and two sends of one file are two links that expire apart. + /// The snapshot is what is served, which is why a later edit or deletion of + /// the sender's original changes nothing (ADR-0058). + // [impl->REQ-WEB-ATTACHMENT-PULL] + pub fn add_attachment( + &mut self, + snapshot_path: &Path, + requested_name: &str, + origin: Option<&str>, + ttl_ms: u64, + audience: Option<&str>, + now_ms: u64, + ) -> io::Result { + if !valid_reference_path(snapshot_path) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "attachment snapshot must be an absolute, lexically normalized path", + )); + } + if !valid_name(requested_name) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid served name")); + } + if !fs::metadata(snapshot_path)?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "attachment snapshot must be a regular file", + )); + } + let kind = ServedKind::Attachment; + let (served_name, next) = self.allocate_name(requested_name, snapshot_path, kind)?; + let entry = ServedEntry { + id: uuid::Uuid::new_v4().to_string(), + kind, + path: snapshot_path.to_path_buf(), + served_name, + registered_at_ms: now_ms, + ttl_ms: Some(ttl_ms), + audience: audience.map(str::to_owned), + origin: origin.map(str::to_owned), + adapter: None, + short_alias: false, + }; + self.next_suffix.insert(requested_name.to_owned(), next); + self.name_owners.insert(entry.served_name.clone(), NameOwner { + path: entry.path.clone(), + kind, + }); + self.entries.push(entry.clone()); + Ok(entry) + } + + /// Narrow an existing live entry's lifetime and audience in place. The + /// FILE_ACCESS_HELPER registers a user-quoted path as an ordinary + /// REFERENCE (edits visible, deletion 404s) that merely expires and is + /// addressed to one endpoint, so this stays a property of an entry rather + /// than a fourth kind (ADR-0058 Amendment 1). + // [impl->REQ-WEB-ENTRY-AUDIENCE] + pub fn scope_entry( + &mut self, + name_or_id: &str, + ttl_ms: Option, + audience: Option<&str>, + origin: Option<&str>, + ) -> Option { + let index = self + .entries + .iter() + .position(|entry| entry.served_name == name_or_id) + .or_else(|| self.entries.iter().position(|entry| entry.id == name_or_id))?; + let entry = &mut self.entries[index]; + entry.ttl_ms = ttl_ms; + entry.audience = audience.map(str::to_owned); + if origin.is_some() { + entry.origin = origin.map(str::to_owned); + } + Some(entry.clone()) + } + + /// Live entries whose lifetime has elapsed. An entry with no `ttl_ms` never + /// expires, and the comparison is registration plus lifetime against now, + /// so a clock that has moved BACKWARDS keeps an entry rather than reaping + /// it early: serving something a moment too long is recoverable, deleting + /// the only copy of an attachment is not. + // [impl->REQ-WEB-ATTACHMENT-PULL] + pub fn expired(&self, now_ms: u64) -> Vec { + self.entries + .iter() + .filter(|entry| match entry.ttl_ms { + Some(ttl) => entry.registered_at_ms.saturating_add(ttl) <= now_ms, + None => false, + }) + .cloned() + .collect() + } + + /// Retire every expired entry, returning them so the caller can delete the + /// snapshot bytes it owns. Name history and counters are retained exactly + /// as an explicit removal retains them, so a reaped name can only ever be + /// reclaimed by the same source path and kind. + // [impl->REQ-WEB-ATTACHMENT-PULL] + pub fn reap_expired(&mut self, now_ms: u64) -> Vec { + let reaped = self.expired(now_ms); + if reaped.is_empty() { + return reaped; + } + let doomed: BTreeSet<&str> = reaped.iter().map(|entry| entry.id.as_str()).collect(); + self.entries.retain(|entry| !doomed.contains(entry.id.as_str())); + reaped + } + /// Retire a live assignment, retaining all name history and counters. pub fn remove(&mut self, name_or_id: &str) -> Option { let index = self.entries.iter().position(|entry| entry.served_name == name_or_id) @@ -382,6 +558,54 @@ pub fn resource_url(node: &str, port: u16, name: &str) -> String { url } +/// Copy a file's bytes into the registry's own snapshot store, returning the +/// snapshot path and its size. +/// +/// The snapshot is named by a fresh uuid rather than by the source's basename, +/// so two sends of one file are two independent copies: one expiring never +/// deletes the other's bytes, and the SERVED name (which is what a reader sees) +/// is still disambiguated from the basename by the registry's own rule. +/// A directory is refused here rather than serving one file of it later. +// [impl->REQ-WEB-ATTACHMENT-PULL] +pub fn write_snapshot_in(home: &Path, source: &Path) -> io::Result<(PathBuf, u64)> { + let metadata = fs::metadata(source)?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "an attachment must be a regular file", + )); + } + let dir = crate::perch::serve_snapshots_dir_in(home); + fs::create_dir_all(&dir)?; + let snapshot = dir.join(uuid::Uuid::new_v4().to_string()); + let bytes = fs::copy(source, &snapshot)?; + Ok((snapshot, bytes)) +} + +/// Whether a proven requester may fetch this entry. +/// +/// An absent audience admits everyone the WEB surface admits, which leaves the +/// default-on subnet posture untouched. A present audience admits exactly the +/// endpoint it names. `requester` is `None` when nothing proved an identity — +/// a LOOPBACK request, which is served regardless, because a local browser +/// presents no endpoint and the machine is already trusted (ADR-0058 Am. 1). +/// +/// The caller decides what counts as proof. Across the subnet the handshake +/// proves the requesting NODE, not an endpoint, so the daemon passes the +/// audience endpoint only after establishing that the proven node hosts it; +/// WEB carries no daemon-stamped sender identity yet (ADR-0060), and this +/// function must not be read as claiming one. +// [impl->REQ-WEB-ENTRY-AUDIENCE] +pub fn audience_admits(entry: &ServedEntry, requester: Option<&str>) -> bool { + match entry.audience.as_deref() { + None => true, + Some(audience) => match requester { + None => true, + Some(requester) => requester == audience, + }, + } +} + /// Canonical audit URL: adapter directories use their adapter facet. // [impl->REQ-WEB-URL-NODE-PREFIX] [impl->REQ-WEB-SERVING-REGISTRY] pub fn entry_url(node: &str, port: u16, entry: &ServedEntry) -> String { @@ -828,4 +1052,155 @@ mod tests { assert_eq!(resource_url("NODE/Other", 9000, "café/part.md"), "http://localhost:9000/node%2Fother/f/caf%C3%A9%2Fpart.md"); } + + /// A snapshot is what is served, so editing the source after the send + /// changes nothing and deleting it does not turn the link into a 404. This + /// is the ONE property separating an attachment from a `serve add` file. + // [unit->REQ-WEB-ATTACHMENT-PULL] + #[test] + fn an_attachment_serves_the_bytes_as_they_were_at_send_time() { + let home = tempdir().unwrap(); + let mut store = registry(&home); + let original = source(&home, "notes.md"); + + let (snapshot, bytes) = write_snapshot_in(home.path(), &original).unwrap(); + assert_eq!(bytes, b"original".len() as u64, "the size is the snapshot's"); + let entry = store + .add_attachment(&snapshot, "notes.md", Some("todlando"), 30_000, None, 100) + .unwrap(); + assert_eq!(entry.kind, ServedKind::Attachment); + assert_eq!(entry.served_name, "notes.md"); + assert_eq!(entry.ttl_ms, Some(30_000)); + + fs::write(&original, b"edited after the send").unwrap(); + assert_eq!(fs::read(&entry.path).unwrap(), b"original", "an edit is invisible"); + fs::remove_file(&original).unwrap(); + assert_eq!(fs::read(&entry.path).unwrap(), b"original", "a delete is invisible"); + assert_eq!(reload(&home, &store).get("notes.md"), Some(&entry)); + } + + /// Two sends of one basename are two entries with two links and two + /// lifetimes: the disambiguation rule applies to attachments unchanged, and + /// neither send is deduplicated into the other. + // [unit->REQ-WEB-ATTACHMENT-PULL] + #[test] + fn two_sends_of_one_name_get_distinct_stable_urls() { + let home = tempdir().unwrap(); + let mut store = registry(&home); + let original = source(&home, "report.md"); + + let (first_snapshot, _) = write_snapshot_in(home.path(), &original).unwrap(); + let (second_snapshot, _) = write_snapshot_in(home.path(), &original).unwrap(); + assert_ne!(first_snapshot, second_snapshot, "each send owns its bytes"); + + let first = store + .add_attachment(&first_snapshot, "report.md", None, 30_000, None, 100) + .unwrap(); + let second = store + .add_attachment(&second_snapshot, "report.md", None, 30_000, None, 101) + .unwrap(); + assert_eq!(first.served_name, "report.md"); + assert_eq!(second.served_name, "report~1.md", "the suffix sits before the extension"); + assert_ne!(first.id, second.id); + } + + /// A directory is refused where the snapshot is taken, not later where one + /// file of it would be served. + // [unit->REQ-WEB-ATTACHMENT-PULL] + #[test] + fn a_directory_is_not_an_attachment() { + let home = tempdir().unwrap(); + let dir = home.path().join("a-folder"); + fs::create_dir(&dir).unwrap(); + assert_eq!( + write_snapshot_in(home.path(), &dir).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + write_snapshot_in(home.path(), &home.path().join("absent")).unwrap_err().kind(), + io::ErrorKind::NotFound, + "a missing path refuses where it is read, so the send can name it" + ); + } + + /// Expiry is registration plus lifetime. An entry with no lifetime never + /// expires, the boundary is inclusive, and a clock that has moved BACKWARDS + /// keeps an entry rather than reaping bytes early. + // [unit->REQ-WEB-ATTACHMENT-PULL] + #[test] + fn expiry_counts_from_registration_and_a_backwards_clock_reaps_nothing() { + let home = tempdir().unwrap(); + let mut store = registry(&home); + let forever = source(&home, "forever.md"); + store.add_reference(&forever, None, None, 1_000).unwrap(); + + let original = source(&home, "brief.md"); + let (snapshot, _) = write_snapshot_in(home.path(), &original).unwrap(); + let attachment = store + .add_attachment(&snapshot, "brief.md", None, 500, None, 1_000) + .unwrap(); + + assert!(store.expired(1_499).is_empty(), "not yet expired"); + assert!(store.expired(0).is_empty(), "a backwards clock expires nothing"); + assert_eq!( + store.expired(1_500).iter().map(|e| e.id.clone()).collect::>(), + vec![attachment.id.clone()], + "the boundary is inclusive and the lifetime-free entry is untouched" + ); + + let reaped = store.reap_expired(1_500); + assert_eq!(reaped.len(), 1, "the reaper returns what it removed, so it can be counted"); + assert_eq!(reaped[0].path, snapshot, "and names the bytes the caller owns"); + assert!(store.get("brief.md").is_none(), "the entry is gone, so the link 404s"); + assert!(store.get("forever.md").is_some(), "the lifetime-free entry survives"); + assert!(store.reap_expired(9_999_999).is_empty(), "a second sweep reaps nothing twice"); + } + + /// An absent audience admits anyone; a present one admits exactly the + /// endpoint it names; and an unproven requester — loopback — is admitted, + /// because a local browser presents no endpoint identity at all. + // [unit->REQ-WEB-ENTRY-AUDIENCE] + #[test] + fn audience_admits_its_endpoint_and_loopback_only() { + let home = tempdir().unwrap(); + let mut store = registry(&home); + let path = source(&home, "quoted.md"); + let open = store.add_reference(&path, None, None, 100).unwrap(); + + assert!(audience_admits(&open, Some("anyone")), "an absent audience admits"); + assert!(audience_admits(&open, None)); + + let scoped = store + .scope_entry(&open.id, Some(86_400_000), Some("todlando"), Some("ABCDEFGH")) + .expect("entry present"); + assert_eq!(scoped.kind, ServedKind::File, "a scoped entry stays a live reference"); + assert_eq!(scoped.ttl_ms, Some(86_400_000)); + assert_eq!(scoped.origin.as_deref(), Some("ABCDEFGH"), "origin is the message short-ID"); + assert!(audience_admits(&scoped, Some("todlando")), "the named endpoint fetches"); + assert!(!audience_admits(&scoped, Some("hertz")), "another endpoint does not"); + assert!(audience_admits(&scoped, None), "loopback is the trusted machine"); + assert_eq!(reload(&home, &store).get("quoted.md").unwrap().audience.as_deref(), Some("todlando")); + } + + // [unit->REQ-WEB-ATTACHMENT-PULL] the `--ttl` parse and its default. The + // refusals are the point: a bare number and an unknown unit are the two + // spellings a sender reaches for by habit, and either one guessed silently + // is an attachment that outlives or predeceases what was intended. + #[test] + fn a_ttl_needs_a_unit_and_the_default_is_thirty_days() { + assert_eq!(parse_ttl_ms("45s"), Ok(45_000)); + assert_eq!(parse_ttl_ms("90m"), Ok(5_400_000)); + assert_eq!(parse_ttl_ms("24h"), Ok(HELPER_ENTRY_TTL_MS)); + assert_eq!(parse_ttl_ms("30d"), Ok(DEFAULT_ATTACHMENT_TTL_MS)); + assert_eq!(parse_ttl_ms(" 7d "), Ok(7 * 24 * 60 * 60 * 1_000)); + + assert!(parse_ttl_ms("30").is_err(), "a bare count has no unit to mean"); + assert!(parse_ttl_ms("30w").is_err(), "an unknown unit is named, not guessed"); + assert!(parse_ttl_ms("d").is_err(), "a unit with no count is not a lifetime"); + assert!(parse_ttl_ms("0d").is_err(), "a zero lifetime is expired on arrival"); + assert!( + parse_ttl_ms("999999999999999999d").is_err(), + "an overflowing lifetime is refused, never saturated into one nobody asked for" + ); + } }