server/lib/src/settings.rs
//! **This box's generic settings** (owner decision 2026-10-26, item 5: "stored as a generic
//! server setting, audited").
//!
//! Bytes under a NAME the client chooses, and nothing more. holger knows no setting name and
//! gives no value a meaning: a console keeps its own switches here (`console.<switch>`, its
//! names, namespaced by it), so a switch an administrator flips outlives a console restart while
//! holger learns nothing about what the switch does. The door is the HTTP door's
//! `/-/settings/<name>` (`exposed::http`): an administrator only, every write audited (who, the
//! name, old → new).
//!
//! Kept in one file under the data root, `settings/settings.json` (a name → the value's hex),
//! written whole to a temporary file, synced, renamed over the old one, and the directory
//! synced (RA2-10), so a crash leaves the old file or the new one, never half of either. A file
//! that does not read is DAMAGED ([`DAMAGED`]): a read answers that, never "nothing stored", so a
//! console that asks keeps its switch OFF; the next write keeps the damaged bytes beside it
//! (`settings.json.damaged-<unix seconds>`), says so as an ALERT, and starts afresh. Bounded, and every refusal
//! names its bound: a name of 1 to [`MAX_NAME_BYTES`] bytes of `[a-z0-9._-]` starting with a
//! letter or digit, a value of at most [`MAX_VALUE_BYTES`] bytes, at most [`MAX_SETTINGS`] names.
use crate::lock::LockOrRecover as _;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// The longest setting name, in bytes.
pub const MAX_NAME_BYTES: usize = 128;
/// The largest setting value, in bytes.
pub const MAX_VALUE_BYTES: usize = 4096;
/// The most settings one box holds.
pub const MAX_SETTINGS: usize = 256;
/// The door's path prefix: `/-/settings/<name>`.
pub const DOOR: &str = "/-/settings/";
/// The words a damaged settings file is named with.
pub const DAMAGED: &str = "[settings-damaged]";
/// One writer at a time in this process.
static WRITER: Mutex<()> = Mutex::new(());
/// The settings file under `root`.
pub fn file_of(root: &Path) -> PathBuf {
root.join("settings").join("settings.json")
}
/// Pure: **is `name` a setting name**: `Err` names the rule it breaks.
pub fn check_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("a setting name is empty".to_owned());
}
if name.len() > MAX_NAME_BYTES {
return Err(format!(
"a setting name of {} bytes is more than the {MAX_NAME_BYTES} a name may have",
name.len()
));
}
if !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(format!(
"the setting name {name:?} has a character outside [a-z0-9._-]"
));
}
if !name.as_bytes()[0].is_ascii_alphanumeric() {
return Err(format!(
"the setting name {name:?} does not start with a letter or a digit"
));
}
Ok(())
}
/// Pure: lowercase hex.
pub fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
s.len().is_multiple_of(2).then_some(())?;
(0..s.len())
.step_by(2)
.map(|i| s.get(i..i + 2).and_then(|p| u8::from_str_radix(p, 16).ok()))
.collect()
}
/// What the settings file holds: the map, or bytes that do not read as one.
enum Loaded {
Map(BTreeMap<String, String>),
Damaged(String),
}
fn load_any(root: &Path) -> Result<Loaded, String> {
let file = file_of(root);
match std::fs::read(&file) {
Ok(bytes) => match serde_json::from_slice::<BTreeMap<String, String>>(&bytes) {
Ok(map) if map.values().all(|v| unhex(v).is_some()) => Ok(Loaded::Map(map)),
Ok(_) => Ok(Loaded::Damaged(format!(
"{DAMAGED} {} holds a value that is not hex",
file.display()
))),
Err(e) => Ok(Loaded::Damaged(format!(
"{DAMAGED} {} does not read: {e}",
file.display()
))),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Loaded::Map(BTreeMap::new())),
Err(e) => Err(format!("{}: {e}", file.display())),
}
}
fn load(root: &Path) -> Result<BTreeMap<String, String>, String> {
match load_any(root)? {
Loaded::Map(map) => Ok(map),
Loaded::Damaged(why) => Err(why),
}
}
/// `bytes` as `file`, whole: a synced temporary file renamed over it, the directory synced.
fn write_whole(file: &Path, bytes: &[u8]) -> Result<(), String> {
use std::io::Write as _;
let dir = file
.parent()
.ok_or_else(|| format!("{} has no directory", file.display()))?;
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let tmp = dir.join(".settings.json.tmp");
let mut f = std::fs::File::create(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
f.write_all(bytes)
.and_then(|()| f.sync_all())
.map_err(|e| format!("{}: {e}", tmp.display()))?;
drop(f);
std::fs::rename(&tmp, file).map_err(|e| format!("{}: {e}", file.display()))?;
std::fs::File::open(dir)
.and_then(|d| d.sync_all())
.map_err(|e| format!("{}: {e}", dir.display()))
}
/// **The value stored under `name`**, `None` where nothing is.
pub fn get(root: &Path, name: &str) -> Result<Option<Vec<u8>>, String> {
check_name(name)?;
match load(root)?.get(name) {
Some(v) => unhex(v)
.map(Some)
.ok_or_else(|| format!("{DAMAGED} the stored setting {name:?} is not hex")),
None => Ok(None),
}
}
/// **Store `value` under `name`**, replacing what was there: the value it replaced, `None` for a
/// new name. Refused by name past a bound.
pub fn set(root: &Path, name: &str, value: &[u8]) -> Result<Option<Vec<u8>>, String> {
check_name(name)?;
if value.len() > MAX_VALUE_BYTES {
return Err(format!(
"a setting value of {} bytes is more than the {MAX_VALUE_BYTES} a value may have",
value.len()
));
}
let _one = WRITER.lock_or_recover();
let mut all = match load_any(root)? {
Loaded::Map(map) => map,
Loaded::Damaged(why) => {
let file = file_of(root);
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let aside = file.with_file_name(format!("settings.json.damaged-{secs}"));
std::fs::rename(&file, &aside).map_err(|e| {
format!("{why}; keeping it aside as {} failed: {e}", aside.display())
})?;
log::error!(
"ALERT {why}: kept aside as {}, and this write starts the settings afresh",
aside.display()
);
BTreeMap::new()
}
};
if !all.contains_key(name) && all.len() >= MAX_SETTINGS {
return Err(format!(
"this box holds {MAX_SETTINGS} settings already, the most it keeps"
));
}
let previous = all
.insert(name.to_owned(), hex(value))
.and_then(|v| unhex(&v));
let bytes = serde_json::to_vec_pretty(&all).map_err(|e| e.to_string())?;
write_whole(&file_of(root), &bytes)?;
Ok(previous)
}
/// **Change `name` to `value`, audited BEFORE it is applied** (RA2-9): the audit line
/// (`setting <name>: old -> new`) is handed to `record` while the store still holds the old
/// value, and a change `record` could not keep is NOT applied (the answer says so). Answers the
/// value it replaced. One writer at a time, so the old value said is the one replaced.
pub fn change(
root: &Path,
name: &str,
value: &[u8],
record: impl FnOnce(&str) -> Result<(), String>,
) -> Result<Option<Vec<u8>>, String> {
check_name(name)?;
if value.len() > MAX_VALUE_BYTES {
return Err(format!(
"a setting value of {} bytes is more than the {MAX_VALUE_BYTES} a value may have",
value.len()
));
}
let _one = CHANGER.lock_or_recover();
let old = match get(root, name) {
Ok(old) => words(old.as_deref()),
Err(why) if why.starts_with(DAMAGED) => "(damaged)".to_owned(),
Err(why) => return Err(why),
};
let line = format!("setting {name}: {old} -> {}", words(Some(value)));
record(&line).map_err(|e| format!("the change was not audited, so it was not applied: {e}"))?;
set(root, name, value)
}
/// One change at a time in this process (around [`WRITER`], which `set` takes).
static CHANGER: Mutex<()> = Mutex::new(());
/// Pure: **a value for the audit line**: the text where it is UTF-8 with no control character
/// (quoted), else `sha256:<hex>`; `(unset)` for none. Never raw bytes in a log.
pub fn words(value: Option<&[u8]>) -> String {
let Some(value) = value else {
return "(unset)".to_owned();
};
match std::str::from_utf8(value) {
Ok(text) if !text.chars().any(char::is_control) => format!("{text:?}"),
_ => {
use sha2::Digest as _;
format!("sha256:{}", hex(&sha2::Sha256::digest(value)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_setting_is_stored_replaced_read_back_and_kept_on_disk() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(get(dir.path(), "console.a-switch").unwrap(), None);
assert_eq!(set(dir.path(), "console.a-switch", b"on").unwrap(), None);
assert_eq!(
set(dir.path(), "console.a-switch", b"off")
.unwrap()
.as_deref(),
Some(&b"on"[..]),
"the value it replaced"
);
assert_eq!(
get(dir.path(), "console.a-switch").unwrap().as_deref(),
Some(&b"off"[..])
);
assert!(file_of(dir.path()).is_file(), "kept in its one file");
assert_eq!(set(dir.path(), "x", &[0, 255]).unwrap(), None);
assert_eq!(
get(dir.path(), "x").unwrap().as_deref(),
Some(&[0u8, 255][..]),
"any bytes"
);
}
#[test]
fn every_bound_is_refused_by_name() {
let dir = tempfile::tempdir().unwrap();
let r = dir.path();
assert!(set(r, "", b"").unwrap_err().contains("empty"));
assert!(set(r, &"a".repeat(129), b"").unwrap_err().contains("128"));
assert!(set(r, &"a".repeat(128), b"").is_ok());
assert!(set(r, "Console.X", b"")
.unwrap_err()
.contains("[a-z0-9._-]"));
assert!(set(r, "-x", b"").unwrap_err().contains("start with"));
assert!(set(r, "v", &[0; 4097]).unwrap_err().contains("4096"));
assert!(set(r, "v", &[0; 4096]).is_ok());
for i in 0..MAX_SETTINGS {
let _ = set(r, &format!("n{i}"), b"1");
}
assert!(set(r, "one-more", b"1").unwrap_err().contains("256"));
assert!(set(r, "n0", b"2").is_ok(), "a held name is still replaced");
}
#[test]
fn a_value_is_audited_as_text_or_as_its_hash() {
assert_eq!(words(Some(b"on")), "\"on\"");
assert_eq!(words(None), "(unset)");
assert!(words(Some(&[0xff])).starts_with("sha256:"));
assert!(words(Some(b"a\nb")).starts_with("sha256:"));
}
/// ★ RA2-10: **a damaged settings file is no value, said as an ALERT, and the next write
/// recovers it**: the damaged bytes are kept beside it for a person to read, and the box
/// keeps settings again.
#[test]
fn a_damaged_file_is_no_value_and_the_next_write_recovers_it() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(file_of(dir.path()).parent().unwrap()).unwrap();
std::fs::write(file_of(dir.path()), b"{\"console.a-switch\": \"6f").unwrap();
let e = get(dir.path(), "console.a-switch").unwrap_err();
assert!(
e.starts_with(DAMAGED),
"a damaged file is named as damaged: {e}"
);
assert_eq!(
set(dir.path(), "console.a-switch", b"off").expect("the next write recovers the file"),
None,
"nothing usable was there"
);
assert_eq!(
get(dir.path(), "console.a-switch").unwrap().as_deref(),
Some(&b"off"[..])
);
let kept: Vec<_> = std::fs::read_dir(file_of(dir.path()).parent().unwrap())
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("settings.json.damaged-")
})
.collect();
assert_eq!(kept.len(), 1, "the damaged bytes are kept beside it");
assert_eq!(
std::fs::read(kept[0].path()).unwrap(),
b"{\"console.a-switch\": \"6f"
);
}
/// ★ RA2-10: a write leaves no temporary file behind, and a temporary file a crash left is
/// never read as the settings.
#[test]
fn a_write_is_whole_or_not_at_all() {
let dir = tempfile::tempdir().unwrap();
set(dir.path(), "a", b"1").unwrap();
let tmp = file_of(dir.path())
.parent()
.unwrap()
.join(".settings.json.tmp");
assert!(!tmp.exists(), "no temporary file is left");
std::fs::write(&tmp, b"half a fi").unwrap();
assert_eq!(
get(dir.path(), "a").unwrap().as_deref(),
Some(&b"1"[..]),
"a crash's half file is not the settings"
);
set(dir.path(), "b", b"2").unwrap();
assert_eq!(
(
get(dir.path(), "a").unwrap().as_deref(),
get(dir.path(), "b").unwrap().as_deref()
),
(Some(&b"1"[..]), Some(&b"2"[..]))
);
}
/// ★ RA2-9: **a change is audited BEFORE it is applied, and a change that cannot be audited
/// is not applied.** The record sees the store still holding the old value.
#[test]
fn a_change_is_audited_before_it_is_applied_or_not_at_all() {
let dir = tempfile::tempdir().unwrap();
let r = dir.path();
set(r, "console.x", b"on").unwrap();
let mut held = None;
let before = change(r, "console.x", b"off", |line| {
held = Some((line.to_owned(), get(r, "console.x").unwrap()));
Ok(())
})
.unwrap();
assert_eq!(before.as_deref(), Some(&b"on"[..]));
assert_eq!(
held,
Some((
"setting console.x: \"on\" -> \"off\"".to_owned(),
Some(b"on".to_vec())
)),
"audited first, the store unchanged"
);
let refused = change(r, "console.x", b"on", |_| {
Err("the audit disk is full".to_owned())
})
.unwrap_err();
assert!(
refused.contains("not audited") && refused.contains("the audit disk is full"),
"{refused}"
);
assert_eq!(
get(r, "console.x").unwrap().as_deref(),
Some(&b"off"[..]),
"a change that was not audited is not applied"
);
let bad = change(r, "Bad", b"1", |_| {
panic!("a refused name is not audited as a change")
})
.unwrap_err();
assert!(bad.contains("[a-z0-9._-]"), "{bad}");
}
}