//! The matrix agreement: where an account's credential lives in the store, and //! what the object at that path holds. //! //! Both halves are one agreement and neither end of it is senior, so they are //! stated together here rather than split between the path module and the //! client. Nothing in [`crate::client`] knows this shape — it moves whatever //! type a caller names — so a second kind of swarm secret gets its own module //! beside this one instead of another field on a shared struct. use serde::{Deserialize, Serialize}; use crate::{ Error, path::{AGENT_PREFIX, checked_segment}, }; /// The path holding `agent`'s token for the external matrix account `account`. /// /// # Errors /// [`Error::PathSegment`] when either name contains anything but /// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing /// another agent's secret. pub fn account_path(agent: &str, account: &str) -> Result { checked_segment("agent", agent)?; checked_segment("account", account)?; Ok(format!("{AGENT_PREFIX}/{agent}/matrix/{account}")) } /// What an account's path holds: the token, plus the homeserver it belongs to. /// /// The homeserver rides with the token rather than on the queue notice that /// triggers a delivery, because a notice is not persistence — re-delivering a /// credential has to reconstruct it, and the store is the only thing that keeps /// it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Credential { /// `glue-matrix-bao-token.nix` reads the store with /// `bao kv get -field=value`, so this name is load-bearing for a reader /// this crate does not control. Renaming it silently breaks that unit. pub value: String, /// Absent on every object written before this field existed, and KV2 keeps /// those versions forever. **`Option` is what tolerates that** — serde /// decodes a missing field to `None` for an optional type, so the type is /// the compatibility guarantee and changing it to a bare `String` is what /// would break every stored credential at once. /// /// `skip_serializing_if` is doing separate work: without it a token-only /// credential serialises `"homeserver":null`, and this object is read by /// `glue-matrix-bao-token.nix` with `bao kv get -field=value`. #[serde(skip_serializing_if = "Option::is_none")] pub homeserver: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn a_well_formed_pair_lands_under_the_agent_prefix() { let p = account_path("atlas", "ops-relay").expect("both segments are legal"); assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); assert!(p.starts_with(AGENT_PREFIX)); } #[test] fn a_segment_cannot_escape_its_own_directory() { // Each of these is a *different* way to address another agent's tree, // and the last two are the ones a charset check catches but a // `contains("..")` check does not. for bad in [ "../argus", "atlas/../argus", "atlas/matrix", "a b", "a.b", "", ] { assert!( account_path(bad, "ops-relay").is_err(), "agent segment {bad:?} must be refused" ); assert!( account_path("atlas", bad).is_err(), "account segment {bad:?} must be refused" ); } } #[test] fn the_legal_charset_is_actually_reachable() { // The control for the test above: if `checked_segment` rejected // everything, the escape cases would pass for the wrong reason. assert!(account_path("a-b_C9", "d-e_F0").is_ok()); } /// KV2 keeps every prior version, so objects written before `homeserver` /// existed are still readable and still get decoded by this type. What /// tolerates the absence is the field being `Option`, not any attribute: /// making it a bare `String` would not surface as a migration, it would /// surface as every previously-stored credential becoming unreadable at /// once. #[test] fn a_credential_stored_before_the_homeserver_field_still_decodes() { let old: Credential = serde_json::from_str(r#"{"value":"t"}"#) .expect("an object written by the previous version must still decode"); assert_eq!(old.value, "t"); assert_eq!(old.homeserver, None); // Control: the field is genuinely read when present, so the arm above // is about absence being tolerated rather than the field being ignored. let new: Credential = serde_json::from_str(r#"{"value":"t","homeserver":"https://hs"}"#) .expect("an object with the field decodes too"); assert_eq!(new.homeserver.as_deref(), Some("https://hs")); } #[test] fn the_value_field_matches_what_the_nix_reader_asks_for() { // The literal is the point: `bao kv get -field=value` is the other end // of this agreement and lives in a file no Rust test can reach, so the // name is pinned here rather than derived from the struct. // // It doubles as the compatibility control for `homeserver`: a // token-only credential must still serialise to exactly these bytes, // with no `homeserver` key at all, so adding the field cannot change // what that unit reads. let json = serde_json::to_string(&Credential { value: "t".to_owned(), homeserver: None, }) .expect("a struct of one String serialises"); assert_eq!(json, r#"{"value":"t"}"#); } }