use crate::error::{EngineError, Result};
use crate::paths::MissionPaths;
use crate::types::{ControlCommand, MissionStatus};
use chrono::Utc;
use std::ffi::{OsStr, OsString};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq as _;
const TIMESTAMP_WIDTH: usize = 20;
const RAND_LEN: usize = 8;
const SIG_FIELD: &str = "sig";
fn signed_payload(mission_id: &str, name: &str, cmd: &ControlCommand) -> Result<String> {
let body = serde_json::to_string(cmd)?;
Ok(format!(
"{}:{mission_id}\n{}:{name}\n{body}",
mission_id.len(),
name.len()
))
}
fn sign(key: &[u8], mission_id: &str, name: &str, cmd: &ControlCommand) -> Result<String> {
Ok(crate::hooks::hmac_sha256_hex(
key,
signed_payload(mission_id, name, cmd)?.as_bytes(),
))
}
fn is_replayed(repo_root: &Path, mission_id: &str, name: &str) -> bool {
crate::paths::read_control_mark(repo_root, mission_id).is_some_and(|mark| name <= mark.as_str())
}
enum ControlRefusal {
Quarantine(String),
Skip(String),
}
fn authenticate(
repo_root: &Path,
mission_id: &str,
name: &str,
content: &str,
) -> std::result::Result<ControlCommand, ControlRefusal> {
if is_replayed(repo_root, mission_id, name) {
return Err(ControlRefusal::Quarantine(
"control command replays a file the engine already acknowledged".to_string(),
));
}
let mut value: serde_json::Value = serde_json::from_str(content)
.map_err(|e| ControlRefusal::Quarantine(format!("unparseable control command: {e}")))?;
let object = value.as_object_mut().ok_or_else(|| {
ControlRefusal::Quarantine("control command is not a JSON object".to_string())
})?;
let Some(presented) = object.remove(SIG_FIELD) else {
return Err(ControlRefusal::Quarantine(
"control command carries no signature".to_string(),
));
};
let Some(presented) = presented.as_str().map(str::to_string) else {
return Err(ControlRefusal::Quarantine(
"control command signature is not a string".to_string(),
));
};
let cmd: ControlCommand = serde_json::from_value(value)
.map_err(|e| ControlRefusal::Quarantine(format!("unparseable control command: {e}")))?;
let Some(key) = crate::paths::load_authority_key(repo_root) else {
return Err(ControlRefusal::Skip(
"no repository authority key available to verify control commands".to_string(),
));
};
let expected = sign(&key, mission_id, name, &cmd)
.map_err(|e| ControlRefusal::Skip(format!("could not recompute signature: {e}")))?;
if bool::from(expected.as_bytes().ct_eq(presented.as_bytes())) {
Ok(cmd)
} else {
Err(ControlRefusal::Quarantine(
"control command signature does not verify".to_string(),
))
}
}
pub fn enqueue(paths: &MissionPaths, cmd: &ControlCommand) -> Result<PathBuf> {
let key = crate::paths::load_or_create_authority_key(&paths.repo_root)?;
let nanos = Utc::now().timestamp_nanos_opt().unwrap_or(0).max(0) as u64;
let rand = uuid::Uuid::new_v4().simple().to_string();
let name = format!(
"{nanos:0width$}-{}.json",
&rand[..RAND_LEN],
width = TIMESTAMP_WIDTH
);
let signature = sign(&key, &paths.mission_id, &name, cmd)?;
let mission_dir = paths.open_mission_dir_nofollow(true)?;
let dir = paths.control_dir();
let control_dir = crate::paths::open_real_subdir(&mission_dir, "control", &dir, true)?;
let final_path = dir.join(&name);
let tmp_name = format!("{name}.tmp");
let json = {
let mut value = serde_json::to_value(cmd)?;
let object = value.as_object_mut().ok_or_else(|| {
EngineError::InvalidState("control command is not a JSON object".to_string())
})?;
object.insert(SIG_FIELD.to_string(), serde_json::Value::String(signature));
serde_json::to_string(&value)?
};
{
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options
.write(true)
.create_new(true)
.follow(FollowSymlinks::No);
let mut file = control_dir.open_with(&tmp_name, &options)?.into_std();
file.write_all(json.as_bytes())?;
file.sync_data()?;
}
control_dir.rename(&tmp_name, &control_dir, &name)?;
Ok(final_path)
}
pub fn drain(paths: &MissionPaths) -> Result<Vec<(PathBuf, ControlCommand)>> {
let mut commands = Vec::new();
let Some(control_dir) = control_dir(paths, false)? else {
return Ok(commands);
};
for name in queued_files(&control_dir)? {
let path = paths.control_dir().join(&name);
let content = match read_control_file(&control_dir, &name) {
Ok(c) => c,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "unreadable control file, skipping");
continue;
}
};
match authenticate(
&paths.repo_root,
&paths.mission_id,
&name.to_string_lossy(),
&content,
) {
Ok(cmd) => commands.push((path, cmd)),
Err(ControlRefusal::Quarantine(reason)) => {
quarantine(&control_dir, &name, &path, &reason)
}
Err(ControlRefusal::Skip(reason)) => {
tracing::warn!(path = %path.display(), reason = %reason, "cannot verify control file, leaving it queued");
}
}
}
Ok(commands)
}
pub fn peek_interrupt(paths: &MissionPaths) -> Result<bool> {
let Some(control_dir) = control_dir(paths, false)? else {
return Ok(false);
};
for name in queued_files(&control_dir)? {
let Ok(content) = read_control_file(&control_dir, &name) else {
continue;
};
match authenticate(
&paths.repo_root,
&paths.mission_id,
&name.to_string_lossy(),
&content,
) {
Ok(ControlCommand::Msg {
interrupt: true, ..
}) => return Ok(true),
Ok(_) | Err(ControlRefusal::Quarantine(_)) => {}
Err(ControlRefusal::Skip(reason)) => {
tracing::warn!(
path = %paths.control_dir().join(&name).display(),
reason = %reason,
"cannot verify a queued control file while checking for an interrupt"
);
}
}
}
Ok(false)
}
pub fn acknowledge(paths: &MissionPaths, path: &Path) -> Result<()> {
if path.parent() != Some(paths.control_dir().as_path()) {
return Err(EngineError::InvalidState(format!(
"refusing control acknowledgement outside {}: {}",
paths.control_dir().display(),
path.display()
)));
}
let name = path.file_name().ok_or_else(|| {
EngineError::InvalidState(format!("control path {} has no file name", path.display()))
})?;
let Some(control_dir) = control_dir(paths, false)? else {
return Err(std::io::Error::from(ErrorKind::NotFound).into());
};
control_dir.remove_file(name)?;
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
crate::paths::record_control_mark(&paths.repo_root, &paths.mission_id, name)?;
}
Ok(())
}
fn mission_status(repo_root: &Path, id: &str) -> Option<MissionStatus> {
let paths = MissionPaths::new(repo_root, id);
let events = crate::event_log::EventLog::read_events(&paths.events_file()).ok()?;
Some(crate::reducer::fold(&events).ok()?.mission.status)
}
pub fn resolve_active_mission(repo_root: &Path, explicit: Option<&str>) -> Result<String> {
let is_terminal = crate::mission_catalog::is_terminal_status;
if let Some(id) = explicit {
if !MissionPaths::is_safe_id(id) {
return Err(EngineError::Other(format!("unknown mission `{id}`")));
}
if MissionPaths::new(repo_root, id)
.require_no_follow()
.is_err()
{
return Err(EngineError::Other(format!("unknown mission `{id}`")));
}
match mission_status(repo_root, id) {
None => Err(EngineError::Other(format!("unknown mission `{id}`"))),
Some(s) if is_terminal(s) => Err(EngineError::Other(format!(
"mission `{id}` is {s:?}; this change applies only to active missions"
))),
Some(_) => Ok(id.to_string()),
}
} else {
let active: Vec<String> = MissionPaths::list_missions(repo_root)
.into_iter()
.filter(|id| mission_status(repo_root, id).is_some_and(|s| !is_terminal(s)))
.collect();
match active.len() {
0 => Err(EngineError::Other(
"no active mission — create one first".into(),
)),
1 => Ok(active.into_iter().next().expect("len == 1")),
_ => Err(EngineError::Other(format!(
"several active missions ({}); name one explicitly",
active.join(", ")
))),
}
}
}
fn control_dir(paths: &MissionPaths, create: bool) -> Result<Option<cap_std::fs::Dir>> {
let mission_dir = match paths.open_mission_dir_nofollow(create) {
Ok(dir) => dir,
Err(EngineError::Io(error)) if error.kind() == ErrorKind::NotFound && !create => {
return Ok(None)
}
Err(error) => return Err(error),
};
match crate::paths::open_real_subdir(&mission_dir, "control", &paths.control_dir(), create) {
Ok(dir) => Ok(Some(dir)),
Err(EngineError::Io(error)) if error.kind() == ErrorKind::NotFound && !create => Ok(None),
Err(error) => Err(error),
}
}
fn queued_files(dir: &cap_std::fs::Dir) -> Result<Vec<OsString>> {
let entries = dir.entries()?;
let mut files = Vec::new();
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
if is_file && Path::new(&name).extension().and_then(|e| e.to_str()) == Some("json") {
files.push(name);
}
}
files.sort();
Ok(files)
}
fn read_control_file(dir: &cap_std::fs::Dir, name: &OsStr) -> Result<String> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
use std::io::Read;
let mut options = cap_std::fs::OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
let mut file = dir.open_with(name, &options)?.into_std();
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
fn quarantine(dir: &cap_std::fs::Dir, name: &OsStr, path: &Path, reason: &str) {
let bad_name = format!("{}.bad", name.to_string_lossy());
tracing::warn!(
path = %path.display(),
error = %reason,
"refused control command, quarantining as .bad"
);
if let Err(e) = dir.rename(name, dir, &bad_name) {
tracing::warn!(path = %path.display(), error = %e, "failed to quarantine control file");
}
}
pub struct ControlWatcher;
impl ControlWatcher {
pub async fn wait_for_interrupt(
paths: MissionPaths,
poll: std::time::Duration,
notify: std::sync::Arc<tokio::sync::Notify>,
) {
loop {
match peek_interrupt(&paths) {
Ok(true) => {
notify.notify_one();
return;
}
Ok(false) => {}
Err(e) => {
tracing::warn!(error = %e, "control watcher poll failed, retrying");
}
}
tokio::time::sleep(poll).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{Event, EventKind};
use crate::types::MissionConfig;
use tempfile::TempDir;
fn events_lines(mission_id: &str, completed: bool) -> String {
let mut lines = String::new();
let created = Event {
seq: 1,
ts: Utc::now(),
mission_id: mission_id.to_string(),
kind: EventKind::MissionCreated {
goal: "goal".into(),
base_branch: "main".into(),
mission_branch: format!("kranz/mission-{mission_id}"),
config: MissionConfig::default(),
},
};
lines.push_str(&serde_json::to_string(&created).unwrap());
lines.push('\n');
if completed {
let done = Event {
seq: 2,
ts: Utc::now(),
mission_id: mission_id.to_string(),
kind: EventKind::MissionCompleted {},
};
lines.push_str(&serde_json::to_string(&done).unwrap());
lines.push('\n');
}
lines
}
fn seed_mission(repo_root: &Path, mission_id: &str, completed: bool) {
let paths = MissionPaths::new(repo_root, mission_id);
std::fs::create_dir_all(paths.mission_dir()).unwrap();
std::fs::write(paths.events_file(), events_lines(mission_id, completed)).unwrap();
}
fn plant(paths: &MissionPaths, name: &str, body: serde_json::Value) {
std::fs::create_dir_all(paths.control_dir()).unwrap();
std::fs::write(
paths.control_dir().join(name),
serde_json::to_string(&body).unwrap(),
)
.unwrap();
}
fn quarantined(paths: &MissionPaths) -> Vec<String> {
std::fs::read_dir(paths.control_dir())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".bad"))
.collect()
}
#[test]
fn unsigned_approve_grant_is_quarantined_and_never_drained() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
enqueue(&paths, &ControlCommand::Pause).unwrap();
plant(
&paths,
"00000000000000000001-aaaaaaaa.json",
serde_json::json!({ "kind": "approve-grant", "command": "cargo publish" }),
);
let drained = drain(&paths).unwrap();
assert!(
drained
.iter()
.all(|(_, cmd)| !matches!(cmd, ControlCommand::ApproveGrant { .. })),
"an unsigned approve-grant must never reach the engine"
);
assert_eq!(
quarantined(&paths),
vec!["00000000000000000001-aaaaaaaa.json.bad".to_string()],
);
}
#[test]
fn wrong_key_signature_is_quarantined() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
enqueue(&paths, &ControlCommand::Pause).unwrap();
let cmd = ControlCommand::ApproveGrant {
command: "cargo publish".to_string(),
};
let forged = sign(
b"not the repository authority key",
&paths.mission_id,
"00000000000000000002-bbbbbbbb.json",
&cmd,
)
.unwrap();
plant(
&paths,
"00000000000000000002-bbbbbbbb.json",
serde_json::json!({
"kind": "approve-grant",
"command": "cargo publish",
"sig": forged,
}),
);
assert!(drain(&paths)
.unwrap()
.iter()
.all(|(_, cmd)| !matches!(cmd, ControlCommand::ApproveGrant { .. })));
assert_eq!(
quarantined(&paths),
vec!["00000000000000000002-bbbbbbbb.json.bad".to_string()],
);
}
#[test]
fn a_correctly_signed_command_drains() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
enqueue(
&paths,
&ControlCommand::ApproveGrant {
command: "cargo publish".to_string(),
},
)
.unwrap();
let drained = drain(&paths).unwrap();
assert_eq!(drained.len(), 1);
assert!(
matches!(&drained[0].1, ControlCommand::ApproveGrant { command } if command == "cargo publish"),
"{:?}",
drained[0].1
);
assert!(quarantined(&paths).is_empty());
}
#[test]
fn signed_fractional_config_values_keep_their_bits() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-fractional");
let costs = [
0.3917785_f64,
f64::from_bits(0.3917785_f64.to_bits() + 1),
0.095758,
];
for cost in costs {
enqueue(
&paths,
&ControlCommand::ConfigChange {
patch: serde_json::json!({"worker": {"maxBudgetUsd": cost}}),
},
)
.unwrap();
}
let drained = drain(&paths).unwrap();
assert_eq!(drained.len(), costs.len());
for ((_, command), expected) in drained.iter().zip(costs) {
let ControlCommand::ConfigChange { patch } = command else {
panic!("wrong command")
};
assert_eq!(
patch["worker"]["maxBudgetUsd"].as_f64().unwrap().to_bits(),
expected.to_bits()
);
}
assert!(quarantined(&paths).is_empty());
}
#[test]
fn a_signature_from_another_mission_does_not_transfer() {
let tmp = TempDir::new().unwrap();
let source = MissionPaths::new(tmp.path(), "m-a");
let target = MissionPaths::new(tmp.path(), "m-b");
let cmd = ControlCommand::ApproveGrant {
command: "cargo publish".to_string(),
};
let file = enqueue(&source, &cmd).unwrap();
let body = std::fs::read_to_string(&file).unwrap();
std::fs::create_dir_all(target.control_dir()).unwrap();
let name = "00000000000000000003-cccccccc.json";
std::fs::write(target.control_dir().join(name), body).unwrap();
assert!(drain(&target).unwrap().is_empty());
assert_eq!(quarantined(&target), vec![format!("{name}.bad")]);
}
#[test]
fn an_unsigned_interrupt_never_aborts_the_run() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
enqueue(&paths, &ControlCommand::Pause).unwrap();
plant(
&paths,
"00000000000000000004-dddddddd.json",
serde_json::json!({ "kind": "msg", "text": "stop", "interrupt": true }),
);
assert!(!peek_interrupt(&paths).unwrap());
}
#[test]
fn resolve_explicit_active_mission_is_accepted() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-a", false);
assert_eq!(
resolve_active_mission(tmp.path(), Some("m-a")).unwrap(),
"m-a"
);
}
#[test]
fn resolve_unknown_mission_is_an_error() {
let tmp = TempDir::new().unwrap();
let err = resolve_active_mission(tmp.path(), Some("m-nope"))
.unwrap_err()
.to_string();
assert!(
err.contains("m-nope"),
"error names the unknown mission: {err}"
);
}
#[test]
fn resolve_explicit_mission_rejects_path_traversal_before_reading() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("nested").join("repo");
std::fs::create_dir_all(repo_root.join(".kranz").join("missions")).unwrap();
let outside = tmp.path().join("nested").join("outside-target");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(
outside.join("events.jsonl"),
events_lines("outside-target", false),
)
.unwrap();
let traversal_id = "../../../outside-target";
assert!(
mission_status(&repo_root, traversal_id)
.is_some_and(|status| !crate::mission_catalog::is_terminal_status(status)),
"fixture: traversal target must fold as an active mission"
);
for id in [traversal_id, "a/b", r"a\b", "C:escape"] {
let error = resolve_active_mission(&repo_root, Some(id))
.unwrap_err()
.to_string();
assert!(error.contains("unknown mission"), "{id}: {error}");
}
}
#[test]
fn resolve_terminal_mission_is_an_error() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-done", true);
let err = resolve_active_mission(tmp.path(), Some("m-done"))
.unwrap_err()
.to_string();
assert!(
err.contains("active missions"),
"honest error, not false success: {err}"
);
}
#[test]
fn resolve_bare_uses_the_single_active_mission() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-only", false);
seed_mission(tmp.path(), "m-done", true);
assert_eq!(resolve_active_mission(tmp.path(), None).unwrap(), "m-only");
}
#[test]
fn resolve_bare_with_no_active_mission_is_an_error() {
let tmp = TempDir::new().unwrap();
assert!(resolve_active_mission(tmp.path(), None).is_err());
}
#[test]
fn resolve_bare_with_several_active_missions_refuses_and_lists_them() {
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-a", false);
seed_mission(tmp.path(), "m-b", false);
let err = resolve_active_mission(tmp.path(), None)
.unwrap_err()
.to_string();
assert!(err.contains("several active missions"), "{err}");
assert!(
err.contains("m-a") && err.contains("m-b"),
"candidates listed: {err}"
);
}
#[cfg(unix)]
#[test]
fn resolve_explicit_mission_refuses_a_symlinked_mission_dir() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
seed_mission(elsewhere.path(), "m-evil", false);
let missions = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(&missions).unwrap();
symlink(
elsewhere
.path()
.join(".kranz")
.join("missions")
.join("m-evil"),
missions.join("m-evil"),
)
.unwrap();
let err = resolve_active_mission(tmp.path(), Some("m-evil"))
.unwrap_err()
.to_string();
assert!(err.contains("unknown mission"), "{err}");
}
#[cfg(unix)]
#[test]
fn enqueue_refuses_a_symlinked_mission_dir_without_touching_the_target() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
let missions = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(&missions).unwrap();
symlink(elsewhere.path(), missions.join("m-evil")).unwrap();
let paths = MissionPaths::new(tmp.path(), "m-evil");
let err = enqueue(&paths, &ControlCommand::Pause).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
assert!(!elsewhere.path().join("control").exists());
}
#[cfg(unix)]
#[test]
fn enqueue_refuses_a_symlinked_control_dir_without_touching_the_target() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
seed_mission(tmp.path(), "m-1", false);
let paths = MissionPaths::new(tmp.path(), "m-1");
let elsewhere = TempDir::new().unwrap();
symlink(elsewhere.path(), paths.control_dir()).unwrap();
let err = enqueue(&paths, &ControlCommand::Pause).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
assert!(std::fs::read_dir(elsewhere.path())
.unwrap()
.next()
.is_none());
}
#[test]
fn rapid_back_to_back_enqueues_drain_in_issue_order() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
for cmd in [
ControlCommand::Pause,
ControlCommand::Resume,
ControlCommand::Pause,
ControlCommand::Resume,
ControlCommand::Pause,
] {
enqueue(&paths, &cmd).unwrap();
}
let order: Vec<bool> = drain(&paths)
.unwrap()
.iter()
.map(|(_, cmd)| matches!(cmd, ControlCommand::Pause))
.collect();
assert_eq!(
order,
vec![true, false, true, false, true],
"rapid enqueues must drain in issue order, never random-suffix order"
);
}
}
#[cfg(test)]
mod replay_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn an_acknowledged_control_file_cannot_be_replayed() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
let cmd = ControlCommand::ApproveGrant {
command: "cargo publish".to_string(),
};
let path = enqueue(&paths, &cmd).unwrap();
let captured = std::fs::read(&path).unwrap();
let drained = drain(&paths).unwrap();
assert_eq!(drained.len(), 1);
acknowledge(&paths, &path).unwrap();
std::fs::write(&path, &captured).unwrap();
let again = drain(&paths).unwrap();
assert!(
again.is_empty(),
"a replayed control file drained: {again:?}"
);
let bad = std::fs::read_dir(paths.control_dir())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".bad"))
.count();
assert_eq!(bad, 1, "the replay must be quarantined");
let fresh = enqueue(&paths, &ControlCommand::Pause).unwrap();
assert!(
fresh.file_name().unwrap().to_string_lossy()
> path.file_name().unwrap().to_string_lossy()
);
assert_eq!(drain(&paths).unwrap().len(), 1);
}
#[test]
fn a_signed_body_moved_to_a_new_name_does_not_verify() {
let tmp = TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-1");
let path = enqueue(&paths, &ControlCommand::Pause).unwrap();
let captured = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::write(
paths
.control_dir()
.join("99999999999999999999-ffffffff.json"),
&captured,
)
.unwrap();
assert!(drain(&paths).unwrap().is_empty());
}
}