use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use core::ops::Range;
use serde::Serialize;
use serde::de::DeserializeOwned;
pub use toml::Spanned;
pub fn unspanned<T>(value: T) -> Spanned<T> {
Spanned::new(0..0, value)
}
#[derive(Debug)]
pub struct Parsed<C> {
pub settings: C,
pub unknown: Vec<String>,
}
pub fn parse<C: DeserializeOwned>(text: &[u8]) -> Result<Parsed<C>, Problem> {
let text = core::str::from_utf8(text).map_err(|e| {
let at = e.valid_up_to();
Problem::new(at..at + 1, "not valid UTF-8")
})?;
let document = toml::de::Deserializer::parse(text).map_err(Problem::from_toml)?;
let mut unknown = Vec::new();
let settings = serde_ignored::deserialize(document, |path| unknown.push(path.to_string()))
.map_err(Problem::from_toml)?;
Ok(Parsed { settings, unknown })
}
#[cfg(feature = "storage")]
#[derive(Debug)]
pub struct Loaded<C> {
pub parsed: Parsed<C>,
pub text: Option<Vec<u8>>,
}
#[cfg(feature = "storage")]
pub fn load<C, D>(
volume: &mut crate::storage::Volume<D>,
path: &str,
) -> Result<Loaded<C>, LoadError<D::Error>>
where
C: DeserializeOwned,
D: resident_fat::BlockDevice,
{
let text = match volume.open(path) {
Ok(file) => Some(volume.read_all(&file).map_err(LoadError::Storage)?),
Err(resident_fat::Error::NotFound { .. }) => None,
Err(error) => return Err(LoadError::Storage(error)),
};
match parse(text.as_deref().unwrap_or_default()) {
Ok(parsed) => Ok(Loaded { parsed, text }),
Err(problem) => Err(LoadError::Invalid {
path: path.to_string(),
text: text.unwrap_or_default(),
problem,
}),
}
}
#[cfg(feature = "storage")]
#[derive(Debug)]
pub enum LoadError<E> {
Storage(resident_fat::Error<E>),
Invalid {
path: String,
text: Vec<u8>,
problem: Problem,
},
}
#[cfg(feature = "storage")]
impl<E: fmt::Debug> fmt::Display for LoadError<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::Storage(error) => write!(f, "{error}"),
LoadError::Invalid {
path,
text,
problem,
} => write!(f, "{}", problem.report(path, text)),
}
}
}
pub fn render<C: Serialize + DeserializeOwned>(settings: &C) -> Result<String, RenderError> {
let text =
toml::to_string_pretty(settings).map_err(|e| RenderError::Serialize(e.to_string()))?;
let reparsed = parse::<C>(text.as_bytes()).map_err(RenderError::Reparse)?;
let again = toml::to_string_pretty(&reparsed.settings)
.map_err(|e| RenderError::Serialize(e.to_string()))?;
if again != text {
return Err(RenderError::NotRoundTrip);
}
Ok(text)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderError {
Serialize(String),
Reparse(Problem),
NotRoundTrip,
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RenderError::Serialize(message) => write!(f, "cannot write settings: {message}"),
RenderError::Reparse(problem) => {
write!(f, "settings as written do not read back: {problem}")
}
RenderError::NotRoundTrip => {
f.write_str("settings as written read back as something different")
}
}
}
}
impl core::error::Error for RenderError {}
#[cfg(feature = "storage")]
pub fn save<C, D>(
volume: &mut crate::storage::Volume<D>,
path: &str,
settings: &C,
) -> Result<Vec<u8>, SaveError<D::Error>>
where
C: Serialize + DeserializeOwned,
D: resident_fat::BlockDevice,
{
let text = render(settings).map_err(SaveError::Render)?.into_bytes();
let staging = format!("{path}{STAGING_SUFFIX}");
let file = volume
.write_file(&staging, &text)
.map_err(SaveError::Write)?;
volume.sync().map_err(SaveError::Sync)?;
let stored = volume.read_all(&file).map_err(SaveError::ReadBack)?;
if stored != text {
return Err(SaveError::Mismatch);
}
volume.rename(&staging, path).map_err(SaveError::Rename)?;
volume.sync().map_err(SaveError::Sync)?;
Ok(text)
}
#[cfg(feature = "storage")]
pub const STAGING_SUFFIX: &str = ".new";
#[cfg(feature = "storage")]
#[derive(Debug)]
pub enum SaveError<E> {
Render(RenderError),
Write(resident_fat::Error<E>),
Sync(resident_fat::Error<E>),
ReadBack(resident_fat::Error<E>),
Mismatch,
Rename(resident_fat::Error<E>),
}
#[cfg(feature = "storage")]
impl<E: fmt::Debug> fmt::Display for SaveError<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SaveError::Render(error) => write!(f, "{error}"),
SaveError::Write(error) => write!(f, "write failed: {error}"),
SaveError::Sync(error) => write!(f, "written, but sync failed: {error}"),
SaveError::ReadBack(error) => write!(f, "written, but reading it back failed: {error}"),
SaveError::Mismatch => f.write_str("written, but reads back different"),
SaveError::Rename(error) => {
write!(
f,
"written and checked, but not renamed into place: {error}"
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem {
span: Option<Range<usize>>,
message: String,
}
impl Problem {
pub fn new(span: Range<usize>, message: impl Into<String>) -> Self {
Problem {
span: Some(span),
message: message.into(),
}
}
fn from_toml(error: toml::de::Error) -> Self {
Problem {
span: error.span(),
message: error.message().to_string(),
}
}
pub fn span(&self) -> Option<Range<usize>> {
self.span.clone()
}
pub fn message(&self) -> &str {
&self.message
}
pub fn locate(&self, text: &[u8]) -> Option<Location> {
self.span
.as_ref()
.map(|span| Location::of(text, span.start))
}
pub fn report<'a>(&'a self, file: &'a str, text: &'a [u8]) -> Report<'a> {
Report {
problem: self,
file,
text,
}
}
}
impl fmt::Display for Problem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl core::error::Error for Problem {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Location {
pub line: usize,
pub column: usize,
}
impl Location {
fn of(text: &[u8], offset: usize) -> Self {
let before = &text[..offset.min(text.len())];
let line_start = before
.iter()
.rposition(|&b| b == b'\n')
.map_or(0, |i| i + 1);
Location {
line: before.iter().filter(|&&b| b == b'\n').count() + 1,
column: before[line_start..]
.iter()
.filter(|&&b| b & 0xC0 != 0x80)
.count()
+ 1,
}
}
}
#[derive(Debug)]
pub struct Report<'a> {
problem: &'a Problem,
file: &'a str,
text: &'a [u8],
}
impl fmt::Display for Report<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.problem.locate(self.text) {
Some(Location { line, column }) => {
write!(f, "{}:{line}:{column}: {}", self.file, self.problem)
}
None => write!(f, "{}: {}", self.file, self.problem),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Invalid {
expected: &'static str,
}
impl Invalid {
pub const fn expected(expected: &'static str) -> Self {
Invalid { expected }
}
pub fn expectation(&self) -> &'static str {
self.expected
}
}
impl fmt::Display for Invalid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "expected {}", self.expected)
}
}
pub trait At<T> {
fn at<S>(self, setting: &Spanned<S>) -> Result<T, Problem>;
}
impl<T> At<T> for Result<T, Invalid> {
fn at<S>(self, setting: &Spanned<S>) -> Result<T, Problem> {
self.map_err(|invalid| Problem::new(setting.span(), format!("{invalid}")))
}
}
pub mod value {
use alloc::format;
use alloc::string::String;
use core::time::Duration;
use super::Invalid;
const LABEL_MAX: usize = 63;
pub fn duration(v: &str) -> Result<Duration, Invalid> {
const EXPECTED: Invalid =
Invalid::expected("a duration: a whole number above zero, followed by `s`, `m` or `h`");
let (digits, multiplier) = match v.as_bytes().last() {
Some(b's') => (&v[..v.len() - 1], 1),
Some(b'm') => (&v[..v.len() - 1], 60),
Some(b'h') => (&v[..v.len() - 1], 60 * 60),
_ => (v, 1),
};
if !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(EXPECTED);
}
let count: u64 = digits.parse().map_err(|_| EXPECTED)?;
if count == 0 {
return Err(EXPECTED);
}
Ok(Duration::from_secs(
count.checked_mul(multiplier).ok_or(EXPECTED)?,
))
}
pub fn duration_text(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds != 0 && seconds.is_multiple_of(60 * 60) {
format!("{}h", seconds / (60 * 60))
} else if seconds != 0 && seconds.is_multiple_of(60) {
format!("{}m", seconds / 60)
} else {
format!("{seconds}s")
}
}
pub fn label(v: &str) -> Result<&str, Invalid> {
const EXPECTED: Invalid = Invalid::expected(
"a name of 1 to 63 letters, digits and hyphens, not starting or ending with a hyphen",
);
let bytes = v.as_bytes();
let label = match bytes.len().checked_sub(".local".len()) {
Some(at) if bytes[at..].eq_ignore_ascii_case(b".local") => &v[..at],
_ => v,
};
if label.is_empty() || label.len() > LABEL_MAX {
return Err(EXPECTED);
}
if label.starts_with('-') || label.ends_with('-') {
return Err(EXPECTED);
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Err(EXPECTED);
}
Ok(label)
}
pub fn host(v: &str) -> Result<&str, Invalid> {
graphic(v).ok_or(Invalid::expected("a host name or address, with no spaces"))
}
pub fn word(v: &str) -> Result<&str, Invalid> {
graphic(v).ok_or(Invalid::expected("printable ASCII with no spaces"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Url<'a> {
pub host: &'a str,
pub port: u16,
pub path: &'a str,
}
pub fn url(v: &str) -> Result<Url<'_>, Invalid> {
const EXPECTED: Invalid = Invalid::expected("a URL starting `https://`");
let rest = v.strip_prefix("https://").ok_or(EXPECTED)?;
let (authority, path) = match rest.find('/') {
Some(at) => rest.split_at(at),
None => (rest, ""),
};
let path = path.trim_end_matches('/');
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (host, port.parse().map_err(|_| EXPECTED)?),
None => (authority, 443),
};
let host = graphic(host).ok_or(EXPECTED)?;
if !path.bytes().all(|b| b.is_ascii_graphic()) {
return Err(EXPECTED);
}
Ok(Url { host, port, path })
}
pub fn address(v: &str) -> Result<&str, Invalid> {
const EXPECTED: Invalid =
Invalid::expected("an email address, or a comma-separated list of them");
if v.is_empty() {
return Err(EXPECTED);
}
for address in v.split(',') {
let address = address.trim();
let (local, domain) = address.split_once('@').ok_or(EXPECTED)?;
if local.is_empty() || domain.is_empty() || graphic(address).is_none() {
return Err(EXPECTED);
}
}
Ok(v)
}
pub fn degrees(v: f64, limit: f64) -> Result<f64, Invalid> {
if (-limit..=limit).contains(&v) {
Ok(v)
} else {
Err(Invalid::expected("decimal degrees within range"))
}
}
const SSID_MAX: usize = 32;
pub fn ssid(v: &str) -> Result<&str, Invalid> {
if v.is_empty() || v.len() > SSID_MAX || v.chars().any(char::is_control) {
return Err(Invalid::expected(
"a network name of 1 to 32 bytes, with no control characters",
));
}
Ok(v)
}
pub fn passphrase(v: &str) -> Result<&str, Invalid> {
if !(8..=63).contains(&v.len()) || !v.bytes().all(|b| b.is_ascii_graphic() || b == b' ') {
return Err(Invalid::expected(
"a WPA2 passphrase of 8 to 63 printable ASCII characters",
));
}
Ok(v)
}
fn graphic(v: &str) -> Option<&str> {
(!v.is_empty() && v.bytes().all(|b| b.is_ascii_graphic())).then_some(v)
}
}
#[derive(Debug, Default, Serialize, serde::Deserialize)]
pub struct NtpSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<Spanned<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_interval: Option<Spanned<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resync_interval: Option<Spanned<String>>,
}
#[cfg(feature = "sntp")]
impl NtpSettings {
pub fn check(&self) -> Result<crate::sntp::NtpConfig<'_>, Problem> {
let mut ntp = crate::sntp::NtpConfig::DEFAULT;
if let Some(server) = &self.server {
ntp.server = value::host(server.as_ref()).at(server)?;
}
if let Some(interval) = &self.retry_interval {
ntp.retry_interval = value::duration(interval.as_ref()).at(interval)?;
}
if let Some(interval) = &self.resync_interval {
ntp.resync_interval = value::duration(interval.as_ref()).at(interval)?;
}
Ok(ntp)
}
}
#[cfg(feature = "sntp")]
impl crate::sntp::NtpConfig<'_> {
pub fn leak(&self) -> crate::sntp::NtpConfig<'static> {
crate::sntp::NtpConfig {
server: String::from(self.server).leak(),
retry_interval: self.retry_interval,
resync_interval: self.resync_interval,
}
}
}
#[derive(Serialize, serde::Deserialize)]
pub struct WifiSettings {
pub ssid: Spanned<String>,
pub passphrase: Spanned<String>,
}
impl WifiSettings {
pub fn new(ssid: impl Into<String>, passphrase: impl Into<String>) -> Self {
WifiSettings {
ssid: unspanned(ssid.into()),
passphrase: unspanned(passphrase.into()),
}
}
pub fn check(&self) -> Result<WifiNetwork<'_>, Problem> {
Ok(WifiNetwork {
ssid: value::ssid(self.ssid.as_ref()).at(&self.ssid)?,
passphrase: value::passphrase(self.passphrase.as_ref()).at(&self.passphrase)?,
})
}
}
impl fmt::Debug for WifiSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WifiSettings")
.field("ssid", self.ssid.as_ref())
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct WifiNetwork<'a> {
pub ssid: &'a str,
pub passphrase: &'a str,
}
impl WifiNetwork<'_> {
pub fn leak(&self) -> WifiNetwork<'static> {
WifiNetwork {
ssid: String::from(self.ssid).leak(),
passphrase: String::from(self.passphrase).leak(),
}
}
}
impl fmt::Debug for WifiNetwork<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WifiNetwork")
.field("ssid", &self.ssid)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use core::time::Duration;
use super::value::{self, Url};
use super::*;
#[derive(Debug, serde::Deserialize)]
struct Settings {
hostname: Spanned<String>,
#[serde(default)]
ntp_resync_interval: Option<Spanned<String>>,
#[serde(default)]
zone: Vec<Zone>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Zone {
name: String,
enabled: bool,
presence_uv: u32,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
struct Defaults {
hostname: String,
port: u16,
}
fn report(text: &str, problem: &Problem) -> String {
format!("{}", problem.report("BOARD.TOML", text.as_bytes()))
}
const GOOD: &str = r#"
hostname = "water-sensor"
ntp_resync_interval = "6h"
[[zone]]
name = "Water heater"
enabled = true
presence_uv = 400000
[[zone]]
name = "Sump"
enabled = false
presence_uv = 350000
"#;
#[test]
fn parses_a_schema_with_tables() {
let parsed = parse::<Settings>(GOOD.as_bytes()).unwrap();
let s = parsed.settings;
assert_eq!(s.hostname.as_ref(), "water-sensor");
assert_eq!(s.zone.len(), 2);
assert_eq!(s.zone[0].name, "Water heater");
assert!(s.zone[0].enabled);
assert_eq!(s.zone[1].presence_uv, 350_000);
assert!(parsed.unknown.is_empty());
let resync = s.ntp_resync_interval.unwrap();
assert_eq!(
value::duration(resync.as_ref()),
Ok(Duration::from_secs(21_600))
);
}
#[test]
fn spans_point_at_the_value() {
let s = parse::<Settings>(GOOD.as_bytes()).unwrap().settings;
assert_eq!(&GOOD[s.hostname.span()], "\"water-sensor\"");
}
#[test]
fn an_empty_file_is_the_defaults() {
let parsed = parse::<Defaults>(b"").unwrap();
assert_eq!(parsed.settings.hostname, "");
assert_eq!(parsed.settings.port, 0);
}
#[test]
fn unknown_keys_are_collected_not_refused() {
let text = "hostname = \"a\"\nfuture = 1\n[[zone]]\nname = \"x\"\nenabled = true\npresence_uv = 1\nnmae = \"y\"\n";
let parsed = parse::<Settings>(text.as_bytes()).unwrap();
assert_eq!(parsed.unknown, vec!["future", "zone.0.nmae"]);
assert_eq!(parsed.settings.hostname.as_ref(), "a");
}
#[test]
fn a_syntax_error_is_located() {
let text = "hostname = \"a\"\nbroken = \n";
let problem = parse::<Settings>(text.as_bytes()).unwrap_err();
assert!(report(text, &problem).starts_with("BOARD.TOML:2:"));
}
#[test]
fn a_type_error_is_located_at_the_value() {
let text =
"hostname = \"a\"\n\n[[zone]]\nname = \"x\"\nenabled = \"yes\"\npresence_uv = 1\n";
let problem = parse::<Settings>(text.as_bytes()).unwrap_err();
let line = report(text, &problem);
assert!(line.starts_with("BOARD.TOML:5:11: "), "{line}");
assert!(line.contains("bool"), "{line}");
}
#[test]
fn a_missing_field_is_an_error() {
let problem = parse::<Settings>(b"ntp_resync_interval = \"6h\"\n").unwrap_err();
assert!(problem.message().contains("hostname"), "{problem}");
}
#[test]
fn invalid_utf8_is_located_at_the_first_bad_byte() {
let text = b"hostname = \"ab\xffc\"\n";
let problem = parse::<Settings>(text).unwrap_err();
assert_eq!(problem.span(), Some(14..15));
assert_eq!(
problem.locate(text),
Some(Location {
line: 1,
column: 15
})
);
}
#[test]
fn an_integer_is_not_a_float() {
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct Place {
latitude: f64,
}
let place = |text: &str| parse::<Place>(text.as_bytes()).unwrap().settings.latitude;
assert_eq!(place("latitude = 41.5"), 41.5);
assert_eq!(place("latitude = 41"), 41.0);
}
#[test]
fn denying_unknown_fields_is_the_schemas_choice() {
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
struct Strict {
a: u32,
}
let text = "a = 1\nb = 2\n";
let problem = parse::<Strict>(text.as_bytes()).unwrap_err();
assert!(report(text, &problem).starts_with("BOARD.TOML:2:1: "));
}
#[test]
fn a_failed_check_is_located_at_the_setting() {
let text = "hostname = \"water sensor\"\n";
let s = parse::<Settings>(text.as_bytes()).unwrap().settings;
let problem = value::label(s.hostname.as_ref())
.at(&s.hostname)
.unwrap_err();
assert_eq!(
report(text, &problem),
"BOARD.TOML:1:12: expected a name of 1 to 63 letters, digits and hyphens, \
not starting or ending with a hyphen"
);
}
#[test]
fn columns_count_characters() {
let text = "# 20°C\nhostname = \"é x\"\n";
let s = parse::<Settings>(text.as_bytes()).unwrap().settings;
let problem = value::host(s.hostname.as_ref())
.at(&s.hostname)
.unwrap_err();
assert_eq!(
problem.locate(text.as_bytes()),
Some(Location {
line: 2,
column: 12
})
);
let text = "a = \"°\" ; b = \"x\"";
assert_eq!(
Location::of(text.as_bytes(), text.find(';').unwrap()),
Location { line: 1, column: 9 }
);
}
#[test]
fn a_problem_without_a_span_names_only_the_file() {
let problem = Problem {
span: None,
message: "nope".into(),
};
assert_eq!(report("", &problem), "BOARD.TOML: nope");
}
#[test]
fn duration() {
assert_eq!(value::duration("30"), Ok(Duration::from_secs(30)));
assert_eq!(value::duration("30s"), Ok(Duration::from_secs(30)));
assert_eq!(value::duration("5m"), Ok(Duration::from_secs(300)));
assert_eq!(value::duration("6h"), Ok(Duration::from_secs(21_600)));
for bad in ["", "0", "0h", "h", "+5m", "-5", "5 m", "5d", "1.5h", "5M"] {
assert!(value::duration(bad).is_err(), "{bad:?}");
}
assert!(value::duration("18446744073709551615h").is_err());
}
#[test]
fn label() {
assert_eq!(value::label("water-sensor"), Ok("water-sensor"));
assert_eq!(value::label("Water-Sensor.Local"), Ok("Water-Sensor"));
assert_eq!(value::label("a"), Ok("a"));
let longest = "a".repeat(63);
assert_eq!(value::label(&longest), Ok(longest.as_str()));
for bad in ["", ".local", "-a", "a-", "a b", "a.b", "a_b", "café"] {
assert!(value::label(bad).is_err(), "{bad:?}");
}
assert!(value::label(&"a".repeat(64)).is_err());
}
#[test]
fn host_and_word() {
assert_eq!(value::host("pool.ntp.org"), Ok("pool.ntp.org"));
assert_eq!(value::host("192.168.1.1"), Ok("192.168.1.1"));
assert_eq!(value::word("tk_abc-123"), Ok("tk_abc-123"));
for bad in ["", "a b", " a", "a\n", "é"] {
assert!(value::host(bad).is_err(), "{bad:?}");
assert!(value::word(bad).is_err(), "{bad:?}");
}
}
#[test]
fn url() {
assert_eq!(
value::url("https://ntfy.sh"),
Ok(Url {
host: "ntfy.sh",
port: 443,
path: ""
})
);
assert_eq!(
value::url("https://example.com:8443/base/"),
Ok(Url {
host: "example.com",
port: 8443,
path: "/base"
})
);
for bad in [
"http://ntfy.sh",
"https://",
"https://:443",
"https://a:port",
"https://a:70000",
"https://a b",
"https://a/b c",
"ntfy.sh",
] {
assert!(value::url(bad).is_err(), "{bad:?}");
}
}
#[test]
fn address() {
assert_eq!(value::address("a@b.c"), Ok("a@b.c"));
assert_eq!(value::address("a@b.c, d@e.f"), Ok("a@b.c, d@e.f"));
for bad in ["", "a", "@b", "a@", "a@b,", "a b@c"] {
assert!(value::address(bad).is_err(), "{bad:?}");
}
}
#[test]
fn the_example_settings_file_is_valid() {
#[derive(Debug, serde::Deserialize)]
struct Example {
hostname: Option<Spanned<String>>,
ntp: Option<NtpSettings>,
wifi: Option<WifiSettings>,
}
let text = include_bytes!("../kickstart.toml.example");
let parsed = parse::<Example>(text).unwrap();
assert!(parsed.unknown.is_empty(), "{:?}", parsed.unknown);
let example = parsed.settings;
assert_eq!(
value::label(example.hostname.unwrap().as_ref()),
Ok("kickstart")
);
assert!(example.wifi.unwrap().check().is_ok());
let ntp = example.ntp.unwrap();
assert!(ntp.server.is_some());
assert!(ntp.retry_interval.is_some());
assert!(ntp.resync_interval.is_some());
#[cfg(feature = "sntp")]
{
let checked = ntp.check().unwrap();
let defaults = crate::sntp::NtpConfig::DEFAULT;
assert_eq!(checked.server, defaults.server);
assert_eq!(checked.retry_interval, defaults.retry_interval);
assert_eq!(checked.resync_interval, defaults.resync_interval);
}
}
#[test]
fn shared_tables_locate_their_problems() {
#[derive(Debug, serde::Deserialize)]
struct Board {
#[allow(dead_code)]
ntp: Option<NtpSettings>,
wifi: Option<WifiSettings>,
}
let text = b"[wifi]\nssid = \"home\"\npassphrase = \"short\"\n";
let wifi = parse::<Board>(text).unwrap().settings.wifi.unwrap();
let problem = wifi.check().unwrap_err();
assert_eq!(
problem.locate(text),
Some(Location {
line: 3,
column: 14
})
);
assert!(parse::<Board>(b"[wifi]\nssid = \"home\"\n").is_err());
#[cfg(feature = "sntp")]
{
let text = b"[ntp]\nresync_interval = \"0s\"\n";
let ntp = parse::<Board>(text).unwrap().settings.ntp.unwrap();
let problem = ntp.check().unwrap_err();
assert_eq!(problem.locate(text).map(|at| at.line), Some(2));
let text = b"[ntp]\nserver = \"time.local\"\n";
let ntp = parse::<Board>(text).unwrap().settings.ntp.unwrap();
let checked = ntp.check().unwrap();
assert_eq!(checked.server, "time.local");
assert_eq!(
checked.resync_interval,
crate::sntp::NtpConfig::DEFAULT.resync_interval
);
}
}
#[test]
fn debug_never_shows_the_passphrase() {
let wifi = WifiSettings::new("home", "correct horse");
let shown = format!("{wifi:?} {:?}", wifi.check().unwrap());
assert!(shown.contains("home"), "{shown}");
assert!(!shown.contains("horse"), "{shown}");
}
#[test]
fn durations_are_written_in_their_largest_exact_unit() {
for (seconds, text) in [
(0, "0s"),
(30, "30s"),
(90, "90s"),
(300, "5m"),
(6 * 60 * 60, "6h"),
(25 * 60, "25m"),
] {
let written = value::duration_text(Duration::from_secs(seconds));
assert_eq!(written, text);
if seconds != 0 {
assert_eq!(value::duration(&written), Ok(Duration::from_secs(seconds)));
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Saved {
hostname: Spanned<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
wifi: Option<SavedWifi>,
#[serde(default)]
zone: Vec<Zone>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct SavedWifi {
ssid: String,
passphrase: String,
}
#[test]
fn render_round_trips_tables_and_spans() {
let settings = parse::<Saved>(
b"hostname = \"a\"\n[[zone]]\nname = \"Sump\"\nenabled = true\npresence_uv = 5\n",
)
.unwrap()
.settings;
let text = render(&settings).unwrap();
let back = parse::<Saved>(text.as_bytes()).unwrap().settings;
assert_eq!(back.hostname.as_ref(), "a");
assert_eq!(back.zone[0].name, "Sump");
assert!(back.wifi.is_none());
assert!(!text.contains("wifi"), "{text}");
}
#[test]
fn render_carries_a_hostile_passphrase_through() {
let passphrase = "p@ss#word \"quoted\" \\back\\ = eq 'single'";
let settings = Saved {
hostname: parse::<Saved>(b"hostname = \"a\"")
.unwrap()
.settings
.hostname,
wifi: Some(SavedWifi {
ssid: "Café".into(),
passphrase: passphrase.into(),
}),
zone: Vec::new(),
};
let text = render(&settings).unwrap();
let back = parse::<Saved>(text.as_bytes()).unwrap().settings;
let wifi = back.wifi.unwrap();
assert_eq!(wifi.passphrase, passphrase);
assert_eq!(wifi.ssid, "Café");
}
#[test]
fn render_refuses_a_field_that_is_written_but_required_back() {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[allow(dead_code)]
struct Lossy {
#[serde(skip_serializing)]
required: u32,
}
let error = render(&Lossy { required: 1 }).unwrap_err();
assert!(matches!(error, RenderError::Reparse(_)), "{error:?}");
}
#[test]
fn render_refuses_settings_that_read_back_differently() {
fn upper<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let s: String = serde::Deserialize::deserialize(d)?;
Ok(s.to_uppercase())
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Changes {
#[serde(deserialize_with = "upper")]
name: String,
}
let error = render(&Changes { name: "a".into() }).unwrap_err();
assert_eq!(error, RenderError::NotRoundTrip);
}
#[test]
fn render_refuses_what_toml_cannot_hold() {
let error = render(&vec![1u32, 2]).unwrap_err();
assert!(matches!(error, RenderError::Serialize(_)), "{error:?}");
}
#[test]
fn ssid() {
assert_eq!(value::ssid("home"), Ok("home"));
assert_eq!(value::ssid("Café Wi-Fi"), Ok("Café Wi-Fi"));
assert_eq!(value::ssid(&"a".repeat(32)), Ok("a".repeat(32).as_str()));
for bad in ["", "a\tb", "a\nb"] {
assert!(value::ssid(bad).is_err(), "{bad:?}");
}
assert!(value::ssid(&"a".repeat(33)).is_err());
}
#[test]
fn passphrase() {
assert_eq!(value::passphrase("12345678"), Ok("12345678"));
assert_eq!(
value::passphrase("p@ss #word \"q\" \\b"),
Ok("p@ss #word \"q\" \\b")
);
assert!(value::passphrase(&"a".repeat(63)).is_ok());
for bad in ["", "1234567", "tab\there!", "café1234"] {
assert!(value::passphrase(bad).is_err(), "{bad:?}");
}
assert!(value::passphrase(&"a".repeat(64)).is_err());
}
#[test]
fn degrees() {
assert_eq!(value::degrees(41.8781, 90.0), Ok(41.8781));
assert_eq!(value::degrees(-180.0, 180.0), Ok(-180.0));
assert!(value::degrees(90.1, 90.0).is_err());
assert!(value::degrees(f64::NAN, 90.0).is_err());
assert!(value::degrees(f64::INFINITY, 180.0).is_err());
}
}