use std::collections::BTreeMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "lowercase"))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum ParamKind {
Bool,
Integer {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
min: Option<i64>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
max: Option<i64>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
unit: Option<String>,
},
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum ParamClass {
Hot,
Reach,
Contract,
}
impl ParamClass {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
ParamClass::Hot => "hot",
ParamClass::Reach => "reach",
ParamClass::Contract => "contract",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ParamSpec {
pub name: String,
#[cfg_attr(feature = "serde", serde(flatten))]
pub kind: ParamKind,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "std::ops::Not::not")
)]
pub sensitive: bool,
pub description: String,
}
impl ParamSpec {
#[must_use]
pub fn new(name: impl Into<String>, kind: ParamKind, description: impl Into<String>) -> Self {
ParamSpec {
name: name.into(),
kind,
sensitive: false,
description: description.into(),
}
}
#[must_use]
pub fn sensitive(mut self) -> Self {
self.sensitive = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ConfigGroup {
pub name: String,
pub class: ParamClass,
pub description: String,
pub parameters: Vec<ParamSpec>,
}
impl ConfigGroup {
#[must_use]
pub fn new(name: impl Into<String>, class: ParamClass, description: impl Into<String>) -> Self {
ConfigGroup {
name: name.into(),
class,
description: description.into(),
parameters: Vec::new(),
}
}
#[must_use]
pub fn with(mut self, spec: ParamSpec) -> Self {
self.parameters.push(spec);
self
}
#[must_use]
pub fn parameter(&self, name: &str) -> Option<&ParamSpec> {
self.parameters.iter().find(|p| p.name == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ConfigSchema {
pub groups: Vec<ConfigGroup>,
}
impl ConfigSchema {
#[must_use]
pub fn new() -> Self {
ConfigSchema::default()
}
#[must_use]
pub fn with(mut self, group: ConfigGroup) -> Self {
self.groups.push(group);
self
}
#[must_use]
pub fn group(&self, name: &str) -> Option<&ConfigGroup> {
self.groups.iter().find(|g| g.name == name)
}
pub fn validate(&self, group: &str, change: &ConfigChange) -> Result<(), ConfigError> {
let g = self
.group(group)
.ok_or_else(|| ConfigError::UnknownGroup(group.to_string()))?;
match g.class {
ParamClass::Contract => return Err(ConfigError::Contract(group.to_string())),
ParamClass::Reach if change.confirm_s.is_none() && !change.dry_run => {
return Err(ConfigError::ReachNeedsConfirm(group.to_string()));
}
ParamClass::Hot | ParamClass::Reach => {}
}
for (name, value) in &change.values {
let spec = g
.parameter(name)
.ok_or_else(|| ConfigError::UnknownParameter {
group: group.to_string(),
name: name.clone(),
})?;
let invalid = |reason: String| ConfigError::InvalidValue {
name: name.clone(),
reason,
};
match (&spec.kind, value) {
(ParamKind::Bool, ParamValue::Bool(_)) | (ParamKind::Text, ParamValue::Text(_)) => {
}
(ParamKind::Integer { min, max, unit }, ParamValue::Integer(v)) => {
let unit = unit.as_deref().map_or(String::new(), |u| format!(" {u}"));
if let Some(min) = min
&& v < min
{
return Err(invalid(format!(
"{v}{unit} is below the minimum of {min}{unit}"
)));
}
if let Some(max) = max
&& v > max
{
return Err(invalid(format!(
"{v}{unit} is above the maximum of {max}{unit}"
)));
}
}
(kind, value) => {
return Err(invalid(format!(
"expected {}, got {value}",
kind_name(kind)
)));
}
}
}
Ok(())
}
}
fn kind_name(kind: &ParamKind) -> &'static str {
match kind {
ParamKind::Bool => "a boolean",
ParamKind::Integer { .. } => "an integer",
ParamKind::Text => "text",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum ParamValue {
Bool(bool),
Integer(i64),
Text(String),
}
impl fmt::Display for ParamValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParamValue::Bool(b) => write!(f, "{b}"),
ParamValue::Integer(i) => write!(f, "{i}"),
ParamValue::Text(t) => write!(f, "{t:?}"),
}
}
}
impl ParamValue {
pub fn parse_as(kind: &ParamKind, text: &str) -> Result<Self, String> {
match kind {
ParamKind::Bool => match text {
"true" => Ok(ParamValue::Bool(true)),
"false" => Ok(ParamValue::Bool(false)),
other => Err(format!("expected true or false, got {other:?}")),
},
ParamKind::Integer { .. } => text
.parse::<i64>()
.map(ParamValue::Integer)
.map_err(|_| format!("expected an integer, got {text:?}")),
ParamKind::Text => Ok(ParamValue::Text(text.to_string())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum ValueSource {
Default,
File,
Overlay,
Runtime,
}
impl ValueSource {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
ValueSource::Default => "default",
ValueSource::File => "file",
ValueSource::Overlay => "overlay",
ValueSource::Runtime => "runtime",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ConfigChange {
pub values: BTreeMap<String, ParamValue>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub expected_revision: Option<u64>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub idempotency_key: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "std::ops::Not::not")
)]
pub dry_run: bool,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub confirm_s: Option<u64>,
}
impl ConfigChange {
#[must_use]
pub fn of(values: impl IntoIterator<Item = (impl Into<String>, ParamValue)>) -> Self {
ConfigChange {
values: values.into_iter().map(|(k, v)| (k.into(), v)).collect(),
..ConfigChange::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ControlRequest {
pub token: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub confirm_s: Option<u64>,
}
impl ControlRequest {
#[must_use]
pub fn of(token: impl Into<String>) -> Self {
ControlRequest {
token: token.into(),
confirm_s: None,
}
}
#[must_use]
pub fn extended_by(mut self, confirm_s: u64) -> Self {
self.confirm_s = Some(confirm_s);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ParamView {
#[cfg_attr(feature = "serde", serde(flatten))]
pub spec: ParamSpec,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub value: Option<ParamValue>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub source: Option<ValueSource>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub startup: Option<ParamValue>,
}
impl ParamView {
#[must_use]
pub fn of(spec: ParamSpec) -> Self {
ParamView {
spec,
value: None,
source: None,
startup: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct GroupView {
pub name: String,
pub class: ParamClass,
pub description: String,
pub parameters: Vec<ParamView>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct PendingChange {
pub token: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub deadline: Option<String>,
pub groups: Vec<String>,
}
impl PendingChange {
#[must_use]
pub fn new(
token: impl Into<String>,
groups: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
PendingChange {
token: token.into(),
deadline: None,
groups: groups.into_iter().map(Into::into).collect(),
}
}
#[must_use]
pub fn until(mut self, deadline: impl Into<String>) -> Self {
self.deadline = Some(deadline.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ConfigView {
pub resource: String,
pub revision: u64,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub pending: Option<PendingChange>,
pub groups: Vec<GroupView>,
}
impl ConfigView {
#[must_use]
pub fn of(resource: impl Into<String>, schema: &ConfigSchema) -> Self {
ConfigView {
resource: resource.into(),
revision: 0,
pending: None,
groups: schema
.groups
.iter()
.map(|g| GroupView {
name: g.name.clone(),
class: g.class,
description: g.description.clone(),
parameters: g.parameters.iter().cloned().map(ParamView::of).collect(),
})
.collect(),
}
}
#[must_use]
pub fn group(&self, name: &str) -> Option<&GroupView> {
self.groups.iter().find(|g| g.name == name)
}
#[must_use]
pub fn schema(&self) -> ConfigSchema {
ConfigSchema {
groups: self
.groups
.iter()
.map(|g| ConfigGroup {
name: g.name.clone(),
class: g.class,
description: g.description.clone(),
parameters: g.parameters.iter().map(|p| p.spec.clone()).collect(),
})
.collect(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub enum ChangeOutcome {
Applied,
Confirmed,
RolledBack,
Partial,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Edit {
pub parameter: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub old: Option<ParamValue>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub new: Option<ParamValue>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "std::ops::Not::not")
)]
pub redacted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ConfigChangeEvent {
pub resource: String,
pub revision: u64,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub token: Option<String>,
pub outcome: ChangeOutcome,
pub edits: Vec<Edit>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub actor: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub request_id: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub claimed_source: Option<String>,
}
impl Edit {
#[must_use]
pub fn of(
parameter: impl Into<String>,
old: Option<ParamValue>,
new: Option<ParamValue>,
) -> Self {
Edit {
parameter: parameter.into(),
old,
new,
redacted: false,
}
}
#[must_use]
pub fn redacted(parameter: impl Into<String>) -> Self {
Edit {
redacted: true,
..Edit::of(parameter, None, None)
}
}
}
impl ConfigChangeEvent {
#[must_use]
pub fn new(
resource: impl Into<String>,
revision: u64,
outcome: ChangeOutcome,
edits: impl IntoIterator<Item = Edit>,
) -> Self {
ConfigChangeEvent {
resource: resource.into(),
revision,
token: None,
outcome,
edits: edits.into_iter().collect(),
actor: None,
request_id: None,
claimed_source: None,
}
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
#[must_use]
pub fn actor(mut self, actor: impl Into<String>) -> Self {
self.actor = Some(actor.into());
self
}
#[must_use]
pub fn request_id(mut self, id: impl Into<String>) -> Self {
self.request_id = Some(id.into());
self
}
#[must_use]
pub fn claimed_source(mut self, source: impl Into<String>) -> Self {
self.claimed_source = Some(source.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfigError {
UnknownGroup(String),
UnknownParameter { group: String, name: String },
InvalidValue { name: String, reason: String },
Contract(String),
ReachNeedsConfirm(String),
StaleRevision { expected: u64, current: u64 },
Busy { token: String },
Device(String),
}
impl ConfigError {
#[must_use]
pub const fn reserved_error(&self) -> Option<&'static str> {
match self {
ConfigError::UnknownGroup(_) | ConfigError::UnknownParameter { .. } => {
Some("error/not-found")
}
ConfigError::InvalidValue { .. }
| ConfigError::ReachNeedsConfirm(_)
| ConfigError::StaleRevision { .. } => Some("error/invalid-args"),
ConfigError::Busy { .. } => Some("error/busy"),
ConfigError::Contract(_) | ConfigError::Device(_) => None,
}
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::UnknownGroup(g) => {
write!(
f,
"unknown group {g:?}; the schema names every group it accepts"
)
}
ConfigError::UnknownParameter { group, name } => {
write!(f, "group {group:?} declares no parameter {name:?}")
}
ConfigError::InvalidValue { name, reason } => write!(f, "{name}: {reason}"),
ConfigError::Contract(g) => write!(
f,
"group {g:?} is part of what this producer was started against and cannot change \
under a running transport; set it in the startup configuration and restart \
(RFC 05 §5.1)"
),
ConfigError::ReachNeedsConfirm(g) => write!(
f,
"group {g:?} can cut the link this reply would travel on; send it with a \
confirm window (`confirm_s`), and confirm over the new link (RFC 05 §5.1)"
),
ConfigError::StaleRevision { expected, current } => write!(
f,
"the change expects revision {expected} and the document is at {current}; read \
it back and decide again"
),
ConfigError::Busy { token } => write!(
f,
"a change is pending on this resource (token {token}); confirm, cancel or extend \
it, or carry its token"
),
ConfigError::Device(text) => write!(f, "the device refused: {text}"),
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Clone, PartialEq, Eq, Hash, Default)]
pub struct Sensitive<T>(T);
impl<T> Sensitive<T> {
pub const fn new(value: T) -> Self {
Sensitive(value)
}
pub const fn expose(&self) -> &T {
&self.0
}
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> fmt::Debug for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
impl<T> fmt::Display for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
#[cfg(feature = "serde")]
mod serde_impls {
use super::Sensitive;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
impl<T> Serialize for Sensitive<T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str("<redacted>")
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Sensitive<T> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
T::deserialize(d).map(Sensitive)
}
}
}
#[cfg(feature = "schemars")]
impl<T> schemars::JsonSchema for Sensitive<T> {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Sensitive".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "a write-only value: accepted on the way in, `<redacted>` on the way out"
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn schema() -> ConfigSchema {
ConfigSchema::new()
.with(
ConfigGroup::new("queue", ParamClass::Hot, "the kernel queue")
.with(ParamSpec::new(
"tx_queue_len",
ParamKind::Integer {
min: Some(1),
max: Some(10_000),
unit: Some("packets".into()),
},
"packets queued in front of the modem",
))
.with(ParamSpec::new("fq", ParamKind::Bool, "fair queueing")),
)
.with(
ConfigGroup::new("link", ParamClass::Reach, "how the node reaches the bus").with(
ParamSpec::new("apn", ParamKind::Text, "the access point name"),
),
)
.with(
ConfigGroup::new("sim", ParamClass::Hot, "the subscriber module")
.with(ParamSpec::new("pin", ParamKind::Text, "the PIN").sensitive()),
)
.with(
ConfigGroup::new(
"transport",
ParamClass::Contract,
"what Zenoh was started against",
)
.with(ParamSpec::new(
"mtu",
ParamKind::Integer {
min: None,
max: None,
unit: Some("bytes".into()),
},
"the SDU size",
)),
)
}
fn change(name: &str, value: ParamValue) -> ConfigChange {
ConfigChange::of([(name, value)])
}
#[test]
fn a_hot_change_of_the_right_kind_in_range_validates() {
let s = schema();
assert_eq!(
s.validate("queue", &change("tx_queue_len", ParamValue::Integer(100))),
Ok(())
);
assert_eq!(
s.validate("queue", &change("fq", ParamValue::Bool(true))),
Ok(())
);
assert_eq!(
s.validate("queue", &ConfigChange::default()),
Ok(()),
"an empty change changes nothing"
);
}
#[test]
fn the_refusals_name_what_the_caller_must_do() {
let s = schema();
let err = s.validate("nope", &ConfigChange::default()).unwrap_err();
assert_eq!(err, ConfigError::UnknownGroup("nope".into()));
assert_eq!(err.reserved_error(), Some("error/not-found"));
let err = s
.validate("queue", &change("txqueue", ParamValue::Integer(1)))
.unwrap_err();
assert!(matches!(err, ConfigError::UnknownParameter { .. }));
let err = s
.validate("queue", &change("tx_queue_len", ParamValue::Integer(0)))
.unwrap_err();
assert_eq!(
err.to_string(),
"tx_queue_len: 0 packets is below the minimum of 1 packets"
);
assert_eq!(err.reserved_error(), Some("error/invalid-args"));
let err = s
.validate(
"queue",
&change("tx_queue_len", ParamValue::Integer(20_000)),
)
.unwrap_err();
assert!(
err.to_string()
.contains("above the maximum of 10000 packets")
);
let err = s
.validate("queue", &change("fq", ParamValue::Integer(1)))
.unwrap_err();
assert_eq!(err.to_string(), "fq: expected a boolean, got 1");
let err = s
.validate("transport", &change("mtu", ParamValue::Integer(220)))
.unwrap_err();
assert_eq!(err, ConfigError::Contract("transport".into()));
assert_eq!(err.reserved_error(), None, "the producer's own name");
assert!(
err.to_string()
.contains("startup configuration and restart")
);
}
#[test]
fn a_reach_change_needs_a_confirm_window_unless_it_is_a_dry_run() {
let s = schema();
let apn = change("apn", ParamValue::Text("iot.example".into()));
let err = s.validate("link", &apn).unwrap_err();
assert_eq!(err, ConfigError::ReachNeedsConfirm("link".into()));
let mut confirmed = apn.clone();
confirmed.confirm_s = Some(120);
assert_eq!(s.validate("link", &confirmed), Ok(()));
let mut dry = apn;
dry.dry_run = true;
assert_eq!(s.validate("link", &dry), Ok(()));
}
#[test]
fn a_view_of_a_schema_carries_no_values_and_a_sensitive_parameter_never_will() {
let view = ConfigView::of("wwan0", &schema());
assert_eq!(view.revision, 0);
let pin = &view.group("sim").unwrap().parameters[0];
assert!(pin.spec.sensitive);
assert!(pin.value.is_none());
}
#[test]
fn a_sensitive_value_never_prints() {
let pin = Sensitive::new(String::from("1234"));
assert_eq!(format!("{pin:?}"), "<redacted>");
assert_eq!(pin.to_string(), "<redacted>");
assert_eq!(pin.expose(), "1234");
}
#[test]
fn a_value_is_read_against_the_declared_kind_and_a_view_gives_its_schema_back() {
let int = ParamKind::Integer {
min: Some(0),
max: Some(30),
unit: Some("dBm".into()),
};
assert_eq!(ParamValue::parse_as(&int, "7"), Ok(ParamValue::Integer(7)));
assert_eq!(
ParamValue::parse_as(&ParamKind::Text, "7"),
Ok(ParamValue::Text("7".into()))
);
assert_eq!(
ParamValue::parse_as(&ParamKind::Bool, "yes"),
Err("expected true or false, got \"yes\"".into())
);
assert!(ParamValue::parse_as(&int, "seven").is_err());
let schema = ConfigSchema::new().with(
ConfigGroup::new("radio", ParamClass::Reach, "the carrier").with(ParamSpec::new(
"tx_power",
int,
"transmit power",
)),
);
let view = ConfigView::of("rf0", &schema);
assert_eq!(view.schema(), schema, "a view carries its schema whole");
let extend = ControlRequest::of("chg-1").extended_by(30);
assert_eq!(extend.confirm_s, Some(30));
}
#[cfg(feature = "serde")]
#[test]
fn the_wire_shapes_round_trip_and_a_sensitive_value_serializes_redacted() {
let view = ConfigView::of("wwan0", &schema());
let json = serde_json::to_string(&view).unwrap();
let back: ConfigView = serde_json::from_str(&json).unwrap();
assert_eq!(back, view);
assert!(json.contains("\"class\":\"reach\""));
assert!(json.contains("\"kind\":\"integer\""));
let change = ConfigChange::of([("tx_queue_len", ParamValue::Integer(100))]);
let json = serde_json::to_string(&change).unwrap();
assert_eq!(
json, r#"{"values":{"tx_queue_len":100}}"#,
"absent guards are absent"
);
let pin = Sensitive::new(String::from("1234"));
assert_eq!(serde_json::to_string(&pin).unwrap(), "\"<redacted>\"");
let back: Sensitive<String> = serde_json::from_str("\"1234\"").unwrap();
assert_eq!(back.expose(), "1234");
}
#[cfg(feature = "serde")]
#[test]
fn a_change_event_built_from_outside_serializes_to_the_convention_shape() {
let event = ConfigChangeEvent::new(
"wwan0",
7,
ChangeOutcome::Applied,
[
Edit::of(
"tx_queue_len",
Some(ParamValue::Integer(100)),
Some(ParamValue::Integer(200)),
),
Edit::redacted("pin"),
],
)
.token("chg-1")
.actor("ops")
.request_id("r-9")
.claimed_source("tcp/10.0.0.2:7447");
let json = serde_json::to_value(&event).unwrap();
assert_eq!(
json,
serde_json::json!({
"resource": "wwan0",
"revision": 7,
"token": "chg-1",
"outcome": "applied",
"edits": [
{"parameter": "tx_queue_len", "old": 100, "new": 200},
{"parameter": "pin", "redacted": true},
],
"actor": "ops",
"request_id": "r-9",
"claimed_source": "tcp/10.0.0.2:7447",
})
);
let back: ConfigChangeEvent = serde_json::from_value(json).unwrap();
assert_eq!(back, event);
let bare = serde_json::to_value(ConfigChangeEvent::new(
"wwan0",
8,
ChangeOutcome::RolledBack,
[],
))
.unwrap();
assert_eq!(
bare,
serde_json::json!({"resource": "wwan0", "revision": 8, "outcome": "rolled-back", "edits": []})
);
}
}