1use std::{
37 path::Path,
38 sync::{
39 atomic::{AtomicU64, Ordering},
40 Arc,
41 },
42};
43
44use murmur_artifact::{ContainmentClass, PeerFilesExport};
45use serde::{Deserialize, Serialize};
46
47use crate::{
48 identity::CapsuleIdentity,
49 mac_token::{self, MacTokenError, MintKey, NONCE_BYTES},
50 outgoing::parse_host_port,
51 resource_plane::{
52 read_export_file_with_policy, resolve_relpath_beneath_root, symlink_policy, DeclaredExport,
53 ReadResponse, ResourceError, ResourceResponse,
54 },
55 trace::{timestamp_ms, ResourceTraceAppender},
56};
57
58pub const PEER_PATH_PREFIX: &str = "/resources/peer";
61
62pub const AUDIENCE_HEADER: &str = "x-murmur-audience";
64
65pub const HANDLE_ID_HEADER: &str = "x-murmur-handle-id";
67
68const TOKEN_VERSION_TAG: &str = "mh1";
70
71const PAYLOAD_VERSION: u8 = 1;
73
74const MAC_DOMAIN: &[u8] = b"murmur-peer-handle-v1";
77
78pub const PEER_INBOX_DIR: &str = "peer-in";
80
81pub type PeerMintKey = MintKey;
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct HandlePayload {
102 pub v: u8,
104 pub iss: String,
106 pub p: String,
110 pub exp: u64,
112 pub n: String,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum HandleError {
121 Malformed,
123 NotValid,
125 Expired,
127}
128
129pub fn handle_id(token: &str) -> String {
133 mac_token::token_id(token)
134}
135
136fn check_shape(token: &str) -> Result<(), HandleError> {
138 mac_token::payload_segment(TOKEN_VERSION_TAG, token)?;
139 Ok(())
140}
141
142pub fn mint(key: &PeerMintKey, payload: &HandlePayload, audience: &str) -> Result<String, String> {
149 let json = serde_json::to_vec(payload)
150 .map_err(|error| format!("failed to serialize the handle payload: {error}"))?;
151 Ok(mac_token::seal(
152 key,
153 TOKEN_VERSION_TAG,
154 MAC_DOMAIN,
155 &json,
156 &[audience],
157 ))
158}
159
160pub fn verify(
178 key: &PeerMintKey,
179 token: &str,
180 audience: &str,
181 session_id: &str,
182) -> Result<HandlePayload, HandleError> {
183 let payload_bytes = mac_token::open(key, TOKEN_VERSION_TAG, MAC_DOMAIN, token, &[audience])?;
184
185 let payload: HandlePayload =
188 serde_json::from_slice(&payload_bytes).map_err(|_| HandleError::Malformed)?;
189 if payload.v != PAYLOAD_VERSION {
190 return Err(HandleError::Malformed);
191 }
192 if payload.iss != session_id {
193 return Err(HandleError::NotValid);
194 }
195 if payload.exp <= timestamp_ms() {
196 return Err(HandleError::Expired);
197 }
198 Ok(payload)
199}
200
201pub fn decode_payload_unverified(token: &str) -> Option<HandlePayload> {
208 let payload_bytes = mac_token::payload_segment(TOKEN_VERSION_TAG, token).ok()?;
209 serde_json::from_slice(&payload_bytes).ok()
210}
211
212pub(crate) fn redact_handle_tokens(text: &str) -> std::borrow::Cow<'_, str> {
220 if !text.contains(TOKEN_VERSION_TAG) {
221 return std::borrow::Cow::Borrowed(text);
222 }
223 let bytes = text.as_bytes();
224 let prefix = format!("{TOKEN_VERSION_TAG}.");
225 let mut out = String::with_capacity(text.len());
226 let mut index = 0;
227 let mut replaced = false;
228 while index < bytes.len() {
229 if text[index..].starts_with(&prefix) {
230 if let Some(end) = token_end(&text[index..]) {
231 let token = &text[index..index + end];
232 out.push_str(&format!("<handle:{}>", handle_id(token)));
233 index += end;
234 replaced = true;
235 continue;
236 }
237 }
238 let step = text[index..].chars().next().map_or(1, char::len_utf8);
240 out.push_str(&text[index..index + step]);
241 index += step;
242 }
243 if replaced {
244 std::borrow::Cow::Owned(out)
245 } else {
246 std::borrow::Cow::Borrowed(text)
247 }
248}
249
250fn token_end(text: &str) -> Option<usize> {
255 let mut cursor = TOKEN_VERSION_TAG.len() + 1;
256 let mut segments = 0;
257 while segments < 2 {
258 let start = cursor;
259 cursor += text[cursor..]
260 .bytes()
261 .take_while(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_')
262 .count();
263 if cursor == start {
264 return None;
265 }
266 segments += 1;
267 if segments == 1 {
268 if text[cursor..].starts_with('.') {
269 cursor += 1;
270 } else {
271 return None;
272 }
273 }
274 }
275 Some(cursor)
276}
277
278pub(crate) fn redact_handles_in_json(value: &mut serde_json::Value) {
280 match value {
281 serde_json::Value::String(text) => {
282 if let std::borrow::Cow::Owned(redacted) = redact_handle_tokens(text) {
283 *text = redacted;
284 }
285 }
286 serde_json::Value::Array(items) => items.iter_mut().for_each(redact_handles_in_json),
287 serde_json::Value::Object(map) => {
288 map.values_mut().for_each(redact_handles_in_json);
289 }
290 _ => {}
291 }
292}
293
294pub fn audience_from_card(card: &serde_json::Value) -> Result<String, String> {
302 let name = card
303 .get("name")
304 .and_then(serde_json::Value::as_str)
305 .filter(|name| !name.trim().is_empty())
306 .ok_or_else(|| "the peer's agent card carries no 'name'".to_string())?;
307 let url = card
308 .get("url")
309 .and_then(serde_json::Value::as_str)
310 .filter(|url| !url.trim().is_empty())
311 .ok_or_else(|| "the peer's agent card carries no 'url'".to_string())?;
312 let host_port = parse_host_port(url)?;
313 Ok(format!("{}@{}", name.trim(), host_port).to_lowercase())
314}
315
316pub(crate) fn own_audience(identity: &CapsuleIdentity) -> String {
321 let host_port =
322 parse_host_port(&identity.capsule_url).unwrap_or_else(|_| identity.capsule_url.clone());
323 format!("{}@{}", identity.capsule_name, host_port).to_lowercase()
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum PeerError {
331 NoPeerPlane,
333 MalformedHandle,
334 MissingAudience,
335 HandleNotValid,
337 HandleExpired,
338 NotFound,
339 OutsideRoot,
340 SymlinkRefused,
341 NotARegularFile,
342 TooLarge {
343 max_bytes: u64,
344 },
345 MethodNotAllowed,
346 IoError(String),
347}
348
349impl PeerError {
350 pub fn code(&self) -> &'static str {
353 match self {
354 PeerError::NoPeerPlane => "no_peer_plane",
355 PeerError::MalformedHandle => "malformed_handle",
356 PeerError::MissingAudience => "missing_audience",
357 PeerError::HandleNotValid => "handle_not_valid",
358 PeerError::HandleExpired => "handle_expired",
359 PeerError::NotFound => "not_found",
360 PeerError::OutsideRoot => "outside_root",
361 PeerError::SymlinkRefused => "symlink_refused",
362 PeerError::NotARegularFile => "not_a_regular_file",
363 PeerError::TooLarge { .. } => "too_large",
364 PeerError::MethodNotAllowed => "method_not_allowed",
365 PeerError::IoError(_) => "io_error",
366 }
367 }
368
369 pub fn status(&self) -> u16 {
370 match self {
371 PeerError::NoPeerPlane | PeerError::NotFound => 404,
372 PeerError::MalformedHandle | PeerError::MissingAudience => 400,
373 PeerError::HandleNotValid
374 | PeerError::OutsideRoot
375 | PeerError::SymlinkRefused
376 | PeerError::NotARegularFile => 403,
377 PeerError::HandleExpired => 410,
378 PeerError::TooLarge { .. } => 413,
379 PeerError::MethodNotAllowed => 405,
380 PeerError::IoError(_) => 500,
381 }
382 }
383
384 pub fn message(&self) -> String {
387 match self {
388 PeerError::NoPeerPlane => {
389 "this capsule declares no exports.peer_files block, so it has no peer plane"
390 .to_string()
391 }
392 PeerError::MalformedHandle => "the handle is not a well-formed mh1 token".to_string(),
393 PeerError::MissingAudience => {
394 format!("a redeem must assert its audience in the {AUDIENCE_HEADER} header")
395 }
396 PeerError::HandleNotValid => "the handle is not valid for this capsule".to_string(),
397 PeerError::HandleExpired => "the handle has expired".to_string(),
398 PeerError::NotFound => "no such file under the peer export root".to_string(),
399 PeerError::OutsideRoot => {
400 "the handle names a path outside the peer export root".to_string()
401 }
402 PeerError::SymlinkRefused => {
403 "a symlink was encountered and the achieved containment class is scoped".to_string()
404 }
405 PeerError::NotARegularFile => "the handle does not name a regular file".to_string(),
406 PeerError::TooLarge { max_bytes } => {
407 format!(
408 "file exceeds the declared exports.peer_files.max_bytes of {max_bytes} bytes"
409 )
410 }
411 PeerError::MethodNotAllowed => {
412 "the peer plane serves GET only; it has no write path and no list verb".to_string()
413 }
414 PeerError::IoError(detail) => format!("read failed: {detail}"),
415 }
416 }
417}
418
419impl From<MacTokenError> for HandleError {
420 fn from(error: MacTokenError) -> Self {
421 match error {
422 MacTokenError::Malformed => HandleError::Malformed,
423 MacTokenError::NotValid => HandleError::NotValid,
424 }
425 }
426}
427
428impl From<HandleError> for PeerError {
429 fn from(error: HandleError) -> Self {
430 match error {
431 HandleError::Malformed => PeerError::MalformedHandle,
432 HandleError::NotValid => PeerError::HandleNotValid,
433 HandleError::Expired => PeerError::HandleExpired,
434 }
435 }
436}
437
438impl From<ResourceError> for PeerError {
439 fn from(error: ResourceError) -> Self {
445 match error {
446 ResourceError::NotFound => PeerError::NotFound,
447 ResourceError::OutsideRoot => PeerError::OutsideRoot,
448 ResourceError::SymlinkRefused => PeerError::SymlinkRefused,
449 ResourceError::NotARegularFile => PeerError::NotARegularFile,
450 ResourceError::TooLarge { max_bytes } => PeerError::TooLarge { max_bytes },
451 ResourceError::MethodNotAllowed => PeerError::MethodNotAllowed,
452 ResourceError::NoResourcePlane => PeerError::NoPeerPlane,
453 ResourceError::IoError(detail) => PeerError::IoError(detail),
454 }
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct MintedHandle {
463 pub handle: String,
464 pub handle_id: String,
465 pub expires_at_ms: u64,
466 pub audience: String,
467 pub path: String,
470}
471
472struct DeclaredPeerExport {
474 export: DeclaredExport,
476 key: Arc<PeerMintKey>,
479 max_ttl_secs: u64,
481}
482
483pub struct PeerPlane {
489 declared: Option<DeclaredPeerExport>,
493 session_id: String,
495 generation: Arc<AtomicU64>,
497 containment_achieved: ContainmentClass,
500 trace: Option<Arc<ResourceTraceAppender>>,
502}
503
504impl PeerPlane {
505 pub fn new(
509 accessible_workdir: &Path,
510 export: Option<(&PeerFilesExport, Arc<PeerMintKey>)>,
511 session_id: String,
512 containment_achieved: ContainmentClass,
513 generation: Arc<AtomicU64>,
514 trace: Option<Arc<ResourceTraceAppender>>,
515 ) -> Self {
516 Self {
517 declared: export.map(|(export, key)| DeclaredPeerExport {
518 export: DeclaredExport::for_peer_files(accessible_workdir, export),
519 key,
520 max_ttl_secs: export.effective_max_ttl_secs(),
521 }),
522 session_id,
523 generation,
524 containment_achieved,
525 trace,
526 }
527 }
528
529 pub fn is_declared(&self) -> bool {
531 self.declared.is_some()
532 }
533
534 pub fn generation(&self) -> u64 {
535 self.generation.load(Ordering::SeqCst)
536 }
537
538 pub fn mint_handle(
547 &self,
548 relpath: &str,
549 audience: &str,
550 ttl_secs: Option<u64>,
551 ) -> Result<MintedHandle, PeerError> {
552 let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
553 let policy = symlink_policy(self.containment_achieved);
554 let (target, canonical_relpath) =
555 resolve_relpath_beneath_root(&declared.export, relpath, policy)?;
556 let metadata = std::fs::metadata(&target).map_err(|error| match error.kind() {
559 std::io::ErrorKind::NotFound => PeerError::NotFound,
560 _ => PeerError::IoError(error.to_string()),
561 })?;
562 if !metadata.is_file() {
563 return Err(PeerError::NotARegularFile);
564 }
565
566 let ttl = ttl_secs
567 .unwrap_or(declared.max_ttl_secs)
568 .min(declared.max_ttl_secs);
569 let payload = HandlePayload {
570 v: PAYLOAD_VERSION,
571 iss: self.session_id.clone(),
572 p: canonical_relpath.clone(),
573 exp: timestamp_ms().saturating_add(ttl.saturating_mul(1000)),
574 n: mac_token::random_hex(NONCE_BYTES).map_err(PeerError::IoError)?,
575 };
576 let token = mint(&declared.key, &payload, audience).map_err(PeerError::IoError)?;
577 Ok(MintedHandle {
578 handle_id: handle_id(&token),
579 handle: token,
580 expires_at_ms: payload.exp,
581 audience: audience.to_string(),
582 path: canonical_relpath,
583 })
584 }
585
586 async fn redeem(
593 &self,
594 token: &str,
595 audience: Option<&str>,
596 ) -> Result<(HandlePayload, ReadResponse), PeerError> {
597 let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
598
599 check_shape(token).map_err(PeerError::from)?;
602
603 let audience = audience
604 .map(str::trim)
605 .filter(|audience| !audience.is_empty())
606 .ok_or(PeerError::MissingAudience)?;
607
608 let payload = verify(&declared.key, token, audience, &self.session_id)?;
609
610 let export = declared.export.clone();
611 let relpath = payload.p.clone();
612 let policy = symlink_policy(self.containment_achieved);
613 let read = tokio::task::spawn_blocking(move || {
614 read_export_file_with_policy(&export, &relpath, policy)
615 })
616 .await
617 .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))?;
618
619 Ok((payload, read))
620 }
621}
622
623pub fn is_peer_path(raw_path: &str) -> bool {
628 let path = raw_path.split('?').next().unwrap_or("");
629 path == PEER_PATH_PREFIX || path.starts_with(&format!("{PEER_PATH_PREFIX}/"))
630}
631
632pub async fn handle_peer_request(
639 plane: &PeerPlane,
640 method: &str,
641 raw_path: &str,
642 audience: Option<&str>,
643) -> ResourceResponse {
644 if method != "GET" {
647 return error_response(&PeerError::MethodNotAllowed, None, None);
648 }
649
650 let path = raw_path.split('?').next().unwrap_or("");
651 let rest = path.strip_prefix(PEER_PATH_PREFIX).unwrap_or("");
652 let Some(token) = rest.strip_prefix('/').filter(|token| !token.is_empty()) else {
656 return error_response(&PeerError::NotFound, None, None);
657 };
658
659 let generation = plane.generation();
660 let hid = handle_id(token);
661
662 match plane.redeem(token, audience).await {
663 Ok((payload, read)) => {
664 if let Some(trace) = &plane.trace {
665 trace
666 .write_peer_handle_redeem(
667 &hid,
668 Some(payload.p.clone()),
669 generation,
670 audience.map(str::to_string),
671 Some(read.bytes.len() as u64),
672 Some(read.sha256.clone()),
673 "ok",
674 None,
675 )
676 .await;
677 }
678 ResourceResponse::framed(
679 200,
680 vec![
681 (
682 "content-type".to_string(),
683 "application/octet-stream".to_string(),
684 ),
685 ("etag".to_string(), format!("\"sha256:{}\"", read.sha256)),
686 ("x-murmur-mtime-ms".to_string(), read.mtime_ms.to_string()),
687 ("x-murmur-generation".to_string(), generation.to_string()),
688 (
689 "x-murmur-containment".to_string(),
690 plane.containment_achieved.to_string(),
691 ),
692 (HANDLE_ID_HEADER.to_string(), hid),
693 ],
694 read.bytes,
695 )
696 }
697 Err(error) => {
698 if let Some(trace) = &plane.trace {
699 trace
703 .write_peer_handle_redeem(
704 &hid,
705 None,
706 generation,
707 audience.map(str::to_string),
708 None,
709 None,
710 error.code(),
711 Some(error.message()),
712 )
713 .await;
714 }
715 error_response(&error, Some(generation), Some(hid))
716 }
717 }
718}
719
720fn error_response(
721 error: &PeerError,
722 generation: Option<u64>,
723 handle_id: Option<String>,
724) -> ResourceResponse {
725 let body = serde_json::json!({"error": error.code(), "message": error.message()});
726 let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
727 if let Some(generation) = generation {
728 headers.push(("x-murmur-generation".to_string(), generation.to_string()));
729 }
730 if let Some(handle_id) = handle_id {
731 headers.push((HANDLE_ID_HEADER.to_string(), handle_id));
732 }
733 if matches!(error, PeerError::MethodNotAllowed) {
734 headers.push(("allow".to_string(), "GET".to_string()));
735 }
736 ResourceResponse::framed(
737 error.status(),
738 headers,
739 serde_json::to_vec(&body).unwrap_or_default(),
740 )
741}
742
743pub fn stored_path_for(handle_id: &str, basename_hint: Option<&str>) -> String {
752 let sanitised: String = basename_hint
753 .and_then(|hint| hint.rsplit('/').next())
754 .map(|name| {
755 name.chars()
756 .map(|c| {
757 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
758 c
759 } else {
760 '_'
761 }
762 })
763 .take(120)
764 .collect()
765 })
766 .filter(|name: &String| !name.is_empty() && name != "." && name != "..")
767 .unwrap_or_else(|| "file".to_string());
768 format!("{PEER_INBOX_DIR}/{handle_id}-{sanitised}")
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use std::fs;
775
776 fn key() -> PeerMintKey {
777 PeerMintKey::generate().unwrap()
778 }
779
780 fn tamper_segment(handle: &str, nth: usize) -> String {
787 let mut segments: Vec<String> = handle.split('.').map(str::to_string).collect();
788 let segment = &mut segments[nth];
789 let first = segment.remove(0);
790 segment.insert(0, if first == 'A' { 'B' } else { 'A' });
791 segments.join(".")
792 }
793
794 fn payload_for(path: &str, session: &str, exp: u64) -> HandlePayload {
795 HandlePayload {
796 v: 1,
797 iss: session.to_string(),
798 p: path.to_string(),
799 exp,
800 n: "0123456789abcdef0123456789abcdef".to_string(),
801 }
802 }
803
804 fn in_an_hour() -> u64 {
805 timestamp_ms() + 3_600_000
806 }
807
808 #[test]
809 fn a_minted_handle_round_trips_through_verify() {
810 let key = key();
811 let payload = payload_for("report.md", "ses_1", in_an_hour());
812 let token = mint(&key, &payload, "reporter@localhost:41234").unwrap();
813 assert!(token.starts_with("mh1."));
814 assert_eq!(token.split('.').count(), 3);
815 let verified = verify(&key, &token, "reporter@localhost:41234", "ses_1").unwrap();
816 assert_eq!(verified, payload);
817 }
818
819 #[test]
820 fn a_token_uses_only_url_safe_unpadded_base64() {
821 let key = key();
822 let token = mint(
824 &key,
825 &payload_for("a/b/c.md", "ses_padding", in_an_hour()),
826 "x@y",
827 )
828 .unwrap();
829 let segments: Vec<&str> = token.split('.').collect();
830 for segment in &segments[1..] {
831 assert!(
832 segment
833 .chars()
834 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
835 "segment '{segment}' must be base64url with no padding"
836 );
837 }
838 }
839
840 #[test]
841 fn a_tampered_payload_is_not_valid() {
842 let key = key();
843 let token = mint(
844 &key,
845 &payload_for("report.md", "ses_1", in_an_hour()),
846 "a@b",
847 )
848 .unwrap();
849 let tampered = tamper_segment(&token, 1);
850 assert_eq!(
851 verify(&key, &tampered, "a@b", "ses_1"),
852 Err(HandleError::NotValid)
853 );
854 }
855
856 #[test]
857 fn a_tampered_mac_is_not_valid() {
858 let key = key();
859 let token = mint(
860 &key,
861 &payload_for("report.md", "ses_1", in_an_hour()),
862 "a@b",
863 )
864 .unwrap();
865 let tampered = tamper_segment(&token, 2);
866 assert_eq!(
867 verify(&key, &tampered, "a@b", "ses_1"),
868 Err(HandleError::NotValid)
869 );
870 }
871
872 #[test]
873 fn the_wrong_audience_is_not_valid() {
874 let key = key();
875 let token = mint(
876 &key,
877 &payload_for("report.md", "ses_1", in_an_hour()),
878 "a@b",
879 )
880 .unwrap();
881 assert_eq!(
882 verify(&key, &token, "attacker@localhost:1", "ses_1"),
883 Err(HandleError::NotValid)
884 );
885 }
886
887 #[test]
891 fn every_refusal_cause_is_indistinguishable_on_the_wire() {
892 let key = key();
893 let other = PeerMintKey::generate().unwrap();
894 let token = mint(
895 &key,
896 &payload_for("report.md", "ses_1", in_an_hour()),
897 "a@b",
898 )
899 .unwrap();
900
901 let mut chars: Vec<char> = token.chars().collect();
902 let payload_end = token.rfind('.').unwrap();
903 chars[payload_end - 1] = if chars[payload_end - 1] == 'A' {
904 'B'
905 } else {
906 'A'
907 };
908 let tampered: String = chars.into_iter().collect();
909
910 let causes = [
911 verify(&key, &tampered, "a@b", "ses_1"),
912 verify(&other, &token, "a@b", "ses_1"),
913 verify(&key, &token, "attacker@localhost:1", "ses_1"),
914 ];
915 for cause in &causes {
916 assert_eq!(*cause, Err(HandleError::NotValid));
917 }
918 let errors: Vec<PeerError> = causes
919 .iter()
920 .map(|cause| PeerError::from(*cause.as_ref().unwrap_err()))
921 .collect();
922 for error in &errors {
923 assert_eq!(error, &PeerError::HandleNotValid);
924 assert_eq!(error.status(), 403);
925 assert_eq!(error.code(), errors[0].code());
926 assert_eq!(error.message(), errors[0].message());
927 }
928 }
929
930 #[test]
931 fn a_handle_from_another_instance_of_the_same_capsule_is_not_valid() {
932 let one = key();
933 let two = key();
934 let token = mint(
935 &one,
936 &payload_for("report.md", "ses_1", in_an_hour()),
937 "a@b",
938 )
939 .unwrap();
940 assert_eq!(
941 verify(&two, &token, "a@b", "ses_1"),
942 Err(HandleError::NotValid)
943 );
944 }
945
946 #[test]
950 fn a_foreign_issuer_is_not_valid_rather_than_a_code_of_its_own() {
951 let key = key();
952 let token = mint(
953 &key,
954 &payload_for("report.md", "ses_other", in_an_hour()),
955 "a@b",
956 )
957 .unwrap();
958 assert_eq!(
959 verify(&key, &token, "a@b", "ses_1"),
960 Err(HandleError::NotValid)
961 );
962 }
963
964 #[test]
965 fn an_expired_handle_reports_expiry() {
966 let key = key();
967 let token = mint(
968 &key,
969 &payload_for("report.md", "ses_1", timestamp_ms() - 1),
970 "a@b",
971 )
972 .unwrap();
973 assert_eq!(
974 verify(&key, &token, "a@b", "ses_1"),
975 Err(HandleError::Expired)
976 );
977 }
978
979 #[test]
980 fn every_malformed_shape_is_malformed() {
981 let key = key();
982 for candidate in [
983 "",
984 "report.md",
985 "mh1",
986 "mh1.",
987 "mh1.abc",
988 "mh1.abc.def.ghi",
989 "mh2.YWJj.YWJj",
990 "mh1.!!!.YWJj",
991 "mh1.YWJj.!!!",
992 ] {
993 assert_eq!(
994 verify(&key, candidate, "a@b", "ses_1"),
995 Err(HandleError::Malformed),
996 "'{candidate}' should be malformed"
997 );
998 }
999 }
1000
1001 #[test]
1005 fn a_non_json_or_wrong_version_payload_is_malformed_behind_the_mac() {
1006 let key = key();
1007 for payload_json in [
1008 b"not json".to_vec(),
1009 br#"{"v":2,"iss":"ses_1","p":"a","exp":99999999999999,"n":"00"}"#.to_vec(),
1010 ] {
1011 let token = mac_token::seal(&key, "mh1", MAC_DOMAIN, &payload_json, &["a@b"]);
1012 assert_eq!(
1013 verify(&key, &token, "a@b", "ses_1"),
1014 Err(HandleError::Malformed)
1015 );
1016 }
1017 }
1018
1019 #[test]
1020 fn the_audience_is_covered_by_the_mac_but_not_carried_in_the_token() {
1021 let key = key();
1022 let payload = payload_for("report.md", "ses_1", in_an_hour());
1023 let one = mint(&key, &payload, "reporter@localhost:1").unwrap();
1024 let two = mint(&key, &payload, "reporter@localhost:2").unwrap();
1025 let (one_payload, two_payload) = (
1026 one.split('.').nth(1).unwrap(),
1027 two.split('.').nth(1).unwrap(),
1028 );
1029 assert_eq!(
1030 one_payload, two_payload,
1031 "the payload segment must not vary with the audience"
1032 );
1033 assert_ne!(one, two, "the MAC must vary with the audience");
1034 let decoded = String::from_utf8(mac_token::payload_segment("mh1", &one).unwrap()).unwrap();
1035 assert!(
1036 !decoded.contains("reporter@localhost:1"),
1037 "the audience must not appear in the payload: {decoded}"
1038 );
1039 }
1040
1041 #[test]
1042 fn two_handles_for_the_same_file_and_audience_are_distinct() {
1043 let key = key();
1044 let plane_nonce_a = mac_token::random_hex(NONCE_BYTES).unwrap();
1045 let plane_nonce_b = mac_token::random_hex(NONCE_BYTES).unwrap();
1046 assert_ne!(plane_nonce_a, plane_nonce_b);
1047 let mut first = payload_for("report.md", "ses_1", in_an_hour());
1048 first.n = plane_nonce_a;
1049 let mut second = payload_for("report.md", "ses_1", in_an_hour());
1050 second.n = plane_nonce_b;
1051 assert_ne!(
1052 mint(&key, &first, "a@b").unwrap(),
1053 mint(&key, &second, "a@b").unwrap()
1054 );
1055 }
1056
1057 #[test]
1058 fn a_handle_id_is_sixteen_lowercase_hex_characters() {
1059 let id = handle_id("mh1.abc.def");
1060 assert_eq!(id.len(), 16);
1061 assert!(id
1062 .chars()
1063 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
1064 assert_eq!(id, handle_id("mh1.abc.def"), "handle_id must be stable");
1065 assert_ne!(id, handle_id("mh1.abc.deg"));
1066 }
1067
1068 #[test]
1069 fn an_audience_comes_from_the_peers_own_card() {
1070 let card = serde_json::json!({"name": "Reporter", "url": "http://localhost:41234/"});
1071 assert_eq!(
1072 audience_from_card(&card).unwrap(),
1073 "reporter@localhost:41234"
1074 );
1075 }
1076
1077 #[test]
1078 fn a_card_without_a_name_or_a_url_is_refused() {
1079 for card in [
1080 serde_json::json!({"url": "localhost:1"}),
1081 serde_json::json!({"name": "a"}),
1082 serde_json::json!({"name": "", "url": "localhost:1"}),
1083 serde_json::json!({"name": "a", "url": ""}),
1084 serde_json::json!("not an object"),
1085 ] {
1086 assert!(audience_from_card(&card).is_err(), "card: {card}");
1087 }
1088 }
1089
1090 #[test]
1091 fn own_audience_matches_what_a_peer_would_derive_from_the_card() {
1092 let identity = CapsuleIdentity {
1093 capsule_name: "Reporter".to_string(),
1094 capsule_version: "0.1.0".to_string(),
1095 session_id: "ses_1".to_string(),
1096 capsule_url: "localhost:41234".to_string(),
1097 };
1098 let card = serde_json::json!({
1099 "name": identity.capsule_name,
1100 "url": identity.capsule_url,
1101 });
1102 assert_eq!(own_audience(&identity), audience_from_card(&card).unwrap());
1103 }
1104
1105 #[test]
1106 fn every_peer_error_has_the_status_the_wire_contract_states() {
1107 let expected: &[(PeerError, u16, &str)] = &[
1108 (PeerError::NoPeerPlane, 404, "no_peer_plane"),
1109 (PeerError::MalformedHandle, 400, "malformed_handle"),
1110 (PeerError::MissingAudience, 400, "missing_audience"),
1111 (PeerError::HandleNotValid, 403, "handle_not_valid"),
1112 (PeerError::HandleExpired, 410, "handle_expired"),
1113 (PeerError::NotFound, 404, "not_found"),
1114 (PeerError::OutsideRoot, 403, "outside_root"),
1115 (PeerError::SymlinkRefused, 403, "symlink_refused"),
1116 (PeerError::NotARegularFile, 403, "not_a_regular_file"),
1117 (PeerError::TooLarge { max_bytes: 1 }, 413, "too_large"),
1118 (PeerError::MethodNotAllowed, 405, "method_not_allowed"),
1119 (PeerError::IoError("x".to_string()), 500, "io_error"),
1120 ];
1121 for (error, status, code) in expected {
1122 assert_eq!(error.status(), *status, "{code}");
1123 assert_eq!(error.code(), *code);
1124 assert!(!error.message().is_empty(), "{code}");
1125 }
1126 }
1127
1128 #[test]
1129 fn a_handle_never_survives_the_trace_boundary() {
1130 let key = key();
1131 let token = mint(
1132 &key,
1133 &payload_for("report.md", "ses_1", in_an_hour()),
1134 "a@b",
1135 )
1136 .unwrap();
1137 let id = handle_id(&token);
1138
1139 let sentence = format!("fetching {token} from the peer.");
1140 let redacted = redact_handle_tokens(&sentence);
1141 assert_eq!(redacted, format!("fetching <handle:{id}> from the peer."));
1142 assert!(!redacted.contains("mh1."));
1143
1144 let pair = format!("{token} and {token}");
1146 assert_eq!(
1147 redact_handle_tokens(&pair),
1148 format!("<handle:{id}> and <handle:{id}>")
1149 );
1150
1151 assert!(matches!(
1153 redact_handle_tokens("no handle here, just mh1 as a word"),
1154 std::borrow::Cow::Borrowed(_)
1155 ));
1156 assert!(matches!(
1157 redact_handle_tokens("nothing at all"),
1158 std::borrow::Cow::Borrowed(_)
1159 ));
1160 let dashed = format!("— {token} —");
1162 assert_eq!(redact_handle_tokens(&dashed), format!("— <handle:{id}> —"));
1163 }
1164
1165 #[test]
1166 fn a_handle_never_survives_the_trace_boundary_in_json() {
1167 let key = key();
1168 let token = mint(
1169 &key,
1170 &payload_for("report.md", "ses_1", in_an_hour()),
1171 "a@b",
1172 )
1173 .unwrap();
1174 let mut value = serde_json::json!({
1175 "peer": "localhost:1",
1176 "handle": token,
1177 "nested": {"list": [token.clone(), "unrelated"]},
1178 });
1179 redact_handles_in_json(&mut value);
1180 let rendered = value.to_string();
1181 assert!(!rendered.contains("mh1."), "{rendered}");
1182 assert!(rendered.contains(&format!("<handle:{}>", handle_id(&token))));
1183 assert_eq!(value["peer"], "localhost:1");
1184 assert_eq!(value["nested"]["list"][1], "unrelated");
1185 }
1186
1187 #[test]
1188 fn a_stored_path_is_runtime_chosen_and_sanitised() {
1189 assert_eq!(
1190 stored_path_for("3f2a", Some("report.md")),
1191 "peer-in/3f2a-report.md"
1192 );
1193 assert_eq!(
1194 stored_path_for("3f2a", Some("../../etc/passwd")),
1195 "peer-in/3f2a-passwd"
1196 );
1197 assert_eq!(stored_path_for("3f2a", Some("..")), "peer-in/3f2a-file");
1198 assert_eq!(stored_path_for("3f2a", Some("")), "peer-in/3f2a-file");
1199 assert_eq!(stored_path_for("3f2a", None), "peer-in/3f2a-file");
1200 assert_eq!(
1201 stored_path_for("3f2a", Some("a b;rm -rf.md")),
1202 "peer-in/3f2a-a_b_rm_-rf.md"
1203 );
1204 }
1205
1206 fn plane_for(root: &Path, export: PeerFilesExport) -> PeerPlane {
1209 PeerPlane::new(
1210 root,
1211 Some((&export, Arc::new(key()))),
1212 "ses_test".to_string(),
1213 ContainmentClass::Sealed,
1214 Arc::new(AtomicU64::new(0)),
1215 None,
1216 )
1217 }
1218
1219 fn undeclared_plane() -> PeerPlane {
1222 PeerPlane::new(
1223 Path::new("/nonexistent"),
1224 None,
1225 "ses_test".to_string(),
1226 ContainmentClass::Sealed,
1227 Arc::new(AtomicU64::new(0)),
1228 None,
1229 )
1230 }
1231
1232 fn peer_export(root: &str) -> PeerFilesExport {
1233 PeerFilesExport {
1234 root: root.to_string(),
1235 max_ttl_secs: Some(60),
1236 max_bytes: 1024 * 1024,
1237 }
1238 }
1239
1240 #[test]
1241 fn a_mint_names_a_file_under_the_root_and_redeems_to_its_bytes() {
1242 let dir = tempfile::tempdir().unwrap();
1243 fs::create_dir_all(dir.path().join("out")).unwrap();
1244 fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1245 let plane = plane_for(dir.path(), peer_export("out/"));
1246
1247 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1248 assert_eq!(minted.path, "report.md");
1249 assert!(minted.handle.starts_with("mh1."));
1250
1251 let runtime = tokio::runtime::Builder::new_current_thread()
1252 .enable_all()
1253 .build()
1254 .unwrap();
1255 let (payload, read) = runtime
1256 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1257 .unwrap();
1258 assert_eq!(payload.p, "report.md");
1259 assert_eq!(read.bytes, b"known bytes");
1260 }
1261
1262 #[test]
1263 fn a_mint_cannot_name_anything_outside_the_declared_subtree() {
1264 let dir = tempfile::tempdir().unwrap();
1265 fs::create_dir_all(dir.path().join("out/handoff")).unwrap();
1266 fs::write(dir.path().join("out/handoff/ok.md"), b"fine").unwrap();
1267 fs::write(dir.path().join("out/secret.md"), b"SECRET").unwrap();
1268 fs::write(dir.path().join("secret-at-top.md"), b"SECRET").unwrap();
1269 std::os::unix::fs::symlink("../secret.md", dir.path().join("out/handoff/escape.md"))
1270 .unwrap();
1271 let plane = plane_for(dir.path(), peer_export("out/handoff/"));
1272
1273 for attempt in [
1274 "../secret.md",
1275 "%2e%2e%2fsecret.md",
1276 "/etc/passwd",
1277 "escape.md",
1278 "handoff/../../secret-at-top.md",
1279 ] {
1280 let error = plane
1281 .mint_handle(attempt, "a@b", None)
1282 .expect_err("'{attempt}' must not mint");
1283 assert!(
1284 matches!(error, PeerError::OutsideRoot | PeerError::SymlinkRefused),
1285 "'{attempt}' produced {error:?}"
1286 );
1287 }
1288 assert!(plane.mint_handle("ok.md", "a@b", None).is_ok());
1289 }
1290
1291 #[test]
1292 fn a_mint_refuses_a_directory_rather_than_handing_out_a_handle_that_cannot_redeem() {
1293 let dir = tempfile::tempdir().unwrap();
1294 fs::create_dir_all(dir.path().join("out/nested")).unwrap();
1295 let plane = plane_for(dir.path(), peer_export("out/"));
1296 assert_eq!(
1297 plane.mint_handle("nested", "a@b", None).unwrap_err(),
1298 PeerError::NotARegularFile
1299 );
1300 assert_eq!(
1301 plane.mint_handle("missing.md", "a@b", None).unwrap_err(),
1302 PeerError::NotFound
1303 );
1304 }
1305
1306 #[test]
1307 fn a_ttl_may_only_narrow() {
1308 let dir = tempfile::tempdir().unwrap();
1309 fs::create_dir_all(dir.path().join("out")).unwrap();
1310 fs::write(dir.path().join("out/report.md"), b"x").unwrap();
1311 let plane = plane_for(dir.path(), peer_export("out/"));
1312
1313 let before = timestamp_ms();
1317 let clamped = plane.mint_handle("report.md", "a@b", Some(86_400)).unwrap();
1318 let after = timestamp_ms();
1319 assert!(
1320 (before + 60_000..=after + 60_000).contains(&clamped.expires_at_ms),
1321 "a ttl above max_ttl must be clamped down to it"
1322 );
1323
1324 let before = timestamp_ms();
1325 let narrowed = plane.mint_handle("report.md", "a@b", Some(5)).unwrap();
1326 let after = timestamp_ms();
1327 assert!(
1328 (before + 5_000..=after + 5_000).contains(&narrowed.expires_at_ms),
1329 "a ttl below max_ttl is applied as given"
1330 );
1331
1332 let before = timestamp_ms();
1333 let defaulted = plane.mint_handle("report.md", "a@b", None).unwrap();
1334 let after = timestamp_ms();
1335 assert!(
1336 (before + 60_000..=after + 60_000).contains(&defaulted.expires_at_ms),
1337 "an absent ttl means max_ttl"
1338 );
1339 }
1340
1341 #[test]
1344 fn a_rewritten_file_redeems_to_its_current_bytes() {
1345 let dir = tempfile::tempdir().unwrap();
1346 fs::create_dir_all(dir.path().join("out")).unwrap();
1347 fs::write(dir.path().join("out/report.md"), b"first").unwrap();
1348 let plane = plane_for(dir.path(), peer_export("out/"));
1349 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1350
1351 let runtime = tokio::runtime::Builder::new_current_thread()
1352 .enable_all()
1353 .build()
1354 .unwrap();
1355 let (_, first) = runtime
1356 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1357 .unwrap();
1358 assert_eq!(first.bytes, b"first");
1359
1360 fs::write(dir.path().join("out/report.md.tmp"), b"second and longer").unwrap();
1361 fs::rename(
1362 dir.path().join("out/report.md.tmp"),
1363 dir.path().join("out/report.md"),
1364 )
1365 .unwrap();
1366
1367 let (_, second) = runtime
1368 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1369 .unwrap();
1370 assert_eq!(second.bytes, b"second and longer");
1371 assert_ne!(first.sha256, second.sha256);
1372 }
1373
1374 #[test]
1375 fn a_redeem_is_idempotent() {
1376 let dir = tempfile::tempdir().unwrap();
1377 fs::create_dir_all(dir.path().join("out")).unwrap();
1378 fs::write(dir.path().join("out/report.md"), b"same bytes").unwrap();
1379 let plane = plane_for(dir.path(), peer_export("out/"));
1380 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1381
1382 let runtime = tokio::runtime::Builder::new_current_thread()
1383 .enable_all()
1384 .build()
1385 .unwrap();
1386 let first = runtime
1387 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1388 .unwrap()
1389 .1;
1390 for _ in 0..4 {
1391 let again = runtime
1392 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1393 .unwrap()
1394 .1;
1395 assert_eq!(again.bytes, first.bytes);
1396 assert_eq!(again.sha256, first.sha256);
1397 }
1398 }
1399
1400 #[test]
1401 fn a_file_above_max_bytes_is_refused() {
1402 let dir = tempfile::tempdir().unwrap();
1403 fs::create_dir_all(dir.path().join("out")).unwrap();
1404 fs::write(dir.path().join("out/big.bin"), vec![0u8; 64]).unwrap();
1405 let plane = plane_for(
1406 dir.path(),
1407 PeerFilesExport {
1408 root: "out/".to_string(),
1409 max_ttl_secs: Some(60),
1410 max_bytes: 16,
1411 },
1412 );
1413 let minted = plane.mint_handle("big.bin", "a@b", None).unwrap();
1414 let runtime = tokio::runtime::Builder::new_current_thread()
1415 .enable_all()
1416 .build()
1417 .unwrap();
1418 assert_eq!(
1419 runtime
1420 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1421 .unwrap_err(),
1422 PeerError::TooLarge { max_bytes: 16 }
1423 );
1424 }
1425
1426 #[test]
1429 fn only_the_peer_segment_routes_to_the_peer_plane() {
1430 assert!(is_peer_path("/resources/peer"));
1431 assert!(is_peer_path("/resources/peer/"));
1432 assert!(is_peer_path("/resources/peer/mh1.a.b"));
1433 assert!(is_peer_path("/resources/peer?x=1"));
1434 assert!(!is_peer_path("/resources/peerless/x"));
1435 assert!(!is_peer_path("/resources/files/report.md"));
1436 assert!(!is_peer_path("/"));
1437 }
1438
1439 fn respond(
1440 plane: &PeerPlane,
1441 method: &str,
1442 path: &str,
1443 audience: Option<&str>,
1444 ) -> ResourceResponse {
1445 tokio::runtime::Builder::new_current_thread()
1446 .enable_all()
1447 .build()
1448 .unwrap()
1449 .block_on(handle_peer_request(plane, method, path, audience))
1450 }
1451
1452 fn body_error(response: &ResourceResponse) -> serde_json::Value {
1453 serde_json::from_slice(&response.body).unwrap()
1454 }
1455
1456 #[test]
1457 fn an_undeclared_capsule_answers_no_peer_plane_and_mints_nothing() {
1458 let plane = undeclared_plane();
1459 assert!(!plane.is_declared());
1460
1461 let response = respond(&plane, "GET", "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1462 assert_eq!(response.status, 404);
1463 assert_eq!(body_error(&response)["error"], "no_peer_plane");
1464
1465 assert_eq!(
1466 plane.mint_handle("report.md", "a@b", None).unwrap_err(),
1467 PeerError::NoPeerPlane
1468 );
1469 }
1470
1471 #[test]
1472 fn there_is_no_list_verb() {
1473 let plane = undeclared_plane();
1474 for path in ["/resources/peer", "/resources/peer/"] {
1475 let response = respond(&plane, "GET", path, Some("a@b"));
1476 assert_eq!(response.status, 404, "{path}");
1477 assert_eq!(body_error(&response)["error"], "not_found", "{path}");
1478 let body = String::from_utf8_lossy(&response.body);
1479 assert!(
1480 !body.contains('['),
1481 "a listing must not be reachable: {body}"
1482 );
1483 }
1484 }
1485
1486 #[test]
1487 fn every_write_verb_is_refused_with_an_allow_header() {
1488 let plane = undeclared_plane();
1489 for method in ["POST", "PUT", "PATCH", "DELETE", "HEAD"] {
1490 let response = respond(&plane, method, "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1491 assert_eq!(response.status, 405, "{method}");
1492 assert_eq!(body_error(&response)["error"], "method_not_allowed");
1493 assert!(response
1494 .headers
1495 .iter()
1496 .any(|(name, value)| name == "allow" && value == "GET"));
1497 }
1498 }
1499
1500 #[test]
1501 fn a_declared_plane_serves_a_redeem_and_refuses_every_tampered_form_identically() {
1502 let dir = tempfile::tempdir().unwrap();
1503 fs::create_dir_all(dir.path().join("out")).unwrap();
1504 fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1505 let plane = plane_for(dir.path(), peer_export("out/"));
1506 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1507
1508 let ok = respond(
1509 &plane,
1510 "GET",
1511 &format!("/resources/peer/{}", minted.handle),
1512 Some("a@b"),
1513 );
1514 assert_eq!(ok.status, 200);
1515 assert_eq!(ok.body, b"known bytes");
1516 assert!(ok
1517 .headers
1518 .iter()
1519 .any(|(name, value)| name == HANDLE_ID_HEADER && value == &minted.handle_id));
1520 assert!(
1521 !ok.headers
1522 .iter()
1523 .any(|(name, _)| name == "x-murmur-export-root"),
1524 "the peer plane discloses no path structure"
1525 );
1526
1527 let bad_payload = tamper_segment(&minted.handle, 1);
1528 let bad_mac = tamper_segment(&minted.handle, 2);
1529
1530 let refusals = [
1531 respond(
1532 &plane,
1533 "GET",
1534 &format!("/resources/peer/{bad_payload}"),
1535 Some("a@b"),
1536 ),
1537 respond(
1538 &plane,
1539 "GET",
1540 &format!("/resources/peer/{bad_mac}"),
1541 Some("a@b"),
1542 ),
1543 respond(
1544 &plane,
1545 "GET",
1546 &format!("/resources/peer/{}", minted.handle),
1547 Some("attacker@localhost:1"),
1548 ),
1549 ];
1550 for refusal in &refusals {
1551 assert_eq!(refusal.status, 403);
1552 assert_eq!(refusal.body, refusals[0].body);
1553 assert_eq!(body_error(refusal)["error"], "handle_not_valid");
1554 assert!(
1555 !String::from_utf8_lossy(&refusal.body).contains("known bytes"),
1556 "a refusal must serve none of the file's bytes"
1557 );
1558 }
1559
1560 let no_audience = respond(
1561 &plane,
1562 "GET",
1563 &format!("/resources/peer/{}", minted.handle),
1564 None,
1565 );
1566 assert_eq!(no_audience.status, 400);
1567 assert_eq!(body_error(&no_audience)["error"], "missing_audience");
1568
1569 let not_a_token = respond(&plane, "GET", "/resources/peer/report.md", Some("a@b"));
1570 assert_eq!(not_a_token.status, 400);
1571 assert_eq!(body_error(¬_a_token)["error"], "malformed_handle");
1572 }
1573
1574 #[test]
1577 fn a_malformed_shape_is_reported_before_the_audience_is_required() {
1578 let dir = tempfile::tempdir().unwrap();
1579 fs::create_dir_all(dir.path().join("out")).unwrap();
1580 let plane = plane_for(dir.path(), peer_export("out/"));
1581 let response = respond(&plane, "GET", "/resources/peer/not-a-token", None);
1582 assert_eq!(body_error(&response)["error"], "malformed_handle");
1583 }
1584
1585 #[test]
1586 fn an_expired_handle_is_refused_with_its_own_code() {
1587 let dir = tempfile::tempdir().unwrap();
1588 fs::create_dir_all(dir.path().join("out")).unwrap();
1589 fs::write(dir.path().join("out/report.md"), b"bytes").unwrap();
1590 let plane = plane_for(
1591 dir.path(),
1592 PeerFilesExport {
1593 root: "out/".to_string(),
1594 max_ttl_secs: Some(1),
1595 max_bytes: 1024,
1596 },
1597 );
1598 let minted = plane.mint_handle("report.md", "a@b", Some(1)).unwrap();
1599 std::thread::sleep(std::time::Duration::from_millis(1100));
1600 let response = respond(
1601 &plane,
1602 "GET",
1603 &format!("/resources/peer/{}", minted.handle),
1604 Some("a@b"),
1605 );
1606 assert_eq!(response.status, 410);
1607 assert_eq!(body_error(&response)["error"], "handle_expired");
1608 }
1609}