use crate::error::{EngineError, Result};
use cap_fs_ext::DirExt as _;
use cap_std::ambient_authority;
use cap_std::fs::Dir;
use std::ffi::OsString;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
pub const KRANZ_GITIGNORE_RULES: &[&str] = &[
".gitignore",
"config.json",
"missions/*/events.jsonl",
"missions/*/events.jsonl.lock",
"missions/*/state.json",
"missions/*/state.json.tmp",
"missions/*/estimate.json",
"missions/*/enqueue-source*.json",
"missions/*/control/",
"missions/*/runs/",
"missions/*/workspace/",
"slack-threads.json",
"queue/",
"hook-status/",
"tickets/*.status",
"serve.token",
"serve.read.token",
];
#[derive(Debug, Clone)]
pub struct MissionPaths {
pub repo_root: PathBuf,
pub mission_id: String,
}
impl MissionPaths {
pub fn new(repo_root: impl Into<PathBuf>, mission_id: impl Into<String>) -> Self {
Self {
repo_root: repo_root.into(),
mission_id: mission_id.into(),
}
}
pub fn is_safe_id(id: &str) -> bool {
!id.is_empty() && !id.contains(['/', '\\', ':']) && !id.contains("..")
}
pub fn kranz_dir(&self) -> PathBuf {
self.repo_root.join(".kranz")
}
pub fn missions_dir(&self) -> PathBuf {
self.kranz_dir().join("missions")
}
pub fn mission_dir(&self) -> PathBuf {
self.missions_dir().join(&self.mission_id)
}
pub fn plan_file(&self) -> PathBuf {
self.mission_dir().join("plan.json")
}
pub fn plan_md_file(&self) -> PathBuf {
self.mission_dir().join("plan.md")
}
pub fn report_file(&self) -> PathBuf {
self.mission_dir().join("report.md")
}
pub fn estimate_file(&self) -> PathBuf {
self.mission_dir().join("estimate.json")
}
pub fn research_file(&self) -> PathBuf {
self.mission_dir().join("research.md")
}
pub fn events_file(&self) -> PathBuf {
self.mission_dir().join("events.jsonl")
}
pub fn lock_file(&self) -> PathBuf {
self.mission_dir().join("events.jsonl.lock")
}
pub fn state_file(&self) -> PathBuf {
self.mission_dir().join("state.json")
}
pub fn control_dir(&self) -> PathBuf {
self.mission_dir().join("control")
}
pub fn runs_dir(&self) -> PathBuf {
self.mission_dir().join("runs")
}
pub fn lessons_dir(&self) -> PathBuf {
self.kranz_dir().join("lessons")
}
pub fn lessons_index(&self) -> PathBuf {
self.lessons_dir().join("index.md")
}
pub fn transcript_file(&self, run_id: &str) -> PathBuf {
self.runs_dir().join(format!("{run_id}.jsonl"))
}
pub fn egress_denials_file(&self) -> PathBuf {
self.runs_dir().join("egress-denials.jsonl")
}
pub fn transcript_rel(run_id: &str) -> String {
format!("runs/{run_id}.jsonl")
}
pub fn list_missions(repo_root: &Path) -> Vec<String> {
let Ok(Some(dir)) = missions_dir_no_follow(repo_root) else {
return Vec::new();
};
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<String> = rd
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
.collect();
out.sort();
out
}
pub fn try_list_missions(repo_root: &Path) -> std::io::Result<Vec<String>> {
let Some(dir) = missions_dir_no_follow(repo_root)? else {
return Ok(Vec::new());
};
let rd = std::fs::read_dir(dir)?;
let mut out = Vec::new();
for entry in rd {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
out.push(name.to_string());
}
}
}
out.sort();
Ok(out)
}
pub fn require_no_follow(&self) -> Result<()> {
match self.open_mission_dir_nofollow(false) {
Ok(_) => Ok(()),
Err(EngineError::Io(e)) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub(crate) fn open_mission_dir_nofollow(&self, create: bool) -> Result<Dir> {
if !Self::is_safe_id(&self.mission_id) {
return Err(unsafe_mission_dir_path(&self.mission_dir()));
}
let mut dir = Dir::open_ambient_dir(&self.repo_root, ambient_authority())?;
let mut walked = self.repo_root.clone();
for segment in [".kranz", "missions", self.mission_id.as_str()] {
walked.push(segment);
dir = open_child_dir_nofollow(&dir, segment, &walked, create)?;
}
Ok(dir)
}
}
fn open_file_nofollow_under<P: AsRef<Path>>(
dir: &Dir,
name: P,
display_path: &Path,
) -> Result<std::fs::File> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
let mut options = cap_std::fs::OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_fs_ext::OpenOptionsSyncExt as _;
options.nonblock(true);
}
let file = dir
.open_with(name, &options)
.map(|file| file.into_std())
.map_err(|e| {
if e.kind() == ErrorKind::NotFound {
e.into()
} else {
unsafe_mission_dir_path(display_path)
}
})?;
if !file.metadata()?.file_type().is_file() {
return Err(unsafe_mission_dir_path(display_path));
}
Ok(file)
}
pub fn read_regular_file_under(dir: &Dir, name: &Path, max_bytes: u64) -> Result<String> {
let mut components = name.components();
if !matches!(components.next(), Some(std::path::Component::Normal(_)))
|| components.next().is_some()
{
return Err(unsafe_mission_dir_path(name));
}
Ok(read_regular_file_bounded(
open_file_nofollow_under(dir, name, name)?,
max_bytes,
)?)
}
pub fn read_regular_file_bounded(file: std::fs::File, max_bytes: u64) -> std::io::Result<String> {
use std::io::Read as _;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"not a regular file",
));
}
let too_large = || std::io::Error::new(ErrorKind::FileTooLarge, "file exceeds read limit");
if metadata.len() > max_bytes {
return Err(too_large());
}
let mut bytes = Vec::new();
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > max_bytes {
return Err(too_large());
}
String::from_utf8(bytes).map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))
}
fn open_child_dir_nofollow(parent: &Dir, name: &str, walked: &Path, create: bool) -> Result<Dir> {
match parent.symlink_metadata(name) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(unsafe_mission_dir_path(walked)),
Err(e) if e.kind() == ErrorKind::NotFound && create => {
match parent.create_dir(name) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
match parent.symlink_metadata(name) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(unsafe_mission_dir_path(walked)),
Err(e) => return Err(e.into()),
}
}
Err(e) => return Err(e.into()),
}
parent
.open_dir_nofollow(name)
.map_err(|_| unsafe_mission_dir_path(walked))
}
pub(crate) fn create_real_subdir(dir: &Dir, name: &str, full_path: &Path) -> Result<()> {
match dir.symlink_metadata(name) {
Ok(metadata) if metadata.file_type().is_dir() => return Ok(()),
Ok(_) => return Err(unsafe_mission_dir_path(full_path)),
Err(e) if e.kind() == ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
match dir.create_dir(name) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::AlreadyExists => match dir.symlink_metadata(name) {
Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
Ok(_) => Err(unsafe_mission_dir_path(full_path)),
Err(e) => Err(e.into()),
},
Err(e) => Err(e.into()),
}
}
pub(crate) fn open_real_subdir(
dir: &Dir,
name: &str,
full_path: &Path,
create: bool,
) -> Result<Dir> {
open_child_dir_nofollow(dir, name, full_path, create)
}
fn mission_layout_anchor(components: &[std::path::Component<'_>]) -> Option<usize> {
components
.iter()
.enumerate()
.rev()
.find_map(|(idx, component)| {
if !matches!(component, std::path::Component::Normal(os) if *os == ".kranz") {
return None;
}
let after = &components[idx + 1..];
match after.first() {
Some(std::path::Component::Normal(os)) if *os == "missions" => {
if let Some(std::path::Component::Normal(id)) = after.get(1) {
MissionPaths::is_safe_id(&id.to_string_lossy()).then_some(idx)
} else {
None
}
}
Some(std::path::Component::Normal(os)) if *os == "tickets" => Some(idx),
_ => None,
}
})
}
pub(crate) fn open_parent_nofollow(path: &Path) -> Result<(Dir, OsString)> {
let name = path.file_name().map(OsString::from).ok_or_else(|| {
EngineError::InvalidState(format!("path {} has no file name", path.display()))
})?;
let components: Vec<_> = path.components().collect();
let anchor_info = mission_layout_anchor(&components);
if let Some(idx) = anchor_info {
let anchor: PathBuf = components[..idx].iter().collect();
let anchor = if anchor.as_os_str().is_empty() {
PathBuf::from(".")
} else {
anchor
};
let mut dir = Dir::open_ambient_dir(&anchor, ambient_authority())?;
let parent_end = components.len().saturating_sub(1);
let mut walked = anchor;
for component in &components[idx..parent_end] {
let std::path::Component::Normal(component) = component else {
return Err(unsafe_mission_dir_path(path));
};
let Some(component) = component.to_str() else {
return Err(unsafe_mission_dir_path(path));
};
walked.push(component);
dir = open_child_dir_nofollow(&dir, component, &walked, false)?;
}
return Ok((dir, name));
}
let parent = path.parent().ok_or_else(|| {
EngineError::InvalidState(format!("path {} has no parent", path.display()))
})?;
let parent = parent.canonicalize()?;
Ok((Dir::open_ambient_dir(parent, ambient_authority())?, name))
}
pub fn open_read_nofollow(path: &Path) -> Result<std::fs::File> {
#[cfg(unix)]
{
use cap_fs_ext::DirExt as _;
let components: Vec<_> = path.components().collect();
let anchor_info = mission_layout_anchor(&components);
if let Some(idx) = anchor_info {
let anchor: PathBuf = components[..idx].iter().collect();
let anchor = if anchor.as_os_str().is_empty() {
PathBuf::from(".")
} else {
anchor
};
let mut dir = Dir::open_ambient_dir(&anchor, ambient_authority())?;
let mut components_iter = components[idx..].iter().peekable();
while let Some(component) = components_iter.next() {
let std::path::Component::Normal(name) = component else {
return Err(unsafe_mission_dir_path(path));
};
if components_iter.peek().is_some() {
dir = dir.open_dir_nofollow(name).map_err(|error| {
if error.kind() == ErrorKind::NotFound {
EngineError::Io(error)
} else {
unsafe_mission_dir_path(path)
}
})?;
} else {
return open_file_nofollow_under(&dir, name, path);
}
}
return Err(unsafe_mission_dir_path(path));
}
open_read_nofollow_weaker_tier(path)
}
#[cfg(not(unix))]
{
let (parent, name) = open_parent_nofollow(path)?;
open_file_nofollow_under(&parent, name, path)
}
}
#[cfg(unix)]
fn open_read_nofollow_weaker_tier(path: &Path) -> Result<std::fs::File> {
use cap_fs_ext::DirExt as _;
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let file_name = path
.file_name()
.ok_or_else(|| unsafe_mission_dir_path(path))?;
let canonical_parent = std::fs::canonicalize(parent)?;
let relative = canonical_parent
.strip_prefix("/")
.map_err(|_| unsafe_mission_dir_path(path))?;
let mut dir = Dir::open_ambient_dir("/", ambient_authority())?;
for component in relative.components() {
let std::path::Component::Normal(name) = component else {
return Err(unsafe_mission_dir_path(path));
};
dir = dir
.open_dir_nofollow(name)
.map_err(|_| unsafe_mission_dir_path(path))?;
}
open_file_nofollow_under(&dir, file_name, path)
}
#[cfg(any(not(unix), test))]
pub(crate) fn ensure_absent_or_regular_file(path: &Path) -> Result<()> {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(EngineError::InvalidState(format!(
"refusing mission runtime file that is not a regular file: {}",
path.display()
))),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn missions_dir_no_follow(repo_root: &Path) -> std::io::Result<Option<PathBuf>> {
let kranz = repo_root.join(".kranz");
let missions = kranz.join("missions");
for path in [&kranz, &missions] {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => {
return Err(std::io::Error::other(format!(
"refusing to list missions through a symlinked or non-directory path: {}",
path.display()
)));
}
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
}
}
Ok(Some(missions))
}
fn unsafe_mission_dir_path(path: &Path) -> EngineError {
EngineError::InvalidState(format!(
"refusing mission path with a symlinked or non-directory component: {}",
path.display()
))
}
pub fn project_config(repo_root: &Path) -> PathBuf {
repo_root.join(".kranz").join("config.json")
}
pub fn global_config() -> Option<PathBuf> {
global_kranz_dir().map(|dir| dir.join("config.json"))
}
pub fn global_kranz_dir() -> Option<PathBuf> {
static GLOBAL: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
GLOBAL
.get_or_init(|| {
if let Some(dir) = std::env::var_os("KRANZ_HOME") {
if !dir.is_empty() {
return Some(PathBuf::from(dir));
}
}
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(|h| PathBuf::from(h).join(".kranz"))
})
.clone()
}
pub const AUTHORITY_KEY_LEN: usize = 32;
fn repo_fingerprint(repo_root: &Path) -> String {
let canonical = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
crate::standards_waiver::sha256_hex(canonical.as_os_str().as_encoded_bytes())
}
pub fn authority_key_path(repo_root: &Path) -> Option<PathBuf> {
global_kranz_dir().map(|dir| {
dir.join("keys")
.join(format!("{}.key", repo_fingerprint(repo_root)))
})
}
pub fn seal_floor_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
if !MissionPaths::is_safe_id(mission_id) {
return None;
}
global_kranz_dir().map(|dir| {
dir.join("seals")
.join(repo_fingerprint(repo_root))
.join(mission_id)
})
}
pub fn read_seal_floor(repo_root: &Path, mission_id: &str) -> Option<u64> {
let path = seal_floor_path(repo_root, mission_id)?;
std::fs::read_to_string(&path).ok()?.trim().parse().ok()
}
pub fn high_water_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
seal_floor_path(repo_root, mission_id).map(|p| {
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
dir.join("hwm").join(mission_id)
})
}
pub fn control_mark_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
seal_floor_path(repo_root, mission_id).map(|p| {
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
dir.join("ctl").join(mission_id)
})
}
pub fn read_control_mark(repo_root: &Path, mission_id: &str) -> Option<String> {
let path = control_mark_path(repo_root, mission_id)?;
let text = std::fs::read_to_string(&path).ok()?;
let name = text.trim();
(!name.is_empty()).then(|| name.to_string())
}
pub fn record_control_mark(repo_root: &Path, mission_id: &str, name: &str) -> Result<()> {
let Some(path) = control_mark_path(repo_root, mission_id) else {
return Ok(());
};
if read_control_mark(repo_root, mission_id).is_some_and(|current| current.as_str() >= name) {
return Ok(());
}
write_mark(&path, name)
}
pub fn read_high_water(repo_root: &Path, mission_id: &str) -> Option<u64> {
let path = high_water_path(repo_root, mission_id)?;
std::fs::read_to_string(&path).ok()?.trim().parse().ok()
}
pub fn record_high_water(repo_root: &Path, mission_id: &str, seq: u64) -> Result<()> {
let Some(path) = high_water_path(repo_root, mission_id) else {
return Ok(());
};
if read_high_water(repo_root, mission_id).is_some_and(|current| current >= seq) {
return Ok(());
}
write_mark(&path, &seq.to_string())
}
fn write_mark(path: &Path, value: &str) -> Result<()> {
let dir = path
.parent()
.ok_or_else(|| EngineError::InvalidState("mark path has no parent".to_string()))?;
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut cursor = Some(dir);
for _ in 0..3 {
let Some(d) = cursor else { break };
let _ = std::fs::set_permissions(d, std::fs::Permissions::from_mode(0o700));
if d.file_name().is_some_and(|n| n == "seals") {
break;
}
cursor = d.parent();
}
}
let tmp = dir.join(format!(".mark.tmp-{}", uuid::Uuid::new_v4().as_simple()));
let write = || -> std::io::Result<()> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&tmp)?;
file.write_all(value.as_bytes())?;
file.sync_data()?;
Ok(())
};
if let Err(error) = write() {
let _ = std::fs::remove_file(&tmp);
return Err(error.into());
}
if let Err(error) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(error.into());
}
Ok(())
}
pub fn record_seal_floor(repo_root: &Path, mission_id: &str, seq: u64) -> Result<()> {
let Some(path) = seal_floor_path(repo_root, mission_id) else {
return Ok(());
};
let dir = path
.parent()
.ok_or_else(|| EngineError::InvalidState("seal floor path has no parent".to_string()))?;
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
if let Some(seals_root) = dir.parent() {
let _ = std::fs::set_permissions(seals_root, std::fs::Permissions::from_mode(0o700));
}
}
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
use std::io::Write as _;
file.write_all(seq.to_string().as_bytes())?;
file.sync_data()?;
Ok(())
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(e.into()),
}
}
fn key_owner_path(key_path: &Path) -> PathBuf {
key_path.with_extension("path")
}
fn record_key_owner(key_path: &Path, repo_root: &Path) {
let canonical = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
let _ = std::fs::write(
key_owner_path(key_path),
canonical.as_os_str().as_encoded_bytes(),
);
}
fn prune_orphan_temp_keys(keys_dir: &Path) {
let temp_root = std::env::temp_dir();
let temp_root = std::fs::canonicalize(&temp_root).unwrap_or(temp_root);
let Ok(entries) = std::fs::read_dir(keys_dir) else {
return;
};
for entry in entries.flatten() {
let owner_path = entry.path();
if owner_path.extension().and_then(|e| e.to_str()) != Some("path") {
continue;
}
let Ok(bytes) = std::fs::read(&owner_path) else {
continue;
};
let repo = PathBuf::from(String::from_utf8_lossy(&bytes).into_owned());
if !repo.starts_with(&temp_root) || repo.exists() {
continue;
}
let stem = owner_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let expected = crate::standards_waiver::sha256_hex(repo.as_os_str().as_encoded_bytes());
if stem != expected {
continue;
}
let key_path = owner_path.with_extension("key");
let _ = std::fs::remove_file(&key_path);
let _ = std::fs::remove_file(&owner_path);
}
}
pub fn load_authority_key(repo_root: &Path) -> Option<Vec<u8>> {
let path = authority_key_path(repo_root)?;
let bytes = std::fs::read(&path).ok()?;
(bytes.len() >= AUTHORITY_KEY_LEN).then_some(bytes)
}
pub fn load_or_create_authority_key(repo_root: &Path) -> Result<Vec<u8>> {
let path = authority_key_path(repo_root).ok_or_else(|| {
EngineError::InvalidState(
"cannot resolve the operator kranz directory for the authority key".to_string(),
)
})?;
if let Some(existing) = load_authority_key(repo_root) {
return Ok(existing);
}
let dir = path
.parent()
.ok_or_else(|| EngineError::InvalidState("authority key path has no parent".to_string()))?;
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
let key = {
let mut key = Vec::with_capacity(AUTHORITY_KEY_LEN);
key.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
key.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
key
};
let tmp = dir.join(format!(".key.tmp-{}", uuid::Uuid::new_v4().as_simple()));
let write_tmp = || -> std::io::Result<()> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&tmp)?;
file.write_all(&key)?;
file.sync_data()?;
Ok(())
};
if let Err(error) = write_tmp() {
let _ = std::fs::remove_file(&tmp);
return Err(error.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
match std::fs::hard_link(&tmp, &path) {
Ok(()) => {
let _ = std::fs::remove_file(&tmp);
record_key_owner(&path, repo_root);
prune_orphan_temp_keys(dir);
Ok(key)
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
let _ = std::fs::remove_file(&tmp);
load_authority_key(repo_root).ok_or_else(|| {
EngineError::InvalidState(format!(
"authority key {} exists but could not be read",
path.display()
))
})
}
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e.into())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lessons_paths_are_repo_level_not_per_mission() {
let paths = MissionPaths::new("/repo", "m-abc123");
assert_eq!(paths.lessons_dir(), PathBuf::from("/repo/.kranz/lessons"));
assert_eq!(
paths.lessons_index(),
PathBuf::from("/repo/.kranz/lessons/index.md")
);
}
#[test]
fn plan_md_and_report_paths_are_per_mission() {
let paths = MissionPaths::new("/repo", "m-abc123");
assert_eq!(
paths.plan_md_file(),
PathBuf::from("/repo/.kranz/missions/m-abc123/plan.md")
);
assert_eq!(
paths.report_file(),
PathBuf::from("/repo/.kranz/missions/m-abc123/report.md")
);
}
#[test]
fn try_list_missions_distinguishes_missing_dir_from_real_listing() {
let tmp = tempfile::TempDir::new().unwrap();
assert_eq!(
MissionPaths::try_list_missions(tmp.path()).unwrap(),
Vec::<String>::new()
);
let missions = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(missions.join("m-bbb222")).unwrap();
std::fs::create_dir_all(missions.join("m-aaa111")).unwrap();
std::fs::write(missions.join("stray.txt"), b"x").unwrap();
let listed = MissionPaths::try_list_missions(tmp.path()).unwrap();
assert_eq!(listed, vec!["m-aaa111".to_string(), "m-bbb222".to_string()]);
assert_eq!(MissionPaths::list_missions(tmp.path()), listed);
}
#[cfg(unix)]
#[test]
fn try_list_missions_reports_errors_instead_of_swallowing_them() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".kranz")).unwrap();
std::fs::write(tmp.path().join(".kranz").join("missions"), b"not a dir").unwrap();
assert!(MissionPaths::try_list_missions(tmp.path()).is_err());
assert_eq!(
MissionPaths::list_missions(tmp.path()),
Vec::<String>::new()
);
}
#[test]
fn safe_id_rejects_path_traversal_shapes() {
for id in ["", "../m-x", "m-x/../../y", "m-x\\..\\y", "c:m-x", "m-.."] {
assert!(!MissionPaths::is_safe_id(id), "{id:?} should be unsafe");
}
for id in ["m-abc123", "m-2026-07-08", "m_ticket.linked"] {
assert!(MissionPaths::is_safe_id(id), "{id:?} should be safe");
}
}
#[cfg(unix)]
#[test]
fn list_missions_excludes_symlinked_mission_dirs() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let missions = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(missions.join("m-real")).unwrap();
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
symlink(&elsewhere, missions.join("m-evil")).unwrap();
assert_eq!(
MissionPaths::list_missions(tmp.path()),
vec!["m-real".to_string()]
);
assert_eq!(
MissionPaths::try_list_missions(tmp.path()).unwrap(),
vec!["m-real".to_string()]
);
}
#[cfg(unix)]
#[test]
fn list_missions_refuses_a_symlinked_missions_dir() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".kranz")).unwrap();
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir_all(elsewhere.join("m-evil")).unwrap();
symlink(&elsewhere, tmp.path().join(".kranz").join("missions")).unwrap();
assert_eq!(
MissionPaths::list_missions(tmp.path()),
Vec::<String>::new()
);
let err = MissionPaths::try_list_missions(tmp.path()).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
}
#[cfg(unix)]
#[test]
fn require_no_follow_refuses_symlinked_components() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let missions = tmp.path().join(".kranz").join("missions");
std::fs::create_dir_all(missions.join("m-real")).unwrap();
assert!(MissionPaths::new(tmp.path(), "m-real")
.require_no_follow()
.is_ok());
assert!(MissionPaths::new(tmp.path(), "m-absent")
.require_no_follow()
.is_ok());
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
symlink(&elsewhere, missions.join("m-evil")).unwrap();
let err = MissionPaths::new(tmp.path(), "m-evil")
.require_no_follow()
.unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
assert!(MissionPaths::new(tmp.path(), "../x")
.require_no_follow()
.is_err());
}
#[cfg(unix)]
#[test]
fn require_no_follow_refuses_a_symlinked_kranz_dir() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir_all(elsewhere.join("missions").join("m-evil")).unwrap();
symlink(&elsewhere, tmp.path().join(".kranz")).unwrap();
assert!(MissionPaths::new(tmp.path(), "m-evil")
.require_no_follow()
.is_err());
assert_eq!(
MissionPaths::list_missions(tmp.path()),
Vec::<String>::new()
);
}
#[cfg(unix)]
#[test]
fn open_mission_dir_nofollow_creates_missing_dirs_but_refuses_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let paths = MissionPaths::new(tmp.path(), "m-new");
paths.open_mission_dir_nofollow(true).unwrap();
assert!(paths.mission_dir().is_dir());
std::fs::remove_dir(paths.mission_dir()).unwrap();
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
symlink(&elsewhere, paths.mission_dir()).unwrap();
assert!(paths.open_mission_dir_nofollow(true).is_err());
}
#[cfg(unix)]
#[test]
fn ensure_absent_or_regular_file_refuses_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target.jsonl");
std::fs::write(&target, b"secret").unwrap();
let link = tmp.path().join("link.jsonl");
symlink(&target, &link).unwrap();
let err = ensure_absent_or_regular_file(&link).unwrap_err();
assert!(err.to_string().contains("refusing"), "{err}");
assert!(ensure_absent_or_regular_file(&target).is_ok());
assert!(ensure_absent_or_regular_file(&tmp.path().join("missing.jsonl")).is_ok());
}
#[test]
fn the_authority_key_lives_outside_the_repository() {
let tmp = tempfile::TempDir::new().unwrap();
let path = authority_key_path(tmp.path()).expect("a home dir in tests");
assert!(
!path.starts_with(tmp.path()),
"the key must never sit inside the repo it authenticates: {}",
path.display()
);
assert!(path.parent().unwrap().ends_with("keys"));
}
#[test]
fn the_authority_key_is_stable_per_repo_and_distinct_between_repos() {
let a = tempfile::TempDir::new().unwrap();
let b = tempfile::TempDir::new().unwrap();
let first = load_or_create_authority_key(a.path()).unwrap();
assert_eq!(first.len(), AUTHORITY_KEY_LEN);
assert_eq!(
load_or_create_authority_key(a.path()).unwrap(),
first,
"a second call adopts the existing key rather than rotating it"
);
assert_ne!(
load_or_create_authority_key(b.path()).unwrap(),
first,
"one repo's key must not authenticate another repo's inbox"
);
let indirect = a
.path()
.join(".")
.join("..")
.join(a.path().file_name().unwrap());
assert_eq!(load_or_create_authority_key(&indirect).unwrap(), first);
}
#[cfg(unix)]
#[test]
fn the_authority_key_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
load_or_create_authority_key(tmp.path()).unwrap();
let path = authority_key_path(tmp.path()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "key mode {mode:o}");
let dir_mode = std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700, "keys dir mode {dir_mode:o}");
}
#[test]
fn the_seal_floor_is_recorded_once_and_never_moved() {
let tmp = tempfile::TempDir::new().unwrap();
assert_eq!(read_seal_floor(tmp.path(), "m-1"), None);
record_seal_floor(tmp.path(), "m-1", 7).unwrap();
assert_eq!(read_seal_floor(tmp.path(), "m-1"), Some(7));
record_seal_floor(tmp.path(), "m-1", 1).unwrap();
record_seal_floor(tmp.path(), "m-1", 99).unwrap();
assert_eq!(read_seal_floor(tmp.path(), "m-1"), Some(7));
assert_eq!(read_seal_floor(tmp.path(), "m-2"), None);
}
#[test]
fn an_unsafe_mission_id_gets_no_seal_path() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(seal_floor_path(tmp.path(), "../../escape").is_none());
assert!(seal_floor_path(tmp.path(), "a/b").is_none());
}
}