Skip to main content

kranz_engine/
paths.rs

1//! Mission data layout under the target repo (plan §4):
2//!
3//! ```text
4//! <repo>/.kranz/
5//! ├── config.json                 # project config (merged over ~/.kranz/config.json)
6//! └── missions/<id>/
7//!     ├── plan.json               # approved plan (also committed to mission branch)
8//!     ├── events.jsonl            # append-only event log (single writer)
9//!     ├── events.jsonl.lock       # exclusive engine lock
10//!     ├── state.json              # derived snapshot (cache; rebuildable)
11//!     ├── control/                # inbox: CLI/server -> engine ControlCommand files
12//!     ├── runs/<runId>.jsonl      # full per-run transcripts
13//!     ├── runs/egress-denials.jsonl # fs+net proxy denial records (gitignored runtime)
14//!     └── workspace/              # container-provider compose files (gitignored runtime)
15//! ```
16//!
17//! All paths built with std::path so Windows stays first-class (§9).
18
19use 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
27/// The canonical `.kranz/.gitignore` rules the engine materializes on init
28/// (`orchestrator::write_kranz_gitignore`). Single source for the engine's
29/// writer and for ready.rs's engine-materialized exception: the file ignores
30/// ITSELF (first rule), so it can never be a committed artifact — its rules
31/// travel with the tool, not the repo.
32pub 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    /// Mission ids that come from untrusted user input are joined into
67    /// filesystem paths. Reject separators, `..`, and drive designators before
68    /// constructing paths from those ids.
69    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    /// Rendered plan markdown (distinct from `plan_file`'s plan.json).
90    pub fn plan_md_file(&self) -> PathBuf {
91        self.mission_dir().join("plan.md")
92    }
93
94    /// Mission report markdown, written on completion.
95    pub fn report_file(&self) -> PathBuf {
96        self.mission_dir().join("report.md")
97    }
98
99    /// Approval-time cost estimate (gitignored runtime bookkeeping, like
100    /// `state.json`): persisted at plan approval / revision so the completion
101    /// report can compare actual cost against the exact estimate the operator
102    /// approved, rather than one recomputed against a later corpus or config.
103    pub fn estimate_file(&self) -> PathBuf {
104        self.mission_dir().join("estimate.json")
105    }
106
107    /// Per-mission research evidence artifact (repo-knowledge-store slice 1),
108    /// committed beside `plan.md` on the mission branch when a plan is approved.
109    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    /// Repo-level (not per-mission) directory of captured cross-mission
134    /// lessons; deliberately survives `kranz clean` and mission deletion.
135    pub fn lessons_dir(&self) -> PathBuf {
136        self.kranz_dir().join("lessons")
137    }
138
139    /// Append-only manifest of captured lessons in capture order.
140    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    /// Mission-shared egress-denial JSONL the per-run egress proxy appends to
149    /// (`fs+net` sessions; see `crate::egress_proxy`). Runtime path under the
150    /// gitignored `runs/` dir; v1 correlation is mission-level.
151    pub fn egress_denials_file(&self) -> PathBuf {
152        self.runs_dir().join("egress-denials.jsonl")
153    }
154
155    /// Relative transcript path recorded in events/state (stable across hosts).
156    pub fn transcript_rel(run_id: &str) -> String {
157        format!("runs/{run_id}.jsonl")
158    }
159
160    /// List mission ids present under a repo (sorted), lenient: an unreadable
161    /// missions dir lists as empty, and an erroring directory entry (fd
162    /// exhaustion, mid-deletion races, permission flaps) is skipped while the
163    /// REST are kept — one bad entry must not collapse the whole listing to
164    /// "no missions". Use [`Self::try_list_missions`] when the caller must
165    /// distinguish "no missions" from "could not list missions" (e.g. before
166    /// pruning per-mission bookkeeping keyed on this listing).
167    ///
168    /// Symlinks are never followed: a symlinked `.kranz` or `missions` dir
169    /// lists as empty (the fallible variant refuses with an error), and a
170    /// symlinked mission-dir entry is excluded rather than resolved into
171    /// another repository's tree.
172    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            // `file_type` does not follow symlinks: a symlinked mission dir
182            // is not a mission — exclude it, never resolve through it.
183            .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    /// List mission ids present under a repo (sorted), distinguishing
191    /// filesystem errors from a genuinely empty listing.
192    ///
193    /// A missing missions dir is `Ok(vec![])` — the repo simply has no
194    /// missions yet. Any other `read_dir` failure, or an erroring directory
195    /// entry (fd exhaustion, mid-deletion races, permission flaps), is `Err`:
196    /// a transient error must not masquerade as "every mission was deleted".
197    /// A symlinked `.kranz`/`missions` dir is `Err` too — it must refuse,
198    /// never be followed into another repository's tree.
199    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    // -----------------------------------------------------------------------
218    // No-follow mission path resolution (P1 mission-path-no-follow)
219    //
220    // The same capability-based no-follow idiom as the lessons provenance
221    // guard (`crate::lessons`): every component of `.kranz/missions/<id>` is
222    // inspected with `symlink_metadata` (the link itself, never its target)
223    // and then opened with `open_dir_nofollow`, so a symlinked component is
224    // REFUSED with a clear `EngineError::InvalidState` — never followed into
225    // another repository's state, transcripts, or control inbox.
226    // -----------------------------------------------------------------------
227
228    /// Refuse when any component of `<repo>/.kranz/missions/<id>` is a
229    /// symlink (or otherwise not a real directory). An ABSENT component is
230    /// not a refusal — callers keep their own missing-mission handling
231    /// (404s, "unknown mission" errors); only a symlinked component — one
232    /// that would be followed into another tree — must fail here.
233    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    /// Open `<repo>/.kranz/missions/<id>` as a capability pinned beneath
242    /// components that are provably NOT symlinks. With `create`, missing
243    /// components are created as plain directories (the no-follow counterpart
244    /// of `create_dir_all` for the mission tree); without it, a missing
245    /// component is an `io` `NotFound` error. A symlinked component is always
246    /// [`unsafe_mission_dir_path`], never a follow.
247    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
261/// Open `name` for reading RELATIVE to an already-pinned capability dir
262/// with `FollowSymlinks::No` — the final step of the pinned-chain reads
263/// ([`MissionPaths::open_mission_file_read_nofollow`] /
264/// [`MissionPaths::open_ticket_file_read_nofollow`]). `NotFound` passes
265/// through as `io` (callers keep missing-file handling); every other
266/// failure maps to the mission-refusal error.
267fn 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    // Opening a FIFO for reading would block before metadata can reject it.
277    #[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
298/// Read one regular file beneath an already-pinned directory. The name must
299/// be a single component; nested callers must pin each directory separately.
300pub 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
313/// Bound both the initial size and bytes actually read, including growth
314/// after opening. Oversized artifacts fail instead of returning partial text.
315pub 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
337/// One [`MissionPaths::open_mission_dir_nofollow`] step: refuse a `name` that
338/// exists but is not a real directory (a symlink most of all), create it when
339/// permitted and missing, then open it with `open_dir_nofollow` — the open is
340/// the authoritative no-follow check, the metadata pass only shapes the error.
341fn 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            // Lost a race or an attacker planted the name: re-verify.
352            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
365/// Create `name` beneath `dir` (a capability already opened no-follow) when
366/// missing, and verify the result is a REAL directory — a symlinked entry
367/// (`control/`, `runs/` planted inside a genuine mission dir) is refused,
368/// never followed.
369pub(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
387/// Open a real child directory beneath an already-pinned capability,
388/// optionally creating it. The returned capability is the one callers must
389/// retain for subsequent reads, writes, removals, and renames; going back to
390/// the absolute display path would reintroduce a parent-swap window.
391pub(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
400/// Locate the LAST `.kranz` component whose suffix has a recognized runtime
401/// layout. Using the last matching component matters when a perfectly valid
402/// repository itself lives below an unrelated ancestor named `.kranz`.
403fn 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
427/// Pin the parent of `path` and return its leaf name. Mission-layout paths
428/// are resolved from the trusted repository prefix and every component from
429/// `.kranz` downward is opened no-follow. This is the write-side counterpart
430/// of [`open_read_nofollow`]: callers perform the eventual open/rename/remove
431/// relative to the returned capability, never through the absolute path that
432/// was checked.
433///
434/// Paths outside the mission/ticket layout are used by unit-test scratch
435/// fixtures and retain the weaker canonical-parent tier described by
436/// [`open_read_nofollow`].
437pub(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
474/// Open `path` for reading with the mission-tree chain pinned
475/// (7th-pass review): when the path carries the mission layout
476/// (`<root>/.kranz/missions/<id>/...` or `<root>/.kranz/tickets/...`), the
477/// prefix BEFORE `.kranz` is taken as the trusted anchor (ambient
478/// authority — the same trust basis [`MissionPaths`] uses for its repo
479/// root), and every component from `.kranz` down is opened per-component
480/// no-follow, the final file with `FollowSymlinks::No`. No
481/// canonicalization of the untrusted region anywhere: the earlier
482/// canonicalize-then-walk shape resolved a hostile parent symlink BEFORE
483/// the no-follow discipline began.
484///
485/// Paths OUTSIDE the mission layout (test scratch dirs under macOS
486/// `/var`, which is itself a system symlink) keep the weaker
487/// canonicalize-then-walk tier: those regions are outside the mission
488/// threat model, and canonicalizing them is the only way macOS tempdirs
489/// resolve at all. Off-unix the capability API supplies the no-follow open.
490pub 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        // The mission layout yields a trusted prefix + an untrusted suffix
497        // to pin; anything else takes the weaker tier.
498        let anchor_info = mission_layout_anchor(&components);
499
500        if let Some(idx) = anchor_info {
501            // Trusted anchor: everything before `.kranz` ("/" when relative).
502            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        // Weaker tier (out-of-model paths): canonicalize the parent, pin
530        // the canonical ancestors, open the final no-follow.
531        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/// The pre-7th-pass behavior for paths outside the mission layout:
541/// canonicalize the parent (resolves macOS `/var`-style system symlinks),
542/// pin the canonical ancestors per-component no-follow, open the final
543/// file with `FollowSymlinks::No`. Used only for out-of-model paths (test
544/// scratch), where canonicalization is required for tempdirs to resolve at
545/// all; the mission-tree paths never reach here.
546#[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/// Refuse `path` when it exists and is anything but a regular file — a
573/// symlink most of all. `symlink_metadata` (never `metadata`) inspects the
574/// link itself, so a symlinked runtime file (events.jsonl, state.json, the
575/// lock file) is rejected at open time instead of being read or written
576/// through into another tree. An absent path is `Ok`: the caller's own
577/// open/read produces its usual `NotFound`.
578///
579/// This is a CHECK ONLY: a caller that opens the file afterwards has a
580/// check-then-open window a concurrent writer could swap a symlink into.
581/// Readers should use [`open_read_nofollow`] instead, which closes that
582/// window on unix.
583#[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
596/// The `<repo>/.kranz/missions` dir for LISTING: `Ok(None)` when a component
597/// is absent (no missions yet), `Err` when a present component is not a real
598/// directory — a symlinked `.kranz`/`missions` must refuse, never be followed
599/// into another repository's tree.
600fn 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
619/// The refusal surfaced when a mission path component is a symlink (or
620/// otherwise not a real directory) — the same refusal family as the lessons
621/// provenance guard: a clear `EngineError::InvalidState`, never a panic and
622/// never a silent follow.
623fn 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
630/// Project config file path.
631pub fn project_config(repo_root: &Path) -> PathBuf {
632    repo_root.join(".kranz").join("config.json")
633}
634
635/// Global config file path (`<global kranz dir>/config.json`), None if no
636/// home dir.
637pub fn global_config() -> Option<PathBuf> {
638    global_kranz_dir().map(|dir| dir.join("config.json"))
639}
640
641/// The operator's global kranz directory: `$KRANZ_HOME` when set and
642/// non-empty, else `~/.kranz` (`%USERPROFILE%\.kranz` on Windows), `None`
643/// when neither resolves.
644///
645/// `KRANZ_HOME` exists so a test harness or a CI runner can point every
646/// global store (config, authority keys, seal floors) at a scratch directory
647/// instead of the operator's real home. It is read from the ENGINE process's
648/// own environment; agent sessions spawn from a cleared environment
649/// (`agent_env`), so a session cannot redirect the key directory it is
650/// denied from reading.
651pub fn global_kranz_dir() -> Option<PathBuf> {
652    // Resolved once per process. The authority key, the seal floors, and
653    // the control marks all hang off this directory, and a signer and a
654    // verifier in the same process must agree on it even if something
655    // rewrites `HOME` in between (a test does; a wrapper script could).
656    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
670// ---------------------------------------------------------------------------
671// Per-repository authority key (audit 2026-09-01 C1 / H6)
672// ---------------------------------------------------------------------------
673
674/// Length of a freshly minted authority key.
675pub const AUTHORITY_KEY_LEN: usize = 32;
676
677/// Stable per-repository id for authority material: sha256 of the CANONICAL
678/// repo root path, so `/repo`, `/repo/../repo`, and macOS `/var` vs
679/// `/private/var` all resolve to one key instead of minting a second one that
680/// would make previously signed control files and log lines unverifiable.
681fn 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
686/// Where this repository's authority key lives:
687/// `<global kranz dir>/keys/<repo fingerprint>.key`.
688///
689/// OUTSIDE the repository on purpose. The key authenticates the control inbox
690/// (`control::enqueue`/`control::drain`) and MACs `events.jsonl`, so an agent
691/// process that can read it can forge operator consent. Keeping it in the
692/// operator's home puts it inside the sandbox's authority-read-deny set
693/// (`sandbox::authority_read_deny_paths`) and, when the sandbox is off,
694/// behind the agent CLI's own `Read(~/.kranz/**)` deny rule
695/// (`permissions::AUTHORITY_DENY`).
696pub 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
703/// Where a mission's SEAL FLOOR lives: the first `events.jsonl` seq that must
704/// carry integrity fields, recorded outside the repository beside the key.
705///
706/// This is the anchor that makes the event log's integrity non-optional. The
707/// log itself cannot carry the requirement: an attacker who rewrites every
708/// line, stripping `h` and `m` from all of them, produces something
709/// indistinguishable from a log written before integrity existed, and a
710/// reader with only the file in front of it has to accept it. A floor the
711/// attacker cannot write says "from seq N on, unsealed is forged", and it
712/// grandfathers the lines below N that legitimately predate the key.
713pub 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
724/// The recorded seal floor for one mission, `None` when none was ever
725/// recorded (so every line is grandfathered).
726pub 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
731/// Where a mission's HIGH-WATER MARK lives: the highest seq the engine has
732/// durably written, recorded beside the seal floor and, like it, outside the
733/// repository.
734///
735/// The seal floor and the chain make a forged or edited line detectable; the
736/// high-water mark makes a TRUNCATED log detectable. Cutting `events.jsonl`
737/// at a line boundary leaves a valid chain and a valid seq run, so nothing in
738/// the file itself can say lines are missing. `state.json` carries a
739/// `last_seq`, but it sits in the repository next to the log, so whoever can
740/// truncate one can rewrite the other. A mark the same writer cannot reach
741/// is the only witness that survives.
742pub fn high_water_path(repo_root: &Path, mission_id: &str) -> Option<PathBuf> {
743    // Own subdirectory, never a suffix on the floor's name: mission ids may
744    // contain dots, so `with_extension` would fold `release-1.2` and
745    // `release-1.3` onto one mark and make mission `foo`'s mark mission
746    // `foo.hwm`'s floor (follow-up review F-5).
747    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
753/// Where a mission's CONTROL MARK lives: the name of the last control file
754/// the engine acknowledged, recorded beside the high-water mark. Control
755/// file names are `<zero-padded-nanos>-<8-hex>.json`, so they order
756/// lexicographically by creation time and the mark never moves backwards.
757///
758/// The mark is what makes a signed control file single-use. The signature
759/// binds the file to its name, and the engine refuses any name at or below
760/// the mark, so a captured `approve-grant` re-dropped after the drain
761/// deleted it authenticates but is refused as a replay (follow-up review
762/// F-1).
763pub 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
770/// The recorded control mark, `None` when none was ever recorded.
771pub 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
778/// Record `name` as the last acknowledged control file. Never lowers.
779pub 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
789/// The recorded high-water mark, `None` when none was ever recorded.
790pub 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
795/// Record `seq` as the highest durably written event. Never lowers an
796/// existing mark. Written through a tmp file and rename so a crash between
797/// the log write and this one leaves the OLD mark, which is at most one
798/// event behind and therefore never accuses a healthy log.
799pub 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
809/// Write a small witness file under the seals tree: parent dirs `0700`,
810/// file `0600`, tmp file plus rename so a crash leaves the previous value.
811fn 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        // Lock every directory we may have just created up to the seals
821        // root: the marks are authority material like the key.
822        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
857/// Record `seq` as this mission's seal floor, once. An existing floor is
858/// never moved: raising it would grandfather away lines that were sealed, and
859/// lowering it would condemn lines that legitimately were not.
860pub 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        // Lock both the per-repo dir and the `seals` root above it: the
872        // floor is what makes a stripped log detectable, so its directory
873        // is authority material like the key's.
874        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        // Already recorded: whoever got there first is authoritative.
894        Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()),
895        Err(e) => Err(e.into()),
896    }
897}
898
899/// Sidecar beside each key naming the repository it was minted for, so the
900/// pruner below can tell an orphaned scratch-repo key from a live one. The
901/// key file itself is named by a one-way fingerprint, which is right for the
902/// deny sets but leaves nothing to stat.
903fn 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
915/// Remove keys (and their seal floors) minted for repositories under the
916/// system temp root that no longer exist.
917///
918/// Every mission a test creates in a `tempfile` repo mints a key, and the
919/// repo is gone the moment the test ends, so without this the operator's key
920/// directory grows by one file per test forever. Scoped to the temp root on
921/// purpose: a key for a real repository is never pruned, even when its path
922/// is momentarily absent (an unmounted volume), because losing the key
923/// strands every log line and control file it signed. Best effort, never an
924/// error: pruning is housekeeping, minting is the job.
925fn 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        // The sidecar was written as encoded bytes of an OsStr; `from_utf8`
940        // is only a lossy view for the containment check below, never an
941        // identity we act on.
942        let repo = PathBuf::from(String::from_utf8_lossy(&bytes).into_owned());
943        if !repo.starts_with(&temp_root) || repo.exists() {
944            continue;
945        }
946        // The sidecar names the key it belongs to only through the
947        // fingerprint. A sidecar whose recorded path does not hash to its
948        // own filename was not written by this code, so it deletes nothing
949        // (follow-up review F-4: a planted `<real fp>.path` naming a dead
950        // temp path must not take a real repository's key with it).
951        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        // The seal directory is left alone on purpose: a stale one costs
964        // bytes, a deleted one costs a witness.
965    }
966}
967
968/// Read this repository's authority key, or `None` when there is none to
969/// read (no home dir, no key minted yet, unreadable file).
970///
971/// The READER form: a verifier that cannot load the key degrades to the
972/// weaker check it can still perform and says so, but it never mints key
973/// material as a side effect of reading.
974pub 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
980/// Read this repository's authority key, minting it on first use.
981///
982/// The WRITER/SIGNER form. Directory `0700`, file `0600`, written through a
983/// tmp file + rename so a crash never leaves a truncated key at the stable
984/// path (the same shape `kranz serve` uses for `serve.token`). A racing
985/// creator wins harmlessly: `create_new` fails `AlreadyExists` and we re-read
986/// whatever landed, so two processes never disagree about the key.
987///
988/// The 32 bytes come from two `uuid` v4 values. `uuid` is already a workspace
989/// dependency drawing on the OS CSPRNG, so this adds no crate to the set
990/// `deny.toml` audits.
991pub 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        // Best effort: an operator who deliberately widened ~/.kranz is not
1008        // overridden loudly, but a directory we just created is ours to lock.
1009        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    // `rename` would clobber a key a racing process just minted, which would
1043    // strand every file it had already signed. Link-then-unlink refuses
1044    // instead, and we adopt the winner's key.
1045    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        // No .kranz/missions dir at all: genuinely empty, not an error.
1099        assert_eq!(
1100            MissionPaths::try_list_missions(tmp.path()).unwrap(),
1101            Vec::<String>::new()
1102        );
1103        // With mission subdirs (plus a stray file that must be skipped):
1104        // listed and sorted.
1105        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        // The lenient wrapper agrees on the happy path.
1112        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        // A missions *file* (not dir) makes read_dir fail with a non-NotFound
1119        // error; the fallible listing must surface it, not return "empty".
1120        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        // The lenient listing stays lenient on the same failure: empty, not a
1125        // panic or an error. (A single erroring dir ENTRY — skipped while the
1126        // rest are kept — is not portably constructible in a test; the walk
1127        // uses `.flatten()` to encode that contract.)
1128        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    // Symlink-creating tests are unix-only, exactly like the lessons guard's
1145    // tests (`std::os::unix::fs::symlink`); Windows needs privileges to
1146    // create symlinks, so CI coverage there comes from the no-symlink cases.
1147
1148    #[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        // A mission dir that is a symlink into another tree is not a mission:
1156        // excluded, never followed.
1157        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        // The lenient listing stays lenient: empty, not another repo's ids.
1180        assert_eq!(
1181            MissionPaths::list_missions(tmp.path()),
1182            Vec::<String>::new()
1183        );
1184        // The fallible listing refuses with a clear error.
1185        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        // A real mission dir passes; an absent one is not a refusal (the
1197        // caller's own missing-mission handling decides).
1198        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        // A symlinked mission dir is refused with a clear error.
1205        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        // Unsafe ids never reach the filesystem.
1213        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        // Fresh repo: the whole chain is created, mirroring create_dir_all.
1241        let paths = MissionPaths::new(tmp.path(), "m-new");
1242        paths.open_mission_dir_nofollow(true).unwrap();
1243        assert!(paths.mission_dir().is_dir());
1244        // A planted symlink in place of the mission dir is refused.
1245        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    // -- authority key (audit 2026-09-01 C1 / H6) --------------------------
1268
1269    #[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        // A path spelled differently but naming the same tree resolves to the
1298        // same key, or every file signed under the other spelling would stop
1299        // verifying.
1300        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        // Raising it would grandfather away lines that were sealed; lowering
1332        // it would condemn lines that legitimately were not.
1333        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}