Skip to main content

gunnar_core/
settings.rs

1//! **The server's generic settings** (`refs/gunnar/settings`, owner decision 2026-10-26).
2//!
3//! Bytes under a NAME the client chooses, and nothing more. The server knows no setting
4//! name and gives no value a meaning: a console stores its own switches here
5//! (`console.<switch>`: its names, namespaced by it), so a switch an administrator
6//! flips is kept on the server, replicated with the rest of the control state, and outlives a
7//! console restart, while gunnar-server learns nothing about what the switch does.
8//!
9//! Bounded, and every refusal names its limit:
10//!
11//! * a name is 1 to [`MAX_NAME_BYTES`] bytes of `[a-z0-9._-]`, starting with a letter or digit;
12//! * a value is at most [`MAX_VALUE_BYTES`] bytes;
13//! * at most [`MAX_SETTINGS`] names are held.
14//!
15//! Who may read or write is the gRPC door's to decide (`Admin.GetSetting`, `Admin.SetSetting`:
16//! server Admin); so is the audit line of each write (who, the name, old → new).
17
18use std::sync::Arc;
19
20use crate::control::ControlStore;
21use crate::doc::{impl_doc, DocRef, ShardedMap};
22use crate::error::{Error, Result};
23
24/// **The name the settings document is stored under** in the control store.
25pub const SETTINGS_REF: &str = "refs/gunnar/settings";
26/// The longest setting name, in bytes.
27pub const MAX_NAME_BYTES: usize = 128;
28/// The largest setting value, in bytes.
29pub const MAX_VALUE_BYTES: usize = 4096;
30/// The most settings one server holds.
31pub const MAX_SETTINGS: usize = 256;
32
33#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
34#[serde(deny_unknown_fields)]
35struct SettingsDoc {
36    /// Name → the value's bytes, hex.
37    #[serde(default)]
38    values: ShardedMap<String>,
39}
40
41impl_doc!(SettingsDoc { values });
42
43/// Read and write the server's generic settings. Cheap to clone; every clone is the same
44/// document.
45#[derive(Clone, Debug)]
46pub struct SettingsStore {
47    doc: DocRef<SettingsDoc>,
48}
49
50/// Pure: **is `name` a setting name**: `Err` names the rule it breaks.
51pub fn check_name(name: &str) -> Result<()> {
52    let refuse = |reason: String| {
53        Err(Error::InvalidInput {
54            what: "setting name",
55            reason,
56        })
57    };
58    if name.is_empty() {
59        return refuse("empty".to_owned());
60    }
61    if name.len() > MAX_NAME_BYTES {
62        return refuse(format!(
63            "{} bytes, more than the {MAX_NAME_BYTES} a name may have",
64            name.len()
65        ));
66    }
67    if !name
68        .bytes()
69        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
70    {
71        return refuse(format!("{name:?} has a character outside [a-z0-9._-]"));
72    }
73    if !name.as_bytes()[0].is_ascii_alphanumeric() {
74        return refuse(format!("{name:?} does not start with a letter or a digit"));
75    }
76    Ok(())
77}
78
79fn hex(b: &[u8]) -> String {
80    b.iter().map(|x| format!("{x:02x}")).collect()
81}
82
83fn unhex(s: &str) -> Result<Vec<u8>> {
84    let bad = || Error::InvalidInput {
85        what: "stored setting",
86        reason: "not hex: the settings document is damaged".to_owned(),
87    };
88    if s.len() % 2 != 0 {
89        return Err(bad());
90    }
91    (0..s.len())
92        .step_by(2)
93        .map(|i| {
94            s.get(i..i + 2)
95                .and_then(|p| u8::from_str_radix(p, 16).ok())
96                .ok_or_else(bad)
97        })
98        .collect()
99}
100
101impl SettingsStore {
102    /// Open the settings document held in `store` (the server's control store).
103    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
104        Ok(SettingsStore {
105            doc: DocRef::new(store, SETTINGS_REF)?,
106        })
107    }
108
109    /// The value stored under `name`, or `None` where nothing is.
110    pub fn get(&self, name: &str) -> Result<Option<Vec<u8>>> {
111        check_name(name)?;
112        self.doc
113            .load()?
114            .values
115            .get(name)?
116            .map(|v| unhex(&v))
117            .transpose()
118    }
119
120    /// Store `value` under `name`, replacing what was there; the value it replaced, `None`
121    /// for a new name. Refused by name past a bound.
122    pub fn set(&self, name: &str, value: &[u8]) -> Result<Option<Vec<u8>>> {
123        check_name(name)?;
124        if value.len() > MAX_VALUE_BYTES {
125            return Err(Error::InvalidInput {
126                what: "setting value",
127                reason: format!(
128                    "{} bytes, more than the {MAX_VALUE_BYTES} a value may have",
129                    value.len()
130                ),
131            });
132        }
133        let previous = self.doc.update(|doc| {
134            if !doc.values.contains_key(name)? && doc.values.len() >= MAX_SETTINGS {
135                return Err(Error::InvalidInput {
136                    what: "setting",
137                    reason: format!(
138                        "this server holds {MAX_SETTINGS} settings already, the most it keeps"
139                    ),
140                });
141            }
142            doc.values.insert(name.to_owned(), hex(value))
143        })?;
144        previous.map(|v| unhex(&v)).transpose()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn store() -> SettingsStore {
153        SettingsStore::new(Arc::new(
154            crate::control::RedbControlStore::in_memory().expect("a store"),
155        ))
156        .expect("the document")
157    }
158
159    #[test]
160    fn a_setting_is_stored_replaced_and_read_back_and_an_unset_one_is_none() {
161        let s = store();
162        assert_eq!(s.get("console.a-switch").unwrap(), None);
163        assert_eq!(
164            s.set("console.a-switch", b"on").unwrap(),
165            None,
166            "a new name replaced nothing"
167        );
168        assert_eq!(
169            s.get("console.a-switch").unwrap().as_deref(),
170            Some(&b"on"[..])
171        );
172        assert_eq!(
173            s.set("console.a-switch", b"off").unwrap().as_deref(),
174            Some(&b"on"[..]),
175            "the value it replaced"
176        );
177        assert_eq!(
178            s.get("console.a-switch").unwrap().as_deref(),
179            Some(&b"off"[..])
180        );
181        assert_eq!(s.set("x", &[0, 255, 7]).unwrap(), None);
182        assert_eq!(
183            s.get("x").unwrap().as_deref(),
184            Some(&[0u8, 255, 7][..]),
185            "any bytes"
186        );
187    }
188
189    #[test]
190    fn every_bound_is_refused_by_name() {
191        let s = store();
192        let words = |e: Error| e.to_string();
193        assert!(words(s.set("", b"").unwrap_err()).contains("empty"));
194        assert!(words(s.set(&"a".repeat(MAX_NAME_BYTES + 1), b"").unwrap_err()).contains("128"));
195        assert!(
196            s.set(&"a".repeat(MAX_NAME_BYTES), b"").is_ok(),
197            "at the bound it is a name"
198        );
199        assert!(words(s.set("Console.X", b"").unwrap_err()).contains("[a-z0-9._-]"));
200        assert!(words(s.set("a/b", b"").unwrap_err()).contains("[a-z0-9._-]"));
201        assert!(words(s.set(".hidden", b"").unwrap_err()).contains("start with"));
202        assert!(words(s.set("v", &vec![0; MAX_VALUE_BYTES + 1]).unwrap_err()).contains("4096"));
203        assert!(
204            s.set("v", &vec![0; MAX_VALUE_BYTES]).is_ok(),
205            "at the bound it is a value"
206        );
207        assert!(
208            words(s.get("A").unwrap_err()).contains("[a-z0-9._-]"),
209            "a read names the rule too"
210        );
211    }
212
213    #[test]
214    fn a_full_server_refuses_a_new_name_but_replaces_a_held_one() {
215        let s = store();
216        for i in 0..MAX_SETTINGS {
217            s.set(&format!("n{i}"), b"1").unwrap();
218        }
219        assert!(s
220            .set("one-more", b"1")
221            .unwrap_err()
222            .to_string()
223            .contains(&MAX_SETTINGS.to_string()));
224        assert!(s.set("n0", b"2").is_ok(), "a held name is still replaced");
225    }
226}