Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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}");
    }
}