Keep each session's taint and untrusted flag in a file
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -5,3 +5,4 @@ pub mod config;
|
||||
pub mod grants;
|
||||
pub mod policy;
|
||||
pub mod runner;
|
||||
pub mod state;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 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,
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Tests for the session state files. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use brokerd::policy::{Label, SessionState};
|
||||
use brokerd::state::{RUNBOOK, StateError, StateStore};
|
||||
use proto::{DataClass, SessionId};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tmp::TempDir;
|
||||
|
||||
fn id(text: &str) -> SessionId {
|
||||
SessionId::new(text).unwrap()
|
||||
}
|
||||
|
||||
fn label(class: DataClass, untrusted: bool) -> Label {
|
||||
Label { class, untrusted }
|
||||
}
|
||||
|
||||
fn state(taint: DataClass, untrusted: bool) -> SessionState {
|
||||
SessionState { taint, untrusted }
|
||||
}
|
||||
|
||||
/// The store's directory is two levels below the temporary one and does not exist yet, as on a
|
||||
/// fresh install.
|
||||
fn store(home: &TempDir) -> StateStore {
|
||||
StateStore::new(&home.path().join("broker/sessions"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_with_no_file_is_private_and_trusted() {
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let fresh = store.read(&id("chat-1")).unwrap();
|
||||
assert_eq!(fresh, state(DataClass::Private, false));
|
||||
assert_eq!(fresh, SessionState::default());
|
||||
// Reading creates nothing.
|
||||
assert!(!home.path().join("broker").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_result_creates_the_file_and_its_directory() {
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let session = id("chat-1");
|
||||
let next = store
|
||||
.raise(
|
||||
&session,
|
||||
SessionState::default(),
|
||||
label(DataClass::Private, false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(next, state(DataClass::Private, false));
|
||||
|
||||
let path = home.path().join("broker/sessions/chat-1.json");
|
||||
assert_eq!(store.path(&session), path);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"{\"taint\":\"private\",\"untrusted\":false}\n"
|
||||
);
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
assert_eq!(mode(&home.path().join("broker/sessions")), 0o700);
|
||||
assert_eq!(mode(&home.path().join("broker")), 0o700);
|
||||
// No temporary file is left behind.
|
||||
assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists());
|
||||
assert_eq!(store.read(&session).unwrap(), next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taint_and_the_untrusted_flag_only_go_up() {
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let session = id("s");
|
||||
let steps = [
|
||||
(
|
||||
label(DataClass::Public, false),
|
||||
state(DataClass::Private, false),
|
||||
),
|
||||
(
|
||||
label(DataClass::Private, true),
|
||||
state(DataClass::Private, true),
|
||||
),
|
||||
(
|
||||
label(DataClass::Secret, false),
|
||||
state(DataClass::Secret, true),
|
||||
),
|
||||
(
|
||||
label(DataClass::Public, false),
|
||||
state(DataClass::Secret, true),
|
||||
),
|
||||
(
|
||||
label(DataClass::Private, false),
|
||||
state(DataClass::Secret, true),
|
||||
),
|
||||
];
|
||||
let mut current = store.read(&session).unwrap();
|
||||
for (result, want) in steps {
|
||||
current = store.raise(&session, current, result).unwrap();
|
||||
assert_eq!(current, want);
|
||||
assert_eq!(store.read(&session).unwrap(), want, "what is on disk");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(store.path(&session)).unwrap(),
|
||||
"{\"taint\":\"secret\",\"untrusted\":true}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_do_not_share_state() {
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
store
|
||||
.raise(
|
||||
&id("a"),
|
||||
SessionState::default(),
|
||||
label(DataClass::Secret, true),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.read(&id("b")).unwrap(), SessionState::default());
|
||||
assert_eq!(
|
||||
store.read(&id("a")).unwrap(),
|
||||
state(DataClass::Secret, true)
|
||||
);
|
||||
}
|
||||
|
||||
/// A file that exists but does not hold a valid state is an error, never "no file".
|
||||
#[test]
|
||||
fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() {
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap();
|
||||
let session = id("hurt");
|
||||
for text in [
|
||||
"",
|
||||
"{",
|
||||
"null",
|
||||
"[]",
|
||||
"{\"taint\":\"secret\"}",
|
||||
"{\"untrusted\":false}",
|
||||
"{\"taint\":\"internal\",\"untrusted\":false}",
|
||||
"{\"taint\":\"secret\",\"untrusted\":\"no\"}",
|
||||
"{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}",
|
||||
"{\"taint\":\"secret\",\"untrusted\":false} trailing",
|
||||
// A session is never below private, so this file was not written by brokerd.
|
||||
"{\"taint\":\"public\",\"untrusted\":false}",
|
||||
] {
|
||||
std::fs::write(store.path(&session), text).unwrap();
|
||||
let err = store.read(&session).expect_err(text);
|
||||
assert!(
|
||||
matches!(err, StateError::Unreadable(..)),
|
||||
"{text:?}: {err:?}"
|
||||
);
|
||||
let shown = err.to_string();
|
||||
assert!(shown.contains("hurt.json"), "{shown}");
|
||||
assert!(shown.ends_with(RUNBOOK), "{shown}");
|
||||
}
|
||||
assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged");
|
||||
// A good file with or without its final newline reads fine.
|
||||
for text in [
|
||||
"{\"taint\":\"secret\",\"untrusted\":true}\n",
|
||||
"{\"taint\":\"secret\",\"untrusted\":true}",
|
||||
] {
|
||||
std::fs::write(store.path(&session), text).unwrap();
|
||||
assert_eq!(
|
||||
store.read(&session).unwrap(),
|
||||
state(DataClass::Secret, true)
|
||||
);
|
||||
}
|
||||
// Not valid UTF-8, and a directory where the file should be.
|
||||
std::fs::write(store.path(&session), b"\xff\xfe").unwrap();
|
||||
assert!(matches!(
|
||||
store.read(&session),
|
||||
Err(StateError::Unreadable(..))
|
||||
));
|
||||
std::fs::remove_file(store.path(&session)).unwrap();
|
||||
std::fs::create_dir(store.path(&session)).unwrap();
|
||||
assert!(matches!(
|
||||
store.read(&session),
|
||||
Err(StateError::Unreadable(..))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_read_permission_is_an_error_not_a_fresh_session() {
|
||||
if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") {
|
||||
return;
|
||||
}
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let session = id("locked");
|
||||
store
|
||||
.raise(
|
||||
&session,
|
||||
SessionState::default(),
|
||||
label(DataClass::Secret, false),
|
||||
)
|
||||
.unwrap();
|
||||
let path = store.path(&session);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
assert!(matches!(
|
||||
store.read(&session),
|
||||
Err(StateError::Unreadable(..))
|
||||
));
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_is_an_error_and_leaves_the_old_state() {
|
||||
if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") {
|
||||
return;
|
||||
}
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let session = id("s");
|
||||
let before = store
|
||||
.raise(
|
||||
&session,
|
||||
SessionState::default(),
|
||||
label(DataClass::Private, true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dir = home.path().join("broker/sessions");
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let err = store
|
||||
.raise(&session, before, label(DataClass::Secret, false))
|
||||
.expect_err("the directory is read-only");
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
|
||||
assert!(matches!(err, StateError::Write(..)), "{err:?}");
|
||||
let shown = err.to_string();
|
||||
assert!(shown.contains("s.json"), "{shown}");
|
||||
assert!(shown.ends_with(RUNBOOK), "{shown}");
|
||||
assert_eq!(store.read(&session).unwrap(), before);
|
||||
}
|
||||
|
||||
/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does
|
||||
/// not stop the next write, and the next write replaces it.
|
||||
#[test]
|
||||
fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() {
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let session = id("s");
|
||||
let dir = home.path().join("broker/sessions");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap();
|
||||
|
||||
assert_eq!(store.read(&session).unwrap(), SessionState::default());
|
||||
let next = store
|
||||
.raise(
|
||||
&session,
|
||||
SessionState::default(),
|
||||
label(DataClass::Secret, false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.read(&session).unwrap(), next);
|
||||
assert!(!dir.join("s.json.tmp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_trusts_the_state_it_is_given_not_the_file() {
|
||||
// The caller read the state under the ledger lock a moment ago; `raise` does not read again.
|
||||
let home = TempDir::new("state");
|
||||
let store = store(&home);
|
||||
let session = id("s");
|
||||
let given = state(DataClass::Secret, true);
|
||||
let next = store
|
||||
.raise(&session, given, label(DataClass::Public, false))
|
||||
.unwrap();
|
||||
assert_eq!(next, given);
|
||||
// Even a state below private is lifted to private on the way to disk.
|
||||
let low = store
|
||||
.raise(
|
||||
&id("low"),
|
||||
state(DataClass::Public, false),
|
||||
label(DataClass::Public, false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(low, state(DataClass::Private, false));
|
||||
}
|
||||
Reference in New Issue
Block a user