/* * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ //! Accepted security to-do items (security to-do list spec, SS-23 to SS-26). //! //! The console runs the checks; the server only keeps what an administrator //! accepted, so every administrator sees the same accepted risks. An //! acceptance names the check, what within it (a domain, a certificate…), //! the value the check saw, and why. It holds only while the check still //! sees that value, which the console compares. Acceptances are created and //! removed, never edited. //! //! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts //! with `Q`, then one byte for the kind: //! //! - `a` + acceptance id (u32): the acceptance, as JSON. //! //! Numbers are big-endian. There are at most [`MAX_ACCEPTANCES`], so //! they're read whole. use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; use store::{ Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, }; use trc::AddContext; const FEATURE: u8 = b'Q'; const KIND_ACCEPTANCE: u8 = b'a'; const CREATE_ATTEMPTS: usize = 5; pub const MAX_ACCEPTANCES: usize = 200; /// The checks are SS-1 to SS-18; a few spare for checks added later. const MAX_CHECK: u32 = 40; const MAX_SUBJECT: usize = 255; const MAX_VALUE_BYTES: usize = 4096; const MAX_NOTE: usize = 500; #[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] #[serde(rename_all = "camelCase")] pub struct Acceptance { #[serde(default)] pub id: u32, /// Which check: `SS-1`, `SS-2`… pub check: String, /// What within the check: empty for a server-wide setting, else the /// domain, strategy or certificate it names. #[serde(default)] pub subject: String, /// The value the check saw when it was accepted. #[serde(default)] pub accepted_value: serde_json::Value, /// Why. Required. pub note: String, #[serde(default)] pub accepted_by: String, /// Seconds since the epoch. #[serde(default)] pub accepted_at: u64, } #[derive(Debug, PartialEq, Eq)] pub struct Invalid { pub property: &'static str, pub reason: String, } fn invalid(property: &'static str, reason: impl Into) -> Invalid { Invalid { property, reason: reason.into(), } } impl Acceptance { /// What an administrator sends is checked whole before it's kept. pub fn validate(&self) -> Result<(), Invalid> { let check_ok = self .check .strip_prefix("SS-") .and_then(|n| n.parse::().ok()) .is_some_and(|n| (1..=MAX_CHECK).contains(&n)); if !check_ok { return Err(invalid("check", "A check is named SS-1, SS-2 and so on.")); } if self.subject.chars().count() > MAX_SUBJECT { return Err(invalid( "subject", format!("At most {MAX_SUBJECT} characters."), )); } let value_bytes = serde_json::to_vec(&self.accepted_value) .map(|v| v.len()) .unwrap_or(usize::MAX); if value_bytes > MAX_VALUE_BYTES { return Err(invalid( "acceptedValue", format!("At most {MAX_VALUE_BYTES} bytes."), )); } let note = self.note.trim(); if note.is_empty() { return Err(invalid("note", "Say why this is accepted.")); } if note.chars().count() > MAX_NOTE { return Err(invalid("note", format!("At most {MAX_NOTE} characters."))); } Ok(()) } } // --- Storage -------------------------------------------------------------- struct Json(T); impl Serialize for Json { fn serialize(&self) -> trc::Result> { serde_json::to_vec(&self.0).map_err(|err| { trc::StoreEvent::UnexpectedError .into_err() .details("Failed to serialize a security acceptance") .reason(err) }) } } impl Deserialize for Json { fn deserialize(bytes: &[u8]) -> trc::Result { serde_json::from_slice(bytes).map(Json).map_err(|err| { trc::StoreEvent::DataCorruption .into_err() .details("Invalid security acceptance") .reason(err) }) } } fn class(id: u32) -> ValueClass { let mut key = Vec::with_capacity(6); key.push(FEATURE); key.push(KIND_ACCEPTANCE); key.extend_from_slice(&id.to_be_bytes()); ValueClass::Any(AnyClass { subspace: SUBSPACE_INBUXA, key, }) } fn key(id: u32) -> ValueKey { ValueKey::from(class(id)) } pub async fn get(data: &Store, id: u32) -> trc::Result> { Ok(data .get_value::>(key(id)) .await .caused_by(trc::location!())? .map(|Json(acceptance)| acceptance)) } /// Every acceptance, oldest first. pub async fn all(data: &Store) -> trc::Result> { let mut out = Vec::new(); data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| { if let Ok(Json(acceptance)) = Json::::deserialize(value) { out.push(acceptance); } Ok(true) }) .await .caused_by(trc::location!())?; out.sort_by_key(|a| a.id); Ok(out) } pub enum Created { Id(u32), /// There are already [`MAX_ACCEPTANCES`]. Full, } /// Keeps a new acceptance under the next free id. Two nodes creating at /// once can't take the same id: the key must be absent. pub async fn create(data: &Store, acceptance: &Acceptance) -> trc::Result { let mut attempt = 0; loop { attempt += 1; let existing = all(data).await?; if existing.len() >= MAX_ACCEPTANCES { return Ok(Created::Full); } let id = existing.iter().map(|a| a.id).max().unwrap_or(0) + 1; let stored = Acceptance { id, ..acceptance.clone() }; let mut batch = BatchBuilder::new(); batch.assert_value(class(id), AssertValue::None); batch.set(class(id), Json(&stored).serialize()?); match data.write(batch.build_all()).await { Ok(_) => return Ok(Created::Id(id)), Err(err) if attempt < CREATE_ATTEMPTS && matches!( err.as_ref(), trc::EventType::Store(trc::StoreEvent::AssertValueFailed) ) => {} Err(err) => return Err(err.caused_by(trc::location!())), } } } pub async fn delete(data: &Store, id: u32) -> trc::Result<()> { let mut batch = BatchBuilder::new(); batch.clear(class(id)); data.write(batch.build_all()) .await .caused_by(trc::location!())?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn acceptance() -> Acceptance { Acceptance { id: 0, check: "SS-1".into(), subject: String::new(), accepted_value: serde_json::json!(true), note: "Old clients on the LAN; closed by 2027.".into(), accepted_by: String::new(), accepted_at: 0, } } #[test] fn a_note_is_required() { assert!(acceptance().validate().is_ok()); let blank = Acceptance { note: " ".into(), ..acceptance() }; assert_eq!(blank.validate().unwrap_err().property, "note"); let long = Acceptance { note: "x".repeat(501), ..acceptance() }; assert_eq!(long.validate().unwrap_err().property, "note"); } #[test] fn only_named_checks() { for bad in ["", "SS-0", "SS-41", "ss-1", "SS-x", "1"] { let a = Acceptance { check: bad.into(), ..acceptance() }; assert_eq!(a.validate().unwrap_err().property, "check", "{bad}"); } } #[test] fn subject_and_value_are_bounded() { let a = Acceptance { subject: "d".repeat(256), ..acceptance() }; assert_eq!(a.validate().unwrap_err().property, "subject"); let a = Acceptance { accepted_value: serde_json::json!("v".repeat(4096)), ..acceptance() }; assert_eq!(a.validate().unwrap_err().property, "acceptedValue"); } }