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:
atlas 2026-09-08 12:58:45 +02:00 committed by mara
commit e263681f1d
6 changed files with 205 additions and 123 deletions

View file

@ -1,6 +1,6 @@
//! A logged-in handle on the store, built from this deployment's environment.
use serde::{Deserialize, Serialize};
use serde::{Serialize, de::DeserializeOwned};
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
use crate::{Error, path::MOUNT};
@ -19,33 +19,6 @@ pub const ENV_CACERT: &str = "BAO_CACERT";
/// The auth mount a cert login goes through, unless a caller says otherwise.
pub const DEFAULT_CERT_MOUNT: &str = "cert";
/// What a credential path holds: the secret itself, plus the configuration a
/// reader needs to use it.
///
/// The homeserver rides here rather than on the queue notice because a notice
/// is not persistence — re-delivering a credential (agent moved, hive
/// re-provisioned, token rotated) has to reconstruct it from somewhere, 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>,
}
/// Where the store is and which identity we present to it.
///
/// Separate from the connect so the environment can be checked without one:
@ -157,21 +130,25 @@ impl SecretStore {
Ok(Self { inner })
}
/// Read the credential stored at `path`.
/// Read the object stored at `path`, decoded as `T`.
///
/// The shape belongs to whoever owns the path — see [`crate::matrix`] for
/// the one kind of secret that exists — so this moves bytes and knows no
/// fields.
///
/// # Errors
/// [`Error::Vault`] when the path does not exist, the token's policy does
/// not cover it, or the stored object has no `value` field.
pub async fn read(&self, path: &str) -> Result<Credential, Error> {
/// not cover it, or the stored object does not decode as `T`.
pub async fn read<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
Ok(vaultrs::kv2::read(&self.inner, MOUNT, path).await?)
}
/// Write `credential` at `path`, creating a new version.
/// Write `value` at `path`, creating a new version.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover the path.
pub async fn write(&self, path: &str, credential: &Credential) -> Result<(), Error> {
vaultrs::kv2::set(&self.inner, MOUNT, path, credential).await?;
pub async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
vaultrs::kv2::set(&self.inner, MOUNT, path, value).await?;
Ok(())
}
}
@ -248,43 +225,4 @@ mod tests {
other => panic!("wanted an Identity error, got {other:?}"),
}
}
/// 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. Mutation-tested — dropping `#[serde(default)]` changed nothing,
/// which is how the redundant attribute was found and removed.
#[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"}"#);
}
}