//! Where the broker keeps what it knows about a session: one JSON line per session in //! `/.json`. A read that fails is an error, never "a new session". Neither value ever //! goes down: `raise` only ever raises, and it writes atomically so a crash leaves the old file. //! //! The file's format is private (`StateFile`), so `SessionState` stays free of serde. use crate::policy::{Label, SessionState}; use proto::{DataClass, SessionId}; use serde::{Deserialize, Serialize}; use std::fs; use std::io::{self, Write}; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; /// Where the owner looks when a session's state file cannot be read or written. pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged"; /// A state file that exists but cannot be read, or a write that could not reach disk. #[derive(Debug)] pub enum StateError { /// The file exists and cannot be read, or is not a state. Unreadable(PathBuf, String), /// The new state could not be put on disk. Write(PathBuf, io::Error), } impl std::fmt::Display for StateError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { StateError::Unreadable(path, detail) => write!( f, "cannot read the session state {}: {}; {}", path.display(), detail, RUNBOOK ), StateError::Write(path, err) => write!( f, "cannot write the session state {}: {}; {}", path.display(), err, RUNBOOK ), } } } impl std::error::Error for StateError {} /// The directory that holds one `.json` per session. #[derive(Debug, Clone)] pub struct StateStore { dir: PathBuf, } impl StateStore { /// A store for `dir`. This does not touch the disk. pub fn new(dir: &Path) -> StateStore { StateStore { dir: dir.to_path_buf(), } } /// The path of the file for this session: `/.json`. pub fn path(&self, session: &SessionId) -> PathBuf { self.dir.join(format!("{}.json", session.as_str())) } /// The session's current state. A missing file is a fresh session; anything else that cannot /// be read is an error. pub fn read(&self, session: &SessionId) -> Result { let path = self.path(session); let text = match fs::read_to_string(&path) { Ok(text) => text, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()), Err(err) => return Err(StateError::Unreadable(path, err.to_string())), }; let file = match serde_json::from_str::(&text) { Ok(file) => file, Err(err) => return Err(StateError::Unreadable(path, err.to_string())), }; if file.taint == DataClass::Public { return Err(StateError::Unreadable( path, "a session's taint is never below private".to_string(), )); } Ok(SessionState { taint: file.taint, untrusted: file.untrusted, }) } /// The raised state and whether its results are untrusted. It trusts `current`, never reads the /// file again, and always writes, so the file exists from the first result on. pub fn raise( &self, session: &SessionId, current: SessionState, label: Label, ) -> Result { let next = SessionState { taint: current.taint.max(label.class).max(DataClass::Private), untrusted: current.untrusted || label.untrusted, }; let path = self.path(session); if let Err(err) = Self::persist(&self.dir, &path, next.taint, next.untrusted) { return Err(StateError::Write(path, err)); } Ok(next) } /// Write the new state to `` atomically, in six steps. Any failure maps to an `io` error /// the caller turns into `Write`; an error leaves the old file untouched. fn persist(dir: &Path, path: &Path, taint: DataClass, untrusted: bool) -> io::Result<()> { // 1. The directory and any missing parents, mode 0700. fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(dir)?; // 2. Serialise and append the final newline. let json = serde_json::to_string(&StateFile { taint, untrusted }).map_err(io::Error::other)?; let bytes = format!("{json}\n"); // 3. A fresh temporary file; `truncate` replaces a leftover one. let tmp = path.with_extension("json.tmp"); let mut file = fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&tmp)?; // 4. Write it and force it to disk. file.write_all(bytes.as_bytes())?; file.sync_all()?; // 5. Swap it into place. fs::rename(&tmp, path)?; // 6. Pin the rename itself to disk. fs::File::open(dir)?.sync_all() } } /// One line on disk: the taint and the untrusted flag. #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct StateFile { taint: DataClass, untrusted: bool, }