150 lines
5.3 KiB
Rust
150 lines
5.3 KiB
Rust
//! Where the broker keeps what it knows about a session: one JSON line per session in
|
|
//! `<dir>/<id>.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 `<id>.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: `<dir>/<id>.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<SessionState, StateError> {
|
|
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::<StateFile>(&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<SessionState, StateError> {
|
|
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 `<path>` 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,
|
|
}
|