swarm-secret-client: one module per kind of secret, not one struct
mara, reviewing the previous commit: "the field is specific to matrix, why add it to the general struct". She is right, and the answer is that there was no general struct — `Credential` had one consumer, the crate's only path builder was `matrix_account`, and `value` is pinned by `glue-matrix-bao-token.nix`, a matrix unit. It was matrix's throughout, wearing a general name; adding `homeserver` is what made that visible. `client` now moves whatever type a caller names and decodes nothing itself. That is forwarding rather than machinery: `vaultrs::kv2::read`/`set` are already generic over the payload. The matrix agreement moves to its own module holding both halves — where a credential lives (`account_path`, was `path::matrix_account`) and what the object at that path holds. `path` keeps only what every path obeys, so a second kind of swarm secret becomes a module beside `matrix` rather than another optional field on a struct it shares. argus raised the same collision from the other direction on #4092: two mutually-exclusive `Option`s modelling one concept is the failure mode this forecloses. `checked_segment` stays public in `path`: hive-priv builds an on-disk path from the same names and must accept the same charset. Behaviour is unchanged. The compatibility properties move with the struct — `Option` is what lets a pre-`homeserver` stored object decode, and `skip_serializing_if` is what keeps a token-only object free of `"homeserver":null` for that nix reader. Refs #3726
This commit is contained in:
parent
d1c0963fbd
commit
e263681f1d
6 changed files with 205 additions and 123 deletions
134
swarm-secret-client/src/matrix.rs
Normal file
134
swarm-secret-client/src/matrix.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! 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<String, Error> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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"}"#);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue