use std::{
path::Path,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
};
use murmur_artifact::{ContainmentClass, PeerFilesExport};
use serde::{Deserialize, Serialize};
use crate::{
identity::CapsuleIdentity,
mac_token::{self, MacTokenError, MintKey, NONCE_BYTES},
outgoing::parse_host_port,
resource_plane::{
read_export_file_with_policy, resolve_relpath_beneath_root, symlink_policy, DeclaredExport,
ReadResponse, ResourceError, ResourceResponse,
},
trace::{timestamp_ms, ResourceTraceAppender},
};
pub const PEER_PATH_PREFIX: &str = "/resources/peer";
pub const AUDIENCE_HEADER: &str = "x-murmur-audience";
pub const HANDLE_ID_HEADER: &str = "x-murmur-handle-id";
const TOKEN_VERSION_TAG: &str = "mh1";
const PAYLOAD_VERSION: u8 = 1;
const MAC_DOMAIN: &[u8] = b"murmur-peer-handle-v1";
pub const PEER_INBOX_DIR: &str = "peer-in";
pub type PeerMintKey = MintKey;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HandlePayload {
pub v: u8,
pub iss: String,
pub p: String,
pub exp: u64,
pub n: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandleError {
Malformed,
NotValid,
Expired,
}
pub fn handle_id(token: &str) -> String {
mac_token::token_id(token)
}
fn check_shape(token: &str) -> Result<(), HandleError> {
mac_token::payload_segment(TOKEN_VERSION_TAG, token)?;
Ok(())
}
pub fn mint(key: &PeerMintKey, payload: &HandlePayload, audience: &str) -> Result<String, String> {
let json = serde_json::to_vec(payload)
.map_err(|error| format!("failed to serialize the handle payload: {error}"))?;
Ok(mac_token::seal(
key,
TOKEN_VERSION_TAG,
MAC_DOMAIN,
&json,
&[audience],
))
}
pub fn verify(
key: &PeerMintKey,
token: &str,
audience: &str,
session_id: &str,
) -> Result<HandlePayload, HandleError> {
let payload_bytes = mac_token::open(key, TOKEN_VERSION_TAG, MAC_DOMAIN, token, &[audience])?;
let payload: HandlePayload =
serde_json::from_slice(&payload_bytes).map_err(|_| HandleError::Malformed)?;
if payload.v != PAYLOAD_VERSION {
return Err(HandleError::Malformed);
}
if payload.iss != session_id {
return Err(HandleError::NotValid);
}
if payload.exp <= timestamp_ms() {
return Err(HandleError::Expired);
}
Ok(payload)
}
pub fn decode_payload_unverified(token: &str) -> Option<HandlePayload> {
let payload_bytes = mac_token::payload_segment(TOKEN_VERSION_TAG, token).ok()?;
serde_json::from_slice(&payload_bytes).ok()
}
pub(crate) fn redact_handle_tokens(text: &str) -> std::borrow::Cow<'_, str> {
if !text.contains(TOKEN_VERSION_TAG) {
return std::borrow::Cow::Borrowed(text);
}
let bytes = text.as_bytes();
let prefix = format!("{TOKEN_VERSION_TAG}.");
let mut out = String::with_capacity(text.len());
let mut index = 0;
let mut replaced = false;
while index < bytes.len() {
if text[index..].starts_with(&prefix) {
if let Some(end) = token_end(&text[index..]) {
let token = &text[index..index + end];
out.push_str(&format!("<handle:{}>", handle_id(token)));
index += end;
replaced = true;
continue;
}
}
let step = text[index..].chars().next().map_or(1, char::len_utf8);
out.push_str(&text[index..index + step]);
index += step;
}
if replaced {
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(text)
}
}
fn token_end(text: &str) -> Option<usize> {
let mut cursor = TOKEN_VERSION_TAG.len() + 1;
let mut segments = 0;
while segments < 2 {
let start = cursor;
cursor += text[cursor..]
.bytes()
.take_while(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_')
.count();
if cursor == start {
return None;
}
segments += 1;
if segments == 1 {
if text[cursor..].starts_with('.') {
cursor += 1;
} else {
return None;
}
}
}
Some(cursor)
}
pub(crate) fn redact_handles_in_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => {
if let std::borrow::Cow::Owned(redacted) = redact_handle_tokens(text) {
*text = redacted;
}
}
serde_json::Value::Array(items) => items.iter_mut().for_each(redact_handles_in_json),
serde_json::Value::Object(map) => {
map.values_mut().for_each(redact_handles_in_json);
}
_ => {}
}
}
pub fn audience_from_card(card: &serde_json::Value) -> Result<String, String> {
let name = card
.get("name")
.and_then(serde_json::Value::as_str)
.filter(|name| !name.trim().is_empty())
.ok_or_else(|| "the peer's agent card carries no 'name'".to_string())?;
let url = crate::identity::jsonrpc_interface_url(card)
.filter(|url| !url.trim().is_empty())
.ok_or_else(|| {
"the peer's agent card has no 'supportedInterfaces' entry with 'protocolBinding' \
JSONRPC and a 'url'"
.to_string()
})?;
let host_port = parse_host_port(url)?;
Ok(format!("{}@{}", name.trim(), host_port).to_lowercase())
}
pub(crate) fn own_audience(identity: &CapsuleIdentity) -> String {
let host_port =
parse_host_port(&identity.capsule_url).unwrap_or_else(|_| identity.capsule_url.clone());
format!("{}@{}", identity.capsule_name, host_port).to_lowercase()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PeerError {
NoPeerPlane,
MalformedHandle,
MissingAudience,
HandleNotValid,
HandleExpired,
NotFound,
OutsideRoot,
SymlinkRefused,
NotARegularFile,
TooLarge {
max_bytes: u64,
},
MethodNotAllowed,
IoError(String),
}
impl PeerError {
pub fn code(&self) -> &'static str {
match self {
PeerError::NoPeerPlane => "no_peer_plane",
PeerError::MalformedHandle => "malformed_handle",
PeerError::MissingAudience => "missing_audience",
PeerError::HandleNotValid => "handle_not_valid",
PeerError::HandleExpired => "handle_expired",
PeerError::NotFound => "not_found",
PeerError::OutsideRoot => "outside_root",
PeerError::SymlinkRefused => "symlink_refused",
PeerError::NotARegularFile => "not_a_regular_file",
PeerError::TooLarge { .. } => "too_large",
PeerError::MethodNotAllowed => "method_not_allowed",
PeerError::IoError(_) => "io_error",
}
}
pub fn status(&self) -> u16 {
match self {
PeerError::NoPeerPlane | PeerError::NotFound => 404,
PeerError::MalformedHandle | PeerError::MissingAudience => 400,
PeerError::HandleNotValid
| PeerError::OutsideRoot
| PeerError::SymlinkRefused
| PeerError::NotARegularFile => 403,
PeerError::HandleExpired => 410,
PeerError::TooLarge { .. } => 413,
PeerError::MethodNotAllowed => 405,
PeerError::IoError(_) => 500,
}
}
pub fn message(&self) -> String {
match self {
PeerError::NoPeerPlane => {
"this capsule declares no exports.peer_files block, so it has no peer plane"
.to_string()
}
PeerError::MalformedHandle => "the handle is not a well-formed mh1 token".to_string(),
PeerError::MissingAudience => {
format!("a redeem must assert its audience in the {AUDIENCE_HEADER} header")
}
PeerError::HandleNotValid => "the handle is not valid for this capsule".to_string(),
PeerError::HandleExpired => "the handle has expired".to_string(),
PeerError::NotFound => "no such file under the peer export root".to_string(),
PeerError::OutsideRoot => {
"the handle names a path outside the peer export root".to_string()
}
PeerError::SymlinkRefused => {
"a symlink was encountered and the achieved containment class is scoped".to_string()
}
PeerError::NotARegularFile => "the handle does not name a regular file".to_string(),
PeerError::TooLarge { max_bytes } => {
format!(
"file exceeds the declared exports.peer_files.max_bytes of {max_bytes} bytes"
)
}
PeerError::MethodNotAllowed => {
"the peer plane serves GET only; it has no write path and no list verb".to_string()
}
PeerError::IoError(detail) => format!("read failed: {detail}"),
}
}
}
impl From<MacTokenError> for HandleError {
fn from(error: MacTokenError) -> Self {
match error {
MacTokenError::Malformed => HandleError::Malformed,
MacTokenError::NotValid => HandleError::NotValid,
}
}
}
impl From<HandleError> for PeerError {
fn from(error: HandleError) -> Self {
match error {
HandleError::Malformed => PeerError::MalformedHandle,
HandleError::NotValid => PeerError::HandleNotValid,
HandleError::Expired => PeerError::HandleExpired,
}
}
}
impl From<ResourceError> for PeerError {
fn from(error: ResourceError) -> Self {
match error {
ResourceError::NotFound => PeerError::NotFound,
ResourceError::OutsideRoot => PeerError::OutsideRoot,
ResourceError::SymlinkRefused => PeerError::SymlinkRefused,
ResourceError::NotARegularFile => PeerError::NotARegularFile,
ResourceError::TooLarge { max_bytes } => PeerError::TooLarge { max_bytes },
ResourceError::MethodNotAllowed => PeerError::MethodNotAllowed,
ResourceError::NoResourcePlane => PeerError::NoPeerPlane,
ResourceError::IoError(detail) => PeerError::IoError(detail),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MintedHandle {
pub handle: String,
pub handle_id: String,
pub expires_at_ms: u64,
pub audience: String,
pub path: String,
}
struct DeclaredPeerExport {
export: DeclaredExport,
key: Arc<PeerMintKey>,
max_ttl_secs: u64,
}
pub struct PeerPlane {
declared: Option<DeclaredPeerExport>,
session_id: String,
generation: Arc<AtomicU64>,
containment_achieved: ContainmentClass,
trace: Option<Arc<ResourceTraceAppender>>,
}
impl PeerPlane {
pub fn new(
accessible_workdir: &Path,
export: Option<(&PeerFilesExport, Arc<PeerMintKey>)>,
session_id: String,
containment_achieved: ContainmentClass,
generation: Arc<AtomicU64>,
trace: Option<Arc<ResourceTraceAppender>>,
) -> Self {
Self {
declared: export.map(|(export, key)| DeclaredPeerExport {
export: DeclaredExport::for_peer_files(accessible_workdir, export),
key,
max_ttl_secs: export.effective_max_ttl_secs(),
}),
session_id,
generation,
containment_achieved,
trace,
}
}
pub fn is_declared(&self) -> bool {
self.declared.is_some()
}
pub fn generation(&self) -> u64 {
self.generation.load(Ordering::SeqCst)
}
pub fn mint_handle(
&self,
relpath: &str,
audience: &str,
ttl_secs: Option<u64>,
) -> Result<MintedHandle, PeerError> {
let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
let policy = symlink_policy(self.containment_achieved);
let (target, canonical_relpath) =
resolve_relpath_beneath_root(&declared.export, relpath, policy)?;
let metadata = std::fs::metadata(&target).map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => PeerError::NotFound,
_ => PeerError::IoError(error.to_string()),
})?;
if !metadata.is_file() {
return Err(PeerError::NotARegularFile);
}
let ttl = ttl_secs
.unwrap_or(declared.max_ttl_secs)
.min(declared.max_ttl_secs);
let payload = HandlePayload {
v: PAYLOAD_VERSION,
iss: self.session_id.clone(),
p: canonical_relpath.clone(),
exp: timestamp_ms().saturating_add(ttl.saturating_mul(1000)),
n: mac_token::random_hex(NONCE_BYTES).map_err(PeerError::IoError)?,
};
let token = mint(&declared.key, &payload, audience).map_err(PeerError::IoError)?;
Ok(MintedHandle {
handle_id: handle_id(&token),
handle: token,
expires_at_ms: payload.exp,
audience: audience.to_string(),
path: canonical_relpath,
})
}
async fn redeem(
&self,
token: &str,
audience: Option<&str>,
) -> Result<(HandlePayload, ReadResponse), PeerError> {
let declared = self.declared.as_ref().ok_or(PeerError::NoPeerPlane)?;
check_shape(token).map_err(PeerError::from)?;
let audience = audience
.map(str::trim)
.filter(|audience| !audience.is_empty())
.ok_or(PeerError::MissingAudience)?;
let payload = verify(&declared.key, token, audience, &self.session_id)?;
let export = declared.export.clone();
let relpath = payload.p.clone();
let policy = symlink_policy(self.containment_achieved);
let read = tokio::task::spawn_blocking(move || {
read_export_file_with_policy(&export, &relpath, policy)
})
.await
.unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))?;
Ok((payload, read))
}
}
pub fn is_peer_path(raw_path: &str) -> bool {
let path = raw_path.split('?').next().unwrap_or("");
path == PEER_PATH_PREFIX || path.starts_with(&format!("{PEER_PATH_PREFIX}/"))
}
pub async fn handle_peer_request(
plane: &PeerPlane,
method: &str,
raw_path: &str,
audience: Option<&str>,
) -> ResourceResponse {
if method != "GET" {
return error_response(&PeerError::MethodNotAllowed, None, None);
}
let path = raw_path.split('?').next().unwrap_or("");
let rest = path.strip_prefix(PEER_PATH_PREFIX).unwrap_or("");
let Some(token) = rest.strip_prefix('/').filter(|token| !token.is_empty()) else {
return error_response(&PeerError::NotFound, None, None);
};
let generation = plane.generation();
let hid = handle_id(token);
match plane.redeem(token, audience).await {
Ok((payload, read)) => {
if let Some(trace) = &plane.trace {
trace
.write_peer_handle_redeem(
&hid,
Some(payload.p.clone()),
generation,
audience.map(str::to_string),
Some(read.bytes.len() as u64),
Some(read.sha256.clone()),
"ok",
None,
)
.await;
}
ResourceResponse::framed(
200,
vec![
(
"content-type".to_string(),
"application/octet-stream".to_string(),
),
("etag".to_string(), format!("\"sha256:{}\"", read.sha256)),
("x-murmur-mtime-ms".to_string(), read.mtime_ms.to_string()),
("x-murmur-generation".to_string(), generation.to_string()),
(
"x-murmur-containment".to_string(),
plane.containment_achieved.to_string(),
),
(HANDLE_ID_HEADER.to_string(), hid),
],
read.bytes,
)
}
Err(error) => {
if let Some(trace) = &plane.trace {
trace
.write_peer_handle_redeem(
&hid,
None,
generation,
audience.map(str::to_string),
None,
None,
error.code(),
Some(error.message()),
)
.await;
}
error_response(&error, Some(generation), Some(hid))
}
}
}
fn error_response(
error: &PeerError,
generation: Option<u64>,
handle_id: Option<String>,
) -> ResourceResponse {
let body = serde_json::json!({"error": error.code(), "message": error.message()});
let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
if let Some(generation) = generation {
headers.push(("x-murmur-generation".to_string(), generation.to_string()));
}
if let Some(handle_id) = handle_id {
headers.push((HANDLE_ID_HEADER.to_string(), handle_id));
}
if matches!(error, PeerError::MethodNotAllowed) {
headers.push(("allow".to_string(), "GET".to_string()));
}
ResourceResponse::framed(
error.status(),
headers,
serde_json::to_vec(&body).unwrap_or_default(),
)
}
pub fn stored_path_for(handle_id: &str, basename_hint: Option<&str>) -> String {
let sanitised: String = basename_hint
.and_then(|hint| hint.rsplit('/').next())
.map(|name| {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'_'
}
})
.take(120)
.collect()
})
.filter(|name: &String| !name.is_empty() && name != "." && name != "..")
.unwrap_or_else(|| "file".to_string());
format!("{PEER_INBOX_DIR}/{handle_id}-{sanitised}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn key() -> PeerMintKey {
PeerMintKey::generate().unwrap()
}
fn tamper_segment(handle: &str, nth: usize) -> String {
let mut segments: Vec<String> = handle.split('.').map(str::to_string).collect();
let segment = &mut segments[nth];
let first = segment.remove(0);
segment.insert(0, if first == 'A' { 'B' } else { 'A' });
segments.join(".")
}
fn payload_for(path: &str, session: &str, exp: u64) -> HandlePayload {
HandlePayload {
v: 1,
iss: session.to_string(),
p: path.to_string(),
exp,
n: "0123456789abcdef0123456789abcdef".to_string(),
}
}
fn in_an_hour() -> u64 {
timestamp_ms() + 3_600_000
}
#[test]
fn a_minted_handle_round_trips_through_verify() {
let key = key();
let payload = payload_for("report.md", "ses_1", in_an_hour());
let token = mint(&key, &payload, "reporter@localhost:41234").unwrap();
assert!(token.starts_with("mh1."));
assert_eq!(token.split('.').count(), 3);
let verified = verify(&key, &token, "reporter@localhost:41234", "ses_1").unwrap();
assert_eq!(verified, payload);
}
#[test]
fn a_token_uses_only_url_safe_unpadded_base64() {
let key = key();
let token = mint(
&key,
&payload_for("a/b/c.md", "ses_padding", in_an_hour()),
"x@y",
)
.unwrap();
let segments: Vec<&str> = token.split('.').collect();
for segment in &segments[1..] {
assert!(
segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"segment '{segment}' must be base64url with no padding"
);
}
}
#[test]
fn a_tampered_payload_is_not_valid() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
let tampered = tamper_segment(&token, 1);
assert_eq!(
verify(&key, &tampered, "a@b", "ses_1"),
Err(HandleError::NotValid)
);
}
#[test]
fn a_tampered_mac_is_not_valid() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
let tampered = tamper_segment(&token, 2);
assert_eq!(
verify(&key, &tampered, "a@b", "ses_1"),
Err(HandleError::NotValid)
);
}
#[test]
fn the_wrong_audience_is_not_valid() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
assert_eq!(
verify(&key, &token, "attacker@localhost:1", "ses_1"),
Err(HandleError::NotValid)
);
}
#[test]
fn every_refusal_cause_is_indistinguishable_on_the_wire() {
let key = key();
let other = PeerMintKey::generate().unwrap();
let token = mint(
&key,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
let mut chars: Vec<char> = token.chars().collect();
let payload_end = token.rfind('.').unwrap();
chars[payload_end - 1] = if chars[payload_end - 1] == 'A' {
'B'
} else {
'A'
};
let tampered: String = chars.into_iter().collect();
let causes = [
verify(&key, &tampered, "a@b", "ses_1"),
verify(&other, &token, "a@b", "ses_1"),
verify(&key, &token, "attacker@localhost:1", "ses_1"),
];
for cause in &causes {
assert_eq!(*cause, Err(HandleError::NotValid));
}
let errors: Vec<PeerError> = causes
.iter()
.map(|cause| PeerError::from(*cause.as_ref().unwrap_err()))
.collect();
for error in &errors {
assert_eq!(error, &PeerError::HandleNotValid);
assert_eq!(error.status(), 403);
assert_eq!(error.code(), errors[0].code());
assert_eq!(error.message(), errors[0].message());
}
}
#[test]
fn a_handle_from_another_instance_of_the_same_capsule_is_not_valid() {
let one = key();
let two = key();
let token = mint(
&one,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
assert_eq!(
verify(&two, &token, "a@b", "ses_1"),
Err(HandleError::NotValid)
);
}
#[test]
fn a_foreign_issuer_is_not_valid_rather_than_a_code_of_its_own() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_other", in_an_hour()),
"a@b",
)
.unwrap();
assert_eq!(
verify(&key, &token, "a@b", "ses_1"),
Err(HandleError::NotValid)
);
}
#[test]
fn an_expired_handle_reports_expiry() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_1", timestamp_ms() - 1),
"a@b",
)
.unwrap();
assert_eq!(
verify(&key, &token, "a@b", "ses_1"),
Err(HandleError::Expired)
);
}
#[test]
fn every_malformed_shape_is_malformed() {
let key = key();
for candidate in [
"",
"report.md",
"mh1",
"mh1.",
"mh1.abc",
"mh1.abc.def.ghi",
"mh2.YWJj.YWJj",
"mh1.!!!.YWJj",
"mh1.YWJj.!!!",
] {
assert_eq!(
verify(&key, candidate, "a@b", "ses_1"),
Err(HandleError::Malformed),
"'{candidate}' should be malformed"
);
}
}
#[test]
fn a_non_json_or_wrong_version_payload_is_malformed_behind_the_mac() {
let key = key();
for payload_json in [
b"not json".to_vec(),
br#"{"v":2,"iss":"ses_1","p":"a","exp":99999999999999,"n":"00"}"#.to_vec(),
] {
let token = mac_token::seal(&key, "mh1", MAC_DOMAIN, &payload_json, &["a@b"]);
assert_eq!(
verify(&key, &token, "a@b", "ses_1"),
Err(HandleError::Malformed)
);
}
}
#[test]
fn the_audience_is_covered_by_the_mac_but_not_carried_in_the_token() {
let key = key();
let payload = payload_for("report.md", "ses_1", in_an_hour());
let one = mint(&key, &payload, "reporter@localhost:1").unwrap();
let two = mint(&key, &payload, "reporter@localhost:2").unwrap();
let (one_payload, two_payload) = (
one.split('.').nth(1).unwrap(),
two.split('.').nth(1).unwrap(),
);
assert_eq!(
one_payload, two_payload,
"the payload segment must not vary with the audience"
);
assert_ne!(one, two, "the MAC must vary with the audience");
let decoded = String::from_utf8(mac_token::payload_segment("mh1", &one).unwrap()).unwrap();
assert!(
!decoded.contains("reporter@localhost:1"),
"the audience must not appear in the payload: {decoded}"
);
}
#[test]
fn two_handles_for_the_same_file_and_audience_are_distinct() {
let key = key();
let plane_nonce_a = mac_token::random_hex(NONCE_BYTES).unwrap();
let plane_nonce_b = mac_token::random_hex(NONCE_BYTES).unwrap();
assert_ne!(plane_nonce_a, plane_nonce_b);
let mut first = payload_for("report.md", "ses_1", in_an_hour());
first.n = plane_nonce_a;
let mut second = payload_for("report.md", "ses_1", in_an_hour());
second.n = plane_nonce_b;
assert_ne!(
mint(&key, &first, "a@b").unwrap(),
mint(&key, &second, "a@b").unwrap()
);
}
#[test]
fn a_handle_id_is_sixteen_lowercase_hex_characters() {
let id = handle_id("mh1.abc.def");
assert_eq!(id.len(), 16);
assert!(id
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
assert_eq!(id, handle_id("mh1.abc.def"), "handle_id must be stable");
assert_ne!(id, handle_id("mh1.abc.deg"));
}
fn card_with_interfaces(name: &str, interfaces: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "name": name, "supportedInterfaces": interfaces })
}
#[test]
fn an_audience_comes_from_the_peers_own_card() {
let card = card_with_interfaces(
"Reporter",
serde_json::json!([{
"url": "http://localhost:41234/",
"protocolBinding": "JSONRPC",
"protocolVersion": "0.3",
}]),
);
assert_eq!(
audience_from_card(&card).unwrap(),
"reporter@localhost:41234"
);
}
#[test]
fn a_card_without_a_name_or_a_jsonrpc_url_is_refused() {
let jsonrpc = serde_json::json!([{
"url": "http://localhost:1",
"protocolBinding": "JSONRPC",
"protocolVersion": "0.3",
}]);
for card in [
serde_json::json!({"name": "a"}),
card_with_interfaces("a", serde_json::json!([])),
card_with_interfaces(
"a",
serde_json::json!([{
"url": "https://localhost:1",
"protocolBinding": "GRPC",
"protocolVersion": "1.0",
}]),
),
card_with_interfaces(
"a",
serde_json::json!([{"url": "", "protocolBinding": "JSONRPC", "protocolVersion": "0.3"}]),
),
serde_json::json!({"supportedInterfaces": jsonrpc}),
card_with_interfaces("", jsonrpc.clone()),
serde_json::json!("not an object"),
serde_json::json!({"name": "a", "url": "localhost:1"}),
] {
assert!(audience_from_card(&card).is_err(), "card: {card}");
}
}
#[test]
fn a_refusal_names_what_the_card_lacks() {
assert_eq!(
audience_from_card(&serde_json::json!({"url": "localhost:1"})).unwrap_err(),
"the peer's agent card carries no 'name'"
);
assert!(
audience_from_card(&serde_json::json!({"name": "a", "url": "localhost:1"}))
.unwrap_err()
.contains("'supportedInterfaces' entry with 'protocolBinding' JSONRPC"),
);
}
#[test]
fn own_audience_matches_what_a_peer_would_derive_from_the_card() {
for capsule_url in ["localhost:41234", "http://127.0.0.1:41234"] {
let identity = CapsuleIdentity {
capsule_name: "Reporter".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_1".to_string(),
capsule_url: capsule_url.to_string(),
};
let card = crate::identity::build_agent_card(
&identity,
&[],
&crate::types::CapabilityPolicy::default(),
&murmur_artifact::TaskAcceptance::Single,
crate::identity::DeclaredPlanes::default(),
false,
crate::identity::TransportCapabilities {
kind: crate::identity::TransportKind::Http,
},
);
assert_eq!(own_audience(&identity), audience_from_card(&card).unwrap());
}
let identity = CapsuleIdentity {
capsule_name: "Reporter".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_1".to_string(),
capsule_url: "localhost:41234".to_string(),
};
assert_eq!(own_audience(&identity), "reporter@localhost:41234");
}
#[test]
fn every_peer_error_has_the_status_the_wire_contract_states() {
let expected: &[(PeerError, u16, &str)] = &[
(PeerError::NoPeerPlane, 404, "no_peer_plane"),
(PeerError::MalformedHandle, 400, "malformed_handle"),
(PeerError::MissingAudience, 400, "missing_audience"),
(PeerError::HandleNotValid, 403, "handle_not_valid"),
(PeerError::HandleExpired, 410, "handle_expired"),
(PeerError::NotFound, 404, "not_found"),
(PeerError::OutsideRoot, 403, "outside_root"),
(PeerError::SymlinkRefused, 403, "symlink_refused"),
(PeerError::NotARegularFile, 403, "not_a_regular_file"),
(PeerError::TooLarge { max_bytes: 1 }, 413, "too_large"),
(PeerError::MethodNotAllowed, 405, "method_not_allowed"),
(PeerError::IoError("x".to_string()), 500, "io_error"),
];
for (error, status, code) in expected {
assert_eq!(error.status(), *status, "{code}");
assert_eq!(error.code(), *code);
assert!(!error.message().is_empty(), "{code}");
}
}
#[test]
fn a_handle_never_survives_the_trace_boundary() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
let id = handle_id(&token);
let sentence = format!("fetching {token} from the peer.");
let redacted = redact_handle_tokens(&sentence);
assert_eq!(redacted, format!("fetching <handle:{id}> from the peer."));
assert!(!redacted.contains("mh1."));
let pair = format!("{token} and {token}");
assert_eq!(
redact_handle_tokens(&pair),
format!("<handle:{id}> and <handle:{id}>")
);
assert!(matches!(
redact_handle_tokens("no handle here, just mh1 as a word"),
std::borrow::Cow::Borrowed(_)
));
assert!(matches!(
redact_handle_tokens("nothing at all"),
std::borrow::Cow::Borrowed(_)
));
let dashed = format!("— {token} —");
assert_eq!(redact_handle_tokens(&dashed), format!("— <handle:{id}> —"));
}
#[test]
fn a_handle_never_survives_the_trace_boundary_in_json() {
let key = key();
let token = mint(
&key,
&payload_for("report.md", "ses_1", in_an_hour()),
"a@b",
)
.unwrap();
let mut value = serde_json::json!({
"peer": "localhost:1",
"handle": token,
"nested": {"list": [token.clone(), "unrelated"]},
});
redact_handles_in_json(&mut value);
let rendered = value.to_string();
assert!(!rendered.contains("mh1."), "{rendered}");
assert!(rendered.contains(&format!("<handle:{}>", handle_id(&token))));
assert_eq!(value["peer"], "localhost:1");
assert_eq!(value["nested"]["list"][1], "unrelated");
}
#[test]
fn a_stored_path_is_runtime_chosen_and_sanitised() {
assert_eq!(
stored_path_for("3f2a", Some("report.md")),
"peer-in/3f2a-report.md"
);
assert_eq!(
stored_path_for("3f2a", Some("../../etc/passwd")),
"peer-in/3f2a-passwd"
);
assert_eq!(stored_path_for("3f2a", Some("..")), "peer-in/3f2a-file");
assert_eq!(stored_path_for("3f2a", Some("")), "peer-in/3f2a-file");
assert_eq!(stored_path_for("3f2a", None), "peer-in/3f2a-file");
assert_eq!(
stored_path_for("3f2a", Some("a b;rm -rf.md")),
"peer-in/3f2a-a_b_rm_-rf.md"
);
}
fn plane_for(root: &Path, export: PeerFilesExport) -> PeerPlane {
PeerPlane::new(
root,
Some((&export, Arc::new(key()))),
"ses_test".to_string(),
ContainmentClass::Sealed,
Arc::new(AtomicU64::new(0)),
None,
)
}
fn undeclared_plane() -> PeerPlane {
PeerPlane::new(
Path::new("/nonexistent"),
None,
"ses_test".to_string(),
ContainmentClass::Sealed,
Arc::new(AtomicU64::new(0)),
None,
)
}
fn peer_export(root: &str) -> PeerFilesExport {
PeerFilesExport {
root: root.to_string(),
max_ttl_secs: Some(60),
max_bytes: 1024 * 1024,
}
}
#[test]
fn a_mint_names_a_file_under_the_root_and_redeems_to_its_bytes() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
assert_eq!(minted.path, "report.md");
assert!(minted.handle.starts_with("mh1."));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (payload, read) = runtime
.block_on(plane.redeem(&minted.handle, Some("a@b")))
.unwrap();
assert_eq!(payload.p, "report.md");
assert_eq!(read.bytes, b"known bytes");
}
#[test]
fn a_mint_cannot_name_anything_outside_the_declared_subtree() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out/handoff")).unwrap();
fs::write(dir.path().join("out/handoff/ok.md"), b"fine").unwrap();
fs::write(dir.path().join("out/secret.md"), b"SECRET").unwrap();
fs::write(dir.path().join("secret-at-top.md"), b"SECRET").unwrap();
std::os::unix::fs::symlink("../secret.md", dir.path().join("out/handoff/escape.md"))
.unwrap();
let plane = plane_for(dir.path(), peer_export("out/handoff/"));
for attempt in [
"../secret.md",
"%2e%2e%2fsecret.md",
"/etc/passwd",
"escape.md",
"handoff/../../secret-at-top.md",
] {
let error = plane
.mint_handle(attempt, "a@b", None)
.expect_err("'{attempt}' must not mint");
assert!(
matches!(error, PeerError::OutsideRoot | PeerError::SymlinkRefused),
"'{attempt}' produced {error:?}"
);
}
assert!(plane.mint_handle("ok.md", "a@b", None).is_ok());
}
#[test]
fn a_mint_refuses_a_directory_rather_than_handing_out_a_handle_that_cannot_redeem() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out/nested")).unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
assert_eq!(
plane.mint_handle("nested", "a@b", None).unwrap_err(),
PeerError::NotARegularFile
);
assert_eq!(
plane.mint_handle("missing.md", "a@b", None).unwrap_err(),
PeerError::NotFound
);
}
#[test]
fn a_ttl_may_only_narrow() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/report.md"), b"x").unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
let before = timestamp_ms();
let clamped = plane.mint_handle("report.md", "a@b", Some(86_400)).unwrap();
let after = timestamp_ms();
assert!(
(before + 60_000..=after + 60_000).contains(&clamped.expires_at_ms),
"a ttl above max_ttl must be clamped down to it"
);
let before = timestamp_ms();
let narrowed = plane.mint_handle("report.md", "a@b", Some(5)).unwrap();
let after = timestamp_ms();
assert!(
(before + 5_000..=after + 5_000).contains(&narrowed.expires_at_ms),
"a ttl below max_ttl is applied as given"
);
let before = timestamp_ms();
let defaulted = plane.mint_handle("report.md", "a@b", None).unwrap();
let after = timestamp_ms();
assert!(
(before + 60_000..=after + 60_000).contains(&defaulted.expires_at_ms),
"an absent ttl means max_ttl"
);
}
#[test]
fn a_rewritten_file_redeems_to_its_current_bytes() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/report.md"), b"first").unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (_, first) = runtime
.block_on(plane.redeem(&minted.handle, Some("a@b")))
.unwrap();
assert_eq!(first.bytes, b"first");
fs::write(dir.path().join("out/report.md.tmp"), b"second and longer").unwrap();
fs::rename(
dir.path().join("out/report.md.tmp"),
dir.path().join("out/report.md"),
)
.unwrap();
let (_, second) = runtime
.block_on(plane.redeem(&minted.handle, Some("a@b")))
.unwrap();
assert_eq!(second.bytes, b"second and longer");
assert_ne!(first.sha256, second.sha256);
}
#[test]
fn a_redeem_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/report.md"), b"same bytes").unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let first = runtime
.block_on(plane.redeem(&minted.handle, Some("a@b")))
.unwrap()
.1;
for _ in 0..4 {
let again = runtime
.block_on(plane.redeem(&minted.handle, Some("a@b")))
.unwrap()
.1;
assert_eq!(again.bytes, first.bytes);
assert_eq!(again.sha256, first.sha256);
}
}
#[test]
fn a_file_above_max_bytes_is_refused() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/big.bin"), vec![0u8; 64]).unwrap();
let plane = plane_for(
dir.path(),
PeerFilesExport {
root: "out/".to_string(),
max_ttl_secs: Some(60),
max_bytes: 16,
},
);
let minted = plane.mint_handle("big.bin", "a@b", None).unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
assert_eq!(
runtime
.block_on(plane.redeem(&minted.handle, Some("a@b")))
.unwrap_err(),
PeerError::TooLarge { max_bytes: 16 }
);
}
#[test]
fn only_the_peer_segment_routes_to_the_peer_plane() {
assert!(is_peer_path("/resources/peer"));
assert!(is_peer_path("/resources/peer/"));
assert!(is_peer_path("/resources/peer/mh1.a.b"));
assert!(is_peer_path("/resources/peer?x=1"));
assert!(!is_peer_path("/resources/peerless/x"));
assert!(!is_peer_path("/resources/files/report.md"));
assert!(!is_peer_path("/"));
}
fn respond(
plane: &PeerPlane,
method: &str,
path: &str,
audience: Option<&str>,
) -> ResourceResponse {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(handle_peer_request(plane, method, path, audience))
}
fn body_error(response: &ResourceResponse) -> serde_json::Value {
serde_json::from_slice(&response.body).unwrap()
}
#[test]
fn an_undeclared_capsule_answers_no_peer_plane_and_mints_nothing() {
let plane = undeclared_plane();
assert!(!plane.is_declared());
let response = respond(&plane, "GET", "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
assert_eq!(response.status, 404);
assert_eq!(body_error(&response)["error"], "no_peer_plane");
assert_eq!(
plane.mint_handle("report.md", "a@b", None).unwrap_err(),
PeerError::NoPeerPlane
);
}
#[test]
fn there_is_no_list_verb() {
let plane = undeclared_plane();
for path in ["/resources/peer", "/resources/peer/"] {
let response = respond(&plane, "GET", path, Some("a@b"));
assert_eq!(response.status, 404, "{path}");
assert_eq!(body_error(&response)["error"], "not_found", "{path}");
let body = String::from_utf8_lossy(&response.body);
assert!(
!body.contains('['),
"a listing must not be reachable: {body}"
);
}
}
#[test]
fn every_write_verb_is_refused_with_an_allow_header() {
let plane = undeclared_plane();
for method in ["POST", "PUT", "PATCH", "DELETE", "HEAD"] {
let response = respond(&plane, method, "/resources/peer/mh1.YWJj.YWJj", Some("a@b"));
assert_eq!(response.status, 405, "{method}");
assert_eq!(body_error(&response)["error"], "method_not_allowed");
assert!(response
.headers
.iter()
.any(|(name, value)| name == "allow" && value == "GET"));
}
}
#[test]
fn a_declared_plane_serves_a_redeem_and_refuses_every_tampered_form_identically() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/report.md"), b"known bytes").unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
let minted = plane.mint_handle("report.md", "a@b", None).unwrap();
let ok = respond(
&plane,
"GET",
&format!("/resources/peer/{}", minted.handle),
Some("a@b"),
);
assert_eq!(ok.status, 200);
assert_eq!(ok.body, b"known bytes");
assert!(ok
.headers
.iter()
.any(|(name, value)| name == HANDLE_ID_HEADER && value == &minted.handle_id));
assert!(
!ok.headers
.iter()
.any(|(name, _)| name == "x-murmur-export-root"),
"the peer plane discloses no path structure"
);
let bad_payload = tamper_segment(&minted.handle, 1);
let bad_mac = tamper_segment(&minted.handle, 2);
let refusals = [
respond(
&plane,
"GET",
&format!("/resources/peer/{bad_payload}"),
Some("a@b"),
),
respond(
&plane,
"GET",
&format!("/resources/peer/{bad_mac}"),
Some("a@b"),
),
respond(
&plane,
"GET",
&format!("/resources/peer/{}", minted.handle),
Some("attacker@localhost:1"),
),
];
for refusal in &refusals {
assert_eq!(refusal.status, 403);
assert_eq!(refusal.body, refusals[0].body);
assert_eq!(body_error(refusal)["error"], "handle_not_valid");
assert!(
!String::from_utf8_lossy(&refusal.body).contains("known bytes"),
"a refusal must serve none of the file's bytes"
);
}
let no_audience = respond(
&plane,
"GET",
&format!("/resources/peer/{}", minted.handle),
None,
);
assert_eq!(no_audience.status, 400);
assert_eq!(body_error(&no_audience)["error"], "missing_audience");
let not_a_token = respond(&plane, "GET", "/resources/peer/report.md", Some("a@b"));
assert_eq!(not_a_token.status, 400);
assert_eq!(body_error(¬_a_token)["error"], "malformed_handle");
}
#[test]
fn a_malformed_shape_is_reported_before_the_audience_is_required() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
let plane = plane_for(dir.path(), peer_export("out/"));
let response = respond(&plane, "GET", "/resources/peer/not-a-token", None);
assert_eq!(body_error(&response)["error"], "malformed_handle");
}
#[test]
fn an_expired_handle_is_refused_with_its_own_code() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("out")).unwrap();
fs::write(dir.path().join("out/report.md"), b"bytes").unwrap();
let plane = plane_for(
dir.path(),
PeerFilesExport {
root: "out/".to_string(),
max_ttl_secs: Some(1),
max_bytes: 1024,
},
);
let minted = plane.mint_handle("report.md", "a@b", Some(1)).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
let response = respond(
&plane,
"GET",
&format!("/resources/peer/{}", minted.handle),
Some("a@b"),
);
assert_eq!(response.status, 410);
assert_eq!(body_error(&response)["error"], "handle_expired");
}
}