1use std::sync::Arc;
25
26#[cfg(target_os = "linux")]
27use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
28
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31use sha2::{Digest, Sha256};
32use tokio::io::{AsyncRead, AsyncWrite};
33use tokio::net::UnixStream;
34use uuid::Uuid;
35
36use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RequestEnvelope};
37
38fn credential_keys_for(action_name: &str) -> &'static [&'static str] {
57 match action_name {
58 "ProAttach" => &["token"],
59 _ => &[],
60 }
61}
62
63fn redact_params(action_name: &str, params: &Value) -> Value {
66 let keys = credential_keys_for(action_name);
67 if keys.is_empty() {
68 return params.clone();
69 }
70 if let Value::Object(map) = params {
71 let mut out = serde_json::Map::with_capacity(map.len());
72 for (k, v) in map {
73 if keys.iter().any(|ck| *ck == k) {
74 out.insert(k.clone(), Value::String("<REDACTED>".to_string()));
75 } else {
76 out.insert(k.clone(), v.clone());
77 }
78 }
79 Value::Object(out)
80 } else {
81 params.clone()
82 }
83}
84
85fn credential_argv_position(action_name: &str) -> Option<usize> {
96 match action_name {
97 "ProAttach" => Some(usize::MAX),
99 _ => None,
100 }
101}
102
103fn redact_argv(action_name: &str, params: &Value, args: &[String]) -> Vec<String> {
118 let keys = credential_keys_for(action_name);
119 if keys.is_empty() {
120 return args.to_vec();
121 }
122 let secrets: Vec<&str> = keys
123 .iter()
124 .filter_map(|k| params.get(k).and_then(|v| v.as_str()))
125 .collect();
126 if secrets.is_empty() {
127 return args.to_vec();
128 }
129
130 let mut out: Vec<String> = args.to_vec();
131
132 if let Some(pos) = credential_argv_position(action_name) {
134 let idx = if pos == usize::MAX {
135 out.len().saturating_sub(1)
137 } else {
138 pos
139 };
140 if idx < out.len() {
141 out[idx] = "<REDACTED>".to_string();
142 }
143 return out;
147 }
148
149 out.iter_mut().for_each(|a| {
151 if secrets.iter().any(|s| s == &a.as_str()) {
152 *a = "<REDACTED>".to_string();
153 }
154 });
155
156 out
157}
158
159use crate::{
160 auth::highest_role_from_groups,
161 executor::{build_action_spec, rollback_spec_for, ActionExecutor},
162 preview::preview_action,
163 state::DaemonState,
164 state_collector::{collect_state, CollectedState, CommandRunner},
165 transactions::NewTransaction,
166 transport::framing::{FramedStream, FramingError},
167};
168
169const VSOCK_AUTH_FRAME_TIMEOUT_SECS: u64 = 10;
180
181const DEFAULT_HISTORY_LIMIT: u32 = 20;
188
189const TRANSACTION_ID_DISPLAY_PREFIX_LEN: usize = 8;
195
196#[derive(Debug, Deserialize)]
201#[serde(transparent)]
202struct TransactionId(String);
203
204impl TransactionId {
205 fn as_str(&self) -> &str {
206 &self.0
207 }
208}
209
210#[derive(Debug, Deserialize)]
211#[serde(transparent)]
212struct ApprovalReceipt(String);
213
214impl ApprovalReceipt {
215 fn as_str(&self) -> &str {
216 &self.0
217 }
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(tag = "type", rename_all = "snake_case")]
222enum DaemonRequest {
223 QueryState {
224 request_id: String,
225 },
226 Preview {
227 request_id: String,
228 action_name: String,
229 params: Value,
230 },
231 Approve {
232 request_id: String,
233 transaction_id: TransactionId,
234 },
235 ApprovalDetails {
236 request_id: String,
237 transaction_id: TransactionId,
238 },
239 Execute {
240 request_id: String,
241 transaction_id: TransactionId,
242 action_name: String,
243 params: Value,
244 approval_receipt: ApprovalReceipt,
245 },
246 Cancel {
250 request_id: String,
251 transaction_id: TransactionId,
252 },
253 QueryAction {
254 request_id: String,
255 action_name: String,
256 params: Value,
257 },
258 QueryHistory {
263 request_id: String,
264 limit: Option<u32>,
265 status_filter: Option<String>,
266 action_filter: Option<String>,
267 since_hours: Option<u32>,
268 },
269 Describe {
270 request_id: String,
271 action_name: String,
272 params: Value,
273 },
274}
275
276#[derive(Debug, Serialize)]
281#[serde(tag = "type", rename_all = "snake_case")]
282enum DaemonResponse {
283 StateResponse {
284 request_id: String,
285 state: CollectedState,
286 },
287 PreviewResponse {
288 request_id: String,
289 preview: PreviewEnvelope,
290 transaction_id: String,
291 },
292 ApprovalResponse {
293 request_id: String,
294 transaction_id: String,
295 approval_receipt: String,
296 },
297 ApprovalDetailsResponse {
298 request_id: String,
299 transaction_id: String,
300 action_name: String,
301 preview: PreviewEnvelope,
302 },
303 JobStarted {
304 request_id: String,
305 job_id: String,
306 transaction_id: String,
307 },
308 JobProgress {
309 job_id: String,
310 line: String,
311 },
312 JobCompleted {
313 job_id: String,
314 result: JobResult,
315 },
316 QueryActionResponse {
317 request_id: String,
318 action_name: String,
319 output: String,
320 },
321 HistoryResponse {
322 request_id: String,
323 entries: Vec<crate::transactions::JobHistoryEntry>,
324 },
325 CancelResponse {
326 request_id: String,
327 transaction_id: String,
328 },
329 DescribeResponse {
330 request_id: String,
331 command: String,
332 risk_level: String,
333 reboot_required: bool,
334 },
335 ErrorResponse {
336 request_id: String,
337 category: String,
338 message: String,
339 },
340 ConflictResponse {
354 request_id: String,
355 message: String,
356 retry_after_seconds: Option<u32>,
359 },
360}
361
362#[derive(Debug, Serialize)]
368struct JobResult {
369 status: String,
372 summary: String,
374 warnings: Vec<String>,
376 job_id: String,
378 needs_reboot: bool,
380 rollback_ref: Option<String>,
383 transaction_id: String,
386}
387
388#[cfg(target_os = "linux")]
397const SO_PEERPIDFD: libc::c_int = 77;
398
399#[cfg(target_os = "linux")]
407fn peer_pidfd(stream: &UnixStream) -> Option<OwnedFd> {
408 let mut fd: libc::c_int = -1;
409 let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
410 let rc = unsafe {
413 libc::getsockopt(
414 stream.as_raw_fd(),
415 libc::SOL_SOCKET,
416 SO_PEERPIDFD,
417 (&mut fd as *mut libc::c_int).cast::<libc::c_void>(),
418 &mut len,
419 )
420 };
421 if rc != 0 || fd < 0 {
422 return None;
423 }
424 Some(unsafe { OwnedFd::from_raw_fd(fd) })
427}
428
429#[cfg(target_os = "linux")]
435fn pidfd_peer_still_live(pidfd: &OwnedFd) -> bool {
436 let rc = unsafe {
439 libc::syscall(
440 libc::SYS_pidfd_send_signal,
441 pidfd.as_raw_fd(),
442 0,
443 std::ptr::null::<libc::c_void>(),
444 0,
445 )
446 };
447 if rc == 0 {
448 return true;
449 }
450 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
453}
454
455pub fn resolve_caller_role(stream: &UnixStream) -> CallerRole {
478 let (pid, primary_gid) = match stream.peer_cred() {
479 Ok(cred) => {
480 let pid = match cred.pid() {
481 Some(p) if p >= 0 => p as u32,
482 _ => return CallerRole::Observer,
483 };
484 (pid, cred.gid())
485 }
486 Err(e) => {
487 eprintln!("[sysknife-daemon] WARNING: peer_cred() failed: {e}; defaulting to Observer");
488 return CallerRole::Observer;
489 }
490 };
491
492 #[cfg(target_os = "linux")]
495 let peer_fd = peer_pidfd(stream);
496
497 let gid_map = read_gid_map();
500 let mut groups = groups_for_pid(pid, &gid_map);
501
502 #[cfg(target_os = "linux")]
506 if let Some(ref fd) = peer_fd {
507 if !pidfd_peer_still_live(fd) {
508 eprintln!(
509 "[sysknife-daemon] WARNING: peer PID {pid} was reaped while resolving its \
510 groups (possible PID reuse); ignoring supplementary groups and using the \
511 SO_PEERCRED primary GID only"
512 );
513 groups.clear();
514 }
515 }
516
517 if let Some(name) = gid_map.get(&primary_gid) {
520 if !groups.contains(name) {
521 groups.push(name.clone());
522 }
523 }
524 if groups.is_empty() {
525 eprintln!(
526 "[sysknife-daemon] WARNING: could not resolve groups for PID {pid}; defaulting to Observer"
527 );
528 }
529 highest_role_from_groups(groups)
530}
531
532fn read_gid_map() -> std::collections::HashMap<u32, String> {
535 let content = match std::fs::read_to_string("/etc/group") {
536 Ok(c) => c,
537 Err(e) => {
538 eprintln!("[sysknife-daemon] WARNING: cannot read /etc/group: {e}; all callers will be demoted to Observer");
541 return std::collections::HashMap::new();
542 }
543 };
544 let mut map = std::collections::HashMap::new();
545 for (line_no, line) in content.lines().enumerate() {
546 let mut parts = line.splitn(4, ':');
547 let name = match parts.next() {
548 Some(n) => n,
549 None => {
550 eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no}: missing name field");
551 continue;
552 }
553 };
554 let _ = parts.next(); let gid_str = match parts.next() {
556 Some(g) => g,
557 None => {
558 eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): missing GID field");
559 continue;
560 }
561 };
562 match gid_str.parse::<u32>() {
563 Ok(gid) => {
564 map.insert(gid, name.to_string());
565 }
566 Err(_) => {
567 eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): GID {gid_str:?} is not a number");
568 }
569 }
570 }
571 map
572}
573
574fn groups_for_pid(pid: u32, gid_map: &std::collections::HashMap<u32, String>) -> Vec<String> {
575 let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
576 Ok(s) => s,
577 Err(e) => {
578 eprintln!(
579 "[sysknife-daemon] could not read /proc/{pid}/status: {e}; treating as no groups"
580 );
581 return vec![];
582 }
583 };
584 for line in status.lines() {
585 if line.starts_with("Groups:") {
586 return line
587 .trim_start_matches("Groups:")
588 .split_whitespace()
589 .filter_map(|s| s.parse::<u32>().ok())
590 .filter_map(|gid| gid_map.get(&gid).cloned())
591 .collect();
592 }
593 }
594 eprintln!(
598 "[sysknife-daemon] WARNING: no Groups: line in /proc/{pid}/status; \
599 supplementary groups unavailable — caller may be demoted to Observer"
600 );
601 vec![]
602}
603
604fn authorize_action(
609 policy: &crate::policy::PolicyTable,
610 caller: &CallerRole,
611 action_name: &str,
612) -> bool {
613 policy.action_allowed(caller, action_name)
614}
615
616async fn authorize_for_transaction(
627 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
628 state: &DaemonState,
629 caller_role: &CallerRole,
630 request_id: &str,
631 transaction_id: &str,
632 missing_category: &str,
633 missing_message: &str,
634) -> Result<bool, HandlerError> {
635 match state.audit.get(transaction_id).await {
636 Ok(Some(transaction)) => {
637 if authorize_action(&state.policy, caller_role, &transaction.action_name) {
638 Ok(true)
639 } else {
640 send_error(
641 framed,
642 request_id,
643 "authorization_failure",
644 format!(
645 "action '{}' is not allowed for {caller_role:?} role",
646 transaction.action_name
647 ),
648 )
649 .await?;
650 Ok(false)
651 }
652 }
653 Ok(None) => {
654 send_error(framed, request_id, missing_category, missing_message).await?;
655 Ok(false)
656 }
657 Err(e) => {
658 send_error(
659 framed,
660 request_id,
661 "transient_infrastructure_failure",
662 format!("failed to load transaction: {e}"),
663 )
664 .await?;
665 Ok(false)
666 }
667 }
668}
669
670use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS};
674
675fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<(), String> {
676 use sysknife_core::distro::DistroFamily;
677
678 let required_family = if DEBIAN_ONLY_ACTIONS.contains(&action_name) {
679 Some(DistroFamily::Debian)
680 } else if FEDORA_ONLY_ACTIONS.contains(&action_name) {
681 Some(DistroFamily::Fedora)
682 } else {
683 None
684 };
685 let is_mutating = crate::policy::min_role_for_action(action_name)
693 .is_some_and(|role| role > CallerRole::Observer);
694 if required_family.is_none() && !is_mutating {
695 return Ok(());
696 }
697
698 let distro = state.host_distro.as_ref().ok_or_else(|| {
699 format!("cannot safely run {action_name}: host distribution could not be detected")
700 })?;
701 if !distro.is_supported() {
702 return Err(format!(
703 "cannot run {action_name} on unsupported host {distro}; see docs/distro-support.md"
704 ));
705 }
706 if required_family.is_some_and(|family| distro.family() != family) {
707 return Err(format!(
708 "action {action_name} is incompatible with supported host {distro}"
709 ));
710 }
711 Ok(())
712}
713
714pub fn compute_request_hash(action_name: &str, params: &Value) -> String {
725 let canonical = canonical_json(params);
726 let mut hasher = Sha256::new();
727 hasher.update(action_name.as_bytes());
728 hasher.update(b"\x00");
729 hasher.update(canonical.as_bytes());
730 let bytes = hasher.finalize();
731 bytes.iter().fold(String::with_capacity(64), |mut s, b| {
732 use std::fmt::Write;
733 let _ = write!(s, "{b:02x}");
735 s
736 })
737}
738
739fn canonical_json(v: &Value) -> String {
740 match v {
741 Value::Object(map) => {
742 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
743 keys.sort_unstable();
744 let pairs = keys
745 .iter()
746 .map(|k| {
747 format!(
750 "{}:{}",
751 Value::String((*k).to_string()),
752 canonical_json(&map[*k])
753 )
754 })
755 .collect::<Vec<_>>()
756 .join(",");
757 format!("{{{pairs}}}")
758 }
759 Value::Array(arr) => {
762 let items = arr.iter().map(canonical_json).collect::<Vec<_>>().join(",");
763 format!("[{items}]")
764 }
765 _ => format!("{v}"),
768 }
769}
770
771pub async fn unix_connection_handler<S>(
799 stream: S,
800 state: DaemonState,
801 runner: Arc<dyn CommandRunner + Send + Sync>,
802 caller_role: CallerRole,
803) where
804 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
805{
806 let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
807 connection_handler_with_executor(stream, state, runner, executor, caller_role).await;
808}
809
810#[cfg(target_os = "linux")]
815pub async fn vsock_connection_handler<S>(
816 stream: S,
817 state: DaemonState,
818 runner: Arc<dyn CommandRunner + Send + Sync>,
819) where
820 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
821{
822 let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
823 vsock_connection_handler_with_executor(stream, state, runner, executor).await;
824}
825
826#[cfg(target_os = "linux")]
828pub async fn vsock_connection_handler_with_executor<S>(
829 stream: S,
830 state: DaemonState,
831 runner: Arc<dyn CommandRunner + Send + Sync>,
832 executor: Arc<dyn ActionExecutor>,
833) where
834 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
835{
836 let mut framed = FramedStream::new(stream);
837
838 let token_path = crate::auth::default_token_path();
839 let caller_role = match authenticate_vsock_token(&mut framed, &token_path).await {
840 Some(role) => role,
841 None => {
842 eprintln!("[sysknife-daemon] vsock auth failed; closing connection");
843 return;
844 }
845 };
846
847 dispatch_loop(&mut framed, state, runner, executor, caller_role).await;
848}
849
850pub async fn connection_handler_with_executor<S>(
855 stream: S,
856 state: DaemonState,
857 runner: Arc<dyn CommandRunner + Send + Sync>,
858 executor: Arc<dyn ActionExecutor>,
859 caller_role: CallerRole,
860) where
861 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
862{
863 let mut framed = FramedStream::new(stream);
864 dispatch_loop(&mut framed, state, runner, executor, caller_role).await;
865}
866
867async fn authenticate_vsock_token(
882 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
883 token_path: &std::path::Path,
884) -> Option<CallerRole> {
885 #[derive(serde::Deserialize)]
886 struct AuthFrame {
887 #[serde(rename = "type")]
888 msg_type: String,
889 token: String,
890 }
891
892 let recv_result = tokio::time::timeout(
893 std::time::Duration::from_secs(VSOCK_AUTH_FRAME_TIMEOUT_SECS),
894 framed.recv(),
895 )
896 .await;
897 let Ok(frame_result) = recv_result else {
898 eprintln!(
899 "[sysknife-daemon] vsock auth: timed out waiting for auth frame after {VSOCK_AUTH_FRAME_TIMEOUT_SECS}s"
900 );
901 return None;
902 };
903 let raw = match frame_result {
904 Ok(raw) => raw,
905 Err(e) => {
906 eprintln!("[sysknife-daemon] vsock auth: framing error reading auth frame: {e}");
907 return None;
908 }
909 };
910 let auth: AuthFrame = match serde_json::from_slice(&raw) {
911 Ok(auth) => auth,
912 Err(e) => {
913 eprintln!("[sysknife-daemon] vsock auth: malformed auth frame JSON: {e}");
914 return None;
915 }
916 };
917 if auth.msg_type != "auth" {
918 eprintln!(
919 "[sysknife-daemon] vsock auth: unexpected frame type {:?} (expected \"auth\")",
920 auth.msg_type
921 );
922 return None;
923 }
924 let role = crate::auth::validate_token_against_file(&auth.token, token_path);
925 if role.is_none() {
926 eprintln!("[sysknife-daemon] vsock auth: token did not validate against token file");
927 }
928 role
929}
930
931const IDLE_CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
941
942async fn dispatch_loop<S>(
943 framed: &mut FramedStream<S>,
944 state: DaemonState,
945 runner: Arc<dyn CommandRunner + Send + Sync>,
946 executor: Arc<dyn ActionExecutor>,
947 caller_role: CallerRole,
948) where
949 S: AsyncRead + AsyncWrite + Unpin,
950{
951 loop {
952 let raw = match tokio::time::timeout(IDLE_CONNECTION_TIMEOUT, framed.recv()).await {
965 Ok(Ok(bytes)) => bytes,
966 Ok(Err(FramingError::Io(_))) => break, Ok(Err(FramingError::MessageTooLarge(_))) => break, Err(_) => {
969 eprintln!(
970 "[sysknife-daemon] closing connection idle for more than {}s",
971 IDLE_CONNECTION_TIMEOUT.as_secs()
972 );
973 break;
974 }
975 };
976
977 let msg: Value = match serde_json::from_slice(&raw) {
978 Ok(v) => v,
979 Err(e) => {
980 eprintln!("[sysknife-daemon] malformed JSON from client, closing connection: {e}");
981 break;
982 }
983 };
984
985 let request: DaemonRequest = match serde_json::from_value(msg) {
986 Ok(r) => r,
987 Err(e) => {
988 if let Err(send_err) = send_error(
989 framed,
990 "",
991 "validation_failure",
992 format!("unknown message type: {e}"),
993 )
994 .await
995 {
996 eprintln!(
997 "[sysknife-daemon] failed to send validation error response: {send_err}"
998 );
999 }
1000 continue;
1001 }
1002 };
1003
1004 let result = match &request {
1005 DaemonRequest::QueryState { request_id } => {
1006 handle_query_state(framed, Arc::clone(&runner), request_id).await
1007 }
1008 DaemonRequest::Preview {
1009 request_id,
1010 action_name,
1011 params,
1012 } => {
1013 handle_preview(
1014 framed,
1015 &state,
1016 Arc::clone(&runner),
1017 &caller_role,
1018 request_id,
1019 action_name,
1020 params,
1021 )
1022 .await
1023 }
1024 DaemonRequest::Execute {
1025 request_id,
1026 transaction_id,
1027 action_name,
1028 params,
1029 approval_receipt,
1030 } => {
1031 handle_execute(
1032 framed,
1033 &state,
1034 Arc::clone(&executor),
1035 &caller_role,
1036 request_id,
1037 transaction_id,
1038 action_name,
1039 params,
1040 approval_receipt,
1041 )
1042 .await
1043 }
1044 DaemonRequest::Approve {
1045 request_id,
1046 transaction_id,
1047 } => {
1048 handle_approve(
1049 framed,
1050 &state,
1051 &caller_role,
1052 request_id,
1053 transaction_id.as_str(),
1054 )
1055 .await
1056 }
1057 DaemonRequest::ApprovalDetails {
1058 request_id,
1059 transaction_id,
1060 } => {
1061 handle_approval_details(
1062 framed,
1063 &state,
1064 &caller_role,
1065 request_id,
1066 transaction_id.as_str(),
1067 )
1068 .await
1069 }
1070 DaemonRequest::QueryAction {
1071 request_id,
1072 action_name,
1073 params,
1074 } => {
1075 handle_query_action(
1076 framed,
1077 Arc::clone(&executor),
1078 &state,
1079 action_name,
1080 params,
1081 request_id,
1082 )
1083 .await
1084 }
1085 DaemonRequest::QueryHistory {
1086 request_id,
1087 limit,
1088 status_filter,
1089 action_filter,
1090 since_hours,
1091 } => {
1092 handle_query_history(
1093 framed,
1094 &state,
1095 request_id,
1096 *limit,
1097 status_filter.clone(),
1098 action_filter.clone(),
1099 *since_hours,
1100 )
1101 .await
1102 }
1103 DaemonRequest::Describe {
1104 request_id,
1105 action_name,
1106 params,
1107 } => {
1108 handle_describe(
1109 framed,
1110 &state,
1111 &caller_role,
1112 action_name,
1113 params,
1114 request_id,
1115 )
1116 .await
1117 }
1118 DaemonRequest::Cancel {
1119 request_id,
1120 transaction_id,
1121 } => {
1122 handle_cancel(
1123 framed,
1124 &state,
1125 &caller_role,
1126 request_id,
1127 transaction_id.as_str(),
1128 )
1129 .await
1130 }
1131 };
1132
1133 if let Err(e) = result {
1134 eprintln!("[sysknife-daemon] connection handler send error: {e}");
1137 }
1138 }
1139}
1140
1141fn receipt_digest(receipt: &str) -> String {
1142 hex::encode(Sha256::digest(receipt.as_bytes()))
1143}
1144
1145async fn handle_approve(
1146 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1147 state: &DaemonState,
1148 caller_role: &CallerRole,
1149 request_id: &str,
1150 transaction_id: &str,
1151) -> Result<(), HandlerError> {
1152 if !authorize_for_transaction(
1153 framed,
1154 state,
1155 caller_role,
1156 request_id,
1157 transaction_id,
1158 "stale_approval",
1159 "transaction is missing, expired, already approved, or no longer queued",
1160 )
1161 .await?
1162 {
1163 return Ok(());
1164 }
1165 let receipt = match state.audit.approve_transaction(transaction_id).await {
1166 Ok(receipt) => receipt,
1167 Err(e @ crate::transactions::TransactionStoreError::DatabaseInvariant(_)) => {
1173 return send_error(
1174 framed,
1175 request_id,
1176 "integrity_failure",
1177 format!("approval rejected by an integrity check: {e}"),
1178 )
1179 .await;
1180 }
1181 Err(e) => {
1182 return send_error(
1183 framed,
1184 request_id,
1185 "transient_infrastructure_failure",
1186 format!("failed to persist approval: {e}"),
1187 )
1188 .await;
1189 }
1190 };
1191 let Some(receipt) = receipt else {
1192 return send_error(
1193 framed,
1194 request_id,
1195 "stale_approval",
1196 "transaction is missing, expired, already approved, or no longer queued",
1197 )
1198 .await;
1199 };
1200 let response = send_response(
1201 framed,
1202 &DaemonResponse::ApprovalResponse {
1203 request_id: request_id.to_string(),
1204 transaction_id: transaction_id.to_string(),
1205 approval_receipt: receipt,
1206 },
1207 )
1208 .await;
1209 if response.is_err() {
1210 if let Err(e) = state.audit.revoke_unconsumed_approval(transaction_id).await {
1211 eprintln!(
1212 "[sysknife-daemon] failed to revoke undelivered approval for \
1213 {transaction_id}: {e}"
1214 );
1215 }
1216 }
1217 response
1218}
1219
1220async fn handle_approval_details(
1221 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1222 state: &DaemonState,
1223 caller_role: &CallerRole,
1224 request_id: &str,
1225 transaction_id: &str,
1226) -> Result<(), HandlerError> {
1227 let transaction = match state.audit.get(transaction_id).await {
1228 Ok(Some(transaction)) if transaction.status == JobState::Queued => transaction,
1229 Ok(_) => {
1230 return send_error(
1231 framed,
1232 request_id,
1233 "stale_approval",
1234 "transaction is missing or no longer queued",
1235 )
1236 .await;
1237 }
1238 Err(e) => {
1239 return send_error(
1240 framed,
1241 request_id,
1242 "transient_infrastructure_failure",
1243 format!("failed to load transaction: {e}"),
1244 )
1245 .await;
1246 }
1247 };
1248 if !authorize_action(&state.policy, caller_role, &transaction.action_name) {
1249 return send_error(
1250 framed,
1251 request_id,
1252 "authorization_failure",
1253 format!(
1254 "action '{}' is not allowed for {caller_role:?} role",
1255 transaction.action_name
1256 ),
1257 )
1258 .await;
1259 }
1260 let preview = match state.audit.get_preview(transaction_id).await {
1261 Ok(Some(preview)) => preview,
1262 Ok(None) => {
1263 return send_error(
1264 framed,
1265 request_id,
1266 "stale_approval",
1267 "transaction has no persisted preview",
1268 )
1269 .await;
1270 }
1271 Err(e) => {
1272 return send_error(
1273 framed,
1274 request_id,
1275 "transient_infrastructure_failure",
1276 format!("failed to load preview: {e}"),
1277 )
1278 .await;
1279 }
1280 };
1281 send_response(
1282 framed,
1283 &DaemonResponse::ApprovalDetailsResponse {
1284 request_id: request_id.to_string(),
1285 transaction_id: transaction_id.to_string(),
1286 action_name: transaction.action_name,
1287 preview,
1288 },
1289 )
1290 .await
1291}
1292
1293async fn handle_query_state(
1298 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1299 runner: Arc<dyn CommandRunner + Send + Sync>,
1300 request_id: &str,
1301) -> Result<(), HandlerError> {
1302 let join_result = tokio::task::spawn_blocking(move || collect_state(&*runner))
1305 .await
1306 .map_err(|e| HandlerError::Internal(format!("collect_state task failed: {e}")))?;
1307 let collected = match join_result {
1308 Ok(s) => s,
1309 Err(e) => {
1310 return send_error(framed, request_id, "state_collection_failed", e.to_string()).await;
1311 }
1312 };
1313 send_response(
1314 framed,
1315 &DaemonResponse::StateResponse {
1316 request_id: request_id.to_string(),
1317 state: collected,
1318 },
1319 )
1320 .await
1321}
1322
1323async fn handle_cancel(
1324 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1325 state: &DaemonState,
1326 caller_role: &CallerRole,
1327 request_id: &str,
1328 transaction_id: &str,
1329) -> Result<(), HandlerError> {
1330 if !authorize_for_transaction(
1331 framed,
1332 state,
1333 caller_role,
1334 request_id,
1335 transaction_id,
1336 "not_cancelable",
1337 "transaction cannot be canceled: it is missing, already executing, or finished",
1338 )
1339 .await?
1340 {
1341 return Ok(());
1342 }
1343 match state.audit.cancel_queued(transaction_id).await {
1344 Ok(true) => {
1345 send_response(
1346 framed,
1347 &DaemonResponse::CancelResponse {
1348 request_id: request_id.to_string(),
1349 transaction_id: transaction_id.to_string(),
1350 },
1351 )
1352 .await
1353 }
1354 Ok(false) => {
1357 send_error(
1358 framed,
1359 request_id,
1360 "not_cancelable",
1361 "transaction cannot be canceled: it is missing, already executing, or finished",
1362 )
1363 .await
1364 }
1365 Err(e) => {
1366 send_error(
1367 framed,
1368 request_id,
1369 "transient_infrastructure_failure",
1370 format!("failed to cancel transaction: {e}"),
1371 )
1372 .await
1373 }
1374 }
1375}
1376
1377async fn handle_query_history(
1378 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1379 state: &DaemonState,
1380 request_id: &str,
1381 limit: Option<u32>,
1382 status_filter: Option<String>,
1383 action_filter: Option<String>,
1384 since_hours: Option<u32>,
1385) -> Result<(), HandlerError> {
1386 match state.policy.min_role_for_action("ListJobHistory") {
1392 Some(CallerRole::Observer) => {}
1393 Some(_) => {
1394 return send_error(
1395 framed,
1396 request_id,
1397 "authorization_failure",
1398 "history has been restricted above read-only by policy; \
1399 it is not available over the structured query path",
1400 )
1401 .await;
1402 }
1403 None => {
1404 return send_error(
1405 framed,
1406 request_id,
1407 "validation_failure",
1408 "ListJobHistory action is not registered",
1409 )
1410 .await;
1411 }
1412 }
1413
1414 let entries = match state
1415 .audit
1416 .list_history(
1417 limit.unwrap_or(DEFAULT_HISTORY_LIMIT),
1418 status_filter.as_deref(),
1419 action_filter.as_deref(),
1420 since_hours,
1421 )
1422 .await
1423 {
1424 Ok(entries) => entries,
1425 Err(e) => {
1426 return send_error(
1427 framed,
1428 request_id,
1429 "execution_failure",
1430 format!("failed to query transaction history: {e}"),
1431 )
1432 .await;
1433 }
1434 };
1435 send_response(
1436 framed,
1437 &DaemonResponse::HistoryResponse {
1438 request_id: request_id.to_string(),
1439 entries,
1440 },
1441 )
1442 .await
1443}
1444
1445async fn handle_query_action(
1446 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1447 executor: Arc<dyn ActionExecutor>,
1448 state: &DaemonState,
1449 action_name: &str,
1450 params: &Value,
1451 request_id: &str,
1452) -> Result<(), HandlerError> {
1453 let audit = &state.audit;
1454
1455 let min_role = match state.policy.min_role_for_action(action_name) {
1459 Some(role) => role,
1460 None => {
1461 return send_error(
1462 framed,
1463 request_id,
1464 "validation_failure",
1465 format!("unknown action: {action_name}"),
1466 )
1467 .await;
1468 }
1469 };
1470
1471 if min_role != CallerRole::Observer {
1472 return send_error(
1473 framed,
1474 request_id,
1475 "authorization_failure",
1476 format!("{action_name} is not a read-only action; use preview+execute instead"),
1477 )
1478 .await;
1479 }
1480
1481 if let Err(message) = validate_action_platform(state, action_name) {
1487 return send_error(framed, request_id, "unsupported_platform", message).await;
1488 }
1489
1490 if action_name == "ListJobHistory" {
1494 let limit = match params.get("limit") {
1495 Some(v) => match v.as_u64() {
1496 Some(n) => n as u32,
1497 None => {
1498 return send_error(
1499 framed,
1500 request_id,
1501 "validation_failure",
1502 format!("'limit' must be an integer, got: {v}"),
1503 )
1504 .await;
1505 }
1506 },
1507 None => DEFAULT_HISTORY_LIMIT,
1508 };
1509 let status_filter = params
1510 .get("status_filter")
1511 .and_then(|v| v.as_str())
1512 .map(|s| s.to_string());
1513 let action_filter = params
1514 .get("action_filter")
1515 .and_then(|v| v.as_str())
1516 .map(|s| s.to_string());
1517 let since_hours = match params.get("since_hours") {
1518 Some(v) => match v.as_u64() {
1519 Some(n) => Some(n as u32),
1520 None => {
1521 return send_error(
1522 framed,
1523 request_id,
1524 "validation_failure",
1525 format!("'since_hours' must be an integer, got: {v}"),
1526 )
1527 .await;
1528 }
1529 },
1530 None => None,
1531 };
1532
1533 let records = match audit
1534 .list_transactions(
1535 limit,
1536 status_filter.as_deref(),
1537 action_filter.as_deref(),
1538 since_hours,
1539 )
1540 .await
1541 {
1542 Ok(r) => r,
1543 Err(e) => {
1544 return send_error(
1545 framed,
1546 request_id,
1547 "execution_failure",
1548 format!("failed to query transaction log: {e}"),
1549 )
1550 .await;
1551 }
1552 };
1553
1554 let output = if records.is_empty() {
1555 let mut msg = "No transactions found".to_string();
1556 let mut filters = Vec::new();
1557 if let Some(s) = &status_filter {
1558 filters.push(format!("status={s}"));
1559 }
1560 if let Some(a) = &action_filter {
1561 filters.push(format!("action={a}"));
1562 }
1563 if let Some(h) = since_hours {
1564 filters.push(format!("since_hours={h}"));
1565 }
1566 if !filters.is_empty() {
1567 msg.push_str(&format!(" (filters: {})", filters.join(", ")));
1568 }
1569 msg.push('.');
1570 msg
1571 } else {
1572 format_job_history(&records)
1573 };
1574 return send_response(
1575 framed,
1576 &DaemonResponse::QueryActionResponse {
1577 request_id: request_id.to_string(),
1578 action_name: action_name.to_string(),
1579 output,
1580 },
1581 )
1582 .await;
1583 }
1584
1585 let spec = match build_action_spec(action_name, params) {
1586 Ok(s) => s,
1587 Err(e) => {
1588 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
1589 }
1590 };
1591
1592 let output = match executor.execute(&spec).await {
1593 Ok(out) => out,
1594 Err(e) => {
1595 return send_error(framed, request_id, "execution_failure", e.to_string()).await;
1596 }
1597 };
1598
1599 let is_informational_exit =
1610 matches!((action_name, output.exit_code), ("GetServiceStatus", 1..=4));
1611
1612 if output.is_nonzero() && !is_informational_exit {
1613 return send_error(
1614 framed,
1615 request_id,
1616 "execution_failure",
1617 format!(
1618 "{action_name} failed with exit code {}{}",
1619 output.exit_code,
1620 if output.stderr.trim().is_empty() {
1621 String::new()
1622 } else {
1623 format!(": {}", output.stderr.trim())
1624 }
1625 ),
1626 )
1627 .await;
1628 }
1629
1630 let output_text = if output.stderr.is_empty() {
1631 output.stdout
1632 } else {
1633 format!("{}\n[stderr]\n{}", output.stdout, output.stderr)
1634 };
1635
1636 send_response(
1637 framed,
1638 &DaemonResponse::QueryActionResponse {
1639 request_id: request_id.to_string(),
1640 action_name: action_name.to_string(),
1641 output: output_text,
1642 },
1643 )
1644 .await
1645}
1646
1647async fn handle_describe(
1653 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1654 state: &DaemonState,
1655 caller_role: &CallerRole,
1656 action_name: &str,
1657 params: &Value,
1658 request_id: &str,
1659) -> Result<(), HandlerError> {
1660 use crate::actions::ActionMechanism;
1661 use crate::executor::build_action_spec;
1662
1663 if state.policy.min_role_for_action(action_name).is_none() {
1675 return send_error(
1676 framed,
1677 request_id,
1678 "validation_failure",
1679 format!("unknown action: {action_name}"),
1680 )
1681 .await;
1682 }
1683 if !authorize_action(&state.policy, caller_role, action_name) {
1684 return send_error(
1685 framed,
1686 request_id,
1687 "authorization_failure",
1688 format!("action '{action_name}' is not allowed for {caller_role:?} role"),
1689 )
1690 .await;
1691 }
1692 if let Err(message) = validate_action_platform(state, action_name) {
1693 return send_error(framed, request_id, "unsupported_platform", message).await;
1694 }
1695
1696 if action_name == "ListJobHistory" {
1700 return send_response(
1701 framed,
1702 &DaemonResponse::DescribeResponse {
1703 request_id: request_id.to_string(),
1704 command: "query daemon job history (SQLite)".to_string(),
1705 risk_level: "low".to_string(),
1706 reboot_required: false,
1707 },
1708 )
1709 .await;
1710 }
1711
1712 let spec = match build_action_spec(action_name, params) {
1713 Ok(s) => s,
1714 Err(e) => {
1715 return send_error(
1716 framed,
1717 request_id,
1718 "validation_failure",
1719 format!("unknown action: {action_name} ({e})"),
1720 )
1721 .await;
1722 }
1723 };
1724
1725 let command = match &spec.mechanism {
1726 ActionMechanism::Command { program, args } => {
1727 let display_args = redact_argv(action_name, params, args);
1731 if display_args.is_empty() {
1732 program.to_string()
1733 } else {
1734 format!("{} {}", program, display_args.join(" "))
1735 }
1736 }
1737 ActionMechanism::FileScan { path } => format!("read {path}"),
1738 ActionMechanism::FileWrite { path, .. } => format!("write {path}"),
1739 ActionMechanism::FilePatch { path, .. } => format!("patch {path}"),
1740 ActionMechanism::FileDelete { path } => format!("rm {path}"),
1741 };
1742
1743 let risk_level = format!("{:?}", spec.risk_level).to_lowercase();
1744
1745 send_response(
1746 framed,
1747 &DaemonResponse::DescribeResponse {
1748 request_id: request_id.to_string(),
1749 command,
1750 risk_level,
1751 reboot_required: spec.reboot_required,
1752 },
1753 )
1754 .await
1755}
1756
1757async fn handle_preview(
1758 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1759 state: &DaemonState,
1760 runner: Arc<dyn CommandRunner + Send + Sync>,
1761 caller_role: &CallerRole,
1762 request_id: &str,
1763 action_name: &str,
1764 params: &Value,
1765) -> Result<(), HandlerError> {
1766 let spec = match build_action_spec(action_name, params) {
1767 Ok(s) => s,
1768 Err(e) => {
1769 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
1770 }
1771 };
1772
1773 if !authorize_action(&state.policy, caller_role, action_name) {
1774 return send_error(
1775 framed,
1776 request_id,
1777 "authorization_failure",
1778 format!("action '{action_name}' is not allowed for {caller_role:?} role"),
1779 )
1780 .await;
1781 }
1782 if let Err(message) = validate_action_platform(state, action_name) {
1783 return send_error(framed, request_id, "unsupported_platform", message).await;
1784 }
1785
1786 let request_hash = compute_request_hash(action_name, params);
1787
1788 let runner_for_preview = Arc::clone(&runner);
1793 let current_state =
1794 match tokio::task::spawn_blocking(move || collect_state(&*runner_for_preview)).await {
1795 Err(e) => {
1796 eprintln!(
1797 "[sysknife-daemon] handle_preview: collect_state task failed ({e}); \
1798 generating preview with empty state"
1799 );
1800 Value::Null
1801 }
1802 Ok(Err(e)) => {
1803 eprintln!(
1804 "[sysknife-daemon] handle_preview: state collection failed ({e}); \
1805 generating preview with empty state"
1806 );
1807 Value::Null
1808 }
1809 Ok(Ok(s)) => match serde_json::to_value(&s) {
1810 Ok(v) => v,
1811 Err(e) => {
1812 eprintln!(
1813 "[sysknife-daemon] handle_preview: failed to serialize collected state ({e}); \
1814 using empty state"
1815 );
1816 Value::Null
1817 }
1818 },
1819 };
1820 let redacted_params = redact_params(action_name, params);
1827 let proposed_change = json!({ "action": action_name, "params": redacted_params });
1828
1829 let envelope = RequestEnvelope {
1830 action_name: action_name.to_string(),
1831 request_id: request_id.to_string(),
1832 params: params.clone(),
1833 caller_role: *caller_role,
1834 request_hash: sysknife_types::RequestHash::new(request_hash.to_string()),
1835 };
1836
1837 let state_unavailable = current_state.is_null();
1842 let mut preview = preview_action(&envelope, current_state, proposed_change);
1843 if state_unavailable {
1844 preview.warnings.push(
1845 "System state could not be collected; this preview was generated without it. \
1846 Review the action carefully before approving."
1847 .to_string(),
1848 );
1849 }
1850
1851 let new_tx = NewTransaction {
1853 request_id: request_id.to_string(),
1854 request_hash,
1855 action_name: action_name.to_string(),
1856 risk_level: spec.risk_level,
1857 summary: preview.summary.clone(),
1858 warnings: preview.warnings.clone(),
1859 caller_role: *caller_role,
1862 };
1863
1864 let recorded = match state.audit.record_previewed(new_tx, preview.clone()).await {
1865 Ok(r) => r,
1866 Err(e) => {
1867 return send_error(
1868 framed,
1869 request_id,
1870 "transient_infrastructure_failure",
1871 format!("failed to record preview transaction: {e}"),
1872 )
1873 .await;
1874 }
1875 };
1876
1877 forward_audit_event(state, &recorded.transaction.transaction_id, caller_role);
1882
1883 send_response(
1884 framed,
1885 &DaemonResponse::PreviewResponse {
1886 request_id: request_id.to_string(),
1887 preview,
1888 transaction_id: recorded.transaction.transaction_id,
1889 },
1890 )
1891 .await
1892}
1893
1894fn forward_audit_event(state: &DaemonState, transaction_id: &str, caller: &CallerRole) {
1902 let Some(forwarder) = state.forwarder.clone() else {
1903 return;
1904 };
1905 let audit = std::sync::Arc::clone(&state.audit);
1906 let transaction_id = transaction_id.to_string();
1907 let caller_label = format!("{caller:?}");
1908 tokio::spawn(async move {
1909 match audit.fetch_chain_row(&transaction_id).await {
1910 Ok(Some(row)) => {
1911 forwarder.submit(crate::audit_forward::AuditEvent {
1912 seq: row.seq,
1913 transaction_id: row.transaction_id,
1914 action_name: row.action_name,
1915 risk_level: row.risk_level,
1916 summary: row.summary,
1917 approval_id: row.approval_id,
1918 created_at: row.created_at,
1919 chain_hash: row.chain_hash,
1920 key_id: row.key_id,
1921 caller_role: Some(caller_label),
1922 final_status: None,
1923 });
1924 }
1925 Ok(None) => {
1926 eprintln!(
1927 "[sysknife-daemon] audit-forward: transaction {transaction_id} \
1928 not found just after insert (race?)"
1929 );
1930 }
1931 Err(e) => {
1932 eprintln!(
1933 "[sysknife-daemon] audit-forward: chain row fetch failed for \
1934 {transaction_id}: {e}"
1935 );
1936 }
1937 }
1938 });
1939}
1940
1941fn forward_status_change_event(
1952 state: &DaemonState,
1953 transaction_id: &str,
1954 caller: &CallerRole,
1955 final_status: JobState,
1956) {
1957 if state.forwarder.is_none() {
1958 return;
1959 }
1960 let audit = std::sync::Arc::clone(&state.audit);
1961 let forwarder = state.forwarder.clone().expect("checked above");
1962 let transaction_id = transaction_id.to_string();
1963 let caller_label = format!("{caller:?}");
1964 let final_status_label = format!("{final_status:?}").to_lowercase();
1965 tokio::spawn(async move {
1966 match audit.fetch_chain_row(&transaction_id).await {
1967 Ok(Some(row)) => {
1968 forwarder.submit(crate::audit_forward::AuditEvent {
1969 seq: row.seq,
1970 transaction_id: row.transaction_id,
1971 action_name: row.action_name,
1972 risk_level: row.risk_level,
1973 summary: row.summary,
1974 approval_id: row.approval_id,
1975 created_at: row.created_at,
1976 chain_hash: row.chain_hash,
1977 key_id: row.key_id,
1978 caller_role: Some(caller_label),
1979 final_status: Some(final_status_label),
1980 });
1981 }
1982 Ok(None) => {
1983 eprintln!(
1984 "[sysknife-daemon] audit-forward: transaction {transaction_id} \
1985 not found for status-change forward (race?)"
1986 );
1987 }
1988 Err(e) => {
1989 eprintln!(
1990 "[sysknife-daemon] audit-forward: chain row fetch failed for \
1991 {transaction_id}: {e}"
1992 );
1993 }
1994 }
1995 });
1996}
1997
1998async fn release_exclusive_slots(
2003 state: &DaemonState,
2004 claimed: &[crate::actions::ExclusiveResource],
2005 request_hash: &str,
2006) {
2007 if claimed.is_empty() {
2008 return;
2009 }
2010
2011 let mut slots = state.running_exclusive.lock().await;
2012 for resource in claimed {
2013 if slots.get(resource).map(String::as_str) == Some(request_hash) {
2014 slots.remove(resource);
2015 }
2016 }
2017}
2018
2019const TERMINAL_STATUS_RETRY_ATTEMPTS: u32 = 3;
2024const TERMINAL_STATUS_RETRY_BACKOFF_MS: u64 = 25;
2028
2029async fn update_terminal_status(
2030 state: &DaemonState,
2031 transaction_id: &str,
2032 target: JobState,
2033) -> Result<(), crate::transactions::TransactionStoreError> {
2034 for attempt in 0..TERMINAL_STATUS_RETRY_ATTEMPTS {
2035 match state.audit.update_status(transaction_id, target).await {
2036 Ok(()) => return Ok(()),
2037 Err(error) => {
2038 if state
2042 .audit
2043 .get(transaction_id)
2044 .await
2045 .ok()
2046 .flatten()
2047 .is_some_and(|record| record.status == target)
2048 {
2049 return Ok(());
2050 }
2051 if attempt == TERMINAL_STATUS_RETRY_ATTEMPTS - 1 {
2052 return Err(error);
2053 }
2054 tokio::time::sleep(std::time::Duration::from_millis(
2055 TERMINAL_STATUS_RETRY_BACKOFF_MS * u64::from(attempt + 1),
2056 ))
2057 .await;
2058 }
2059 }
2060 }
2061 unreachable!("bounded retry loop always returns")
2062}
2063
2064#[allow(clippy::too_many_arguments)]
2065async fn handle_execute(
2066 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2067 state: &DaemonState,
2068 executor: Arc<dyn ActionExecutor>,
2069 caller_role: &CallerRole,
2070 request_id: &str,
2071 transaction_id: &TransactionId,
2072 action_name: &str,
2073 params: &Value,
2074 approval_receipt: &ApprovalReceipt,
2075) -> Result<(), HandlerError> {
2076 let transaction_id = transaction_id.as_str();
2077 let approval_receipt = approval_receipt.as_str();
2078 let spec = match build_action_spec(action_name, params) {
2079 Ok(s) => s,
2080 Err(e) => {
2081 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2082 }
2083 };
2084
2085 if !authorize_action(&state.policy, caller_role, action_name) {
2086 return send_error(
2087 framed,
2088 request_id,
2089 "authorization_failure",
2090 format!("action '{action_name}' is not allowed for {caller_role:?} role"),
2091 )
2092 .await;
2093 }
2094 if let Err(message) = validate_action_platform(state, action_name) {
2095 return send_error(framed, request_id, "unsupported_platform", message).await;
2096 }
2097
2098 let submitted_hash = compute_request_hash(action_name, params);
2099 let prior_tx = match state.audit.get(transaction_id).await {
2100 Ok(tx) => tx,
2101 Err(e) => {
2102 return send_error(
2103 framed,
2104 request_id,
2105 "transient_infrastructure_failure",
2106 format!("transaction lookup failed: {e}"),
2107 )
2108 .await;
2109 }
2110 };
2111
2112 let prior_tx = match prior_tx {
2113 Some(tx) => tx,
2114 None => {
2115 return send_error(
2116 framed,
2117 request_id,
2118 "stale_approval",
2119 "no prior preview found for this transaction; preview before executing",
2120 )
2121 .await;
2122 }
2123 };
2124
2125 if prior_tx.action_name != action_name || prior_tx.request_hash != submitted_hash {
2126 return send_error(
2127 framed,
2128 request_id,
2129 "stale_approval",
2130 "action or parameters differ from the approved preview",
2131 )
2132 .await;
2133 }
2134 let stored_hash = prior_tx.request_hash;
2135
2136 let is_high_risk_reboot =
2153 spec.risk_level == sysknife_types::RiskLevel::High && spec.reboot_required;
2154
2155 let policy_says_mutating = match state.policy.min_role_for_action(action_name) {
2156 Some(r) => r > CallerRole::Observer,
2157 None => {
2158 eprintln!(
2163 "[sysknife-daemon] WARNING: no policy entry for {action_name:?} at the \
2164 concurrency gate; treating it as mutating"
2165 );
2166 true
2167 }
2168 };
2169
2170 let is_mutating = policy_says_mutating || is_high_risk_reboot;
2177 if is_high_risk_reboot && !policy_says_mutating {
2178 eprintln!(
2179 "[sysknife-daemon] WARNING: {action_name:?} is High-risk + reboot-required but the \
2180 policy table does not require Dev/Admin for it; gating it as mutating anyway. \
2181 The risk and role tables have drifted."
2182 );
2183 }
2184
2185 let mut to_claim: Vec<crate::actions::ExclusiveResource> = Vec::new();
2187 if is_high_risk_reboot {
2188 to_claim.push(crate::actions::ExclusiveResource::System);
2189 }
2190 let own_resource = crate::actions::exclusive_resource(&spec);
2191 if let Some(r) = own_resource {
2192 if !to_claim.contains(&r) {
2193 to_claim.push(r);
2194 }
2195 }
2196 if !is_mutating {
2197 to_claim.clear();
2198 }
2199
2200 if is_mutating {
2202 let mut slots = state.running_exclusive.lock().await;
2203 let blocking = slots
2206 .get(&crate::actions::ExclusiveResource::System)
2207 .map(|h| (crate::actions::ExclusiveResource::System, h.clone()))
2208 .or_else(|| own_resource.and_then(|r| slots.get(&r).map(|h| (r, h.clone()))));
2209 if let Some((blocking_resource, running_hash)) = blocking {
2210 let ttl = crate::transactions::APPROVAL_RECEIPT_TTL_MINUTES;
2228 let held = blocking_resource.label();
2229 let msg = format!(
2230 "another mutating action is already executing and holds {held} (request_hash \
2231 {running_hash}); retry after the current job completes. This approval \
2232 expires {ttl} minutes after it was issued, and that window is anchored to \
2233 when the preview was created — re-running `sysknife approve` on this same \
2234 transaction will not renew it. If the running job is still going once the \
2235 window has passed, re-run the preview (which mints a fresh transaction) and \
2236 approve that new transaction instead"
2237 );
2238 drop(slots); return send_response(
2240 framed,
2241 &DaemonResponse::ConflictResponse {
2242 request_id: request_id.to_string(),
2243 message: msg,
2244 retry_after_seconds: None,
2245 },
2246 )
2247 .await;
2248 }
2249 for resource in &to_claim {
2252 slots.insert(*resource, stored_hash.clone());
2253 }
2254 }
2256
2257 let claimed = match state
2258 .audit
2259 .claim_approved_for_execution(transaction_id, &receipt_digest(approval_receipt))
2260 .await
2261 {
2262 Ok(c) => c,
2263 Err(e) => {
2264 release_exclusive_slots(state, &to_claim, &stored_hash).await;
2265 return send_error(
2266 framed,
2267 request_id,
2268 "transient_infrastructure_failure",
2269 format!("failed to claim transaction: {e}"),
2270 )
2271 .await;
2272 }
2273 };
2274 if !claimed {
2275 release_exclusive_slots(state, &to_claim, &stored_hash).await;
2276 return send_error(
2277 framed,
2278 request_id,
2279 "stale_approval",
2280 "transaction is not approved for this receipt, expired, or already consumed",
2281 )
2282 .await;
2283 }
2284
2285 let job_id = Uuid::new_v4().to_string();
2286
2287 if let Err(send_error) = send_response(
2288 framed,
2289 &DaemonResponse::JobStarted {
2290 request_id: request_id.to_string(),
2291 job_id: job_id.clone(),
2292 transaction_id: transaction_id.to_string(),
2293 },
2294 )
2295 .await
2296 {
2297 release_exclusive_slots(state, &to_claim, &stored_hash).await;
2298 if let Err(status_error) =
2299 update_terminal_status(state, transaction_id, JobState::Failed).await
2300 {
2301 eprintln!(
2302 "[sysknife-daemon] failed to mark disconnected transaction {transaction_id} \
2303 as Failed: {status_error}"
2304 );
2305 }
2306 return Err(send_error);
2307 }
2308
2309 let _ = send_response(
2310 framed,
2311 &DaemonResponse::JobProgress {
2312 job_id: job_id.clone(),
2313 line: format!("Authorization passed for {action_name}"),
2314 },
2315 )
2316 .await;
2317
2318 let _ = send_response(
2319 framed,
2320 &DaemonResponse::JobProgress {
2321 job_id: job_id.clone(),
2322 line: format!("Executing {action_name}..."),
2323 },
2324 )
2325 .await;
2326
2327 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
2331 let execution = executor.execute_with_progress(&spec, progress_tx);
2332 tokio::pin!(execution);
2333 let mut progress_open = true;
2334 let output = loop {
2335 tokio::select! {
2336 result = &mut execution => break result,
2337 line = progress_rx.recv(), if progress_open => {
2338 let Some(line) = line else {
2339 progress_open = false;
2340 continue;
2341 };
2342 if let Err(e) = send_response(
2343 framed,
2344 &DaemonResponse::JobProgress {
2345 job_id: job_id.clone(),
2346 line,
2347 },
2348 ).await {
2349 eprintln!(
2350 "[sysknife-daemon] progress send failed (client disconnected?): {e}"
2351 );
2352 }
2353 }
2354 }
2355 };
2356
2357 while let Ok(line) = progress_rx.try_recv() {
2361 if let Err(e) = send_response(
2362 framed,
2363 &DaemonResponse::JobProgress {
2364 job_id: job_id.clone(),
2365 line,
2366 },
2367 )
2368 .await
2369 {
2370 eprintln!("[sysknife-daemon] final progress send failed: {e}");
2371 }
2372 }
2373
2374 let (initial_status, initial_summary) = match &output {
2375 Ok(out) if out.is_success() => {
2376 if spec.reboot_required {
2377 (
2378 JobState::NeedsReboot,
2379 format!("{action_name} completed; reboot required"),
2380 )
2381 } else {
2382 (
2383 JobState::Succeeded,
2384 format!("{action_name} completed successfully"),
2385 )
2386 }
2387 }
2388 Ok(out) => (
2389 JobState::Failed,
2390 format!("{action_name} failed with exit code {}", out.exit_code),
2391 ),
2392 Err(e) => (JobState::Failed, format!("{action_name} failed: {e}")),
2393 };
2394
2395 let completion_line = match &output {
2396 Ok(out) => format!("{action_name} completed with exit code {}", out.exit_code),
2397 Err(e) => format!("{action_name} completed with error: {e}"),
2398 };
2399 let _ = send_response(
2400 framed,
2401 &DaemonResponse::JobProgress {
2402 job_id: job_id.clone(),
2403 line: completion_line,
2404 },
2405 )
2406 .await;
2407
2408 let (final_status, summary, rollback_ref) = attempt_rollback_if_needed(
2410 framed,
2411 &executor,
2412 &job_id,
2413 action_name,
2414 &spec,
2415 initial_status,
2416 initial_summary,
2417 )
2418 .await;
2419
2420 release_exclusive_slots(state, &to_claim, &stored_hash).await;
2425
2426 let mut warnings = Vec::new();
2430 match update_terminal_status(state, transaction_id, final_status).await {
2431 Ok(()) => {
2432 forward_status_change_event(state, transaction_id, caller_role, final_status);
2437 }
2438 Err(e) => {
2439 eprintln!(
2440 "[sysknife-daemon] failed to update transaction {transaction_id} to \
2441 {final_status:?}: {e}"
2442 );
2443 warnings.push(format!("audit trail update failed: {e}"));
2444 }
2445 }
2446
2447 send_response(
2448 framed,
2449 &DaemonResponse::JobCompleted {
2450 job_id: job_id.clone(),
2451 result: JobResult {
2452 status: job_state_str(&final_status).to_string(),
2453 summary,
2454 warnings,
2455 job_id: job_id.clone(),
2456 needs_reboot: matches!(final_status, JobState::NeedsReboot),
2457 rollback_ref,
2458 transaction_id: transaction_id.to_string(),
2459 },
2460 },
2461 )
2462 .await
2463}
2464
2465async fn attempt_rollback_if_needed(
2471 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2472 executor: &Arc<dyn ActionExecutor>,
2473 job_id: &str,
2474 action_name: &str,
2475 spec: &crate::actions::ActionSpec,
2476 status: JobState,
2477 summary: String,
2478) -> (JobState, String, Option<String>) {
2479 if !matches!(status, JobState::Failed) || !spec.rollback_available {
2480 return (status, summary, None);
2481 }
2482 let Some(rb_spec) = rollback_spec_for(action_name) else {
2483 return (status, summary, None);
2484 };
2485
2486 eprintln!("[sysknife-daemon] {action_name} failed; attempting automatic rollback");
2487
2488 let _ = send_response(
2489 framed,
2490 &DaemonResponse::JobProgress {
2491 job_id: job_id.to_string(),
2492 line: format!(
2493 "{action_name} failed — attempting automatic rollback via rpm-ostree rollback"
2494 ),
2495 },
2496 )
2497 .await;
2498
2499 match executor.execute(&rb_spec).await {
2500 Ok(rb_out) if rb_out.is_success() => {
2501 let _ = send_response(
2502 framed,
2503 &DaemonResponse::JobProgress {
2504 job_id: job_id.to_string(),
2505 line: "Rollback succeeded — previous deployment restored".to_string(),
2506 },
2507 )
2508 .await;
2509 (
2510 JobState::RolledBack,
2511 format!(
2512 "{action_name} failed and was automatically rolled back to the previous deployment"
2513 ),
2514 Some("rpm-ostree rollback".to_string()),
2515 )
2516 }
2517 other => {
2518 let detail = match &other {
2519 Ok(o) => format!("exit code {}", o.exit_code),
2520 Err(e) => e.to_string(),
2521 };
2522 eprintln!("[sysknife-daemon] rollback also failed: {detail}");
2523 let _ = send_response(
2524 framed,
2525 &DaemonResponse::JobProgress {
2526 job_id: job_id.to_string(),
2527 line: format!(
2528 "Rollback also failed ({detail}) — system may need manual intervention"
2529 ),
2530 },
2531 )
2532 .await;
2533 (status, summary, None)
2534 }
2535 }
2536}
2537
2538fn job_state_str(state: &JobState) -> &'static str {
2539 match state {
2540 JobState::Queued => "queued",
2541 JobState::Running => "running",
2542 JobState::Succeeded => "succeeded",
2543 JobState::Failed => "failed",
2544 JobState::Canceled => "canceled",
2545 JobState::RolledBack => "rolled_back",
2546 JobState::NeedsReboot => "needs_reboot",
2547 }
2548}
2549
2550#[derive(Debug, thiserror::Error)]
2555enum HandlerError {
2556 #[error("framing error: {0}")]
2557 Framing(#[from] FramingError),
2558 #[error("serialization error: {0}")]
2559 Serialization(#[from] serde_json::Error),
2560 #[error("internal error: {0}")]
2561 Internal(String),
2562}
2563
2564fn format_job_history(records: &[sysknife_types::TransactionRecord]) -> String {
2565 if records.is_empty() {
2566 return "No transactions found.".to_string();
2567 }
2568
2569 let mut output = format!("Transaction history ({} entries):\n\n", records.len());
2570 for r in records {
2571 output.push_str(&format!(
2572 " {} {:30} {:12} {}\n",
2573 r.transaction_id
2574 .chars()
2575 .take(TRANSACTION_ID_DISPLAY_PREFIX_LEN)
2576 .collect::<String>(),
2577 r.action_name,
2578 format!("{:?}", r.status).to_lowercase(),
2579 r.summary,
2580 ));
2581 }
2582 output
2583}
2584
2585async fn send_response(
2586 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2587 response: &DaemonResponse,
2588) -> Result<(), HandlerError> {
2589 let json = serde_json::to_vec(response)?;
2590 framed.send(&json).await.map_err(HandlerError::Framing)
2591}
2592
2593async fn send_error(
2594 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2595 request_id: &str,
2596 category: &str,
2597 message: impl Into<String>,
2598) -> Result<(), HandlerError> {
2599 send_response(
2600 framed,
2601 &DaemonResponse::ErrorResponse {
2602 request_id: request_id.to_string(),
2603 category: category.to_string(),
2604 message: message.into(),
2605 },
2606 )
2607 .await
2608}
2609
2610#[cfg(test)]
2615mod tests {
2616 use super::*;
2617 use crate::{
2618 state::{DaemonConfig, DaemonState},
2619 transport::listen::ListenTarget,
2620 };
2621 use std::io;
2622 use tempfile::tempdir;
2623
2624 #[cfg(target_os = "linux")]
2629 #[tokio::test]
2630 async fn peer_pidfd_reports_live_self() {
2631 let (a, _b) = UnixStream::pair().unwrap();
2632 if let Some(fd) = peer_pidfd(&a) {
2637 assert!(
2638 pidfd_peer_still_live(&fd),
2639 "the connecting (self) process must read as live"
2640 );
2641 }
2642 }
2643
2644 #[tokio::test]
2645 async fn resolve_caller_role_matches_the_peers_actual_groups() {
2646 let (a, _b) = UnixStream::pair().unwrap();
2656 let role = resolve_caller_role(&a);
2657
2658 let gid_map = read_gid_map();
2659 let expected =
2660 crate::auth::highest_role_from_groups(groups_for_pid(std::process::id(), &gid_map));
2661
2662 assert_eq!(
2663 role, expected,
2664 "the resolved role must be exactly what this process's groups justify"
2665 );
2666 }
2667
2668 #[tokio::test]
2669 async fn resolve_caller_role_never_exceeds_observer_without_a_privileged_group() {
2670 let (a, _b) = UnixStream::pair().unwrap();
2673 let role = resolve_caller_role(&a);
2674
2675 let gid_map = read_gid_map();
2676 let groups = groups_for_pid(std::process::id(), &gid_map);
2677 let privileged = groups.iter().any(|g| {
2678 matches!(
2679 g.as_str(),
2680 crate::auth::DEV_GROUP
2681 | crate::auth::ADMIN_GROUP
2682 | crate::auth::BOOT_GROUP
2683 | crate::auth::WHEEL_GROUP
2684 )
2685 });
2686
2687 if !privileged {
2688 assert_eq!(
2689 role,
2690 CallerRole::Observer,
2691 "a process in no privileged group must resolve to Observer"
2692 );
2693 }
2694 }
2695
2696 #[test]
2701 fn redact_params_replaces_pro_attach_token() {
2702 let params = json!({"token": "super-secret-test-only", "extra": "kept"});
2703 let r = redact_params("ProAttach", ¶ms);
2704 assert_eq!(r["token"].as_str(), Some("<REDACTED>"));
2706 assert_eq!(r["extra"].as_str(), Some("kept"));
2708 let s = serde_json::to_string(&r).unwrap();
2710 assert!(
2711 !s.contains("super-secret-test-only"),
2712 "token leaked into proposed_change JSON: {s}"
2713 );
2714 }
2715
2716 #[test]
2717 fn redact_params_passes_through_for_actions_with_no_credentials() {
2718 let params = json!({"package": "vim"});
2719 let r = redact_params("AptInstall", ¶ms);
2720 assert_eq!(r["package"].as_str(), Some("vim"));
2721 }
2722
2723 #[test]
2724 fn redact_argv_replaces_argv_element_matching_token_value() {
2725 let params = json!({"token": "super-secret-test-only"});
2728 let argv = vec![
2729 "pro".to_string(),
2730 "attach".to_string(),
2731 "super-secret-test-only".to_string(),
2732 ];
2733 let r = redact_argv("ProAttach", ¶ms, &argv);
2734 assert_eq!(r, vec!["pro", "attach", "<REDACTED>"]);
2735 }
2736
2737 #[test]
2738 fn redact_argv_passes_through_when_no_credentials() {
2739 let params = json!({"package": "vim"});
2740 let argv = vec![
2741 "apt-get".to_string(),
2742 "install".to_string(),
2743 "vim".to_string(),
2744 ];
2745 let r = redact_argv("AptInstall", ¶ms, &argv);
2746 assert_eq!(r, argv);
2747 }
2748
2749 #[test]
2760 fn redact_argv_token_equal_to_attach_preserves_structural_attach() {
2761 let params = json!({"token": "attach"});
2762 let argv = vec![
2764 "sudo".to_string(),
2765 "pro".to_string(),
2766 "attach".to_string(),
2767 "attach".to_string(), ];
2769 let r = redact_argv("ProAttach", ¶ms, &argv);
2770 assert_eq!(
2775 r,
2776 vec!["sudo", "pro", "attach", "<REDACTED>"],
2777 "structural 'attach' must survive; only the positional token gets redacted"
2778 );
2779 }
2780
2781 struct MockRunner;
2786 impl CommandRunner for MockRunner {
2787 fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
2788 match program {
2789 "hostname" => Ok("testhost\n".to_string()),
2790 _ => Ok(String::new()),
2791 }
2792 }
2793 }
2794
2795 fn test_state(dir: &tempfile::TempDir) -> DaemonState {
2796 let db_path = dir.path().join("sysknife-test.db");
2797 let sock_path = dir.path().join("sysknife-test.sock");
2798 let config = DaemonConfig::new(ListenTarget::Unix(sock_path), db_path);
2799 DaemonState::open(config).unwrap()
2800 }
2801
2802 fn runner() -> Arc<dyn CommandRunner + Send + Sync> {
2803 Arc::new(MockRunner)
2804 }
2805
2806 async fn approve_preview(
2807 framed: &mut FramedStream<tokio::net::UnixStream>,
2808 transaction_id: &str,
2809 ) -> String {
2810 framed
2811 .send(
2812 &serde_json::to_vec(&json!({
2813 "type": "approve",
2814 "request_id": format!("approve-{transaction_id}"),
2815 "transaction_id": transaction_id,
2816 }))
2817 .unwrap(),
2818 )
2819 .await
2820 .unwrap();
2821 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2822 assert_eq!(response["type"], "approval_response");
2823 response["approval_receipt"]
2824 .as_str()
2825 .expect("approval receipt")
2826 .to_string()
2827 }
2828
2829 async fn preview_and_approve(
2830 framed: &mut FramedStream<tokio::net::UnixStream>,
2831 action_name: &str,
2832 params: Value,
2833 ) -> (String, String) {
2834 framed
2835 .send(
2836 &serde_json::to_vec(&json!({
2837 "type": "preview",
2838 "request_id": format!("preview-{action_name}"),
2839 "action_name": action_name,
2840 "params": params,
2841 }))
2842 .unwrap(),
2843 )
2844 .await
2845 .unwrap();
2846 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2847 assert_eq!(response["type"], "preview_response");
2848 let transaction_id = response["transaction_id"].as_str().unwrap().to_string();
2849 let receipt = approve_preview(framed, &transaction_id).await;
2850 (transaction_id, receipt)
2851 }
2852
2853 #[tokio::test]
2854 async fn approval_details_returns_persisted_preview_before_approval() {
2855 let dir = tempdir().unwrap();
2856 let state = test_state(&dir);
2857 let (client, server) = tokio::net::UnixStream::pair().unwrap();
2858 tokio::spawn(async move {
2859 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
2860 });
2861 let mut framed = FramedStream::new(client);
2862 framed
2863 .send(
2864 &serde_json::to_vec(&json!({
2865 "type": "preview",
2866 "request_id": "details-preview",
2867 "action_name": "GetSystemState",
2868 "params": {}
2869 }))
2870 .unwrap(),
2871 )
2872 .await
2873 .unwrap();
2874 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2875 let transaction_id = preview["transaction_id"].as_str().unwrap();
2876
2877 framed
2878 .send(
2879 &serde_json::to_vec(&json!({
2880 "type": "approval_details",
2881 "request_id": "details-read",
2882 "transaction_id": transaction_id
2883 }))
2884 .unwrap(),
2885 )
2886 .await
2887 .unwrap();
2888 let details: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2889 assert_eq!(details["type"], "approval_details_response");
2890 assert_eq!(details["transaction_id"], transaction_id);
2891 assert_eq!(details["action_name"], "GetSystemState");
2892 assert_eq!(details["preview"]["risk_level"], "low");
2893 assert!(!details["preview"]["summary"].as_str().unwrap().is_empty());
2894 }
2895
2896 #[tokio::test]
2897 async fn approve_missing_transaction_maps_to_stale_approval() {
2898 let dir = tempdir().unwrap();
2899 let responses = exchange(
2900 test_state(&dir),
2901 CallerRole::Observer,
2902 vec![json!({
2903 "type": "approve",
2904 "request_id": "approve-missing",
2905 "transaction_id": "missing-transaction",
2906 })],
2907 1,
2908 )
2909 .await;
2910
2911 assert_eq!(responses[0]["type"], "error_response");
2912 assert_eq!(responses[0]["category"], "stale_approval");
2913 }
2914
2915 #[tokio::test]
2916 async fn approving_same_transaction_twice_maps_second_to_stale_approval() {
2917 let dir = tempdir().unwrap();
2918 let state = test_state(&dir);
2919 let (client, server) = tokio::net::UnixStream::pair().unwrap();
2920 tokio::spawn(async move {
2921 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
2922 });
2923 let mut framed = FramedStream::new(client);
2924 let (transaction_id, _) =
2925 preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
2926
2927 framed
2928 .send(
2929 &serde_json::to_vec(&json!({
2930 "type": "approve",
2931 "request_id": "approve-again",
2932 "transaction_id": transaction_id,
2933 }))
2934 .unwrap(),
2935 )
2936 .await
2937 .unwrap();
2938 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2939
2940 assert_eq!(response["type"], "error_response");
2941 assert_eq!(response["category"], "stale_approval");
2942 }
2943
2944 #[tokio::test]
2945 async fn approve_with_forged_commitment_maps_to_integrity_failure() {
2946 let dir = tempdir().unwrap();
2951 let db_path = dir.path().join("sysknife-test.db");
2952 let state = test_state(&dir);
2953 let (client, server) = tokio::net::UnixStream::pair().unwrap();
2954 tokio::spawn(async move {
2955 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
2956 });
2957 let mut framed = FramedStream::new(client);
2958
2959 framed
2960 .send(
2961 &serde_json::to_vec(&json!({
2962 "type": "preview",
2963 "request_id": "preview-forge",
2964 "action_name": "GetSystemState",
2965 "params": {},
2966 }))
2967 .unwrap(),
2968 )
2969 .await
2970 .unwrap();
2971 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2972 let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
2973
2974 rusqlite::Connection::open(&db_path)
2977 .unwrap()
2978 .execute(
2979 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
2980 rusqlite::params![transaction_id],
2981 )
2982 .unwrap();
2983
2984 framed
2985 .send(
2986 &serde_json::to_vec(&json!({
2987 "type": "approve",
2988 "request_id": "approve-forged",
2989 "transaction_id": transaction_id,
2990 }))
2991 .unwrap(),
2992 )
2993 .await
2994 .unwrap();
2995 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
2996
2997 assert_eq!(response["type"], "error_response");
2998 assert_eq!(response["category"], "integrity_failure");
2999 }
3000
3001 #[tokio::test]
3002 async fn query_history_returns_structured_rows_with_created_at_and_risk_level() {
3003 let dir = tempdir().unwrap();
3006 let state = test_state(&dir);
3007 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3008 tokio::spawn(async move {
3009 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
3010 });
3011 let mut framed = FramedStream::new(client);
3012
3013 framed
3014 .send(
3015 &serde_json::to_vec(&json!({
3016 "type": "preview",
3017 "request_id": "preview-hist",
3018 "action_name": "GetSystemState",
3019 "params": {},
3020 }))
3021 .unwrap(),
3022 )
3023 .await
3024 .unwrap();
3025 let _ = framed.recv().await.unwrap();
3026
3027 framed
3028 .send(
3029 &serde_json::to_vec(&json!({
3030 "type": "query_history",
3031 "request_id": "hist-1",
3032 "limit": 10,
3033 }))
3034 .unwrap(),
3035 )
3036 .await
3037 .unwrap();
3038 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3039
3040 assert_eq!(response["type"], "history_response");
3041 let entries = response["entries"].as_array().expect("entries array");
3042 assert_eq!(entries.len(), 1);
3043 assert_eq!(entries[0]["action_name"], "GetSystemState");
3044 assert!(
3045 !entries[0]["created_at"].as_str().unwrap_or("").is_empty(),
3046 "created_at must be populated over the structured IPC"
3047 );
3048 assert_eq!(
3049 entries[0]["risk_level"], "low",
3050 "GetSystemState is a low-risk action; risk_level must carry the exact typed value"
3051 );
3052 }
3053
3054 #[tokio::test]
3055 async fn query_history_honours_policy_override_raising_history_above_observer() {
3056 let dir = tempdir().unwrap();
3060 let real = test_state(&dir);
3061 let overrides =
3064 std::collections::HashMap::from([("ListJobHistory".to_string(), "high".to_string())]);
3065 let policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
3066 let state = crate::state::DaemonState::open_with_audit(
3067 real.config.clone(),
3068 policy,
3069 None,
3070 real.audit.clone(),
3071 );
3072
3073 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3074 tokio::spawn(async move {
3075 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
3076 });
3077 let mut framed = FramedStream::new(client);
3078 framed
3079 .send(
3080 &serde_json::to_vec(&json!({
3081 "type": "query_history",
3082 "request_id": "hist-denied",
3083 "limit": 10,
3084 }))
3085 .unwrap(),
3086 )
3087 .await
3088 .unwrap();
3089 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3090
3091 assert_eq!(response["type"], "error_response");
3092 assert_eq!(response["category"], "authorization_failure");
3093 }
3094
3095 #[tokio::test]
3096 async fn cancel_queued_transaction_succeeds_and_marks_it_canceled() {
3097 let dir = tempdir().unwrap();
3098 let state = test_state(&dir);
3099 let handler_state = state.clone();
3100 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3101 tokio::spawn(async move {
3102 unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3103 });
3104 let mut framed = FramedStream::new(client);
3105
3106 framed
3107 .send(
3108 &serde_json::to_vec(&json!({
3109 "type": "preview",
3110 "request_id": "preview-cancel",
3111 "action_name": "GetSystemState",
3112 "params": {},
3113 }))
3114 .unwrap(),
3115 )
3116 .await
3117 .unwrap();
3118 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3119 let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
3120
3121 framed
3122 .send(
3123 &serde_json::to_vec(&json!({
3124 "type": "cancel",
3125 "request_id": "cancel-1",
3126 "transaction_id": transaction_id,
3127 }))
3128 .unwrap(),
3129 )
3130 .await
3131 .unwrap();
3132 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3133
3134 assert_eq!(response["type"], "cancel_response");
3135 assert_eq!(
3136 state
3137 .audit
3138 .get(&transaction_id)
3139 .await
3140 .unwrap()
3141 .unwrap()
3142 .status,
3143 JobState::Canceled
3144 );
3145 }
3146
3147 #[tokio::test]
3148 async fn cancel_running_transaction_is_rejected_and_left_running() {
3149 let dir = tempdir().unwrap();
3150 let state = test_state(&dir);
3151 let handler_state = state.clone();
3152 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3153 tokio::spawn(async move {
3154 unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3155 });
3156 let mut framed = FramedStream::new(client);
3157 let (transaction_id, receipt) =
3158 preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
3159 assert!(state
3161 .audit
3162 .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3163 .await
3164 .unwrap());
3165
3166 framed
3167 .send(
3168 &serde_json::to_vec(&json!({
3169 "type": "cancel",
3170 "request_id": "cancel-running",
3171 "transaction_id": transaction_id,
3172 }))
3173 .unwrap(),
3174 )
3175 .await
3176 .unwrap();
3177 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3178
3179 assert_eq!(response["type"], "error_response");
3180 assert_eq!(response["category"], "not_cancelable");
3181 assert_eq!(
3182 state
3183 .audit
3184 .get(&transaction_id)
3185 .await
3186 .unwrap()
3187 .unwrap()
3188 .status,
3189 JobState::Running,
3190 "an in-flight action must not be disturbed by cancel"
3191 );
3192 }
3193
3194 #[tokio::test]
3195 async fn cancel_missing_transaction_is_rejected() {
3196 let dir = tempdir().unwrap();
3197 let responses = exchange(
3198 test_state(&dir),
3199 CallerRole::Observer,
3200 vec![json!({
3201 "type": "cancel",
3202 "request_id": "cancel-missing",
3203 "transaction_id": "no-such-transaction",
3204 })],
3205 1,
3206 )
3207 .await;
3208 assert_eq!(responses[0]["type"], "error_response");
3209 assert_eq!(responses[0]["category"], "not_cancelable");
3210 }
3211
3212 #[tokio::test]
3213 async fn undelivered_approval_is_revoked_so_the_user_can_retry() {
3214 let dir = tempdir().unwrap();
3215 let state = test_state(&dir);
3216 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3217 let handler_state = state.clone();
3218 tokio::spawn(async move {
3219 unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3220 });
3221 let mut framed = FramedStream::new(client);
3222 framed
3223 .send(
3224 &serde_json::to_vec(&json!({
3225 "type": "preview",
3226 "request_id": "preview-undelivered",
3227 "action_name": "GetSystemState",
3228 "params": {},
3229 }))
3230 .unwrap(),
3231 )
3232 .await
3233 .unwrap();
3234 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3235 let transaction_id = preview["transaction_id"].as_str().unwrap();
3236
3237 let (peer, broken_stream) = tokio::io::duplex(64);
3238 drop(peer);
3239 let mut broken_framed = FramedStream::new(broken_stream);
3240 assert!(handle_approve(
3241 &mut broken_framed,
3242 &state,
3243 &CallerRole::Admin,
3244 "approve-undelivered",
3245 transaction_id,
3246 )
3247 .await
3248 .is_err());
3249
3250 let (retry_stream, receiver) = tokio::io::duplex(4096);
3251 let mut retry_framed = FramedStream::new(retry_stream);
3252 handle_approve(
3253 &mut retry_framed,
3254 &state,
3255 &CallerRole::Admin,
3256 "approve-retry",
3257 transaction_id,
3258 )
3259 .await
3260 .unwrap();
3261 let mut receiver = FramedStream::new(receiver);
3262 let response: Value = serde_json::from_slice(&receiver.recv().await.unwrap()).unwrap();
3263 assert_eq!(response["type"], "approval_response");
3264 }
3265
3266 #[tokio::test]
3267 async fn approval_details_for_running_transaction_maps_to_stale_approval() {
3268 let dir = tempdir().unwrap();
3269 let state = test_state(&dir);
3270 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3271 let handler_state = state.clone();
3272 tokio::spawn(async move {
3273 unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3274 });
3275 let mut framed = FramedStream::new(client);
3276 let (transaction_id, receipt) =
3277 preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
3278 assert!(state
3279 .audit
3280 .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3281 .await
3282 .unwrap());
3283
3284 framed
3285 .send(
3286 &serde_json::to_vec(&json!({
3287 "type": "approval_details",
3288 "request_id": "details-running",
3289 "transaction_id": transaction_id,
3290 }))
3291 .unwrap(),
3292 )
3293 .await
3294 .unwrap();
3295 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3296
3297 assert_eq!(response["type"], "error_response");
3298 assert_eq!(response["category"], "stale_approval");
3299 }
3300
3301 async fn exchange(
3304 state: DaemonState,
3305 role: CallerRole,
3306 requests: Vec<Value>,
3307 want_responses: usize,
3308 ) -> Vec<Value> {
3309 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3310 tokio::spawn(async move {
3311 unix_connection_handler(server, state, runner(), role).await;
3312 });
3313
3314 let mut framed = FramedStream::new(client);
3315 for req in &requests {
3316 let bytes = serde_json::to_vec(req).unwrap();
3317 framed.send(&bytes).await.unwrap();
3318 }
3319
3320 let mut responses = Vec::new();
3321 for _ in 0..want_responses {
3322 let raw = framed.recv().await.unwrap();
3323 responses.push(serde_json::from_slice::<Value>(&raw).unwrap());
3324 }
3325 responses
3326 }
3327
3328 #[tokio::test]
3333 async fn query_state_returns_state_response() {
3334 let dir = tempdir().unwrap();
3335 let state = test_state(&dir);
3336
3337 let resps = exchange(
3338 state,
3339 CallerRole::Observer,
3340 vec![json!({"type": "query_state", "request_id": "r1"})],
3341 1,
3342 )
3343 .await;
3344
3345 assert_eq!(resps[0]["type"], "state_response");
3346 assert_eq!(resps[0]["request_id"], "r1");
3347 assert_eq!(resps[0]["state"]["host_name"], "testhost");
3348 }
3349
3350 #[tokio::test]
3355 async fn preview_returns_hash_and_transaction_id() {
3356 let dir = tempdir().unwrap();
3357 let state = test_state(&dir);
3358
3359 let resps = exchange(
3360 state,
3361 CallerRole::Observer,
3362 vec![json!({
3363 "type": "preview",
3364 "request_id": "r1",
3365 "action_name": "GetSystemState",
3366 "params": {}
3367 })],
3368 1,
3369 )
3370 .await;
3371
3372 assert_eq!(resps[0]["type"], "preview_response");
3373 assert_eq!(resps[0]["request_id"], "r1");
3374 let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
3375 assert_eq!(
3376 hash.len(),
3377 64,
3378 "request_hash should be a 64-char hex SHA-256"
3379 );
3380 assert!(
3381 !resps[0]["transaction_id"].as_str().unwrap().is_empty(),
3382 "transaction_id must be set"
3383 );
3384 }
3385
3386 #[tokio::test]
3387 async fn preview_hash_is_deterministic() {
3388 let hash1 = compute_request_hash(
3390 "InstallFlatpak",
3391 &json!({"app_id": "org.gnome.Builder", "remote": "flathub"}),
3392 );
3393 let hash2 = compute_request_hash(
3394 "InstallFlatpak",
3395 &json!({"remote": "flathub", "app_id": "org.gnome.Builder"}),
3396 );
3397 assert_eq!(hash1, hash2, "canonical JSON must sort keys");
3398 }
3399
3400 #[test]
3401 fn canonical_json_recurses_into_arrays() {
3402 let hash1 =
3406 compute_request_hash("InstallPackages", &json!({"packages": [{"b": 1, "a": 2}]}));
3407 let hash2 =
3408 compute_request_hash("InstallPackages", &json!({"packages": [{"a": 2, "b": 1}]}));
3409 assert_eq!(hash1, hash2, "nested object keys in arrays must be sorted");
3410 }
3411
3412 #[test]
3413 fn canonical_json_preserves_array_element_order() {
3414 let hash1 = compute_request_hash("Op", &json!({"items": ["a", "b"]}));
3417 let hash2 = compute_request_hash("Op", &json!({"items": ["b", "a"]}));
3418 assert_ne!(hash1, hash2, "array element order must be preserved");
3419 }
3420
3421 #[tokio::test]
3426 async fn high_risk_action_rejected_for_observer() {
3427 let dir = tempdir().unwrap();
3428 let state = test_state(&dir);
3429
3430 let resps = exchange(
3431 state,
3432 CallerRole::Observer, vec![json!({
3434 "type": "preview",
3435 "request_id": "r1",
3436 "action_name": "UpdateSystem",
3437 "params": {}
3438 })],
3439 1,
3440 )
3441 .await;
3442
3443 assert_eq!(resps[0]["type"], "error_response");
3444 assert_eq!(resps[0]["category"], "authorization_failure");
3445 }
3446
3447 #[tokio::test]
3448 async fn under_privileged_caller_cannot_act_on_admin_transaction() {
3449 let dir = tempdir().unwrap();
3455 let state = test_state(&dir);
3456
3457 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3459 let handler_state = state.clone();
3460 tokio::spawn(async move {
3461 unix_connection_handler(server, handler_state, runner(), CallerRole::Admin).await;
3462 });
3463 let mut framed = FramedStream::new(client);
3464 framed
3465 .send(
3466 &serde_json::to_vec(&json!({
3467 "type": "preview",
3468 "request_id": "preview-admin",
3469 "action_name": "UpdateSystem",
3470 "params": {},
3471 }))
3472 .unwrap(),
3473 )
3474 .await
3475 .unwrap();
3476 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3477 assert_eq!(preview["type"], "preview_response");
3478 let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
3479
3480 for (request_id, verb) in [
3483 ("obs-approve", "approve"),
3484 ("obs-cancel", "cancel"),
3485 ("obs-details", "details"),
3486 ] {
3487 let (stream, rx) = tokio::io::duplex(4096);
3488 let mut caller = FramedStream::new(stream);
3489 match verb {
3490 "approve" => handle_approve(
3491 &mut caller,
3492 &state,
3493 &CallerRole::Observer,
3494 request_id,
3495 &transaction_id,
3496 )
3497 .await
3498 .unwrap(),
3499 "cancel" => handle_cancel(
3500 &mut caller,
3501 &state,
3502 &CallerRole::Observer,
3503 request_id,
3504 &transaction_id,
3505 )
3506 .await
3507 .unwrap(),
3508 _ => handle_approval_details(
3509 &mut caller,
3510 &state,
3511 &CallerRole::Observer,
3512 request_id,
3513 &transaction_id,
3514 )
3515 .await
3516 .unwrap(),
3517 }
3518 let mut rx = FramedStream::new(rx);
3519 let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
3520 assert_eq!(resp["type"], "error_response", "{verb} must be refused");
3521 assert_eq!(
3522 resp["category"], "authorization_failure",
3523 "{verb} refusal must be an authorization failure"
3524 );
3525 }
3526
3527 let (stream, rx) = tokio::io::duplex(4096);
3530 let mut admin = FramedStream::new(stream);
3531 handle_approve(
3532 &mut admin,
3533 &state,
3534 &CallerRole::Admin,
3535 "admin-approve",
3536 &transaction_id,
3537 )
3538 .await
3539 .unwrap();
3540 let mut rx = FramedStream::new(rx);
3541 let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
3542 assert_eq!(resp["type"], "approval_response");
3543 }
3544
3545 #[tokio::test]
3546 async fn medium_risk_action_rejected_for_observer() {
3547 let dir = tempdir().unwrap();
3548 let state = test_state(&dir);
3549
3550 let resps = exchange(
3551 state,
3552 CallerRole::Observer, vec![json!({
3554 "type": "preview",
3555 "request_id": "r1",
3556 "action_name": "InstallFlatpak",
3557 "params": {"username": "alice", "app_id": "org.gnome.Builder", "remote": "flathub"}
3558 })],
3559 1,
3560 )
3561 .await;
3562
3563 assert_eq!(resps[0]["type"], "error_response");
3564 assert_eq!(resps[0]["category"], "authorization_failure");
3565 }
3566
3567 #[tokio::test]
3568 async fn low_risk_action_allowed_for_observer() {
3569 let dir = tempdir().unwrap();
3570 let state = test_state(&dir);
3571
3572 let resps = exchange(
3573 state,
3574 CallerRole::Observer,
3575 vec![json!({
3576 "type": "preview",
3577 "request_id": "r1",
3578 "action_name": "GetSystemState",
3579 "params": {}
3580 })],
3581 1,
3582 )
3583 .await;
3584
3585 assert_eq!(resps[0]["type"], "preview_response");
3586 }
3587
3588 #[test]
3589 fn unsupported_distros_cannot_reach_mutation_paths() {
3590 let dir = tempdir().unwrap();
3591 let mut state = test_state(&dir);
3592 state.host_distro = Some(sysknife_core::distro::DistroId::UbuntuCore {
3593 major: 24,
3594 minor: 4,
3595 });
3596 assert!(validate_action_platform(&state, "AptInstall").is_err());
3597
3598 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3599 major: 20,
3600 minor: 4,
3601 });
3602 assert!(validate_action_platform(&state, "UpdateSystem").is_err());
3603 }
3604
3605 #[test]
3606 fn raising_a_read_only_action_via_risk_overrides_does_not_arm_the_platform_fence() {
3607 let dir = tempdir().unwrap();
3613 let mut state = test_state(&dir);
3614 state.host_distro = None;
3615 let overrides =
3616 std::collections::HashMap::from([("GetDiskUsage".to_string(), "high".to_string())]);
3617 state.policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
3618
3619 assert_eq!(
3620 state.policy.min_role_for_action("GetDiskUsage"),
3621 Some(CallerRole::Admin),
3622 "the override still governs authorization"
3623 );
3624 assert!(
3625 validate_action_platform(&state, "GetDiskUsage").is_ok(),
3626 "a read-only action stays exempt from the distro fence"
3627 );
3628 }
3629
3630 #[test]
3631 fn supported_hosts_still_enforce_action_family() {
3632 let dir = tempdir().unwrap();
3633 let mut state = test_state(&dir);
3634 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3635 major: 24,
3636 minor: 4,
3637 });
3638 assert!(validate_action_platform(&state, "AptInstall").is_ok());
3639 assert!(validate_action_platform(&state, "AddLayeredPackage").is_err());
3640 }
3641
3642 #[tokio::test]
3647 async fn query_action_refuses_an_action_the_host_family_cannot_run() {
3648 let dir = tempdir().unwrap();
3654 let mut state = test_state(&dir);
3655 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3656 major: 24,
3657 minor: 4,
3658 });
3659
3660 let resps = exchange(
3661 state,
3662 CallerRole::Observer,
3663 vec![json!({
3664 "type": "query_action",
3665 "request_id": "r1",
3666 "action_name": "GetLayeredPackages",
3667 "params": {}
3668 })],
3669 1,
3670 )
3671 .await;
3672
3673 assert_eq!(resps[0]["type"], "error_response");
3674 assert_eq!(resps[0]["category"], "unsupported_platform");
3675 }
3676
3677 #[tokio::test]
3678 async fn describe_refuses_an_action_the_caller_is_not_allowed_to_run() {
3679 let dir = tempdir().unwrap();
3683 let state = test_state(&dir);
3684
3685 let resps = exchange(
3686 state,
3687 CallerRole::Observer,
3688 vec![json!({
3689 "type": "describe",
3690 "request_id": "r1",
3691 "action_name": "GrantSudoAccess",
3692 "params": {"username": "alice", "commands": "/usr/bin/ls", "nopasswd": false}
3693 })],
3694 1,
3695 )
3696 .await;
3697
3698 assert_eq!(resps[0]["type"], "error_response");
3699 assert_eq!(resps[0]["category"], "authorization_failure");
3700 }
3701
3702 #[tokio::test]
3703 async fn describe_refuses_an_action_the_host_family_cannot_run() {
3704 let dir = tempdir().unwrap();
3705 let mut state = test_state(&dir);
3706 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
3707 major: 24,
3708 minor: 4,
3709 });
3710
3711 let resps = exchange(
3712 state,
3713 CallerRole::Admin,
3714 vec![json!({
3715 "type": "describe",
3716 "request_id": "r1",
3717 "action_name": "AddLayeredPackage",
3718 "params": {"package": "vim"}
3719 })],
3720 1,
3721 )
3722 .await;
3723
3724 assert_eq!(resps[0]["type"], "error_response");
3725 assert_eq!(resps[0]["category"], "unsupported_platform");
3726 }
3727
3728 #[tokio::test]
3729 async fn describe_still_answers_for_an_allowed_action() {
3730 let dir = tempdir().unwrap();
3733 let state = test_state(&dir);
3734
3735 let resps = exchange(
3736 state,
3737 CallerRole::Observer,
3738 vec![json!({
3739 "type": "describe",
3740 "request_id": "r1",
3741 "action_name": "GetDiskUsage",
3742 "params": {}
3743 })],
3744 1,
3745 )
3746 .await;
3747
3748 assert_eq!(resps[0]["type"], "describe_response");
3749 }
3750
3751 #[tokio::test]
3756 async fn execute_without_prior_preview_returns_stale_approval() {
3757 let dir = tempdir().unwrap();
3758 let state = test_state(&dir);
3759
3760 let resps = exchange(
3761 state,
3762 CallerRole::Observer,
3763 vec![json!({
3764 "type": "execute",
3765 "request_id": "r1",
3766 "transaction_id": "missing-transaction",
3767 "action_name": "GetSystemState",
3768 "params": {},
3769 "approval_receipt": "unissued-receipt"
3770 })],
3771 1,
3772 )
3773 .await;
3774
3775 assert_eq!(resps[0]["type"], "error_response");
3776 assert_eq!(resps[0]["category"], "stale_approval");
3777 }
3778
3779 #[tokio::test]
3780 async fn execute_with_changed_params_rejects_without_consuming_approval() {
3781 let dir = tempdir().unwrap();
3782 let state = test_state(&dir);
3783 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3784 let handler_state = state.clone();
3785 tokio::spawn(async move {
3786 unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3787 });
3788 let mut framed = FramedStream::new(client);
3789 let (transaction_id, receipt) = preview_and_approve(
3790 &mut framed,
3791 "GetServiceStatus",
3792 json!({"unit": "sshd.service"}),
3793 )
3794 .await;
3795
3796 framed
3797 .send(
3798 &serde_json::to_vec(&json!({
3799 "type": "execute",
3800 "request_id": "changed-params",
3801 "transaction_id": transaction_id,
3802 "action_name": "GetServiceStatus",
3803 "params": {"unit": "cron.service"},
3804 "approval_receipt": receipt,
3805 }))
3806 .unwrap(),
3807 )
3808 .await
3809 .unwrap();
3810 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3811
3812 assert_eq!(response["type"], "error_response");
3813 assert_eq!(response["category"], "stale_approval");
3814 assert_eq!(
3815 state
3816 .audit
3817 .get(&transaction_id)
3818 .await
3819 .unwrap()
3820 .unwrap()
3821 .status,
3822 JobState::Queued
3823 );
3824 assert!(state
3825 .audit
3826 .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3827 .await
3828 .unwrap());
3829 }
3830
3831 #[tokio::test]
3832 async fn execute_with_changed_action_rejects_without_consuming_approval() {
3833 let dir = tempdir().unwrap();
3834 let state = test_state(&dir);
3835 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3836 let handler_state = state.clone();
3837 tokio::spawn(async move {
3838 unix_connection_handler(server, handler_state, runner(), CallerRole::Observer).await;
3839 });
3840 let mut framed = FramedStream::new(client);
3841 let (transaction_id, receipt) =
3842 preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
3843
3844 framed
3845 .send(
3846 &serde_json::to_vec(&json!({
3847 "type": "execute",
3848 "request_id": "changed-action",
3849 "transaction_id": transaction_id,
3850 "action_name": "GetDiskUsage",
3851 "params": {},
3852 "approval_receipt": receipt,
3853 }))
3854 .unwrap(),
3855 )
3856 .await
3857 .unwrap();
3858 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
3859
3860 assert_eq!(response["type"], "error_response");
3861 assert_eq!(response["category"], "stale_approval");
3862 assert_eq!(
3863 state
3864 .audit
3865 .get(&transaction_id)
3866 .await
3867 .unwrap()
3868 .unwrap()
3869 .status,
3870 JobState::Queued
3871 );
3872 assert!(state
3873 .audit
3874 .claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
3875 .await
3876 .unwrap());
3877 }
3878
3879 #[tokio::test]
3880 async fn job_started_send_failure_releases_slot_and_marks_transaction_failed() {
3881 let dir = tempdir().unwrap();
3882 let mut state = test_state(&dir);
3883 state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 44 });
3884 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3885 let handler_state = state.clone();
3886 tokio::spawn(async move {
3887 unix_connection_handler(server, handler_state, runner(), CallerRole::Admin).await;
3888 });
3889 let mut preview_stream = FramedStream::new(client);
3890 let params = json!({"package": "vim"});
3891 let (transaction_id, receipt) =
3892 preview_and_approve(&mut preview_stream, "AddLayeredPackage", params.clone()).await;
3893
3894 let (peer, broken_stream) = tokio::io::duplex(64);
3895 drop(peer);
3896 let mut broken_framed = FramedStream::new(broken_stream);
3897 let typed_transaction_id = TransactionId(transaction_id.clone());
3898 let typed_receipt = ApprovalReceipt(receipt);
3899 let result = handle_execute(
3900 &mut broken_framed,
3901 &state,
3902 Arc::new(crate::executor::RealActionExecutor),
3903 &CallerRole::Admin,
3904 "execute-disconnected",
3905 &typed_transaction_id,
3906 "AddLayeredPackage",
3907 ¶ms,
3908 &typed_receipt,
3909 )
3910 .await;
3911
3912 assert!(result.is_err(), "the initial response write must fail");
3913 assert!(
3914 state.running_exclusive.lock().await.is_empty(),
3915 "a disconnected client must not retain any exclusion lock"
3916 );
3917 assert_eq!(
3918 state
3919 .audit
3920 .get(&transaction_id)
3921 .await
3922 .unwrap()
3923 .unwrap()
3924 .status,
3925 JobState::Failed,
3926 "a claimed transaction must not remain Running when execution never starts"
3927 );
3928 }
3929
3930 #[tokio::test]
3935 async fn execute_after_preview_with_receipt_returns_job_completed() {
3936 let dir = tempdir().unwrap();
3937 let state = test_state(&dir);
3938 let (client, server) = tokio::net::UnixStream::pair().unwrap();
3939
3940 tokio::spawn(async move {
3941 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
3942 });
3943
3944 let mut framed = FramedStream::new(client);
3945
3946 let preview_req = json!({
3948 "type": "preview",
3949 "request_id": "r1",
3950 "action_name": "GetSystemState",
3951 "params": {}
3952 });
3953 framed
3954 .send(&serde_json::to_vec(&preview_req).unwrap())
3955 .await
3956 .unwrap();
3957
3958 let raw = framed.recv().await.unwrap();
3959 let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
3960 assert_eq!(preview_resp["type"], "preview_response");
3961 let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
3962 let receipt = approve_preview(&mut framed, transaction_id).await;
3963
3964 let exec_req = json!({
3966 "type": "execute",
3967 "request_id": "r2",
3968 "transaction_id": transaction_id,
3969 "action_name": "GetSystemState",
3970 "params": {},
3971 "approval_receipt": receipt
3972 });
3973 framed
3974 .send(&serde_json::to_vec(&exec_req).unwrap())
3975 .await
3976 .unwrap();
3977
3978 let raw = framed.recv().await.unwrap();
3980 let started: Value = serde_json::from_slice(&raw).unwrap();
3981 assert_eq!(started["type"], "job_started");
3982 let job_id = started["job_id"].as_str().unwrap().to_string();
3983
3984 loop {
3986 let raw = framed.recv().await.unwrap();
3987 let msg: Value = serde_json::from_slice(&raw).unwrap();
3988 let msg_type = msg["type"].as_str().unwrap();
3989 match msg_type {
3990 "job_progress" => {
3991 assert_eq!(msg["job_id"], job_id);
3992 }
3993 "job_completed" => {
3994 assert_eq!(msg["job_id"], job_id);
3995 let status = msg["result"]["status"].as_str().unwrap();
3996 assert!(
3998 matches!(status, "succeeded" | "failed" | "needs_reboot"),
3999 "unexpected status: {status}"
4000 );
4001 break;
4002 }
4003 other => panic!("unexpected message type: {other}"),
4004 }
4005 }
4006 }
4007
4008 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4020 async fn update_status_failure_surfaces_as_audit_warning_in_job_completed() {
4021 use crate::audit_chain::{AuditKey, ChainRow, VerifyOutcome};
4022 use crate::store::AuditStore;
4023 use crate::transactions::{
4024 NewTransaction as NewTx, RecordedPreviewedTransaction, TransactionStoreError,
4025 };
4026 use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
4027
4028 #[derive(Debug)]
4035 struct UpdateStatusFails(std::sync::Arc<dyn AuditStore>);
4036
4037 #[async_trait::async_trait]
4038 impl AuditStore for UpdateStatusFails {
4039 async fn record(&self, t: NewTx) -> Result<TransactionRecord, TransactionStoreError> {
4040 self.0.record(t).await
4041 }
4042 async fn record_previewed(
4043 &self,
4044 t: NewTx,
4045 p: PreviewEnvelope,
4046 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
4047 self.0.record_previewed(t, p).await
4048 }
4049 async fn get(
4050 &self,
4051 id: &str,
4052 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
4053 self.0.get(id).await
4054 }
4055 async fn get_preview(
4056 &self,
4057 id: &str,
4058 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
4059 self.0.get_preview(id).await
4060 }
4061 async fn update_status(
4062 &self,
4063 id: &str,
4064 _s: JobState,
4065 ) -> Result<(), TransactionStoreError> {
4066 Err(TransactionStoreError::NotFound(id.to_string()))
4067 }
4068 async fn approve_transaction(
4069 &self,
4070 id: &str,
4071 ) -> Result<Option<String>, TransactionStoreError> {
4072 self.0.approve_transaction(id).await
4073 }
4074 async fn revoke_unconsumed_approval(
4075 &self,
4076 id: &str,
4077 ) -> Result<bool, TransactionStoreError> {
4078 self.0.revoke_unconsumed_approval(id).await
4079 }
4080 async fn claim_approved_for_execution(
4081 &self,
4082 id: &str,
4083 digest: &str,
4084 ) -> Result<bool, TransactionStoreError> {
4085 self.0.claim_approved_for_execution(id, digest).await
4086 }
4087 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
4088 self.0.cleanup_stale_queued().await
4089 }
4090 async fn cancel_queued(&self, id: &str) -> Result<bool, TransactionStoreError> {
4091 self.0.cancel_queued(id).await
4092 }
4093 async fn list_transactions(
4094 &self,
4095 limit: u32,
4096 status: Option<&str>,
4097 action: Option<&str>,
4098 since: Option<u32>,
4099 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
4100 self.0.list_transactions(limit, status, action, since).await
4101 }
4102 async fn list_history(
4103 &self,
4104 limit: u32,
4105 status: Option<&str>,
4106 action: Option<&str>,
4107 since: Option<u32>,
4108 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>
4109 {
4110 self.0.list_history(limit, status, action, since).await
4111 }
4112 async fn fetch_chain_row(
4113 &self,
4114 id: &str,
4115 ) -> Result<Option<ChainRow>, TransactionStoreError> {
4116 self.0.fetch_chain_row(id).await
4117 }
4118 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
4119 self.0.fetch_chain_rows().await
4120 }
4121 async fn fetch_event_rows(
4122 &self,
4123 ) -> Result<Vec<crate::audit_chain::EventRow>, TransactionStoreError> {
4124 self.0.fetch_event_rows().await
4125 }
4126 async fn verify_audit_chain(
4127 &self,
4128 k: &AuditKey,
4129 ) -> Result<VerifyOutcome, TransactionStoreError> {
4130 self.0.verify_audit_chain(k).await
4131 }
4132 }
4133
4134 let dir = tempdir().unwrap();
4135 let real = test_state(&dir);
4136 let faulty: std::sync::Arc<dyn AuditStore> =
4138 std::sync::Arc::new(UpdateStatusFails(real.audit.clone()));
4139 let cfg = real.config.clone();
4140 let state = crate::state::DaemonState::open_with_audit(
4141 cfg,
4142 crate::policy::PolicyTable::empty(),
4143 None,
4144 faulty,
4145 );
4146
4147 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4148 tokio::spawn(async move {
4149 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
4150 });
4151 let mut framed = FramedStream::new(client);
4152
4153 framed
4155 .send(
4156 &serde_json::to_vec(&json!({
4157 "type": "preview",
4158 "request_id": "r1",
4159 "action_name": "GetSystemState",
4160 "params": {}
4161 }))
4162 .unwrap(),
4163 )
4164 .await
4165 .unwrap();
4166 let preview_resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4167 let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
4168 let receipt = approve_preview(&mut framed, transaction_id).await;
4169
4170 framed
4171 .send(
4172 &serde_json::to_vec(&json!({
4173 "type": "execute",
4174 "request_id": "r2",
4175 "transaction_id": transaction_id,
4176 "action_name": "GetSystemState",
4177 "params": {},
4178 "approval_receipt": receipt
4179 }))
4180 .unwrap(),
4181 )
4182 .await
4183 .unwrap();
4184
4185 let mut completed: Option<Value> = None;
4187 for _ in 0..30 {
4188 let raw = framed.recv().await.unwrap();
4189 let msg: Value = serde_json::from_slice(&raw).unwrap();
4190 if msg["type"] == "job_completed" {
4191 completed = Some(msg);
4192 break;
4193 }
4194 }
4195 let completed = completed.expect("dispatcher must emit job_completed");
4196 let warnings = completed["result"]["warnings"]
4197 .as_array()
4198 .expect("warnings array on job_completed");
4199
4200 assert!(
4201 warnings
4202 .iter()
4203 .any(|w| w.as_str().unwrap_or("").contains("audit trail update failed")),
4204 "expected an `audit trail update failed` warning when update_status returns Err; got: {warnings:#?}"
4205 );
4206 }
4207
4208 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4225 async fn concurrent_executes_against_one_preview_serialise_to_one_winner() {
4226 let dir = tempdir().unwrap();
4227 let state = test_state(&dir);
4228
4229 let (pc, ps) = tokio::net::UnixStream::pair().unwrap();
4231 {
4232 let state = state.clone();
4233 tokio::spawn(async move {
4234 unix_connection_handler(ps, state, runner(), CallerRole::Observer).await;
4235 });
4236 }
4237 let mut framed_p = FramedStream::new(pc);
4238 framed_p
4239 .send(
4240 &serde_json::to_vec(&json!({
4241 "type": "preview",
4242 "request_id": "preview-once",
4243 "action_name": "GetSystemState",
4244 "params": {}
4245 }))
4246 .unwrap(),
4247 )
4248 .await
4249 .unwrap();
4250 let raw = framed_p.recv().await.unwrap();
4251 let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
4252 let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
4253 let approval_receipt = approve_preview(&mut framed_p, &transaction_id).await;
4254
4255 async fn exec_once(
4257 state: crate::state::DaemonState,
4258 transaction_id: String,
4259 approval_receipt: String,
4260 request_id: &'static str,
4261 ) -> Vec<Value> {
4262 let (c, s) = tokio::net::UnixStream::pair().unwrap();
4263 tokio::spawn(async move {
4264 unix_connection_handler(s, state, runner(), CallerRole::Observer).await;
4265 });
4266 let mut f = FramedStream::new(c);
4267 f.send(
4268 &serde_json::to_vec(&json!({
4269 "type": "execute",
4270 "request_id": request_id,
4271 "transaction_id": transaction_id,
4272 "action_name": "GetSystemState",
4273 "params": {},
4274 "approval_receipt": approval_receipt
4275 }))
4276 .unwrap(),
4277 )
4278 .await
4279 .unwrap();
4280
4281 let mut frames = Vec::new();
4285 for _ in 0..30 {
4286 let raw = match f.recv().await {
4287 Ok(b) => b,
4288 Err(_) => break,
4289 };
4290 let v: Value = serde_json::from_slice(&raw).unwrap();
4291 let kind = v["type"].as_str().unwrap_or("").to_string();
4292 frames.push(v);
4293 if kind == "job_completed" || kind == "error_response" {
4294 break;
4295 }
4296 }
4297 frames
4298 }
4299
4300 let (a, b) = tokio::join!(
4301 exec_once(
4302 state.clone(),
4303 transaction_id.clone(),
4304 approval_receipt.clone(),
4305 "exec-A"
4306 ),
4307 exec_once(
4308 state.clone(),
4309 transaction_id.clone(),
4310 approval_receipt.clone(),
4311 "exec-B"
4312 ),
4313 );
4314
4315 let terminal = |frames: &[Value]| -> &'static str {
4317 for f in frames {
4318 match f["type"].as_str().unwrap_or("") {
4319 "job_completed" => return "win",
4320 "error_response" => return "lose",
4321 _ => {}
4322 }
4323 }
4324 "neither"
4325 };
4326 let outcomes = [terminal(&a), terminal(&b)];
4327
4328 let mut sorted = outcomes;
4330 sorted.sort();
4331 assert_eq!(
4332 sorted,
4333 ["lose", "win"],
4334 "exactly one execute must win and one must lose; got {outcomes:?}\n A={a:#?}\n B={b:#?}"
4335 );
4336
4337 let loser = if outcomes[0] == "lose" { &a } else { &b };
4340 let err_frame = loser
4341 .iter()
4342 .find(|f| f["type"] == "error_response")
4343 .expect("loser has an error_response");
4344 assert_eq!(
4345 err_frame["category"], "stale_approval",
4346 "loser must be classified stale_approval; got {err_frame:#?}"
4347 );
4348 }
4349
4350 #[tokio::test]
4360 async fn re_executing_a_completed_approval_returns_stale_approval() {
4361 let dir = tempdir().unwrap();
4362 let state = test_state(&dir);
4363 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4364
4365 tokio::spawn(async move {
4366 unix_connection_handler(server, state, runner(), CallerRole::Observer).await;
4367 });
4368
4369 let mut framed = FramedStream::new(client);
4370
4371 framed
4374 .send(
4375 &serde_json::to_vec(&json!({
4376 "type": "preview",
4377 "request_id": "r1",
4378 "action_name": "GetSystemState",
4379 "params": {}
4380 }))
4381 .unwrap(),
4382 )
4383 .await
4384 .unwrap();
4385
4386 let raw = framed.recv().await.unwrap();
4387 let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
4388 assert_eq!(preview_resp["type"], "preview_response");
4389 let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
4390 let approval_receipt = approve_preview(&mut framed, &transaction_id).await;
4391
4392 framed
4394 .send(
4395 &serde_json::to_vec(&json!({
4396 "type": "execute",
4397 "request_id": "r2",
4398 "transaction_id": transaction_id,
4399 "action_name": "GetSystemState",
4400 "params": {},
4401 "approval_receipt": approval_receipt
4402 }))
4403 .unwrap(),
4404 )
4405 .await
4406 .unwrap();
4407
4408 loop {
4411 let raw = framed.recv().await.unwrap();
4412 let msg: Value = serde_json::from_slice(&raw).unwrap();
4413 match msg["type"].as_str().unwrap() {
4414 "job_started" | "job_progress" => continue,
4415 "job_completed" => break,
4416 other => panic!("unexpected message during first execute: {other}"),
4417 }
4418 }
4419
4420 framed
4423 .send(
4424 &serde_json::to_vec(&json!({
4425 "type": "execute",
4426 "request_id": "r3-replay",
4427 "transaction_id": transaction_id,
4428 "action_name": "GetSystemState",
4429 "params": {},
4430 "approval_receipt": approval_receipt
4431 }))
4432 .unwrap(),
4433 )
4434 .await
4435 .unwrap();
4436
4437 let raw = framed.recv().await.unwrap();
4438 let resp: Value = serde_json::from_slice(&raw).unwrap();
4439 assert_eq!(
4440 resp["type"], "error_response",
4441 "replay must produce error_response, got: {resp}"
4442 );
4443 assert_eq!(
4444 resp["category"], "stale_approval",
4445 "replay must be classified as stale_approval, got: {}",
4446 resp["category"]
4447 );
4448 assert_eq!(resp["request_id"], "r3-replay");
4449 }
4450
4451 #[tokio::test]
4456 async fn describe_returns_command_and_risk_for_known_action() {
4457 let dir = tempdir().unwrap();
4458 let state = test_state(&dir);
4459
4460 let resps = exchange(
4461 state,
4462 CallerRole::Observer,
4463 vec![json!({
4464 "type": "describe",
4465 "request_id": "r1",
4466 "action_name": "GetDateTime",
4467 "params": {}
4468 })],
4469 1,
4470 )
4471 .await;
4472
4473 assert_eq!(resps[0]["type"], "describe_response");
4474 assert_eq!(resps[0]["request_id"], "r1");
4475 assert_eq!(resps[0]["command"], "timedatectl");
4476 assert_eq!(resps[0]["risk_level"], "low");
4477 assert_eq!(resps[0]["reboot_required"], false);
4478 }
4479
4480 #[tokio::test]
4481 async fn describe_returns_error_for_unknown_action() {
4482 let dir = tempdir().unwrap();
4483 let state = test_state(&dir);
4484
4485 let resps = exchange(
4486 state,
4487 CallerRole::Observer,
4488 vec![json!({
4489 "type": "describe",
4490 "request_id": "r1",
4491 "action_name": "NotARealAction",
4492 "params": {}
4493 })],
4494 1,
4495 )
4496 .await;
4497
4498 assert_eq!(resps[0]["type"], "error_response");
4499 assert_eq!(resps[0]["category"], "validation_failure");
4500 }
4501
4502 #[tokio::test]
4507 async fn unknown_message_type_returns_validation_failure() {
4508 let dir = tempdir().unwrap();
4509 let state = test_state(&dir);
4510
4511 let resps = exchange(
4512 state,
4513 CallerRole::Observer,
4514 vec![json!({"type": "does_not_exist", "request_id": "r1"})],
4515 1,
4516 )
4517 .await;
4518
4519 assert_eq!(resps[0]["type"], "error_response");
4520 assert_eq!(resps[0]["category"], "validation_failure");
4521 }
4522
4523 #[tokio::test]
4528 async fn fast_executor_progress_is_drained_before_job_completed() {
4529 struct FastProgressExecutor;
4530
4531 #[async_trait::async_trait]
4532 impl ActionExecutor for FastProgressExecutor {
4533 async fn execute(
4534 &self,
4535 _spec: &crate::actions::ActionSpec,
4536 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
4537 {
4538 unreachable!("dispatcher uses execute_with_progress")
4539 }
4540
4541 async fn execute_with_progress(
4542 &self,
4543 _spec: &crate::actions::ActionSpec,
4544 progress: tokio::sync::mpsc::UnboundedSender<String>,
4545 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
4546 {
4547 progress.send("last line before exit".to_string()).unwrap();
4548 Ok(crate::executor::ExecutionOutput {
4549 stdout: "last line before exit\n".to_string(),
4550 stderr: String::new(),
4551 exit_code: 0,
4552 })
4553 }
4554 }
4555
4556 let dir = tempdir().unwrap();
4557 let state = test_state(&dir);
4558 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4559 tokio::spawn(async move {
4560 connection_handler_with_executor(
4561 server,
4562 state,
4563 runner(),
4564 Arc::new(FastProgressExecutor),
4565 CallerRole::Observer,
4566 )
4567 .await;
4568 });
4569 let mut framed = FramedStream::new(client);
4570 let (transaction_id, receipt) =
4571 preview_and_approve(&mut framed, "GetSystemState", json!({})).await;
4572 framed
4573 .send(
4574 &serde_json::to_vec(&json!({
4575 "type": "execute",
4576 "request_id": "execute-fast-progress",
4577 "transaction_id": transaction_id,
4578 "action_name": "GetSystemState",
4579 "params": {},
4580 "approval_receipt": receipt,
4581 }))
4582 .unwrap(),
4583 )
4584 .await
4585 .unwrap();
4586
4587 let mut progress_lines = Vec::new();
4588 loop {
4589 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4590 match response["type"].as_str().unwrap() {
4591 "job_progress" => {
4592 progress_lines.push(response["line"].as_str().unwrap().to_string())
4593 }
4594 "job_started" => {}
4595 "job_completed" => break,
4596 other => panic!("unexpected response: {other}"),
4597 }
4598 }
4599 assert!(progress_lines
4600 .iter()
4601 .any(|line| line == "last line before exit"));
4602 }
4603
4604 #[cfg(target_os = "linux")]
4609 mod vsock_auth {
4610 use super::*;
4611 use std::sync::Arc;
4612
4613 fn executor() -> Arc<dyn ActionExecutor> {
4614 Arc::new(crate::executor::RealActionExecutor)
4615 }
4616
4617 fn write_token_mode(path: &std::path::Path, mode: u32) {
4622 use std::os::unix::fs::PermissionsExt;
4623 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
4624 }
4625
4626 #[tokio::test]
4630 async fn group_or_world_readable_token_file_is_rejected() {
4631 let dir = tempdir().unwrap();
4632 let token_path = dir.path().join("token");
4633 std::fs::write(&token_path, "test-secret").unwrap();
4634 write_token_mode(&token_path, 0o644);
4635
4636 assert!(
4637 crate::auth::validate_token_against_file("test-secret", &token_path).is_none(),
4638 "a 0644 token file must be rejected even when the token matches"
4639 );
4640
4641 write_token_mode(&token_path, 0o600);
4643 assert!(
4644 crate::auth::validate_token_against_file("test-secret", &token_path).is_some(),
4645 "a 0600 token file with a matching token must be accepted"
4646 );
4647 }
4648
4649 #[tokio::test]
4651 async fn valid_token_grants_access() {
4652 let dir = tempdir().unwrap();
4653 let token_path = dir.path().join("token");
4654 std::fs::write(&token_path, "test-secret").unwrap();
4655 write_token_mode(&token_path, 0o600);
4656
4657 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4658 let state = test_state(&dir);
4659
4660 tokio::spawn(async move {
4661 let mut framed = FramedStream::new(server);
4662 let role = authenticate_vsock_token(&mut framed, &token_path).await;
4663 if let Some(r) = role {
4664 dispatch_loop(&mut framed, state, runner(), executor(), r).await;
4665 }
4666 });
4667
4668 let mut framed = FramedStream::new(client);
4669 let auth = serde_json::json!({"type": "auth", "token": "test-secret"});
4670 framed
4671 .send(&serde_json::to_vec(&auth).unwrap())
4672 .await
4673 .unwrap();
4674 let req = serde_json::json!({"type": "query_state", "request_id": "vsock-r1"});
4675 framed
4676 .send(&serde_json::to_vec(&req).unwrap())
4677 .await
4678 .unwrap();
4679 let raw = framed.recv().await.unwrap();
4680 let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
4681 assert_eq!(resp["type"], "state_response");
4682 }
4683
4684 #[tokio::test]
4686 async fn wrong_token_closes_connection() {
4687 let dir = tempdir().unwrap();
4688 let token_path = dir.path().join("token");
4689 std::fs::write(&token_path, "correct").unwrap();
4690 write_token_mode(&token_path, 0o600);
4691
4692 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4693 let state = test_state(&dir);
4694
4695 tokio::spawn(async move {
4696 let mut framed = FramedStream::new(server);
4697 let _ = authenticate_vsock_token(&mut framed, &token_path).await;
4698 drop(state);
4700 });
4701
4702 let mut framed = FramedStream::new(client);
4703 let auth = serde_json::json!({"type": "auth", "token": "wrong"});
4704 framed
4705 .send(&serde_json::to_vec(&auth).unwrap())
4706 .await
4707 .unwrap();
4708 let result = framed.recv().await;
4710 assert!(
4711 result.is_err(),
4712 "expected EOF after wrong token, got {:?}",
4713 result
4714 );
4715 }
4716
4717 #[tokio::test]
4719 async fn eof_before_auth_frame_closes_connection() {
4720 let dir = tempdir().unwrap();
4721 let token_path = dir.path().join("token");
4722 std::fs::write(&token_path, "secret").unwrap();
4723 write_token_mode(&token_path, 0o600);
4724
4725 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4726
4727 tokio::spawn(async move {
4728 let mut framed = FramedStream::new(server);
4729 let result = authenticate_vsock_token(&mut framed, &token_path).await;
4730 assert!(result.is_none());
4731 });
4732
4733 drop(client);
4735 }
4736
4737 #[tokio::test]
4739 async fn malformed_json_frame_closes_connection() {
4740 let dir = tempdir().unwrap();
4741 let token_path = dir.path().join("token");
4742 std::fs::write(&token_path, "secret").unwrap();
4743 write_token_mode(&token_path, 0o600);
4744
4745 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4746
4747 let handle = tokio::spawn(async move {
4748 let mut framed = FramedStream::new(server);
4749 let result = authenticate_vsock_token(&mut framed, &token_path).await;
4750 assert!(result.is_none());
4751 });
4752
4753 let mut framed = FramedStream::new(client);
4754 framed.send(b"not json at all {{{{").await.unwrap();
4755 drop(framed);
4756 handle.await.unwrap();
4757 }
4758
4759 #[tokio::test]
4761 async fn wrong_msg_type_closes_connection() {
4762 let dir = tempdir().unwrap();
4763 let token_path = dir.path().join("token");
4764 std::fs::write(&token_path, "secret").unwrap();
4765 write_token_mode(&token_path, 0o600);
4766
4767 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4768
4769 let handle = tokio::spawn(async move {
4770 let mut framed = FramedStream::new(server);
4771 let result = authenticate_vsock_token(&mut framed, &token_path).await;
4772 assert!(result.is_none());
4773 });
4774
4775 let mut framed = FramedStream::new(client);
4776 let bad = serde_json::json!({"type": "query_state", "token": "secret"});
4777 framed
4778 .send(&serde_json::to_vec(&bad).unwrap())
4779 .await
4780 .unwrap();
4781 drop(framed);
4782 handle.await.unwrap();
4783 }
4784 }
4785
4786 #[tokio::test]
4791 async fn connection_handler_accepts_duplex_stream() {
4792 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
4795 let dir = tempdir().unwrap();
4796 let state = test_state(&dir);
4797 tokio::spawn(unix_connection_handler(
4798 server,
4799 state,
4800 runner(),
4801 CallerRole::Observer,
4802 ));
4803
4804 let mut framed = FramedStream::new(client);
4805 let req = json!({"type": "query_state", "request_id": "duplex-r1"});
4806 framed
4807 .send(&serde_json::to_vec(&req).unwrap())
4808 .await
4809 .unwrap();
4810 let raw = framed.recv().await.unwrap();
4811 let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
4812 assert_eq!(resp["type"], "state_response");
4813 assert_eq!(resp["request_id"], "duplex-r1");
4814 }
4815}