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> {
304 let name = card
305 .get("name")
306 .and_then(serde_json::Value::as_str)
307 .filter(|name| !name.trim().is_empty())
308 .ok_or_else(|| "the peer's agent card carries no 'name'".to_string())?;
309 let url = crate::identity::jsonrpc_interface_url(card)
310 .filter(|url| !url.trim().is_empty())
311 .ok_or_else(|| {
312 "the peer's agent card has no 'supportedInterfaces' entry with 'protocolBinding' \
313 JSONRPC and a 'url'"
314 .to_string()
315 })?;
316 let host_port = parse_host_port(url)?;
317 Ok(format!("{}@{}", name.trim(), host_port).to_lowercase())
318}
319
320pub(crate) fn own_audience(identity: &CapsuleIdentity) -> String {
326 let host_port =
327 parse_host_port(&identity.capsule_url).unwrap_or_else(|_| identity.capsule_url.clone());
328 format!("{}@{}", identity.capsule_name, host_port).to_lowercase()
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum PeerError {
336 NoPeerPlane,
338 MalformedHandle,
339 MissingAudience,
340 HandleNotValid,
342 HandleExpired,
343 NotFound,
344 OutsideRoot,
345 SymlinkRefused,
346 NotARegularFile,
347 TooLarge {
348 max_bytes: u64,
349 },
350 MethodNotAllowed,
351 IoError(String),
352}
353
354impl PeerError {
355 pub fn code(&self) -> &'static str {
358 match self {
359 PeerError::NoPeerPlane => "no_peer_plane",
360 PeerError::MalformedHandle => "malformed_handle",
361 PeerError::MissingAudience => "missing_audience",
362 PeerError::HandleNotValid => "handle_not_valid",
363 PeerError::HandleExpired => "handle_expired",
364 PeerError::NotFound => "not_found",
365 PeerError::OutsideRoot => "outside_root",
366 PeerError::SymlinkRefused => "symlink_refused",
367 PeerError::NotARegularFile => "not_a_regular_file",
368 PeerError::TooLarge { .. } => "too_large",
369 PeerError::MethodNotAllowed => "method_not_allowed",
370 PeerError::IoError(_) => "io_error",
371 }
372 }
373
374 pub fn status(&self) -> u16 {
375 match self {
376 PeerError::NoPeerPlane | PeerError::NotFound => 404,
377 PeerError::MalformedHandle | PeerError::MissingAudience => 400,
378 PeerError::HandleNotValid
379 | PeerError::OutsideRoot
380 | PeerError::SymlinkRefused
381 | PeerError::NotARegularFile => 403,
382 PeerError::HandleExpired => 410,
383 PeerError::TooLarge { .. } => 413,
384 PeerError::MethodNotAllowed => 405,
385 PeerError::IoError(_) => 500,
386 }
387 }
388
389 pub fn message(&self) -> String {
392 match self {
393 PeerError::NoPeerPlane => {
394 "this capsule declares no exports.peer_files block, so it has no peer plane"
395 .to_string()
396 }
397 PeerError::MalformedHandle => "the handle is not a well-formed mh1 token".to_string(),
398 PeerError::MissingAudience => {
399 format!("a redeem must assert its audience in the {AUDIENCE_HEADER} header")
400 }
401 PeerError::HandleNotValid => "the handle is not valid for this capsule".to_string(),
402 PeerError::HandleExpired => "the handle has expired".to_string(),
403 PeerError::NotFound => "no such file under the peer export root".to_string(),
404 PeerError::OutsideRoot => {
405 "the handle names a path outside the peer export root".to_string()
406 }
407 PeerError::SymlinkRefused => {
408 "a symlink was encountered and the achieved containment class is scoped".to_string()
409 }
410 PeerError::NotARegularFile => "the handle does not name a regular file".to_string(),
411 PeerError::TooLarge { max_bytes } => {
412 format!(
413 "file exceeds the declared exports.peer_files.max_bytes of {max_bytes} bytes"
414 )
415 }
416 PeerError::MethodNotAllowed => {
417 "the peer plane serves GET only; it has no write path and no list verb".to_string()
418 }
419 PeerError::IoError(detail) => format!("read failed: {detail}"),
420 }
421 }
422}
423
424impl From<MacTokenError> for HandleError {
425 fn from(error: MacTokenError) -> Self {
426 match error {
427 MacTokenError::Malformed => HandleError::Malformed,
428 MacTokenError::NotValid => HandleError::NotValid,
429 }
430 }
431}
432
433impl From<HandleError> for PeerError {
434 fn from(error: HandleError) -> Self {
435 match error {
436 HandleError::Malformed => PeerError::MalformedHandle,
437 HandleError::NotValid => PeerError::HandleNotValid,
438 HandleError::Expired => PeerError::HandleExpired,
439 }
440 }
441}
442
443impl From<ResourceError> for PeerError {
444 fn from(error: ResourceError) -> Self {
450 match error {
451 ResourceError::NotFound => PeerError::NotFound,
452 ResourceError::OutsideRoot => PeerError::OutsideRoot,
453 ResourceError::SymlinkRefused => PeerError::SymlinkRefused,
454 ResourceError::NotARegularFile => PeerError::NotARegularFile,
455 ResourceError::TooLarge { max_bytes } => PeerError::TooLarge { max_bytes },
456 ResourceError::MethodNotAllowed => PeerError::MethodNotAllowed,
457 ResourceError::NoResourcePlane => PeerError::NoPeerPlane,
458 ResourceError::IoError(detail) => PeerError::IoError(detail),
459 }
460 }
461}
462
463#[derive(Debug, Clone, PartialEq, Eq)]
467pub struct MintedHandle {
468 pub handle: String,
469 pub handle_id: String,
470 pub expires_at_ms: u64,
471 pub audience: String,
472 pub path: String,
475}
476
477struct DeclaredPeerExport {
479 export: DeclaredExport,
481 key: Arc<PeerMintKey>,
484 max_ttl_secs: u64,
486}
487
488pub struct PeerPlane {
494 declared: Option<DeclaredPeerExport>,
498 session_id: String,
500 generation: Arc<AtomicU64>,
502 containment_achieved: ContainmentClass,
505 trace: Option<Arc<ResourceTraceAppender>>,
507}
508
509impl PeerPlane {
510 pub fn new(
514 accessible_workdir: &Path,
515 export: Option<(&PeerFilesExport, Arc<PeerMintKey>)>,
516 session_id: String,
517 containment_achieved: ContainmentClass,
518 generation: Arc<AtomicU64>,
519 trace: Option<Arc<ResourceTraceAppender>>,
520 ) -> Self {
521 Self {
522 declared: export.map(|(export, key)| DeclaredPeerExport {
523 export: DeclaredExport::for_peer_files(accessible_workdir, export),
524 key,
525 max_ttl_secs: export.effective_max_ttl_secs(),
526 }),
527 session_id,
528 generation,
529 containment_achieved,
530 trace,
531 }
532 }
533
534 pub fn is_declared(&self) -> bool {
536 self.declared.is_some()
537 }
538
539 pub fn generation(&self) -> u64 {
540 self.generation.load(Ordering::SeqCst)
541 }
542
543 pub fn mint_handle(
552 &self,
553 relpath: &str,
554 audience: &str,
555 ttl_secs: Option<u64>,
556 ) -> Result<MintedHandle, PeerError> {
557 let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
558 let policy = symlink_policy(self.containment_achieved);
559 let (target, canonical_relpath) =
560 resolve_relpath_beneath_root(&declared.export, relpath, policy)?;
561 let metadata = std::fs::metadata(&target).map_err(|error| match error.kind() {
564 std::io::ErrorKind::NotFound => PeerError::NotFound,
565 _ => PeerError::IoError(error.to_string()),
566 })?;
567 if !metadata.is_file() {
568 return Err(PeerError::NotARegularFile);
569 }
570
571 let ttl = ttl_secs
572 .unwrap_or(declared.max_ttl_secs)
573 .min(declared.max_ttl_secs);
574 let payload = HandlePayload {
575 v: PAYLOAD_VERSION,
576 iss: self.session_id.clone(),
577 p: canonical_relpath.clone(),
578 exp: timestamp_ms().saturating_add(ttl.saturating_mul(1000)),
579 n: mac_token::random_hex(NONCE_BYTES).map_err(PeerError::IoError)?,
580 };
581 let token = mint(&declared.key, &payload, audience).map_err(PeerError::IoError)?;
582 Ok(MintedHandle {
583 handle_id: handle_id(&token),
584 handle: token,
585 expires_at_ms: payload.exp,
586 audience: audience.to_string(),
587 path: canonical_relpath,
588 })
589 }
590
591 async fn redeem(
598 &self,
599 token: &str,
600 audience: Option<&str>,
601 ) -> Result<(HandlePayload, ReadResponse), PeerError> {
602 let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
603
604 check_shape(token).map_err(PeerError::from)?;
607
608 let audience = audience
609 .map(str::trim)
610 .filter(|audience| !audience.is_empty())
611 .ok_or(PeerError::MissingAudience)?;
612
613 let payload = verify(&declared.key, token, audience, &self.session_id)?;
614
615 let export = declared.export.clone();
616 let relpath = payload.p.clone();
617 let policy = symlink_policy(self.containment_achieved);
618 let read = tokio::task::spawn_blocking(move || {
619 read_export_file_with_policy(&export, &relpath, policy)
620 })
621 .await
622 .unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))?;
623
624 Ok((payload, read))
625 }
626}
627
628pub fn is_peer_path(raw_path: &str) -> bool {
633 let path = raw_path.split('?').next().unwrap_or("");
634 path == PEER_PATH_PREFIX || path.starts_with(&format!("{PEER_PATH_PREFIX}/"))
635}
636
637pub async fn handle_peer_request(
644 plane: &PeerPlane,
645 method: &str,
646 raw_path: &str,
647 audience: Option<&str>,
648) -> ResourceResponse {
649 if method != "GET" {
652 return error_response(&PeerError::MethodNotAllowed, None, None);
653 }
654
655 let path = raw_path.split('?').next().unwrap_or("");
656 let rest = path.strip_prefix(PEER_PATH_PREFIX).unwrap_or("");
657 let Some(token) = rest.strip_prefix('/').filter(|token| !token.is_empty()) else {
661 return error_response(&PeerError::NotFound, None, None);
662 };
663
664 let generation = plane.generation();
665 let hid = handle_id(token);
666
667 match plane.redeem(token, audience).await {
668 Ok((payload, read)) => {
669 if let Some(trace) = &plane.trace {
670 trace
671 .write_peer_handle_redeem(
672 &hid,
673 Some(payload.p.clone()),
674 generation,
675 audience.map(str::to_string),
676 Some(read.bytes.len() as u64),
677 Some(read.sha256.clone()),
678 "ok",
679 None,
680 )
681 .await;
682 }
683 ResourceResponse::framed(
684 200,
685 vec![
686 (
687 "content-type".to_string(),
688 "application/octet-stream".to_string(),
689 ),
690 ("etag".to_string(), format!("\"sha256:{}\"", read.sha256)),
691 ("x-murmur-mtime-ms".to_string(), read.mtime_ms.to_string()),
692 ("x-murmur-generation".to_string(), generation.to_string()),
693 (
694 "x-murmur-containment".to_string(),
695 plane.containment_achieved.to_string(),
696 ),
697 (HANDLE_ID_HEADER.to_string(), hid),
698 ],
699 read.bytes,
700 )
701 }
702 Err(error) => {
703 if let Some(trace) = &plane.trace {
704 trace
708 .write_peer_handle_redeem(
709 &hid,
710 None,
711 generation,
712 audience.map(str::to_string),
713 None,
714 None,
715 error.code(),
716 Some(error.message()),
717 )
718 .await;
719 }
720 error_response(&error, Some(generation), Some(hid))
721 }
722 }
723}
724
725fn error_response(
726 error: &PeerError,
727 generation: Option<u64>,
728 handle_id: Option<String>,
729) -> ResourceResponse {
730 let body = serde_json::json!({"error": error.code(), "message": error.message()});
731 let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
732 if let Some(generation) = generation {
733 headers.push(("x-murmur-generation".to_string(), generation.to_string()));
734 }
735 if let Some(handle_id) = handle_id {
736 headers.push((HANDLE_ID_HEADER.to_string(), handle_id));
737 }
738 if matches!(error, PeerError::MethodNotAllowed) {
739 headers.push(("allow".to_string(), "GET".to_string()));
740 }
741 ResourceResponse::framed(
742 error.status(),
743 headers,
744 serde_json::to_vec(&body).unwrap_or_default(),
745 )
746}
747
748pub fn stored_path_for(handle_id: &str, basename_hint: Option<&str>) -> String {
757 let sanitised: String = basename_hint
758 .and_then(|hint| hint.rsplit('/').next())
759 .map(|name| {
760 name.chars()
761 .map(|c| {
762 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
763 c
764 } else {
765 '_'
766 }
767 })
768 .take(120)
769 .collect()
770 })
771 .filter(|name: &String| !name.is_empty() && name != "." && name != "..")
772 .unwrap_or_else(|| "file".to_string());
773 format!("{PEER_INBOX_DIR}/{handle_id}-{sanitised}")
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use std::fs;
780
781 fn key() -> PeerMintKey {
782 PeerMintKey::generate().unwrap()
783 }
784
785 fn tamper_segment(handle: &str, nth: usize) -> String {
792 let mut segments: Vec<String> = handle.split('.').map(str::to_string).collect();
793 let segment = &mut segments[nth];
794 let first = segment.remove(0);
795 segment.insert(0, if first == 'A' { 'B' } else { 'A' });
796 segments.join(".")
797 }
798
799 fn payload_for(path: &str, session: &str, exp: u64) -> HandlePayload {
800 HandlePayload {
801 v: 1,
802 iss: session.to_string(),
803 p: path.to_string(),
804 exp,
805 n: "0123456789abcdef0123456789abcdef".to_string(),
806 }
807 }
808
809 fn in_an_hour() -> u64 {
810 timestamp_ms() + 3_600_000
811 }
812
813 #[test]
814 fn a_minted_handle_round_trips_through_verify() {
815 let key = key();
816 let payload = payload_for("report.md", "ses_1", in_an_hour());
817 let token = mint(&key, &payload, "reporter@localhost:41234").unwrap();
818 assert!(token.starts_with("mh1."));
819 assert_eq!(token.split('.').count(), 3);
820 let verified = verify(&key, &token, "reporter@localhost:41234", "ses_1").unwrap();
821 assert_eq!(verified, payload);
822 }
823
824 #[test]
825 fn a_token_uses_only_url_safe_unpadded_base64() {
826 let key = key();
827 let token = mint(
829 &key,
830 &payload_for("a/b/c.md", "ses_padding", in_an_hour()),
831 "x@y",
832 )
833 .unwrap();
834 let segments: Vec<&str> = token.split('.').collect();
835 for segment in &segments[1..] {
836 assert!(
837 segment
838 .chars()
839 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
840 "segment '{segment}' must be base64url with no padding"
841 );
842 }
843 }
844
845 #[test]
846 fn a_tampered_payload_is_not_valid() {
847 let key = key();
848 let token = mint(
849 &key,
850 &payload_for("report.md", "ses_1", in_an_hour()),
851 "a@b",
852 )
853 .unwrap();
854 let tampered = tamper_segment(&token, 1);
855 assert_eq!(
856 verify(&key, &tampered, "a@b", "ses_1"),
857 Err(HandleError::NotValid)
858 );
859 }
860
861 #[test]
862 fn a_tampered_mac_is_not_valid() {
863 let key = key();
864 let token = mint(
865 &key,
866 &payload_for("report.md", "ses_1", in_an_hour()),
867 "a@b",
868 )
869 .unwrap();
870 let tampered = tamper_segment(&token, 2);
871 assert_eq!(
872 verify(&key, &tampered, "a@b", "ses_1"),
873 Err(HandleError::NotValid)
874 );
875 }
876
877 #[test]
878 fn the_wrong_audience_is_not_valid() {
879 let key = key();
880 let token = mint(
881 &key,
882 &payload_for("report.md", "ses_1", in_an_hour()),
883 "a@b",
884 )
885 .unwrap();
886 assert_eq!(
887 verify(&key, &token, "attacker@localhost:1", "ses_1"),
888 Err(HandleError::NotValid)
889 );
890 }
891
892 #[test]
896 fn every_refusal_cause_is_indistinguishable_on_the_wire() {
897 let key = key();
898 let other = PeerMintKey::generate().unwrap();
899 let token = mint(
900 &key,
901 &payload_for("report.md", "ses_1", in_an_hour()),
902 "a@b",
903 )
904 .unwrap();
905
906 let mut chars: Vec<char> = token.chars().collect();
907 let payload_end = token.rfind('.').unwrap();
908 chars[payload_end - 1] = if chars[payload_end - 1] == 'A' {
909 'B'
910 } else {
911 'A'
912 };
913 let tampered: String = chars.into_iter().collect();
914
915 let causes = [
916 verify(&key, &tampered, "a@b", "ses_1"),
917 verify(&other, &token, "a@b", "ses_1"),
918 verify(&key, &token, "attacker@localhost:1", "ses_1"),
919 ];
920 for cause in &causes {
921 assert_eq!(*cause, Err(HandleError::NotValid));
922 }
923 let errors: Vec<PeerError> = causes
924 .iter()
925 .map(|cause| PeerError::from(*cause.as_ref().unwrap_err()))
926 .collect();
927 for error in &errors {
928 assert_eq!(error, &PeerError::HandleNotValid);
929 assert_eq!(error.status(), 403);
930 assert_eq!(error.code(), errors[0].code());
931 assert_eq!(error.message(), errors[0].message());
932 }
933 }
934
935 #[test]
936 fn a_handle_from_another_instance_of_the_same_capsule_is_not_valid() {
937 let one = key();
938 let two = key();
939 let token = mint(
940 &one,
941 &payload_for("report.md", "ses_1", in_an_hour()),
942 "a@b",
943 )
944 .unwrap();
945 assert_eq!(
946 verify(&two, &token, "a@b", "ses_1"),
947 Err(HandleError::NotValid)
948 );
949 }
950
951 #[test]
955 fn a_foreign_issuer_is_not_valid_rather_than_a_code_of_its_own() {
956 let key = key();
957 let token = mint(
958 &key,
959 &payload_for("report.md", "ses_other", in_an_hour()),
960 "a@b",
961 )
962 .unwrap();
963 assert_eq!(
964 verify(&key, &token, "a@b", "ses_1"),
965 Err(HandleError::NotValid)
966 );
967 }
968
969 #[test]
970 fn an_expired_handle_reports_expiry() {
971 let key = key();
972 let token = mint(
973 &key,
974 &payload_for("report.md", "ses_1", timestamp_ms() - 1),
975 "a@b",
976 )
977 .unwrap();
978 assert_eq!(
979 verify(&key, &token, "a@b", "ses_1"),
980 Err(HandleError::Expired)
981 );
982 }
983
984 #[test]
985 fn every_malformed_shape_is_malformed() {
986 let key = key();
987 for candidate in [
988 "",
989 "report.md",
990 "mh1",
991 "mh1.",
992 "mh1.abc",
993 "mh1.abc.def.ghi",
994 "mh2.YWJj.YWJj",
995 "mh1.!!!.YWJj",
996 "mh1.YWJj.!!!",
997 ] {
998 assert_eq!(
999 verify(&key, candidate, "a@b", "ses_1"),
1000 Err(HandleError::Malformed),
1001 "'{candidate}' should be malformed"
1002 );
1003 }
1004 }
1005
1006 #[test]
1010 fn a_non_json_or_wrong_version_payload_is_malformed_behind_the_mac() {
1011 let key = key();
1012 for payload_json in [
1013 b"not json".to_vec(),
1014 br#"{"v":2,"iss":"ses_1","p":"a","exp":99999999999999,"n":"00"}"#.to_vec(),
1015 ] {
1016 let token = mac_token::seal(&key, "mh1", MAC_DOMAIN, &payload_json, &["a@b"]);
1017 assert_eq!(
1018 verify(&key, &token, "a@b", "ses_1"),
1019 Err(HandleError::Malformed)
1020 );
1021 }
1022 }
1023
1024 #[test]
1025 fn the_audience_is_covered_by_the_mac_but_not_carried_in_the_token() {
1026 let key = key();
1027 let payload = payload_for("report.md", "ses_1", in_an_hour());
1028 let one = mint(&key, &payload, "reporter@localhost:1").unwrap();
1029 let two = mint(&key, &payload, "reporter@localhost:2").unwrap();
1030 let (one_payload, two_payload) = (
1031 one.split('.').nth(1).unwrap(),
1032 two.split('.').nth(1).unwrap(),
1033 );
1034 assert_eq!(
1035 one_payload, two_payload,
1036 "the payload segment must not vary with the audience"
1037 );
1038 assert_ne!(one, two, "the MAC must vary with the audience");
1039 let decoded = String::from_utf8(mac_token::payload_segment("mh1", &one).unwrap()).unwrap();
1040 assert!(
1041 !decoded.contains("reporter@localhost:1"),
1042 "the audience must not appear in the payload: {decoded}"
1043 );
1044 }
1045
1046 #[test]
1047 fn two_handles_for_the_same_file_and_audience_are_distinct() {
1048 let key = key();
1049 let plane_nonce_a = mac_token::random_hex(NONCE_BYTES).unwrap();
1050 let plane_nonce_b = mac_token::random_hex(NONCE_BYTES).unwrap();
1051 assert_ne!(plane_nonce_a, plane_nonce_b);
1052 let mut first = payload_for("report.md", "ses_1", in_an_hour());
1053 first.n = plane_nonce_a;
1054 let mut second = payload_for("report.md", "ses_1", in_an_hour());
1055 second.n = plane_nonce_b;
1056 assert_ne!(
1057 mint(&key, &first, "a@b").unwrap(),
1058 mint(&key, &second, "a@b").unwrap()
1059 );
1060 }
1061
1062 #[test]
1063 fn a_handle_id_is_sixteen_lowercase_hex_characters() {
1064 let id = handle_id("mh1.abc.def");
1065 assert_eq!(id.len(), 16);
1066 assert!(id
1067 .chars()
1068 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
1069 assert_eq!(id, handle_id("mh1.abc.def"), "handle_id must be stable");
1070 assert_ne!(id, handle_id("mh1.abc.deg"));
1071 }
1072
1073 fn card_with_interfaces(name: &str, interfaces: serde_json::Value) -> serde_json::Value {
1075 serde_json::json!({ "name": name, "supportedInterfaces": interfaces })
1076 }
1077
1078 #[test]
1079 fn an_audience_comes_from_the_peers_own_card() {
1080 let card = card_with_interfaces(
1081 "Reporter",
1082 serde_json::json!([{
1083 "url": "http://localhost:41234/",
1084 "protocolBinding": "JSONRPC",
1085 "protocolVersion": "0.3",
1086 }]),
1087 );
1088 assert_eq!(
1089 audience_from_card(&card).unwrap(),
1090 "reporter@localhost:41234"
1091 );
1092 }
1093
1094 #[test]
1095 fn a_card_without_a_name_or_a_jsonrpc_url_is_refused() {
1096 let jsonrpc = serde_json::json!([{
1097 "url": "http://localhost:1",
1098 "protocolBinding": "JSONRPC",
1099 "protocolVersion": "0.3",
1100 }]);
1101 for card in [
1102 serde_json::json!({"name": "a"}),
1103 card_with_interfaces("a", serde_json::json!([])),
1104 card_with_interfaces(
1105 "a",
1106 serde_json::json!([{
1107 "url": "https://localhost:1",
1108 "protocolBinding": "GRPC",
1109 "protocolVersion": "1.0",
1110 }]),
1111 ),
1112 card_with_interfaces(
1113 "a",
1114 serde_json::json!([{"url": "", "protocolBinding": "JSONRPC", "protocolVersion": "0.3"}]),
1115 ),
1116 serde_json::json!({"supportedInterfaces": jsonrpc}),
1117 card_with_interfaces("", jsonrpc.clone()),
1118 serde_json::json!("not an object"),
1119 serde_json::json!({"name": "a", "url": "localhost:1"}),
1120 ] {
1121 assert!(audience_from_card(&card).is_err(), "card: {card}");
1122 }
1123 }
1124
1125 #[test]
1126 fn a_refusal_names_what_the_card_lacks() {
1127 assert_eq!(
1128 audience_from_card(&serde_json::json!({"url": "localhost:1"})).unwrap_err(),
1129 "the peer's agent card carries no 'name'"
1130 );
1131 assert!(
1132 audience_from_card(&serde_json::json!({"name": "a", "url": "localhost:1"}))
1133 .unwrap_err()
1134 .contains("'supportedInterfaces' entry with 'protocolBinding' JSONRPC"),
1135 );
1136 }
1137
1138 #[test]
1139 fn own_audience_matches_what_a_peer_would_derive_from_the_card() {
1140 for capsule_url in ["localhost:41234", "http://127.0.0.1:41234"] {
1141 let identity = CapsuleIdentity {
1142 capsule_name: "Reporter".to_string(),
1143 capsule_version: "0.1.0".to_string(),
1144 session_id: "ses_1".to_string(),
1145 capsule_url: capsule_url.to_string(),
1146 };
1147 let card = crate::identity::build_agent_card(
1148 &identity,
1149 &[],
1150 &crate::types::CapabilityPolicy::default(),
1151 &murmur_artifact::TaskAcceptance::Single,
1152 crate::identity::DeclaredPlanes::default(),
1153 false,
1154 crate::identity::TransportCapabilities {
1155 kind: crate::identity::TransportKind::Http,
1156 },
1157 );
1158 assert_eq!(own_audience(&identity), audience_from_card(&card).unwrap());
1159 }
1160 let identity = CapsuleIdentity {
1161 capsule_name: "Reporter".to_string(),
1162 capsule_version: "0.1.0".to_string(),
1163 session_id: "ses_1".to_string(),
1164 capsule_url: "localhost:41234".to_string(),
1165 };
1166 assert_eq!(own_audience(&identity), "reporter@localhost:41234");
1167 }
1168
1169 #[test]
1170 fn every_peer_error_has_the_status_the_wire_contract_states() {
1171 let expected: &[(PeerError, u16, &str)] = &[
1172 (PeerError::NoPeerPlane, 404, "no_peer_plane"),
1173 (PeerError::MalformedHandle, 400, "malformed_handle"),
1174 (PeerError::MissingAudience, 400, "missing_audience"),
1175 (PeerError::HandleNotValid, 403, "handle_not_valid"),
1176 (PeerError::HandleExpired, 410, "handle_expired"),
1177 (PeerError::NotFound, 404, "not_found"),
1178 (PeerError::OutsideRoot, 403, "outside_root"),
1179 (PeerError::SymlinkRefused, 403, "symlink_refused"),
1180 (PeerError::NotARegularFile, 403, "not_a_regular_file"),
1181 (PeerError::TooLarge { max_bytes: 1 }, 413, "too_large"),
1182 (PeerError::MethodNotAllowed, 405, "method_not_allowed"),
1183 (PeerError::IoError("x".to_string()), 500, "io_error"),
1184 ];
1185 for (error, status, code) in expected {
1186 assert_eq!(error.status(), *status, "{code}");
1187 assert_eq!(error.code(), *code);
1188 assert!(!error.message().is_empty(), "{code}");
1189 }
1190 }
1191
1192 #[test]
1193 fn a_handle_never_survives_the_trace_boundary() {
1194 let key = key();
1195 let token = mint(
1196 &key,
1197 &payload_for("report.md", "ses_1", in_an_hour()),
1198 "a@b",
1199 )
1200 .unwrap();
1201 let id = handle_id(&token);
1202
1203 let sentence = format!("fetching {token} from the peer.");
1204 let redacted = redact_handle_tokens(&sentence);
1205 assert_eq!(redacted, format!("fetching <handle:{id}> from the peer."));
1206 assert!(!redacted.contains("mh1."));
1207
1208 let pair = format!("{token} and {token}");
1210 assert_eq!(
1211 redact_handle_tokens(&pair),
1212 format!("<handle:{id}> and <handle:{id}>")
1213 );
1214
1215 assert!(matches!(
1217 redact_handle_tokens("no handle here, just mh1 as a word"),
1218 std::borrow::Cow::Borrowed(_)
1219 ));
1220 assert!(matches!(
1221 redact_handle_tokens("nothing at all"),
1222 std::borrow::Cow::Borrowed(_)
1223 ));
1224 let dashed = format!("— {token} —");
1226 assert_eq!(redact_handle_tokens(&dashed), format!("— <handle:{id}> —"));
1227 }
1228
1229 #[test]
1230 fn a_handle_never_survives_the_trace_boundary_in_json() {
1231 let key = key();
1232 let token = mint(
1233 &key,
1234 &payload_for("report.md", "ses_1", in_an_hour()),
1235 "a@b",
1236 )
1237 .unwrap();
1238 let mut value = serde_json::json!({
1239 "peer": "localhost:1",
1240 "handle": token,
1241 "nested": {"list": [token.clone(), "unrelated"]},
1242 });
1243 redact_handles_in_json(&mut value);
1244 let rendered = value.to_string();
1245 assert!(!rendered.contains("mh1."), "{rendered}");
1246 assert!(rendered.contains(&format!("<handle:{}>", handle_id(&token))));
1247 assert_eq!(value["peer"], "localhost:1");
1248 assert_eq!(value["nested"]["list"][1], "unrelated");
1249 }
1250
1251 #[test]
1252 fn a_stored_path_is_runtime_chosen_and_sanitised() {
1253 assert_eq!(
1254 stored_path_for("3f2a", Some("report.md")),
1255 "peer-in/3f2a-report.md"
1256 );
1257 assert_eq!(
1258 stored_path_for("3f2a", Some("../../etc/passwd")),
1259 "peer-in/3f2a-passwd"
1260 );
1261 assert_eq!(stored_path_for("3f2a", Some("..")), "peer-in/3f2a-file");
1262 assert_eq!(stored_path_for("3f2a", Some("")), "peer-in/3f2a-file");
1263 assert_eq!(stored_path_for("3f2a", None), "peer-in/3f2a-file");
1264 assert_eq!(
1265 stored_path_for("3f2a", Some("a b;rm -rf.md")),
1266 "peer-in/3f2a-a_b_rm_-rf.md"
1267 );
1268 }
1269
1270 fn plane_for(root: &Path, export: PeerFilesExport) -> PeerPlane {
1273 PeerPlane::new(
1274 root,
1275 Some((&export, Arc::new(key()))),
1276 "ses_test".to_string(),
1277 ContainmentClass::Sealed,
1278 Arc::new(AtomicU64::new(0)),
1279 None,
1280 )
1281 }
1282
1283 fn undeclared_plane() -> PeerPlane {
1286 PeerPlane::new(
1287 Path::new("/nonexistent"),
1288 None,
1289 "ses_test".to_string(),
1290 ContainmentClass::Sealed,
1291 Arc::new(AtomicU64::new(0)),
1292 None,
1293 )
1294 }
1295
1296 fn peer_export(root: &str) -> PeerFilesExport {
1297 PeerFilesExport {
1298 root: root.to_string(),
1299 max_ttl_secs: Some(60),
1300 max_bytes: 1024 * 1024,
1301 }
1302 }
1303
1304 #[test]
1305 fn a_mint_names_a_file_under_the_root_and_redeems_to_its_bytes() {
1306 let dir = tempfile::tempdir().unwrap();
1307 fs::create_dir_all(dir.path().join("out")).unwrap();
1308 fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1309 let plane = plane_for(dir.path(), peer_export("out/"));
1310
1311 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1312 assert_eq!(minted.path, "report.md");
1313 assert!(minted.handle.starts_with("mh1."));
1314
1315 let runtime = tokio::runtime::Builder::new_current_thread()
1316 .enable_all()
1317 .build()
1318 .unwrap();
1319 let (payload, read) = runtime
1320 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1321 .unwrap();
1322 assert_eq!(payload.p, "report.md");
1323 assert_eq!(read.bytes, b"known bytes");
1324 }
1325
1326 #[test]
1327 fn a_mint_cannot_name_anything_outside_the_declared_subtree() {
1328 let dir = tempfile::tempdir().unwrap();
1329 fs::create_dir_all(dir.path().join("out/handoff")).unwrap();
1330 fs::write(dir.path().join("out/handoff/ok.md"), b"fine").unwrap();
1331 fs::write(dir.path().join("out/secret.md"), b"SECRET").unwrap();
1332 fs::write(dir.path().join("secret-at-top.md"), b"SECRET").unwrap();
1333 std::os::unix::fs::symlink("../secret.md", dir.path().join("out/handoff/escape.md"))
1334 .unwrap();
1335 let plane = plane_for(dir.path(), peer_export("out/handoff/"));
1336
1337 for attempt in [
1338 "../secret.md",
1339 "%2e%2e%2fsecret.md",
1340 "/etc/passwd",
1341 "escape.md",
1342 "handoff/../../secret-at-top.md",
1343 ] {
1344 let error = plane
1345 .mint_handle(attempt, "a@b", None)
1346 .expect_err("'{attempt}' must not mint");
1347 assert!(
1348 matches!(error, PeerError::OutsideRoot | PeerError::SymlinkRefused),
1349 "'{attempt}' produced {error:?}"
1350 );
1351 }
1352 assert!(plane.mint_handle("ok.md", "a@b", None).is_ok());
1353 }
1354
1355 #[test]
1356 fn a_mint_refuses_a_directory_rather_than_handing_out_a_handle_that_cannot_redeem() {
1357 let dir = tempfile::tempdir().unwrap();
1358 fs::create_dir_all(dir.path().join("out/nested")).unwrap();
1359 let plane = plane_for(dir.path(), peer_export("out/"));
1360 assert_eq!(
1361 plane.mint_handle("nested", "a@b", None).unwrap_err(),
1362 PeerError::NotARegularFile
1363 );
1364 assert_eq!(
1365 plane.mint_handle("missing.md", "a@b", None).unwrap_err(),
1366 PeerError::NotFound
1367 );
1368 }
1369
1370 #[test]
1371 fn a_ttl_may_only_narrow() {
1372 let dir = tempfile::tempdir().unwrap();
1373 fs::create_dir_all(dir.path().join("out")).unwrap();
1374 fs::write(dir.path().join("out/report.md"), b"x").unwrap();
1375 let plane = plane_for(dir.path(), peer_export("out/"));
1376
1377 let before = timestamp_ms();
1381 let clamped = plane.mint_handle("report.md", "a@b", Some(86_400)).unwrap();
1382 let after = timestamp_ms();
1383 assert!(
1384 (before + 60_000..=after + 60_000).contains(&clamped.expires_at_ms),
1385 "a ttl above max_ttl must be clamped down to it"
1386 );
1387
1388 let before = timestamp_ms();
1389 let narrowed = plane.mint_handle("report.md", "a@b", Some(5)).unwrap();
1390 let after = timestamp_ms();
1391 assert!(
1392 (before + 5_000..=after + 5_000).contains(&narrowed.expires_at_ms),
1393 "a ttl below max_ttl is applied as given"
1394 );
1395
1396 let before = timestamp_ms();
1397 let defaulted = plane.mint_handle("report.md", "a@b", None).unwrap();
1398 let after = timestamp_ms();
1399 assert!(
1400 (before + 60_000..=after + 60_000).contains(&defaulted.expires_at_ms),
1401 "an absent ttl means max_ttl"
1402 );
1403 }
1404
1405 #[test]
1408 fn a_rewritten_file_redeems_to_its_current_bytes() {
1409 let dir = tempfile::tempdir().unwrap();
1410 fs::create_dir_all(dir.path().join("out")).unwrap();
1411 fs::write(dir.path().join("out/report.md"), b"first").unwrap();
1412 let plane = plane_for(dir.path(), peer_export("out/"));
1413 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1414
1415 let runtime = tokio::runtime::Builder::new_current_thread()
1416 .enable_all()
1417 .build()
1418 .unwrap();
1419 let (_, first) = runtime
1420 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1421 .unwrap();
1422 assert_eq!(first.bytes, b"first");
1423
1424 fs::write(dir.path().join("out/report.md.tmp"), b"second and longer").unwrap();
1425 fs::rename(
1426 dir.path().join("out/report.md.tmp"),
1427 dir.path().join("out/report.md"),
1428 )
1429 .unwrap();
1430
1431 let (_, second) = runtime
1432 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1433 .unwrap();
1434 assert_eq!(second.bytes, b"second and longer");
1435 assert_ne!(first.sha256, second.sha256);
1436 }
1437
1438 #[test]
1439 fn a_redeem_is_idempotent() {
1440 let dir = tempfile::tempdir().unwrap();
1441 fs::create_dir_all(dir.path().join("out")).unwrap();
1442 fs::write(dir.path().join("out/report.md"), b"same bytes").unwrap();
1443 let plane = plane_for(dir.path(), peer_export("out/"));
1444 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1445
1446 let runtime = tokio::runtime::Builder::new_current_thread()
1447 .enable_all()
1448 .build()
1449 .unwrap();
1450 let first = runtime
1451 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1452 .unwrap()
1453 .1;
1454 for _ in 0..4 {
1455 let again = runtime
1456 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1457 .unwrap()
1458 .1;
1459 assert_eq!(again.bytes, first.bytes);
1460 assert_eq!(again.sha256, first.sha256);
1461 }
1462 }
1463
1464 #[test]
1465 fn a_file_above_max_bytes_is_refused() {
1466 let dir = tempfile::tempdir().unwrap();
1467 fs::create_dir_all(dir.path().join("out")).unwrap();
1468 fs::write(dir.path().join("out/big.bin"), vec![0u8; 64]).unwrap();
1469 let plane = plane_for(
1470 dir.path(),
1471 PeerFilesExport {
1472 root: "out/".to_string(),
1473 max_ttl_secs: Some(60),
1474 max_bytes: 16,
1475 },
1476 );
1477 let minted = plane.mint_handle("big.bin", "a@b", None).unwrap();
1478 let runtime = tokio::runtime::Builder::new_current_thread()
1479 .enable_all()
1480 .build()
1481 .unwrap();
1482 assert_eq!(
1483 runtime
1484 .block_on(plane.redeem(&minted.handle, Some("a@b")))
1485 .unwrap_err(),
1486 PeerError::TooLarge { max_bytes: 16 }
1487 );
1488 }
1489
1490 #[test]
1493 fn only_the_peer_segment_routes_to_the_peer_plane() {
1494 assert!(is_peer_path("/resources/peer"));
1495 assert!(is_peer_path("/resources/peer/"));
1496 assert!(is_peer_path("/resources/peer/mh1.a.b"));
1497 assert!(is_peer_path("/resources/peer?x=1"));
1498 assert!(!is_peer_path("/resources/peerless/x"));
1499 assert!(!is_peer_path("/resources/files/report.md"));
1500 assert!(!is_peer_path("/"));
1501 }
1502
1503 fn respond(
1504 plane: &PeerPlane,
1505 method: &str,
1506 path: &str,
1507 audience: Option<&str>,
1508 ) -> ResourceResponse {
1509 tokio::runtime::Builder::new_current_thread()
1510 .enable_all()
1511 .build()
1512 .unwrap()
1513 .block_on(handle_peer_request(plane, method, path, audience))
1514 }
1515
1516 fn body_error(response: &ResourceResponse) -> serde_json::Value {
1517 serde_json::from_slice(&response.body).unwrap()
1518 }
1519
1520 #[test]
1521 fn an_undeclared_capsule_answers_no_peer_plane_and_mints_nothing() {
1522 let plane = undeclared_plane();
1523 assert!(!plane.is_declared());
1524
1525 let response = respond(&plane, "GET", "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1526 assert_eq!(response.status, 404);
1527 assert_eq!(body_error(&response)["error"], "no_peer_plane");
1528
1529 assert_eq!(
1530 plane.mint_handle("report.md", "a@b", None).unwrap_err(),
1531 PeerError::NoPeerPlane
1532 );
1533 }
1534
1535 #[test]
1536 fn there_is_no_list_verb() {
1537 let plane = undeclared_plane();
1538 for path in ["/resources/peer", "/resources/peer/"] {
1539 let response = respond(&plane, "GET", path, Some("a@b"));
1540 assert_eq!(response.status, 404, "{path}");
1541 assert_eq!(body_error(&response)["error"], "not_found", "{path}");
1542 let body = String::from_utf8_lossy(&response.body);
1543 assert!(
1544 !body.contains('['),
1545 "a listing must not be reachable: {body}"
1546 );
1547 }
1548 }
1549
1550 #[test]
1551 fn every_write_verb_is_refused_with_an_allow_header() {
1552 let plane = undeclared_plane();
1553 for method in ["POST", "PUT", "PATCH", "DELETE", "HEAD"] {
1554 let response = respond(&plane, method, "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
1555 assert_eq!(response.status, 405, "{method}");
1556 assert_eq!(body_error(&response)["error"], "method_not_allowed");
1557 assert!(response
1558 .headers
1559 .iter()
1560 .any(|(name, value)| name == "allow" && value == "GET"));
1561 }
1562 }
1563
1564 #[test]
1565 fn a_declared_plane_serves_a_redeem_and_refuses_every_tampered_form_identically() {
1566 let dir = tempfile::tempdir().unwrap();
1567 fs::create_dir_all(dir.path().join("out")).unwrap();
1568 fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
1569 let plane = plane_for(dir.path(), peer_export("out/"));
1570 let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
1571
1572 let ok = respond(
1573 &plane,
1574 "GET",
1575 &format!("/resources/peer/{}", minted.handle),
1576 Some("a@b"),
1577 );
1578 assert_eq!(ok.status, 200);
1579 assert_eq!(ok.body, b"known bytes");
1580 assert!(ok
1581 .headers
1582 .iter()
1583 .any(|(name, value)| name == HANDLE_ID_HEADER && value == &minted.handle_id));
1584 assert!(
1585 !ok.headers
1586 .iter()
1587 .any(|(name, _)| name == "x-murmur-export-root"),
1588 "the peer plane discloses no path structure"
1589 );
1590
1591 let bad_payload = tamper_segment(&minted.handle, 1);
1592 let bad_mac = tamper_segment(&minted.handle, 2);
1593
1594 let refusals = [
1595 respond(
1596 &plane,
1597 "GET",
1598 &format!("/resources/peer/{bad_payload}"),
1599 Some("a@b"),
1600 ),
1601 respond(
1602 &plane,
1603 "GET",
1604 &format!("/resources/peer/{bad_mac}"),
1605 Some("a@b"),
1606 ),
1607 respond(
1608 &plane,
1609 "GET",
1610 &format!("/resources/peer/{}", minted.handle),
1611 Some("attacker@localhost:1"),
1612 ),
1613 ];
1614 for refusal in &refusals {
1615 assert_eq!(refusal.status, 403);
1616 assert_eq!(refusal.body, refusals[0].body);
1617 assert_eq!(body_error(refusal)["error"], "handle_not_valid");
1618 assert!(
1619 !String::from_utf8_lossy(&refusal.body).contains("known bytes"),
1620 "a refusal must serve none of the file's bytes"
1621 );
1622 }
1623
1624 let no_audience = respond(
1625 &plane,
1626 "GET",
1627 &format!("/resources/peer/{}", minted.handle),
1628 None,
1629 );
1630 assert_eq!(no_audience.status, 400);
1631 assert_eq!(body_error(&no_audience)["error"], "missing_audience");
1632
1633 let not_a_token = respond(&plane, "GET", "/resources/peer/report.md", Some("a@b"));
1634 assert_eq!(not_a_token.status, 400);
1635 assert_eq!(body_error(¬_a_token)["error"], "malformed_handle");
1636 }
1637
1638 #[test]
1641 fn a_malformed_shape_is_reported_before_the_audience_is_required() {
1642 let dir = tempfile::tempdir().unwrap();
1643 fs::create_dir_all(dir.path().join("out")).unwrap();
1644 let plane = plane_for(dir.path(), peer_export("out/"));
1645 let response = respond(&plane, "GET", "/resources/peer/not-a-token", None);
1646 assert_eq!(body_error(&response)["error"], "malformed_handle");
1647 }
1648
1649 #[test]
1650 fn an_expired_handle_is_refused_with_its_own_code() {
1651 let dir = tempfile::tempdir().unwrap();
1652 fs::create_dir_all(dir.path().join("out")).unwrap();
1653 fs::write(dir.path().join("out/report.md"), b"bytes").unwrap();
1654 let plane = plane_for(
1655 dir.path(),
1656 PeerFilesExport {
1657 root: "out/".to_string(),
1658 max_ttl_secs: Some(1),
1659 max_bytes: 1024,
1660 },
1661 );
1662 let minted = plane.mint_handle("report.md", "a@b", Some(1)).unwrap();
1663 std::thread::sleep(std::time::Duration::from_millis(1100));
1664 let response = respond(
1665 &plane,
1666 "GET",
1667 &format!("/resources/peer/{}", minted.handle),
1668 Some("a@b"),
1669 );
1670 assert_eq!(response.status, 410);
1671 assert_eq!(body_error(&response)["error"], "handle_expired");
1672 }
1673}