swarm-secret-client: the agreements both ends of the store must share

mara ruled (a) on #3726: a thin workspace crate over `vaultrs` rather than
keeping bao access in nix and having each end trigger units. The HTTP is the
SDK's job; what this crate owns is the things the controller and a hive must
say *identically*, and which have no other home because neither end is senior
to the other.

Three such agreements:

`path::matrix_account` builds where a credential lives. It is fallible rather
than a `format!`, because both names reach it from elsewhere -- the agent name
from the topology, the account name from an agent's own config -- and a `/` or
`..` in either does not produce a malformed path, it produces a valid path to
a *different agent's* secret. The charset mirrors the KV bucket-name rule.

`Credential`'s `value` field is not a free choice: glue-matrix-bao-token.nix
reads the store with `bao kv get -field=value`, so the name is load-bearing
for a consumer no Rust test can reach. A test pins the serialised shape.

`client::Settings` reads BAO_ADDR / BAO_CLIENT_CERT / BAO_CLIENT_KEY /
BAO_CACERT explicitly instead of letting vaultrs fall through to its own
defaults, which look for VAULT_ADDR / VAULT_CLIENT_CERT / VAULT_CLIENT_KEY.
Every unit in this tree sets the BAO_ spellings, so the defaults would yield a
client with no identity at all -- surfacing as a TLS handshake failure, which
names neither the missing variable nor the reason.

The env read is split from the connect so every misconfiguration arm is
testable without a reachable store and without touching process-global env.

Dependency impact, measured against the lock at forge/main rather than assumed:
native-tls 0 -> 0, openssl-sys 0 -> 0, one reqwest (0.13.4) which vaultrs
shares, and 10 new crates that are all derive/proc-macro helpers.

Refs #3726
This commit is contained in:
atlas 2026-09-02 22:27:58 +02:00 committed by mara
commit 4384a1fffa
6 changed files with 584 additions and 6 deletions

154
Cargo.lock generated
View file

@ -894,14 +894,38 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.23.0",
"darling_macro", "darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
] ]
[[package]] [[package]]
@ -917,13 +941,24 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.23.0" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.23.0",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
] ]
@ -1020,6 +1055,37 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.119",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "1.0.0" version = "1.0.0"
@ -3823,7 +3889,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
dependencies = [ dependencies = [
"darling", "darling 0.23.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_json", "serde_json",
@ -4012,6 +4078,40 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustify"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4800ce4c1cc2fec12c559dae2ddbf0e17fcee7569b796e6d75898efef443368b"
dependencies = [
"anyhow",
"async-trait",
"bytes",
"http",
"reqwest",
"rustify_derive",
"serde",
"serde_json",
"serde_urlencoded",
"thiserror 1.0.69",
"tracing",
"url",
]
[[package]]
name = "rustify_derive"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ea7fda74240f7410d0198b603a8a2f662acc7d76b6667a49f9b162cd8d9b4f"
dependencies = [
"proc-macro2",
"quote",
"regex",
"serde_urlencoded",
"syn 1.0.109",
"synstructure 0.12.6",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -4642,6 +4742,17 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "swarm-secret-client"
version = "0.1.0"
dependencies = [
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.18",
"vaultrs",
]
[[package]] [[package]]
name = "swarmctl" name = "swarmctl"
version = "0.1.0" version = "0.1.0"
@ -4696,6 +4807,18 @@ dependencies = [
"futures-core", "futures-core",
] ]
[[package]]
name = "synstructure"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"unicode-xid",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.13.2" version = "0.13.2"
@ -5337,6 +5460,25 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vaultrs"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30ffcc0e81025065dda612ec1e26a3d81bb16ef3062354873d17a35965d68522"
dependencies = [
"async-trait",
"derive_builder",
"http",
"reqwest",
"rustify",
"rustify_derive",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
"url",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@ -5873,7 +6015,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@ -5914,7 +6056,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]

View file

@ -27,6 +27,7 @@ members = [
"swarm-controller", "swarm-controller",
"swarm-nats-auth", "swarm-nats-auth",
"swarm-queue-client", "swarm-queue-client",
"swarm-secret-client",
"swarmctl", "swarmctl",
] ]
@ -90,7 +91,9 @@ hive-sock-client = { path = "hive-sock-client" }
hive-types = { path = "hive-types" } hive-types = { path = "hive-types" }
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
swarm-queue-client = { path = "swarm-queue-client" } swarm-queue-client = { path = "swarm-queue-client" }
swarm-secret-client = { path = "swarm-secret-client" }
thiserror = "2" thiserror = "2"
vaultrs = "0.8"
tower-http = { version = "0.7", features = ["fs"] } tower-http = { version = "0.7", features = ["fs"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
rmcp = { version = "2", default-features = false, features = [ rmcp = { version = "2", default-features = false, features = [

View file

@ -0,0 +1,19 @@
[package]
name = "swarm-secret-client"
version.workspace = true
edition.workspace = true
[dependencies]
# `Identity` is re-exported by vaultrs, but the identity is built here (from
# the `BAO_*` files) rather than by its env defaults, so the dependency is
# direct rather than incidental.
reqwest.workspace = true
serde.workspace = true
thiserror.workspace = true
vaultrs.workspace = true
[dev-dependencies]
serde_json.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,248 @@
//! A logged-in handle on the store, built from this deployment's environment.
use serde::{Deserialize, Serialize};
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
use crate::{Error, path::MOUNT};
/// The store's address.
pub const ENV_ADDR: &str = "BAO_ADDR";
/// PEM client certificate presented to the store's listener.
pub const ENV_CLIENT_CERT: &str = "BAO_CLIENT_CERT";
/// PEM private key for [`ENV_CLIENT_CERT`].
pub const ENV_CLIENT_KEY: &str = "BAO_CLIENT_KEY";
/// CA bundle the store's own certificate is verified against. Optional:
/// absent means the system trust store, which is what a deployment with a
/// real CA wants and what a self-signed one must not be left with.
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";
#[derive(Serialize, Deserialize)]
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.
value: String,
}
/// Where the store is and which identity we present to it.
///
/// Separate from the connect so the environment can be checked without one:
/// every arm below is a misconfiguration an operator has to read an error
/// about, and none of them needs a reachable store to happen.
#[derive(Debug, PartialEq, Eq)]
pub struct Settings {
address: String,
cert_path: String,
key_path: String,
ca_path: Option<String>,
}
impl Settings {
/// Read the `BAO_*` variables from the process environment.
///
/// # Errors
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
pub fn from_env() -> Result<Self, Error> {
Self::from_lookup(|k| std::env::var(k).ok())
}
/// [`Settings::from_env`] against an arbitrary lookup.
///
/// [`vaultrs`]'s own defaults are deliberately not used: it looks for
/// `VAULT_ADDR` / `VAULT_CLIENT_CERT` / `VAULT_CLIENT_KEY`, and every unit
/// in this tree sets the `BAO_` spellings. Falling through to those
/// defaults yields a client with **no identity**, which fails at the TLS
/// handshake rather than anywhere that names the cause.
///
/// # Errors
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self, Error> {
let required = |var: &'static str| -> Result<String, Error> {
get(var)
.filter(|v| !v.is_empty())
.ok_or(Error::MissingEnv(var))
};
Ok(Self {
address: required(ENV_ADDR)?,
cert_path: required(ENV_CLIENT_CERT)?,
key_path: required(ENV_CLIENT_KEY)?,
ca_path: get(ENV_CACERT).filter(|v| !v.is_empty()),
})
}
}
/// A client that has already exchanged its certificate for a token.
pub struct SecretStore {
inner: VaultClient,
}
fn read_file(var: &'static str, path: &str) -> Result<Vec<u8>, Error> {
std::fs::read(path).map_err(|source| Error::Identity {
var,
path: path.to_owned(),
source,
})
}
impl SecretStore {
/// Connect using the `BAO_*` environment and log in with the certificate
/// auth method, returning a handle that already holds a token.
///
/// `cert_role` is the role configured on the store's `cert` mount; it
/// selects which policy the returned token carries.
///
/// # Errors
/// As [`SecretStore::connect`], plus [`Error::MissingEnv`].
pub async fn from_env(cert_role: &str) -> Result<Self, Error> {
Self::connect(&Settings::from_env()?, cert_role, DEFAULT_CERT_MOUNT).await
}
/// Present `settings`' identity to the store and log in at `cert_mount`.
///
/// # Errors
/// [`Error::Identity`] when a named file cannot be read, [`Error::Tls`]
/// when the cert/key pair does not form a usable identity,
/// [`Error::Settings`] when the address will not parse, and
/// [`Error::Vault`] when the store refuses the login — which is what an
/// unconfigured `cert` mount looks like from here.
pub async fn connect(
settings: &Settings,
cert_role: &str,
cert_mount: &str,
) -> Result<Self, Error> {
// One PEM blob holding both, which is the shape `Identity::from_pem`
// wants; the two stay separate on disk because the key is the half
// that gets `0600`.
let mut pem = read_file(ENV_CLIENT_CERT, &settings.cert_path)?;
pem.push(b'\n');
pem.extend_from_slice(&read_file(ENV_CLIENT_KEY, &settings.key_path)?);
let identity = reqwest::Identity::from_pem(&pem).map_err(Error::Tls)?;
let mut builder = VaultClientSettingsBuilder::default();
builder
.address(settings.address.clone())
.identity(Some(identity));
if let Some(ca) = &settings.ca_path {
builder.ca_certs(vec![ca.clone()]);
}
let built = builder
.build()
.map_err(|e| Error::Settings(e.to_string()))?;
let mut inner = VaultClient::new(built)?;
let auth = vaultrs::auth::cert::login(&inner, cert_mount, cert_role).await?;
inner.set_token(&auth.client_token);
Ok(Self { inner })
}
/// Read the credential stored at `path`.
///
/// # 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<String, Error> {
let c: Credential = vaultrs::kv2::read(&self.inner, MOUNT, path).await?;
Ok(c.value)
}
/// Write `value` as the credential 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, value: &str) -> Result<(), Error> {
let body = Credential {
value: value.to_owned(),
};
vaultrs::kv2::set(&self.inner, MOUNT, path, &body).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A lookup standing in for a fully-configured unit's environment.
fn full(k: &str) -> Option<String> {
match k {
ENV_ADDR => Some("https://store.invalid:8200".to_owned()),
ENV_CLIENT_CERT => Some("/run/c.pem".to_owned()),
ENV_CLIENT_KEY => Some("/run/k.pem".to_owned()),
_ => None,
}
}
#[test]
fn a_complete_environment_is_accepted() {
// The control: without this, every assertion below could be passing
// because `from_lookup` rejects everything.
let s = Settings::from_lookup(full).expect("every required variable is set");
assert_eq!(s.address, "https://store.invalid:8200");
assert_eq!(s.ca_path, None, "an absent CA is the system trust store");
}
#[test]
fn each_required_variable_is_named_when_it_is_the_missing_one() {
for var in [ENV_ADDR, ENV_CLIENT_CERT, ENV_CLIENT_KEY] {
let e = Settings::from_lookup(|k| if k == var { None } else { full(k) })
.expect_err("one required variable is absent");
assert!(
matches!(e, Error::MissingEnv(v) if v == var),
"dropping {var} should name {var}, got {e:?}"
);
}
}
#[test]
fn an_empty_variable_is_as_absent_as_an_unset_one() {
// systemd writes `Environment=BAO_CACERT=` for an unset nix option, so
// empty is the shape this actually arrives in.
let e = Settings::from_lookup(|k| {
if k == ENV_ADDR {
Some(String::new())
} else {
full(k)
}
})
.expect_err("an empty address is not an address");
assert!(matches!(e, Error::MissingEnv(ENV_ADDR)), "got {e:?}");
let s = Settings::from_lookup(|k| {
if k == ENV_CACERT {
Some(String::new())
} else {
full(k)
}
})
.expect("an empty CA is optional, not fatal");
assert_eq!(s.ca_path, None);
}
#[test]
fn an_unreadable_cert_names_the_file_and_the_variable() {
let e = read_file(ENV_CLIENT_CERT, "/nonexistent/cert.pem")
.expect_err("the file does not exist");
match e {
Error::Identity { var, ref path, .. } => {
assert_eq!(var, ENV_CLIENT_CERT);
assert_eq!(path, "/nonexistent/cert.pem");
}
other => panic!("wanted an Identity error, got {other:?}"),
}
}
#[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.
let json = serde_json::to_string(&Credential {
value: "t".to_owned(),
})
.expect("a struct of one String serialises");
assert_eq!(json, r#"{"value":"t"}"#);
}
}

View file

@ -0,0 +1,68 @@
//! The swarm's secret-store client: where a credential lives, and how both ends
//! reach it.
//!
//! The HTTP is [`vaultrs`]'s job. What this crate owns is the *agreements* —
//! the path a credential is written to and read from ([`path`]), the field its
//! bytes live in, and the translation from this deployment's environment into
//! a logged-in client ([`client`]). Each of those is a thing the controller and
//! a hive must say identically, so it is said once here.
pub mod client;
pub mod path;
pub use client::SecretStore;
/// What can go wrong between "we have a client certificate" and "we have the
/// credential".
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A name that would have addressed something other than what the caller
/// meant. See [`path`].
#[error("{kind} name {value:?} is not a single path segment of [A-Za-z0-9_-]")]
PathSegment {
/// Which name was rejected — `agent` or `account`.
kind: &'static str,
/// The offending value, quoted in the message because the caller
/// usually got it from config and needs to see which one.
value: String,
},
/// A variable the store's address or identity comes from is unset or
/// empty. Named rather than defaulted: a wrong store address fails much
/// later and much less clearly than a missing one.
#[error("{0} is unset or empty")]
MissingEnv(&'static str),
/// A client-certificate file named by the environment could not be read.
#[error("reading {path} (from {var}): {source}")]
Identity {
/// The variable that named the file.
var: &'static str,
/// The path it named.
path: String,
/// The underlying IO failure.
source: std::io::Error,
},
/// The address would not parse into a URL the client can use.
#[error("the store's settings are unusable: {0}")]
Settings(String),
/// The store refused us, was unreachable, or answered something we could
/// not parse.
#[error(transparent)]
Vault(#[from] Box<vaultrs::error::ClientError>),
/// The client certificate and key did not form a usable identity, or the
/// CA bundle did not parse.
#[error("building the TLS identity: {0}")]
Tls(#[source] reqwest::Error),
}
impl From<vaultrs::error::ClientError> for Error {
fn from(e: vaultrs::error::ClientError) -> Self {
// Boxed because `ClientError` is large enough that carrying it inline
// makes every `Result` in the crate pay for the rare arm.
Self::Vault(Box::new(e))
}
}

View file

@ -0,0 +1,98 @@
//! Where a credential lives, for both ends of the store.
//!
//! The controller writes and a hive reads, and neither is senior to the other,
//! so the path they must agree on is built here rather than formatted at each
//! call site.
use crate::Error;
/// The KV v2 mount every swarm secret lives under.
///
/// A literal because the store's existing reader already hardcodes the same
/// one (`secret/swarm/matrix/registration-token`); an option nobody sets would
/// be two ways to say one thing.
pub const MOUNT: &str = "secret";
/// The prefix under [`MOUNT`] owned by per-agent credentials.
pub const AGENT_PREFIX: &str = "swarm/agents";
/// A path segment that cannot change the path's shape.
///
/// The charset is deliberately narrower than what the store accepts: a `/`
/// turns one agent's segment into another agent's directory, and `..` walks
/// out of the prefix entirely. Both are names this crate receives from
/// elsewhere — an agent name from the topology, an account name from an
/// agent's own config — so neither is trusted to be well-formed here.
fn checked_segment(kind: &'static str, value: &str) -> Result<(), Error> {
if value.is_empty() {
return Err(Error::PathSegment {
kind,
value: value.to_owned(),
});
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(Error::PathSegment {
kind,
value: value.to_owned(),
});
}
Ok(())
}
/// 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 matrix_account(agent: &str, account: &str) -> Result<String, Error> {
checked_segment("agent", agent)?;
checked_segment("account", account)?;
Ok(format!("{AGENT_PREFIX}/{agent}/matrix/{account}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_well_formed_pair_lands_under_the_agent_prefix() {
let p = matrix_account("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!(
matrix_account(bad, "ops-relay").is_err(),
"agent segment {bad:?} must be refused"
);
assert!(
matrix_account("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!(matrix_account("a-b_C9", "d-e_F0").is_ok());
}
}