1use crate::error::{EngineError, Result};
37use crate::paths::MissionPaths;
38use crate::types::{ControlCommand, MissionStatus};
39use chrono::Utc;
40use std::ffi::{OsStr, OsString};
41use std::io::{ErrorKind, Write};
42use std::path::{Path, PathBuf};
43use subtle::ConstantTimeEq as _;
44
45const TIMESTAMP_WIDTH: usize = 20;
48
49const RAND_LEN: usize = 8;
51
52const SIG_FIELD: &str = "sig";
54
55fn signed_payload(mission_id: &str, name: &str, cmd: &ControlCommand) -> Result<String> {
70 let body = serde_json::to_string(cmd)?;
71 Ok(format!(
72 "{}:{mission_id}\n{}:{name}\n{body}",
73 mission_id.len(),
74 name.len()
75 ))
76}
77
78fn sign(key: &[u8], mission_id: &str, name: &str, cmd: &ControlCommand) -> Result<String> {
80 Ok(crate::hooks::hmac_sha256_hex(
81 key,
82 signed_payload(mission_id, name, cmd)?.as_bytes(),
83 ))
84}
85
86fn is_replayed(repo_root: &Path, mission_id: &str, name: &str) -> bool {
90 crate::paths::read_control_mark(repo_root, mission_id).is_some_and(|mark| name <= mark.as_str())
91}
92
93enum ControlRefusal {
97 Quarantine(String),
99 Skip(String),
101}
102
103fn authenticate(
111 repo_root: &Path,
112 mission_id: &str,
113 name: &str,
114 content: &str,
115) -> std::result::Result<ControlCommand, ControlRefusal> {
116 if is_replayed(repo_root, mission_id, name) {
117 return Err(ControlRefusal::Quarantine(
118 "control command replays a file the engine already acknowledged".to_string(),
119 ));
120 }
121 let mut value: serde_json::Value = serde_json::from_str(content)
122 .map_err(|e| ControlRefusal::Quarantine(format!("unparseable control command: {e}")))?;
123 let object = value.as_object_mut().ok_or_else(|| {
124 ControlRefusal::Quarantine("control command is not a JSON object".to_string())
125 })?;
126 let Some(presented) = object.remove(SIG_FIELD) else {
127 return Err(ControlRefusal::Quarantine(
128 "control command carries no signature".to_string(),
129 ));
130 };
131 let Some(presented) = presented.as_str().map(str::to_string) else {
132 return Err(ControlRefusal::Quarantine(
133 "control command signature is not a string".to_string(),
134 ));
135 };
136 let cmd: ControlCommand = serde_json::from_value(value)
137 .map_err(|e| ControlRefusal::Quarantine(format!("unparseable control command: {e}")))?;
138
139 let Some(key) = crate::paths::load_authority_key(repo_root) else {
142 return Err(ControlRefusal::Skip(
143 "no repository authority key available to verify control commands".to_string(),
144 ));
145 };
146 let expected = sign(&key, mission_id, name, &cmd)
147 .map_err(|e| ControlRefusal::Skip(format!("could not recompute signature: {e}")))?;
148 if bool::from(expected.as_bytes().ct_eq(presented.as_bytes())) {
149 Ok(cmd)
150 } else {
151 Err(ControlRefusal::Quarantine(
152 "control command signature does not verify".to_string(),
153 ))
154 }
155}
156
157pub fn enqueue(paths: &MissionPaths, cmd: &ControlCommand) -> Result<PathBuf> {
171 let key = crate::paths::load_or_create_authority_key(&paths.repo_root)?;
175
176 let nanos = Utc::now().timestamp_nanos_opt().unwrap_or(0).max(0) as u64;
177 let rand = uuid::Uuid::new_v4().simple().to_string();
178 let name = format!(
179 "{nanos:0width$}-{}.json",
180 &rand[..RAND_LEN],
181 width = TIMESTAMP_WIDTH
182 );
183 let signature = sign(&key, &paths.mission_id, &name, cmd)?;
185
186 let mission_dir = paths.open_mission_dir_nofollow(true)?;
187 let dir = paths.control_dir();
188 let control_dir = crate::paths::open_real_subdir(&mission_dir, "control", &dir, true)?;
189
190 let final_path = dir.join(&name);
191 let tmp_name = format!("{name}.tmp");
192
193 let json = {
194 let mut value = serde_json::to_value(cmd)?;
195 let object = value.as_object_mut().ok_or_else(|| {
196 EngineError::InvalidState("control command is not a JSON object".to_string())
197 })?;
198 object.insert(SIG_FIELD.to_string(), serde_json::Value::String(signature));
199 serde_json::to_string(&value)?
200 };
201 {
202 use cap_fs_ext::OpenOptionsFollowExt as _;
203 use cap_primitives::fs::FollowSymlinks;
204 let mut options = cap_std::fs::OpenOptions::new();
205 options
206 .write(true)
207 .create_new(true)
208 .follow(FollowSymlinks::No);
209 let mut file = control_dir.open_with(&tmp_name, &options)?.into_std();
210 file.write_all(json.as_bytes())?;
211 file.sync_data()?;
212 }
213 control_dir.rename(&tmp_name, &control_dir, &name)?;
214 Ok(final_path)
215}
216
217pub fn drain(paths: &MissionPaths) -> Result<Vec<(PathBuf, ControlCommand)>> {
247 let mut commands = Vec::new();
248 let Some(control_dir) = control_dir(paths, false)? else {
249 return Ok(commands);
250 };
251 for name in queued_files(&control_dir)? {
252 let path = paths.control_dir().join(&name);
253 let content = match read_control_file(&control_dir, &name) {
254 Ok(c) => c,
255 Err(e) => {
256 tracing::warn!(path = %path.display(), error = %e, "unreadable control file, skipping");
258 continue;
259 }
260 };
261 match authenticate(
262 &paths.repo_root,
263 &paths.mission_id,
264 &name.to_string_lossy(),
265 &content,
266 ) {
267 Ok(cmd) => commands.push((path, cmd)),
268 Err(ControlRefusal::Quarantine(reason)) => {
269 quarantine(&control_dir, &name, &path, &reason)
270 }
271 Err(ControlRefusal::Skip(reason)) => {
272 tracing::warn!(path = %path.display(), reason = %reason, "cannot verify control file, leaving it queued");
273 }
274 }
275 }
276 Ok(commands)
277}
278
279pub fn peek_interrupt(paths: &MissionPaths) -> Result<bool> {
287 let Some(control_dir) = control_dir(paths, false)? else {
288 return Ok(false);
289 };
290 for name in queued_files(&control_dir)? {
291 let Ok(content) = read_control_file(&control_dir, &name) else {
292 continue;
293 };
294 match authenticate(
295 &paths.repo_root,
296 &paths.mission_id,
297 &name.to_string_lossy(),
298 &content,
299 ) {
300 Ok(ControlCommand::Msg {
301 interrupt: true, ..
302 }) => return Ok(true),
303 Ok(_) | Err(ControlRefusal::Quarantine(_)) => {}
304 Err(ControlRefusal::Skip(reason)) => {
305 tracing::warn!(
309 path = %paths.control_dir().join(&name).display(),
310 reason = %reason,
311 "cannot verify a queued control file while checking for an interrupt"
312 );
313 }
314 }
315 }
316 Ok(false)
317}
318
319pub fn acknowledge(paths: &MissionPaths, path: &Path) -> Result<()> {
324 if path.parent() != Some(paths.control_dir().as_path()) {
325 return Err(EngineError::InvalidState(format!(
326 "refusing control acknowledgement outside {}: {}",
327 paths.control_dir().display(),
328 path.display()
329 )));
330 }
331 let name = path.file_name().ok_or_else(|| {
332 EngineError::InvalidState(format!("control path {} has no file name", path.display()))
333 })?;
334 let Some(control_dir) = control_dir(paths, false)? else {
335 return Err(std::io::Error::from(ErrorKind::NotFound).into());
336 };
337 control_dir.remove_file(name)?;
338 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
341 crate::paths::record_control_mark(&paths.repo_root, &paths.mission_id, name)?;
342 }
343 Ok(())
344}
345
346fn mission_status(repo_root: &Path, id: &str) -> Option<MissionStatus> {
348 let paths = MissionPaths::new(repo_root, id);
349 let events = crate::event_log::EventLog::read_events(&paths.events_file()).ok()?;
350 Some(crate::reducer::fold(&events).ok()?.mission.status)
351}
352
353pub fn resolve_active_mission(repo_root: &Path, explicit: Option<&str>) -> Result<String> {
366 let is_terminal = crate::mission_catalog::is_terminal_status;
367 if let Some(id) = explicit {
368 if !MissionPaths::is_safe_id(id) {
369 return Err(EngineError::Other(format!("unknown mission `{id}`")));
370 }
371 if MissionPaths::new(repo_root, id)
374 .require_no_follow()
375 .is_err()
376 {
377 return Err(EngineError::Other(format!("unknown mission `{id}`")));
378 }
379 match mission_status(repo_root, id) {
380 None => Err(EngineError::Other(format!("unknown mission `{id}`"))),
381 Some(s) if is_terminal(s) => Err(EngineError::Other(format!(
382 "mission `{id}` is {s:?}; this change applies only to active missions"
383 ))),
384 Some(_) => Ok(id.to_string()),
385 }
386 } else {
387 let active: Vec<String> = MissionPaths::list_missions(repo_root)
388 .into_iter()
389 .filter(|id| mission_status(repo_root, id).is_some_and(|s| !is_terminal(s)))
390 .collect();
391 match active.len() {
392 0 => Err(EngineError::Other(
393 "no active mission — create one first".into(),
394 )),
395 1 => Ok(active.into_iter().next().expect("len == 1")),
396 _ => Err(EngineError::Other(format!(
397 "several active missions ({}); name one explicitly",
398 active.join(", ")
399 ))),
400 }
401 }
402}
403
404fn control_dir(paths: &MissionPaths, create: bool) -> Result<Option<cap_std::fs::Dir>> {
408 let mission_dir = match paths.open_mission_dir_nofollow(create) {
409 Ok(dir) => dir,
410 Err(EngineError::Io(error)) if error.kind() == ErrorKind::NotFound && !create => {
411 return Ok(None)
412 }
413 Err(error) => return Err(error),
414 };
415 match crate::paths::open_real_subdir(&mission_dir, "control", &paths.control_dir(), create) {
416 Ok(dir) => Ok(Some(dir)),
417 Err(EngineError::Io(error)) if error.kind() == ErrorKind::NotFound && !create => Ok(None),
418 Err(error) => Err(error),
419 }
420}
421
422fn queued_files(dir: &cap_std::fs::Dir) -> Result<Vec<OsString>> {
424 let entries = dir.entries()?;
425 let mut files = Vec::new();
426 for entry in entries {
427 let entry = entry?;
428 let name = entry.file_name();
429 let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
430 if is_file && Path::new(&name).extension().and_then(|e| e.to_str()) == Some("json") {
431 files.push(name);
432 }
433 }
434 files.sort();
435 Ok(files)
436}
437
438fn read_control_file(dir: &cap_std::fs::Dir, name: &OsStr) -> Result<String> {
439 use cap_fs_ext::OpenOptionsFollowExt as _;
440 use cap_primitives::fs::FollowSymlinks;
441 use std::io::Read;
442 let mut options = cap_std::fs::OpenOptions::new();
443 options.read(true).follow(FollowSymlinks::No);
444 let mut file = dir.open_with(name, &options)?.into_std();
445 let mut content = String::new();
446 file.read_to_string(&mut content)?;
447 Ok(content)
448}
449
450fn quarantine(dir: &cap_std::fs::Dir, name: &OsStr, path: &Path, reason: &str) {
453 let bad_name = format!("{}.bad", name.to_string_lossy());
454 tracing::warn!(
455 path = %path.display(),
456 error = %reason,
457 "refused control command, quarantining as .bad"
458 );
459 if let Err(e) = dir.rename(name, dir, &bad_name) {
460 tracing::warn!(path = %path.display(), error = %e, "failed to quarantine control file");
461 }
462}
463
464pub struct ControlWatcher;
468
469impl ControlWatcher {
470 pub async fn wait_for_interrupt(
482 paths: MissionPaths,
483 poll: std::time::Duration,
484 notify: std::sync::Arc<tokio::sync::Notify>,
485 ) {
486 loop {
487 match peek_interrupt(&paths) {
488 Ok(true) => {
489 notify.notify_one();
490 return;
491 }
492 Ok(false) => {}
493 Err(e) => {
494 tracing::warn!(error = %e, "control watcher poll failed, retrying");
495 }
496 }
497 tokio::time::sleep(poll).await;
498 }
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::events::{Event, EventKind};
506 use crate::types::MissionConfig;
507 use tempfile::TempDir;
508
509 fn events_lines(mission_id: &str, completed: bool) -> String {
512 let mut lines = String::new();
513 let created = Event {
514 seq: 1,
515 ts: Utc::now(),
516 mission_id: mission_id.to_string(),
517 kind: EventKind::MissionCreated {
518 goal: "goal".into(),
519 base_branch: "main".into(),
520 mission_branch: format!("kranz/mission-{mission_id}"),
521 config: MissionConfig::default(),
522 },
523 };
524 lines.push_str(&serde_json::to_string(&created).unwrap());
525 lines.push('\n');
526 if completed {
527 let done = Event {
528 seq: 2,
529 ts: Utc::now(),
530 mission_id: mission_id.to_string(),
531 kind: EventKind::MissionCompleted {},
532 };
533 lines.push_str(&serde_json::to_string(&done).unwrap());
534 lines.push('\n');
535 }
536 lines
537 }
538
539 fn seed_mission(repo_root: &Path, mission_id: &str, completed: bool) {
541 let paths = MissionPaths::new(repo_root, mission_id);
542 std::fs::create_dir_all(paths.mission_dir()).unwrap();
543 std::fs::write(paths.events_file(), events_lines(mission_id, completed)).unwrap();
544 }
545
546 fn plant(paths: &MissionPaths, name: &str, body: serde_json::Value) {
551 std::fs::create_dir_all(paths.control_dir()).unwrap();
552 std::fs::write(
553 paths.control_dir().join(name),
554 serde_json::to_string(&body).unwrap(),
555 )
556 .unwrap();
557 }
558
559 fn quarantined(paths: &MissionPaths) -> Vec<String> {
560 std::fs::read_dir(paths.control_dir())
561 .unwrap()
562 .flatten()
563 .map(|e| e.file_name().to_string_lossy().into_owned())
564 .filter(|n| n.ends_with(".bad"))
565 .collect()
566 }
567
568 #[test]
569 fn unsigned_approve_grant_is_quarantined_and_never_drained() {
570 let tmp = TempDir::new().unwrap();
571 let paths = MissionPaths::new(tmp.path(), "m-1");
572 enqueue(&paths, &ControlCommand::Pause).unwrap();
576 plant(
577 &paths,
578 "00000000000000000001-aaaaaaaa.json",
579 serde_json::json!({ "kind": "approve-grant", "command": "cargo publish" }),
580 );
581
582 let drained = drain(&paths).unwrap();
583 assert!(
584 drained
585 .iter()
586 .all(|(_, cmd)| !matches!(cmd, ControlCommand::ApproveGrant { .. })),
587 "an unsigned approve-grant must never reach the engine"
588 );
589 assert_eq!(
590 quarantined(&paths),
591 vec!["00000000000000000001-aaaaaaaa.json.bad".to_string()],
592 );
593 }
594
595 #[test]
596 fn wrong_key_signature_is_quarantined() {
597 let tmp = TempDir::new().unwrap();
598 let paths = MissionPaths::new(tmp.path(), "m-1");
599 enqueue(&paths, &ControlCommand::Pause).unwrap();
600 let cmd = ControlCommand::ApproveGrant {
601 command: "cargo publish".to_string(),
602 };
603 let forged = sign(
604 b"not the repository authority key",
605 &paths.mission_id,
606 "00000000000000000002-bbbbbbbb.json",
607 &cmd,
608 )
609 .unwrap();
610 plant(
611 &paths,
612 "00000000000000000002-bbbbbbbb.json",
613 serde_json::json!({
614 "kind": "approve-grant",
615 "command": "cargo publish",
616 "sig": forged,
617 }),
618 );
619
620 assert!(drain(&paths)
621 .unwrap()
622 .iter()
623 .all(|(_, cmd)| !matches!(cmd, ControlCommand::ApproveGrant { .. })));
624 assert_eq!(
625 quarantined(&paths),
626 vec!["00000000000000000002-bbbbbbbb.json.bad".to_string()],
627 );
628 }
629
630 #[test]
631 fn a_correctly_signed_command_drains() {
632 let tmp = TempDir::new().unwrap();
633 let paths = MissionPaths::new(tmp.path(), "m-1");
634 enqueue(
635 &paths,
636 &ControlCommand::ApproveGrant {
637 command: "cargo publish".to_string(),
638 },
639 )
640 .unwrap();
641
642 let drained = drain(&paths).unwrap();
643 assert_eq!(drained.len(), 1);
644 assert!(
645 matches!(&drained[0].1, ControlCommand::ApproveGrant { command } if command == "cargo publish"),
646 "{:?}",
647 drained[0].1
648 );
649 assert!(quarantined(&paths).is_empty());
650 }
651
652 #[test]
653 fn signed_fractional_config_values_keep_their_bits() {
654 let tmp = TempDir::new().unwrap();
655 let paths = MissionPaths::new(tmp.path(), "m-fractional");
656 let costs = [
657 0.3917785_f64,
658 f64::from_bits(0.3917785_f64.to_bits() + 1),
659 0.095758,
660 ];
661 for cost in costs {
662 enqueue(
663 &paths,
664 &ControlCommand::ConfigChange {
665 patch: serde_json::json!({"worker": {"maxBudgetUsd": cost}}),
666 },
667 )
668 .unwrap();
669 }
670 let drained = drain(&paths).unwrap();
671 assert_eq!(drained.len(), costs.len());
672 for ((_, command), expected) in drained.iter().zip(costs) {
673 let ControlCommand::ConfigChange { patch } = command else {
674 panic!("wrong command")
675 };
676 assert_eq!(
677 patch["worker"]["maxBudgetUsd"].as_f64().unwrap().to_bits(),
678 expected.to_bits()
679 );
680 }
681 assert!(quarantined(&paths).is_empty());
682 }
683
684 #[test]
685 fn a_signature_from_another_mission_does_not_transfer() {
686 let tmp = TempDir::new().unwrap();
690 let source = MissionPaths::new(tmp.path(), "m-a");
691 let target = MissionPaths::new(tmp.path(), "m-b");
692 let cmd = ControlCommand::ApproveGrant {
693 command: "cargo publish".to_string(),
694 };
695 let file = enqueue(&source, &cmd).unwrap();
696 let body = std::fs::read_to_string(&file).unwrap();
697 std::fs::create_dir_all(target.control_dir()).unwrap();
698 let name = "00000000000000000003-cccccccc.json";
699 std::fs::write(target.control_dir().join(name), body).unwrap();
700
701 assert!(drain(&target).unwrap().is_empty());
702 assert_eq!(quarantined(&target), vec![format!("{name}.bad")]);
703 }
704
705 #[test]
706 fn an_unsigned_interrupt_never_aborts_the_run() {
707 let tmp = TempDir::new().unwrap();
708 let paths = MissionPaths::new(tmp.path(), "m-1");
709 enqueue(&paths, &ControlCommand::Pause).unwrap();
710 plant(
711 &paths,
712 "00000000000000000004-dddddddd.json",
713 serde_json::json!({ "kind": "msg", "text": "stop", "interrupt": true }),
714 );
715 assert!(!peek_interrupt(&paths).unwrap());
716 }
717
718 #[test]
719 fn resolve_explicit_active_mission_is_accepted() {
720 let tmp = TempDir::new().unwrap();
721 seed_mission(tmp.path(), "m-a", false);
722 assert_eq!(
723 resolve_active_mission(tmp.path(), Some("m-a")).unwrap(),
724 "m-a"
725 );
726 }
727
728 #[test]
729 fn resolve_unknown_mission_is_an_error() {
730 let tmp = TempDir::new().unwrap();
731 let err = resolve_active_mission(tmp.path(), Some("m-nope"))
732 .unwrap_err()
733 .to_string();
734 assert!(
735 err.contains("m-nope"),
736 "error names the unknown mission: {err}"
737 );
738 }
739
740 #[test]
741 fn resolve_explicit_mission_rejects_path_traversal_before_reading() {
742 let tmp = TempDir::new().unwrap();
743 let repo_root = tmp.path().join("nested").join("repo");
744 std::fs::create_dir_all(repo_root.join(".kranz").join("missions")).unwrap();
745 let outside = tmp.path().join("nested").join("outside-target");
751 std::fs::create_dir_all(&outside).unwrap();
752 std::fs::write(
753 outside.join("events.jsonl"),
754 events_lines("outside-target", false),
755 )
756 .unwrap();
757 let traversal_id = "../../../outside-target";
758 assert!(
761 mission_status(&repo_root, traversal_id)
762 .is_some_and(|status| !crate::mission_catalog::is_terminal_status(status)),
763 "fixture: traversal target must fold as an active mission"
764 );
765
766 for id in [traversal_id, "a/b", r"a\b", "C:escape"] {
767 let error = resolve_active_mission(&repo_root, Some(id))
768 .unwrap_err()
769 .to_string();
770 assert!(error.contains("unknown mission"), "{id}: {error}");
771 }
772 }
773
774 #[test]
775 fn resolve_terminal_mission_is_an_error() {
776 let tmp = TempDir::new().unwrap();
777 seed_mission(tmp.path(), "m-done", true);
778 let err = resolve_active_mission(tmp.path(), Some("m-done"))
779 .unwrap_err()
780 .to_string();
781 assert!(
782 err.contains("active missions"),
783 "honest error, not false success: {err}"
784 );
785 }
786
787 #[test]
788 fn resolve_bare_uses_the_single_active_mission() {
789 let tmp = TempDir::new().unwrap();
790 seed_mission(tmp.path(), "m-only", false);
791 seed_mission(tmp.path(), "m-done", true);
793 assert_eq!(resolve_active_mission(tmp.path(), None).unwrap(), "m-only");
794 }
795
796 #[test]
797 fn resolve_bare_with_no_active_mission_is_an_error() {
798 let tmp = TempDir::new().unwrap();
799 assert!(resolve_active_mission(tmp.path(), None).is_err());
800 }
801
802 #[test]
803 fn resolve_bare_with_several_active_missions_refuses_and_lists_them() {
804 let tmp = TempDir::new().unwrap();
805 seed_mission(tmp.path(), "m-a", false);
806 seed_mission(tmp.path(), "m-b", false);
807 let err = resolve_active_mission(tmp.path(), None)
808 .unwrap_err()
809 .to_string();
810 assert!(err.contains("several active missions"), "{err}");
811 assert!(
812 err.contains("m-a") && err.contains("m-b"),
813 "candidates listed: {err}"
814 );
815 }
816
817 #[cfg(unix)]
821 #[test]
822 fn resolve_explicit_mission_refuses_a_symlinked_mission_dir() {
823 use std::os::unix::fs::symlink;
824 let tmp = TempDir::new().unwrap();
825 let elsewhere = TempDir::new().unwrap();
826 seed_mission(elsewhere.path(), "m-evil", false);
829 let missions = tmp.path().join(".kranz").join("missions");
830 std::fs::create_dir_all(&missions).unwrap();
831 symlink(
832 elsewhere
833 .path()
834 .join(".kranz")
835 .join("missions")
836 .join("m-evil"),
837 missions.join("m-evil"),
838 )
839 .unwrap();
840 let err = resolve_active_mission(tmp.path(), Some("m-evil"))
841 .unwrap_err()
842 .to_string();
843 assert!(err.contains("unknown mission"), "{err}");
844 }
845
846 #[cfg(unix)]
847 #[test]
848 fn enqueue_refuses_a_symlinked_mission_dir_without_touching_the_target() {
849 use std::os::unix::fs::symlink;
850 let tmp = TempDir::new().unwrap();
851 let elsewhere = TempDir::new().unwrap();
852 let missions = tmp.path().join(".kranz").join("missions");
853 std::fs::create_dir_all(&missions).unwrap();
854 symlink(elsewhere.path(), missions.join("m-evil")).unwrap();
855 let paths = MissionPaths::new(tmp.path(), "m-evil");
856 let err = enqueue(&paths, &ControlCommand::Pause).unwrap_err();
857 assert!(err.to_string().contains("refusing"), "{err}");
858 assert!(!elsewhere.path().join("control").exists());
860 }
861
862 #[cfg(unix)]
863 #[test]
864 fn enqueue_refuses_a_symlinked_control_dir_without_touching_the_target() {
865 use std::os::unix::fs::symlink;
866 let tmp = TempDir::new().unwrap();
867 seed_mission(tmp.path(), "m-1", false);
868 let paths = MissionPaths::new(tmp.path(), "m-1");
869 let elsewhere = TempDir::new().unwrap();
870 symlink(elsewhere.path(), paths.control_dir()).unwrap();
871 let err = enqueue(&paths, &ControlCommand::Pause).unwrap_err();
872 assert!(err.to_string().contains("refusing"), "{err}");
873 assert!(std::fs::read_dir(elsewhere.path())
875 .unwrap()
876 .next()
877 .is_none());
878 }
879
880 #[test]
881 fn rapid_back_to_back_enqueues_drain_in_issue_order() {
882 let tmp = TempDir::new().unwrap();
888 let paths = MissionPaths::new(tmp.path(), "m-1");
889 for cmd in [
890 ControlCommand::Pause,
891 ControlCommand::Resume,
892 ControlCommand::Pause,
893 ControlCommand::Resume,
894 ControlCommand::Pause,
895 ] {
896 enqueue(&paths, &cmd).unwrap();
897 }
898 let order: Vec<bool> = drain(&paths)
899 .unwrap()
900 .iter()
901 .map(|(_, cmd)| matches!(cmd, ControlCommand::Pause))
902 .collect();
903 assert_eq!(
904 order,
905 vec![true, false, true, false, true],
906 "rapid enqueues must drain in issue order, never random-suffix order"
907 );
908 }
909}
910
911#[cfg(test)]
912mod replay_tests {
913 use super::*;
914 use tempfile::TempDir;
915
916 #[test]
919 fn an_acknowledged_control_file_cannot_be_replayed() {
920 let tmp = TempDir::new().unwrap();
921 let paths = MissionPaths::new(tmp.path(), "m-1");
922 let cmd = ControlCommand::ApproveGrant {
923 command: "cargo publish".to_string(),
924 };
925 let path = enqueue(&paths, &cmd).unwrap();
926 let captured = std::fs::read(&path).unwrap();
927
928 let drained = drain(&paths).unwrap();
929 assert_eq!(drained.len(), 1);
930 acknowledge(&paths, &path).unwrap();
931
932 std::fs::write(&path, &captured).unwrap();
933 let again = drain(&paths).unwrap();
934 assert!(
935 again.is_empty(),
936 "a replayed control file drained: {again:?}"
937 );
938 let bad = std::fs::read_dir(paths.control_dir())
939 .unwrap()
940 .flatten()
941 .filter(|e| e.file_name().to_string_lossy().ends_with(".bad"))
942 .count();
943 assert_eq!(bad, 1, "the replay must be quarantined");
944
945 let fresh = enqueue(&paths, &ControlCommand::Pause).unwrap();
948 assert!(
949 fresh.file_name().unwrap().to_string_lossy()
950 > path.file_name().unwrap().to_string_lossy()
951 );
952 assert_eq!(drain(&paths).unwrap().len(), 1);
953 }
954
955 #[test]
958 fn a_signed_body_moved_to_a_new_name_does_not_verify() {
959 let tmp = TempDir::new().unwrap();
960 let paths = MissionPaths::new(tmp.path(), "m-1");
961 let path = enqueue(&paths, &ControlCommand::Pause).unwrap();
962 let captured = std::fs::read(&path).unwrap();
963 std::fs::remove_file(&path).unwrap();
964 std::fs::write(
965 paths
966 .control_dir()
967 .join("99999999999999999999-ffffffff.json"),
968 &captured,
969 )
970 .unwrap();
971 assert!(drain(&paths).unwrap().is_empty());
972 }
973}