1use crate::error::{EngineError, Result};
20use cap_fs_ext::DirExt as _;
21use cap_std::ambient_authority;
22use cap_std::fs::Dir;
23use std::ffi::OsString;
24use std::io::ErrorKind;
25use std::path::{Path, PathBuf};
26
27pub const KRANZ_GITIGNORE_RULES: &[&str] = &[
33 ".gitignore",
34 "config.json",
35 "missions/*/events.jsonl",
36 "missions/*/events.jsonl.lock",
37 "missions/*/state.json",
38 "missions/*/state.json.tmp",
39 "missions/*/estimate.json",
40 "missions/*/enqueue-source*.json",
41 "missions/*/control/",
42 "missions/*/runs/",
43 "missions/*/workspace/",
44 "slack-threads.json",
45 "queue/",
46 "hook-status/",
47 "tickets/*.status",
48 "serve.token",
49 "serve.read.token",
50];
51
52#[derive(Debug, Clone)]
53pub struct MissionPaths {
54 pub repo_root: PathBuf,
55 pub mission_id: String,
56}
57
58impl MissionPaths {
59 pub fn new(repo_root: impl Into<PathBuf>, mission_id: impl Into<String>) -> Self {
60 Self {
61 repo_root: repo_root.into(),
62 mission_id: mission_id.into(),
63 }
64 }
65
66 pub fn is_safe_id(id: &str) -> bool {
70 !id.is_empty() && !id.contains(['/', '\\', ':']) && !id.contains("..")
71 }
72
73 pub fn kranz_dir(&self) -> PathBuf {
74 self.repo_root.join(".kranz")
75 }
76
77 pub fn missions_dir(&self) -> PathBuf {
78 self.kranz_dir().join("missions")
79 }
80
81 pub fn mission_dir(&self) -> PathBuf {
82 self.missions_dir().join(&self.mission_id)
83 }
84
85 pub fn plan_file(&self) -> PathBuf {
86 self.mission_dir().join("plan.json")
87 }
88
89 pub fn plan_md_file(&self) -> PathBuf {
91 self.mission_dir().join("plan.md")
92 }
93
94 pub fn report_file(&self) -> PathBuf {
96 self.mission_dir().join("report.md")
97 }
98
99 pub fn estimate_file(&self) -> PathBuf {
104 self.mission_dir().join("estimate.json")
105 }
106
107 pub fn research_file(&self) -> PathBuf {
110 self.mission_dir().join("research.md")
111 }
112
113 pub fn events_file(&self) -> PathBuf {
114 self.mission_dir().join("events.jsonl")
115 }
116
117 pub fn lock_file(&self) -> PathBuf {
118 self.mission_dir().join("events.jsonl.lock")
119 }
120
121 pub fn state_file(&self) -> PathBuf {
122 self.mission_dir().join("state.json")
123 }
124
125 pub fn control_dir(&self) -> PathBuf {
126 self.mission_dir().join("control")
127 }
128
129 pub fn runs_dir(&self) -> PathBuf {
130 self.mission_dir().join("runs")
131 }
132
133 pub fn lessons_dir(&self) -> PathBuf {
136 self.kranz_dir().join("lessons")
137 }
138
139 pub fn lessons_index(&self) -> PathBuf {
141 self.lessons_dir().join("index.md")
142 }
143
144 pub fn transcript_file(&self, run_id: &str) -> PathBuf {
145 self.runs_dir().join(format!("{run_id}.jsonl"))
146 }
147
148 pub fn egress_denials_file(&self) -> PathBuf {
152 self.runs_dir().join("egress-denials.jsonl")
153 }
154
155 pub fn transcript_rel(run_id: &str) -> String {
157 format!("runs/{run_id}.jsonl")
158 }
159
160 pub fn list_missions(repo_root: &Path) -> Vec<String> {
173 let Ok(Some(dir)) = missions_dir_no_follow(repo_root) else {
174 return Vec::new();
175 };
176 let Ok(rd) = std::fs::read_dir(dir) else {
177 return Vec::new();
178 };
179 let mut out: Vec<String> = rd
180 .flatten()
181 .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
184 .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
185 .collect();
186 out.sort();
187 out
188 }
189
190 pub fn try_list_missions(repo_root: &Path) -> std::io::Result<Vec<String>> {
200 let Some(dir) = missions_dir_no_follow(repo_root)? else {
201 return Ok(Vec::new());
202 };
203 let rd = std::fs::read_dir(dir)?;
204 let mut out = Vec::new();
205 for entry in rd {
206 let entry = entry?;
207 if entry.file_type()?.is_dir() {
208 if let Some(name) = entry.file_name().to_str() {
209 out.push(name.to_string());
210 }
211 }
212 }
213 out.sort();
214 Ok(out)
215 }
216
217 pub fn require_no_follow(&self) -> Result<()> {
234 match self.open_mission_dir_nofollow(false) {
235 Ok(_) => Ok(()),
236 Err(EngineError::Io(e)) if e.kind() == ErrorKind::NotFound => Ok(()),
237 Err(e) => Err(e),
238 }
239 }
240
241 pub(crate) fn open_mission_dir_nofollow(&self, create: bool) -> Result<Dir> {
248 if !Self::is_safe_id(&self.mission_id) {
249 return Err(unsafe_mission_dir_path(&self.mission_dir()));
250 }
251 let mut dir = Dir::open_ambient_dir(&self.repo_root, ambient_authority())?;
252 let mut walked = self.repo_root.clone();
253 for segment in [".kranz", "missions", self.mission_id.as_str()] {
254 walked.push(segment);
255 dir = open_child_dir_nofollow(&dir, segment, &walked, create)?;
256 }
257 Ok(dir)
258 }
259}
260
261fn open_file_nofollow_under<P: AsRef<Path>>(
268 dir: &Dir,
269 name: P,
270 display_path: &Path,
271) -> Result<std::fs::File> {
272 use cap_fs_ext::OpenOptionsFollowExt as _;
273 use cap_primitives::fs::FollowSymlinks;
274 let mut options = cap_std::fs::OpenOptions::new();
275 options.read(true).follow(FollowSymlinks::No);
276 #[cfg(unix)]
278 {
279 use cap_fs_ext::OpenOptionsSyncExt as _;
280 options.nonblock(true);
281 }
282 let file = dir
283 .open_with(name, &options)
284 .map(|file| file.into_std())
285 .map_err(|e| {
286 if e.kind() == ErrorKind::NotFound {
287 e.into()
288 } else {
289 unsafe_mission_dir_path(display_path)
290 }
291 })?;
292 if !file.metadata()?.file_type().is_file() {
293 return Err(unsafe_mission_dir_path(display_path));
294 }
295 Ok(file)
296}
297
298pub fn read_regular_file_under(dir: &Dir, name: &Path, max_bytes: u64) -> Result<String> {
301 let mut components = name.components();
302 if !matches!(components.next(), Some(std::path::Component::Normal(_)))
303 || components.next().is_some()
304 {
305 return Err(unsafe_mission_dir_path(name));
306 }
307 Ok(read_regular_file_bounded(
308 open_file_nofollow_under(dir, name, name)?,
309 max_bytes,
310 )?)
311}
312
313pub fn read_regular_file_bounded(file: std::fs::File, max_bytes: u64) -> std::io::Result<String> {
316 use std::io::Read as _;
317 let metadata = file.metadata()?;
318 if !metadata.is_file() {
319 return Err(std::io::Error::new(
320 ErrorKind::InvalidInput,
321 "not a regular file",
322 ));
323 }
324 let too_large = || std::io::Error::new(ErrorKind::FileTooLarge, "file exceeds read limit");
325 if metadata.len() > max_bytes {
326 return Err(too_large());
327 }
328 let mut bytes = Vec::new();
329 file.take(max_bytes.saturating_add(1))
330 .read_to_end(&mut bytes)?;
331 if bytes.len() as u64 > max_bytes {
332 return Err(too_large());
333 }
334 String::from_utf8(bytes).map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))
335}
336
337fn open_child_dir_nofollow(parent: &Dir, name: &str, walked: &Path, create: bool) -> Result<Dir> {
342 match parent.symlink_metadata(name) {
343 Ok(metadata) if metadata.file_type().is_dir() => {}
344 Ok(_) => return Err(unsafe_mission_dir_path(walked)),
345 Err(e) if e.kind() == ErrorKind::NotFound && create => {
346 match parent.create_dir(name) {
347 Ok(()) => {}
348 Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
349 Err(e) => return Err(e.into()),
350 }
351 match parent.symlink_metadata(name) {
353 Ok(metadata) if metadata.file_type().is_dir() => {}
354 Ok(_) => return Err(unsafe_mission_dir_path(walked)),
355 Err(e) => return Err(e.into()),
356 }
357 }
358 Err(e) => return Err(e.into()),
359 }
360 parent
361 .open_dir_nofollow(name)
362 .map_err(|_| unsafe_mission_dir_path(walked))
363}
364
365pub(crate) fn create_real_subdir(dir: &Dir, name: &str, full_path: &Path) -> Result<()> {
370 match dir.symlink_metadata(name) {
371 Ok(metadata) if metadata.file_type().is_dir() => return Ok(()),
372 Ok(_) => return Err(unsafe_mission_dir_path(full_path)),
373 Err(e) if e.kind() == ErrorKind::NotFound => {}
374 Err(e) => return Err(e.into()),
375 }
376 match dir.create_dir(name) {
377 Ok(()) => Ok(()),
378 Err(e) if e.kind() == ErrorKind::AlreadyExists => match dir.symlink_metadata(name) {
379 Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
380 Ok(_) => Err(unsafe_mission_dir_path(full_path)),
381 Err(e) => Err(e.into()),
382 },
383 Err(e) => Err(e.into()),
384 }
385}
386
387pub(crate) fn open_real_subdir(
392 dir: &Dir,
393 name: &str,
394 full_path: &Path,
395 create: bool,
396) -> Result<Dir> {
397 open_child_dir_nofollow(dir, name, full_path, create)
398}
399
400fn mission_layout_anchor(components: &[std::path::Component<'_>]) -> Option<usize> {
404 components
405 .iter()
406 .enumerate()
407 .rev()
408 .find_map(|(idx, component)| {
409 if !matches!(component, std::path::Component::Normal(os) if *os == ".kranz") {
410 return None;
411 }
412 let after = &components[idx + 1..];
413 match after.first() {
414 Some(std::path::Component::Normal(os)) if *os == "missions" => {
415 if let Some(std::path::Component::Normal(id)) = after.get(1) {
416 MissionPaths::is_safe_id(&id.to_string_lossy()).then_some(idx)
417 } else {
418 None
419 }
420 }
421 Some(std::path::Component::Normal(os)) if *os == "tickets" => Some(idx),
422 _ => None,
423 }
424 })
425}
426
427pub(crate) fn open_parent_nofollow(path: &Path) -> Result<(Dir, OsString)> {
438 let name = path.file_name().map(OsString::from).ok_or_else(|| {
439 EngineError::InvalidState(format!("path {} has no file name", path.display()))
440 })?;
441 let components: Vec<_> = path.components().collect();
442 let anchor_info = mission_layout_anchor(&components);
443
444 if let Some(idx) = anchor_info {
445 let anchor: PathBuf = components[..idx].iter().collect();
446 let anchor = if anchor.as_os_str().is_empty() {
447 PathBuf::from(".")
448 } else {
449 anchor
450 };
451 let mut dir = Dir::open_ambient_dir(&anchor, ambient_authority())?;
452 let parent_end = components.len().saturating_sub(1);
453 let mut walked = anchor;
454 for component in &components[idx..parent_end] {
455 let std::path::Component::Normal(component) = component else {
456 return Err(unsafe_mission_dir_path(path));
457 };
458 let Some(component) = component.to_str() else {
459 return Err(unsafe_mission_dir_path(path));
460 };
461 walked.push(component);
462 dir = open_child_dir_nofollow(&dir, component, &walked, false)?;
463 }
464 return Ok((dir, name));
465 }
466
467 let parent = path.parent().ok_or_else(|| {
468 EngineError::InvalidState(format!("path {} has no parent", path.display()))
469 })?;
470 let parent = parent.canonicalize()?;
471 Ok((Dir::open_ambient_dir(parent, ambient_authority())?, name))
472}
473
474pub fn open_read_nofollow(path: &Path) -> Result<std::fs::File> {
491 #[cfg(unix)]
492 {
493 use cap_fs_ext::DirExt as _;
494
495 let components: Vec<_> = path.components().collect();
496 let anchor_info = mission_layout_anchor(&components);
499
500 if let Some(idx) = anchor_info {
501 let anchor: PathBuf = components[..idx].iter().collect();
503 let anchor = if anchor.as_os_str().is_empty() {
504 PathBuf::from(".")
505 } else {
506 anchor
507 };
508 let mut dir = Dir::open_ambient_dir(&anchor, ambient_authority())?;
509 let mut components_iter = components[idx..].iter().peekable();
510 while let Some(component) = components_iter.next() {
511 let std::path::Component::Normal(name) = component else {
512 return Err(unsafe_mission_dir_path(path));
513 };
514 if components_iter.peek().is_some() {
515 dir = dir.open_dir_nofollow(name).map_err(|error| {
516 if error.kind() == ErrorKind::NotFound {
517 EngineError::Io(error)
518 } else {
519 unsafe_mission_dir_path(path)
520 }
521 })?;
522 } else {
523 return open_file_nofollow_under(&dir, name, path);
524 }
525 }
526 return Err(unsafe_mission_dir_path(path));
527 }
528
529 open_read_nofollow_weaker_tier(path)
532 }
533 #[cfg(not(unix))]
534 {
535 let (parent, name) = open_parent_nofollow(path)?;
536 open_file_nofollow_under(&parent, name, path)
537 }
538}
539
540#[cfg(unix)]
547fn open_read_nofollow_weaker_tier(path: &Path) -> Result<std::fs::File> {
548 use cap_fs_ext::DirExt as _;
549 let parent = path
550 .parent()
551 .filter(|p| !p.as_os_str().is_empty())
552 .unwrap_or(Path::new("."));
553 let file_name = path
554 .file_name()
555 .ok_or_else(|| unsafe_mission_dir_path(path))?;
556 let canonical_parent = std::fs::canonicalize(parent)?;
557 let relative = canonical_parent
558 .strip_prefix("/")
559 .map_err(|_| unsafe_mission_dir_path(path))?;
560 let mut dir = Dir::open_ambient_dir("/", ambient_authority())?;
561 for component in relative.components() {
562 let std::path::Component::Normal(name) = component else {
563 return Err(unsafe_mission_dir_path(path));
564 };
565 dir = dir
566 .open_dir_nofollow(name)
567 .map_err(|_| unsafe_mission_dir_path(path))?;
568 }
569 open_file_nofollow_under(&dir, file_name, path)
570}
571
572#[cfg(any(not(unix), test))]
584pub(crate) fn ensure_absent_or_regular_file(path: &Path) -> Result<()> {
585 match std::fs::symlink_metadata(path) {
586 Ok(metadata) if metadata.file_type().is_file() => Ok(()),
587 Ok(_) => Err(EngineError::InvalidState(format!(
588 "refusing mission runtime file that is not a regular file: {}",
589 path.display()
590 ))),
591 Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
592 Err(e) => Err(e.into()),
593 }
594}
595
596fn missions_dir_no_follow(repo_root: &Path) -> std::io::Result<Option<PathBuf>> {
601 let kranz = repo_root.join(".kranz");
602 let missions = kranz.join("missions");
603 for path in [&kranz, &missions] {
604 match std::fs::symlink_metadata(path) {
605 Ok(metadata) if metadata.file_type().is_dir() => {}
606 Ok(_) => {
607 return Err(std::io::Error::other(format!(
608 "refusing to list missions through a symlinked or non-directory path: {}",
609 path.display()
610 )));
611 }
612 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
613 Err(e) => return Err(e),
614 }
615 }
616 Ok(Some(missions))
617}
618
619fn unsafe_mission_dir_path(path: &Path) -> EngineError {
624 EngineError::InvalidState(format!(
625 "refusing mission path with a symlinked or non-directory component: {}",
626 path.display()
627 ))
628}
629
630pub fn project_config(repo_root: &Path) -> PathBuf {
632 repo_root.join(".kranz").join("config.json")
633}
634
635pub fn global_config() -> Option<PathBuf> {
638 global_kranz_dir().map(|dir| dir.join("config.json"))
639}
640
641pub fn global_kranz_dir() -> Option<PathBuf> {
652 static GLOBAL: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
657 GLOBAL
658 .get_or_init(|| {
659 if let Some(dir) = std::env::var_os("KRANZ_HOME") {
660 if !dir.is_empty() {
661 return Some(PathBuf::from(dir));
662 }
663 }
664 std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
665 .map(|h| PathBuf::from(h).join(".kranz"))
666 })
667 .clone()
668}
669
670pub const AUTHORITY_KEY_LEN: usize = 32;
676
677fn repo_fingerprint(repo_root: &Path) -> String {
682 let canonical = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
683 crate::standards_waiver::sha256_hex(canonical.as_os_str().as_encoded_bytes())
684}
685
686pub fn authority_key_path(repo_root: &Path) -> Option<PathBuf> {
697 global_kranz_dir().map(|dir| {
698 dir.join("keys")
699 .join(format!("{}.key", repo_fingerprint(repo_root)))
700 })
701}
702
703pub fn seal_floor_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
714 if !MissionPaths::is_safe_id(mission_id) {
715 return None;
716 }
717 global_kranz_dir().map(|dir| {
718 dir.join("seals")
719 .join(repo_fingerprint(repo_root))
720 .join(mission_id)
721 })
722}
723
724pub fn read_seal_floor(repo_root: &Path, mission_id: &str) -> Option<u64> {
727 let path = seal_floor_path(repo_root, mission_id)?;
728 std::fs::read_to_string(&path).ok()?.trim().parse().ok()
729}
730
731pub fn high_water_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
743 seal_floor_path(repo_root, mission_id).map(|p| {
748 let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
749 dir.join("hwm").join(mission_id)
750 })
751}
752
753pub fn control_mark_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
764 seal_floor_path(repo_root, mission_id).map(|p| {
765 let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
766 dir.join("ctl").join(mission_id)
767 })
768}
769
770pub fn read_control_mark(repo_root: &Path, mission_id: &str) -> Option<String> {
772 let path = control_mark_path(repo_root, mission_id)?;
773 let text = std::fs::read_to_string(&path).ok()?;
774 let name = text.trim();
775 (!name.is_empty()).then(|| name.to_string())
776}
777
778pub fn record_control_mark(repo_root: &Path, mission_id: &str, name: &str) -> Result<()> {
780 let Some(path) = control_mark_path(repo_root, mission_id) else {
781 return Ok(());
782 };
783 if read_control_mark(repo_root, mission_id).is_some_and(|current| current.as_str() >= name) {
784 return Ok(());
785 }
786 write_mark(&path, name)
787}
788
789pub fn read_high_water(repo_root: &Path, mission_id: &str) -> Option<u64> {
791 let path = high_water_path(repo_root, mission_id)?;
792 std::fs::read_to_string(&path).ok()?.trim().parse().ok()
793}
794
795pub fn record_high_water(repo_root: &Path, mission_id: &str, seq: u64) -> Result<()> {
800 let Some(path) = high_water_path(repo_root, mission_id) else {
801 return Ok(());
802 };
803 if read_high_water(repo_root, mission_id).is_some_and(|current| current >= seq) {
804 return Ok(());
805 }
806 write_mark(&path, &seq.to_string())
807}
808
809fn write_mark(path: &Path, value: &str) -> Result<()> {
812 let dir = path
813 .parent()
814 .ok_or_else(|| EngineError::InvalidState("mark path has no parent".to_string()))?;
815 std::fs::create_dir_all(dir)?;
816 #[cfg(unix)]
817 {
818 use std::os::unix::fs::PermissionsExt;
819 let mut cursor = Some(dir);
820 for _ in 0..3 {
823 let Some(d) = cursor else { break };
824 let _ = std::fs::set_permissions(d, std::fs::Permissions::from_mode(0o700));
825 if d.file_name().is_some_and(|n| n == "seals") {
826 break;
827 }
828 cursor = d.parent();
829 }
830 }
831 let tmp = dir.join(format!(".mark.tmp-{}", uuid::Uuid::new_v4().as_simple()));
832 let write = || -> std::io::Result<()> {
833 use std::io::Write as _;
834 let mut options = std::fs::OpenOptions::new();
835 options.create_new(true).write(true);
836 #[cfg(unix)]
837 {
838 use std::os::unix::fs::OpenOptionsExt;
839 options.mode(0o600);
840 }
841 let mut file = options.open(&tmp)?;
842 file.write_all(value.as_bytes())?;
843 file.sync_data()?;
844 Ok(())
845 };
846 if let Err(error) = write() {
847 let _ = std::fs::remove_file(&tmp);
848 return Err(error.into());
849 }
850 if let Err(error) = std::fs::rename(&tmp, path) {
851 let _ = std::fs::remove_file(&tmp);
852 return Err(error.into());
853 }
854 Ok(())
855}
856
857pub fn record_seal_floor(repo_root: &Path, mission_id: &str, seq: u64) -> Result<()> {
861 let Some(path) = seal_floor_path(repo_root, mission_id) else {
862 return Ok(());
863 };
864 let dir = path
865 .parent()
866 .ok_or_else(|| EngineError::InvalidState("seal floor path has no parent".to_string()))?;
867 std::fs::create_dir_all(dir)?;
868 #[cfg(unix)]
869 {
870 use std::os::unix::fs::PermissionsExt;
871 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
875 if let Some(seals_root) = dir.parent() {
876 let _ = std::fs::set_permissions(seals_root, std::fs::Permissions::from_mode(0o700));
877 }
878 }
879 let mut options = std::fs::OpenOptions::new();
880 options.create_new(true).write(true);
881 #[cfg(unix)]
882 {
883 use std::os::unix::fs::OpenOptionsExt;
884 options.mode(0o600);
885 }
886 match options.open(&path) {
887 Ok(mut file) => {
888 use std::io::Write as _;
889 file.write_all(seq.to_string().as_bytes())?;
890 file.sync_data()?;
891 Ok(())
892 }
893 Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()),
895 Err(e) => Err(e.into()),
896 }
897}
898
899fn key_owner_path(key_path: &Path) -> PathBuf {
904 key_path.with_extension("path")
905}
906
907fn record_key_owner(key_path: &Path, repo_root: &Path) {
908 let canonical = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
909 let _ = std::fs::write(
910 key_owner_path(key_path),
911 canonical.as_os_str().as_encoded_bytes(),
912 );
913}
914
915fn prune_orphan_temp_keys(keys_dir: &Path) {
926 let temp_root = std::env::temp_dir();
927 let temp_root = std::fs::canonicalize(&temp_root).unwrap_or(temp_root);
928 let Ok(entries) = std::fs::read_dir(keys_dir) else {
929 return;
930 };
931 for entry in entries.flatten() {
932 let owner_path = entry.path();
933 if owner_path.extension().and_then(|e| e.to_str()) != Some("path") {
934 continue;
935 }
936 let Ok(bytes) = std::fs::read(&owner_path) else {
937 continue;
938 };
939 let repo = PathBuf::from(String::from_utf8_lossy(&bytes).into_owned());
943 if !repo.starts_with(&temp_root) || repo.exists() {
944 continue;
945 }
946 let stem = owner_path
952 .file_stem()
953 .and_then(|s| s.to_str())
954 .unwrap_or_default()
955 .to_string();
956 let expected = crate::standards_waiver::sha256_hex(repo.as_os_str().as_encoded_bytes());
957 if stem != expected {
958 continue;
959 }
960 let key_path = owner_path.with_extension("key");
961 let _ = std::fs::remove_file(&key_path);
962 let _ = std::fs::remove_file(&owner_path);
963 }
966}
967
968pub fn load_authority_key(repo_root: &Path) -> Option<Vec<u8>> {
975 let path = authority_key_path(repo_root)?;
976 let bytes = std::fs::read(&path).ok()?;
977 (bytes.len() >= AUTHORITY_KEY_LEN).then_some(bytes)
978}
979
980pub fn load_or_create_authority_key(repo_root: &Path) -> Result<Vec<u8>> {
992 let path = authority_key_path(repo_root).ok_or_else(|| {
993 EngineError::InvalidState(
994 "cannot resolve the operator kranz directory for the authority key".to_string(),
995 )
996 })?;
997 if let Some(existing) = load_authority_key(repo_root) {
998 return Ok(existing);
999 }
1000 let dir = path
1001 .parent()
1002 .ok_or_else(|| EngineError::InvalidState("authority key path has no parent".to_string()))?;
1003 std::fs::create_dir_all(dir)?;
1004 #[cfg(unix)]
1005 {
1006 use std::os::unix::fs::PermissionsExt;
1007 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
1010 }
1011
1012 let key = {
1013 let mut key = Vec::with_capacity(AUTHORITY_KEY_LEN);
1014 key.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
1015 key.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
1016 key
1017 };
1018 let tmp = dir.join(format!(".key.tmp-{}", uuid::Uuid::new_v4().as_simple()));
1019 let write_tmp = || -> std::io::Result<()> {
1020 use std::io::Write as _;
1021 let mut options = std::fs::OpenOptions::new();
1022 options.create_new(true).write(true);
1023 #[cfg(unix)]
1024 {
1025 use std::os::unix::fs::OpenOptionsExt;
1026 options.mode(0o600);
1027 }
1028 let mut file = options.open(&tmp)?;
1029 file.write_all(&key)?;
1030 file.sync_data()?;
1031 Ok(())
1032 };
1033 if let Err(error) = write_tmp() {
1034 let _ = std::fs::remove_file(&tmp);
1035 return Err(error.into());
1036 }
1037 #[cfg(unix)]
1038 {
1039 use std::os::unix::fs::PermissionsExt;
1040 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1041 }
1042 match std::fs::hard_link(&tmp, &path) {
1046 Ok(()) => {
1047 let _ = std::fs::remove_file(&tmp);
1048 record_key_owner(&path, repo_root);
1049 prune_orphan_temp_keys(dir);
1050 Ok(key)
1051 }
1052 Err(e) if e.kind() == ErrorKind::AlreadyExists => {
1053 let _ = std::fs::remove_file(&tmp);
1054 load_authority_key(repo_root).ok_or_else(|| {
1055 EngineError::InvalidState(format!(
1056 "authority key {} exists but could not be read",
1057 path.display()
1058 ))
1059 })
1060 }
1061 Err(e) => {
1062 let _ = std::fs::remove_file(&tmp);
1063 Err(e.into())
1064 }
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 #[test]
1073 fn lessons_paths_are_repo_level_not_per_mission() {
1074 let paths = MissionPaths::new("/repo", "m-abc123");
1075 assert_eq!(paths.lessons_dir(), PathBuf::from("/repo/.kranz/lessons"));
1076 assert_eq!(
1077 paths.lessons_index(),
1078 PathBuf::from("/repo/.kranz/lessons/index.md")
1079 );
1080 }
1081
1082 #[test]
1083 fn plan_md_and_report_paths_are_per_mission() {
1084 let paths = MissionPaths::new("/repo", "m-abc123");
1085 assert_eq!(
1086 paths.plan_md_file(),
1087 PathBuf::from("/repo/.kranz/missions/m-abc123/plan.md")
1088 );
1089 assert_eq!(
1090 paths.report_file(),
1091 PathBuf::from("/repo/.kranz/missions/m-abc123/report.md")
1092 );
1093 }
1094
1095 #[test]
1096 fn try_list_missions_distinguishes_missing_dir_from_real_listing() {
1097 let tmp = tempfile::TempDir::new().unwrap();
1098 assert_eq!(
1100 MissionPaths::try_list_missions(tmp.path()).unwrap(),
1101 Vec::<String>::new()
1102 );
1103 let missions = tmp.path().join(".kranz").join("missions");
1106 std::fs::create_dir_all(missions.join("m-bbb222")).unwrap();
1107 std::fs::create_dir_all(missions.join("m-aaa111")).unwrap();
1108 std::fs::write(missions.join("stray.txt"), b"x").unwrap();
1109 let listed = MissionPaths::try_list_missions(tmp.path()).unwrap();
1110 assert_eq!(listed, vec!["m-aaa111".to_string(), "m-bbb222".to_string()]);
1111 assert_eq!(MissionPaths::list_missions(tmp.path()), listed);
1113 }
1114
1115 #[cfg(unix)]
1116 #[test]
1117 fn try_list_missions_reports_errors_instead_of_swallowing_them() {
1118 let tmp = tempfile::TempDir::new().unwrap();
1121 std::fs::create_dir_all(tmp.path().join(".kranz")).unwrap();
1122 std::fs::write(tmp.path().join(".kranz").join("missions"), b"not a dir").unwrap();
1123 assert!(MissionPaths::try_list_missions(tmp.path()).is_err());
1124 assert_eq!(
1129 MissionPaths::list_missions(tmp.path()),
1130 Vec::<String>::new()
1131 );
1132 }
1133
1134 #[test]
1135 fn safe_id_rejects_path_traversal_shapes() {
1136 for id in ["", "../m-x", "m-x/../../y", "m-x\\..\\y", "c:m-x", "m-.."] {
1137 assert!(!MissionPaths::is_safe_id(id), "{id:?} should be unsafe");
1138 }
1139 for id in ["m-abc123", "m-2026-07-08", "m_ticket.linked"] {
1140 assert!(MissionPaths::is_safe_id(id), "{id:?} should be safe");
1141 }
1142 }
1143
1144 #[cfg(unix)]
1149 #[test]
1150 fn list_missions_excludes_symlinked_mission_dirs() {
1151 use std::os::unix::fs::symlink;
1152 let tmp = tempfile::TempDir::new().unwrap();
1153 let missions = tmp.path().join(".kranz").join("missions");
1154 std::fs::create_dir_all(missions.join("m-real")).unwrap();
1155 let elsewhere = tmp.path().join("elsewhere");
1158 std::fs::create_dir_all(&elsewhere).unwrap();
1159 symlink(&elsewhere, missions.join("m-evil")).unwrap();
1160 assert_eq!(
1161 MissionPaths::list_missions(tmp.path()),
1162 vec!["m-real".to_string()]
1163 );
1164 assert_eq!(
1165 MissionPaths::try_list_missions(tmp.path()).unwrap(),
1166 vec!["m-real".to_string()]
1167 );
1168 }
1169
1170 #[cfg(unix)]
1171 #[test]
1172 fn list_missions_refuses_a_symlinked_missions_dir() {
1173 use std::os::unix::fs::symlink;
1174 let tmp = tempfile::TempDir::new().unwrap();
1175 std::fs::create_dir_all(tmp.path().join(".kranz")).unwrap();
1176 let elsewhere = tmp.path().join("elsewhere");
1177 std::fs::create_dir_all(elsewhere.join("m-evil")).unwrap();
1178 symlink(&elsewhere, tmp.path().join(".kranz").join("missions")).unwrap();
1179 assert_eq!(
1181 MissionPaths::list_missions(tmp.path()),
1182 Vec::<String>::new()
1183 );
1184 let err = MissionPaths::try_list_missions(tmp.path()).unwrap_err();
1186 assert!(err.to_string().contains("refusing"), "{err}");
1187 }
1188
1189 #[cfg(unix)]
1190 #[test]
1191 fn require_no_follow_refuses_symlinked_components() {
1192 use std::os::unix::fs::symlink;
1193 let tmp = tempfile::TempDir::new().unwrap();
1194 let missions = tmp.path().join(".kranz").join("missions");
1195 std::fs::create_dir_all(missions.join("m-real")).unwrap();
1196 assert!(MissionPaths::new(tmp.path(), "m-real")
1199 .require_no_follow()
1200 .is_ok());
1201 assert!(MissionPaths::new(tmp.path(), "m-absent")
1202 .require_no_follow()
1203 .is_ok());
1204 let elsewhere = tmp.path().join("elsewhere");
1206 std::fs::create_dir_all(&elsewhere).unwrap();
1207 symlink(&elsewhere, missions.join("m-evil")).unwrap();
1208 let err = MissionPaths::new(tmp.path(), "m-evil")
1209 .require_no_follow()
1210 .unwrap_err();
1211 assert!(err.to_string().contains("refusing"), "{err}");
1212 assert!(MissionPaths::new(tmp.path(), "../x")
1214 .require_no_follow()
1215 .is_err());
1216 }
1217
1218 #[cfg(unix)]
1219 #[test]
1220 fn require_no_follow_refuses_a_symlinked_kranz_dir() {
1221 use std::os::unix::fs::symlink;
1222 let tmp = tempfile::TempDir::new().unwrap();
1223 let elsewhere = tmp.path().join("elsewhere");
1224 std::fs::create_dir_all(elsewhere.join("missions").join("m-evil")).unwrap();
1225 symlink(&elsewhere, tmp.path().join(".kranz")).unwrap();
1226 assert!(MissionPaths::new(tmp.path(), "m-evil")
1227 .require_no_follow()
1228 .is_err());
1229 assert_eq!(
1230 MissionPaths::list_missions(tmp.path()),
1231 Vec::<String>::new()
1232 );
1233 }
1234
1235 #[cfg(unix)]
1236 #[test]
1237 fn open_mission_dir_nofollow_creates_missing_dirs_but_refuses_symlinks() {
1238 use std::os::unix::fs::symlink;
1239 let tmp = tempfile::TempDir::new().unwrap();
1240 let paths = MissionPaths::new(tmp.path(), "m-new");
1242 paths.open_mission_dir_nofollow(true).unwrap();
1243 assert!(paths.mission_dir().is_dir());
1244 std::fs::remove_dir(paths.mission_dir()).unwrap();
1246 let elsewhere = tmp.path().join("elsewhere");
1247 std::fs::create_dir_all(&elsewhere).unwrap();
1248 symlink(&elsewhere, paths.mission_dir()).unwrap();
1249 assert!(paths.open_mission_dir_nofollow(true).is_err());
1250 }
1251
1252 #[cfg(unix)]
1253 #[test]
1254 fn ensure_absent_or_regular_file_refuses_symlinks() {
1255 use std::os::unix::fs::symlink;
1256 let tmp = tempfile::TempDir::new().unwrap();
1257 let target = tmp.path().join("target.jsonl");
1258 std::fs::write(&target, b"secret").unwrap();
1259 let link = tmp.path().join("link.jsonl");
1260 symlink(&target, &link).unwrap();
1261 let err = ensure_absent_or_regular_file(&link).unwrap_err();
1262 assert!(err.to_string().contains("refusing"), "{err}");
1263 assert!(ensure_absent_or_regular_file(&target).is_ok());
1264 assert!(ensure_absent_or_regular_file(&tmp.path().join("missing.jsonl")).is_ok());
1265 }
1266
1267 #[test]
1270 fn the_authority_key_lives_outside_the_repository() {
1271 let tmp = tempfile::TempDir::new().unwrap();
1272 let path = authority_key_path(tmp.path()).expect("a home dir in tests");
1273 assert!(
1274 !path.starts_with(tmp.path()),
1275 "the key must never sit inside the repo it authenticates: {}",
1276 path.display()
1277 );
1278 assert!(path.parent().unwrap().ends_with("keys"));
1279 }
1280
1281 #[test]
1282 fn the_authority_key_is_stable_per_repo_and_distinct_between_repos() {
1283 let a = tempfile::TempDir::new().unwrap();
1284 let b = tempfile::TempDir::new().unwrap();
1285 let first = load_or_create_authority_key(a.path()).unwrap();
1286 assert_eq!(first.len(), AUTHORITY_KEY_LEN);
1287 assert_eq!(
1288 load_or_create_authority_key(a.path()).unwrap(),
1289 first,
1290 "a second call adopts the existing key rather than rotating it"
1291 );
1292 assert_ne!(
1293 load_or_create_authority_key(b.path()).unwrap(),
1294 first,
1295 "one repo's key must not authenticate another repo's inbox"
1296 );
1297 let indirect = a
1301 .path()
1302 .join(".")
1303 .join("..")
1304 .join(a.path().file_name().unwrap());
1305 assert_eq!(load_or_create_authority_key(&indirect).unwrap(), first);
1306 }
1307
1308 #[cfg(unix)]
1309 #[test]
1310 fn the_authority_key_is_owner_only() {
1311 use std::os::unix::fs::PermissionsExt;
1312 let tmp = tempfile::TempDir::new().unwrap();
1313 load_or_create_authority_key(tmp.path()).unwrap();
1314 let path = authority_key_path(tmp.path()).unwrap();
1315 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1316 assert_eq!(mode, 0o600, "key mode {mode:o}");
1317 let dir_mode = std::fs::metadata(path.parent().unwrap())
1318 .unwrap()
1319 .permissions()
1320 .mode()
1321 & 0o777;
1322 assert_eq!(dir_mode, 0o700, "keys dir mode {dir_mode:o}");
1323 }
1324
1325 #[test]
1326 fn the_seal_floor_is_recorded_once_and_never_moved() {
1327 let tmp = tempfile::TempDir::new().unwrap();
1328 assert_eq!(read_seal_floor(tmp.path(), "m-1"), None);
1329 record_seal_floor(tmp.path(), "m-1", 7).unwrap();
1330 assert_eq!(read_seal_floor(tmp.path(), "m-1"), Some(7));
1331 record_seal_floor(tmp.path(), "m-1", 1).unwrap();
1334 record_seal_floor(tmp.path(), "m-1", 99).unwrap();
1335 assert_eq!(read_seal_floor(tmp.path(), "m-1"), Some(7));
1336 assert_eq!(read_seal_floor(tmp.path(), "m-2"), None);
1337 }
1338
1339 #[test]
1340 fn an_unsafe_mission_id_gets_no_seal_path() {
1341 let tmp = tempfile::TempDir::new().unwrap();
1342 assert!(seal_floor_path(tmp.path(), "../../escape").is_none());
1343 assert!(seal_floor_path(tmp.path(), "a/b").is_none());
1344 }
1345}