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 "ConfigureWifi" => &["password"],
60 _ => &[],
61 }
62}
63
64fn redact_params(action_name: &str, params: &Value) -> Value {
67 let keys = credential_keys_for(action_name);
68 if keys.is_empty() {
69 return params.clone();
70 }
71 if let Value::Object(map) = params {
72 let mut out = serde_json::Map::with_capacity(map.len());
73 for (k, v) in map {
74 if keys.iter().any(|ck| *ck == k) {
75 out.insert(k.clone(), Value::String("<REDACTED>".to_string()));
76 } else {
77 out.insert(k.clone(), v.clone());
78 }
79 }
80 Value::Object(out)
81 } else {
82 params.clone()
83 }
84}
85
86fn credential_argv_spec(action_name: &str) -> Option<CredentialArgvSpec> {
97 match action_name {
98 "ProAttach" => Some(CredentialArgvSpec::Last),
100 "ConfigureWifi" => Some(CredentialArgvSpec::AfterKeyword("password")),
104 _ => None,
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114enum CredentialArgvSpec {
115 Last,
117 AfterKeyword(&'static str),
119}
120
121fn redact_argv(action_name: &str, params: &Value, args: &[String]) -> Vec<String> {
136 let keys = credential_keys_for(action_name);
137 if keys.is_empty() {
138 return args.to_vec();
139 }
140 let secrets: Vec<&str> = keys
141 .iter()
142 .filter_map(|k| params.get(k).and_then(|v| v.as_str()))
143 .collect();
144 if secrets.is_empty() {
145 return args.to_vec();
146 }
147
148 let mut out: Vec<String> = args.to_vec();
149
150 if let Some(spec) = credential_argv_spec(action_name) {
152 let idx = match spec {
153 CredentialArgvSpec::Last => Some(out.len().saturating_sub(1)),
154 CredentialArgvSpec::AfterKeyword(keyword) => out
159 .iter()
160 .enumerate()
161 .rev()
162 .find(|(i, arg)| arg.as_str() == keyword && i + 1 < out.len())
163 .map(|(i, _)| i + 1),
164 };
165 if let Some(idx) = idx.filter(|i| *i < out.len()) {
166 out[idx] = "<REDACTED>".to_string();
167 }
168 return out;
172 }
173
174 out.iter_mut().for_each(|a| {
176 if secrets.iter().any(|s| s == &a.as_str()) {
177 *a = "<REDACTED>".to_string();
178 }
179 });
180
181 out
182}
183
184use crate::{
185 audit_chain::RequestHashKey,
186 auth::{highest_role_from_groups, CallerAttribution},
187 executor::{build_action_spec, rollback_spec_for, ActionExecutor},
188 preview::preview_action,
189 state::DaemonState,
190 state_collector::{collect_state, CollectedState, CommandRunner},
191 transactions::{HistorySince, NewTransaction},
192 transport::framing::{FramedStream, FramingError},
193};
194
195const VSOCK_AUTH_FRAME_TIMEOUT_SECS: u64 = 10;
206
207const DEFAULT_HISTORY_LIMIT: u32 = 20;
214
215const TRANSACTION_ID_DISPLAY_PREFIX_LEN: usize = 8;
221
222#[derive(Debug, Deserialize)]
227#[serde(transparent)]
228struct TransactionId(String);
229
230impl TransactionId {
231 fn as_str(&self) -> &str {
232 &self.0
233 }
234}
235
236#[derive(Debug, Deserialize)]
237#[serde(transparent)]
238struct ApprovalReceipt(String);
239
240impl ApprovalReceipt {
241 fn as_str(&self) -> &str {
242 &self.0
243 }
244}
245
246#[derive(Debug, Deserialize)]
247#[serde(tag = "type", rename_all = "snake_case")]
248enum DaemonRequest {
249 QueryState {
250 request_id: String,
251 },
252 Preview {
253 request_id: String,
254 action_name: String,
255 params: Value,
256 #[serde(default)]
265 unattended: bool,
266 },
267 Approve {
268 request_id: String,
269 transaction_id: TransactionId,
270 },
271 ApprovalDetails {
272 request_id: String,
273 transaction_id: TransactionId,
274 },
275 Execute {
276 request_id: String,
277 transaction_id: TransactionId,
278 action_name: String,
279 params: Value,
280 approval_receipt: ApprovalReceipt,
281 },
282 Cancel {
286 request_id: String,
287 transaction_id: TransactionId,
288 },
289 QueryAction {
290 request_id: String,
291 action_name: String,
292 params: Value,
293 },
294 QueryHistory {
299 request_id: String,
300 limit: Option<u32>,
301 status_filter: Option<String>,
302 action_filter: Option<String>,
303 since_hours: Option<u32>,
304 since: Option<String>,
307 },
308 Describe {
309 request_id: String,
310 action_name: String,
311 params: Value,
312 },
313}
314
315#[cfg(fuzzing)]
326pub fn fuzz_decode_request(raw: &[u8]) -> bool {
327 match serde_json::from_slice::<Value>(raw) {
328 Ok(msg) => serde_json::from_value::<DaemonRequest>(msg).is_ok(),
329 Err(_) => false,
330 }
331}
332
333#[derive(Debug, Serialize)]
338#[serde(tag = "type", rename_all = "snake_case")]
339enum DaemonResponse {
340 StateResponse {
341 request_id: String,
342 state: CollectedState,
343 },
344 PreviewResponse {
345 request_id: String,
346 preview: PreviewEnvelope,
347 transaction_id: String,
348 },
349 ApprovalResponse {
350 request_id: String,
351 transaction_id: String,
352 approval_receipt: String,
353 },
354 ApprovalDetailsResponse {
355 request_id: String,
356 transaction_id: String,
357 action_name: String,
358 preview: PreviewEnvelope,
359 },
360 JobStarted {
361 request_id: String,
362 job_id: String,
363 transaction_id: String,
364 },
365 JobProgress {
366 job_id: String,
367 line: String,
368 },
369 JobCompleted {
370 job_id: String,
371 result: JobResult,
372 },
373 QueryActionResponse {
374 request_id: String,
375 action_name: String,
376 output: String,
377 },
378 HistoryResponse {
379 request_id: String,
380 entries: Vec<crate::transactions::JobHistoryEntry>,
381 },
382 CancelResponse {
383 request_id: String,
384 transaction_id: String,
385 },
386 DescribeResponse {
387 request_id: String,
388 command: String,
389 risk_level: String,
390 reboot_required: bool,
391 },
392 ErrorResponse {
393 request_id: String,
394 category: String,
395 message: String,
396 },
397 ConflictResponse {
411 request_id: String,
412 message: String,
413 retry_after_seconds: Option<u32>,
416 },
417}
418
419#[derive(Debug, Serialize)]
425struct JobResult {
426 status: String,
429 summary: String,
431 warnings: Vec<String>,
433 job_id: String,
435 needs_reboot: bool,
437 rollback_ref: Option<String>,
440 transaction_id: String,
443}
444
445#[cfg(target_os = "linux")]
454const SO_PEERPIDFD: libc::c_int = 77;
455
456#[cfg(target_os = "linux")]
457enum PeerPin {
458 Pinned(OwnedFd),
459 Unsupported,
460 Unpinnable,
461}
462
463#[cfg(target_os = "linux")]
464impl PeerPin {
465 fn from_error(error: &std::io::Error) -> Self {
466 if error.raw_os_error() == Some(libc::ENOPROTOOPT) {
467 Self::Unsupported
468 } else {
469 Self::Unpinnable
470 }
471 }
472}
473
474#[cfg(target_os = "linux")]
482fn peer_pidfd(stream: &UnixStream) -> PeerPin {
483 let mut fd: libc::c_int = -1;
484 let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
485 let rc = unsafe {
488 libc::getsockopt(
489 stream.as_raw_fd(),
490 libc::SOL_SOCKET,
491 SO_PEERPIDFD,
492 (&mut fd as *mut libc::c_int).cast::<libc::c_void>(),
493 &mut len,
494 )
495 };
496 if rc != 0 {
497 return PeerPin::from_error(&std::io::Error::last_os_error());
498 }
499 if fd < 0 {
500 return PeerPin::Unpinnable;
501 }
502 PeerPin::Pinned(unsafe { OwnedFd::from_raw_fd(fd) })
505}
506
507#[cfg(target_os = "linux")]
510fn groups_for_pinned_peer(
511 pid: u32,
512 pin: PeerPin,
513 read_groups: impl FnOnce() -> Vec<String>,
514) -> Vec<String> {
515 match pin {
516 PeerPin::Unsupported => read_groups(),
517 PeerPin::Unpinnable => {
518 eprintln!(
519 "[sysknife-daemon] WARNING: cannot pin peer PID {pid}; ignoring supplementary \
520 groups and using the SO_PEERCRED primary GID only"
521 );
522 Vec::new()
523 }
524 PeerPin::Pinned(fd) => {
525 let groups = read_groups();
526 if pidfd_peer_still_live(&fd) {
527 groups
528 } else {
529 eprintln!(
530 "[sysknife-daemon] WARNING: peer PID {pid} was reaped while resolving its \
531 groups (possible PID reuse); ignoring supplementary groups and using the \
532 SO_PEERCRED primary GID only"
533 );
534 Vec::new()
535 }
536 }
537 }
538}
539
540#[cfg(target_os = "linux")]
546fn pidfd_peer_still_live(pidfd: &OwnedFd) -> bool {
547 let rc = unsafe {
550 libc::syscall(
551 libc::SYS_pidfd_send_signal,
552 pidfd.as_raw_fd(),
553 0,
554 std::ptr::null::<libc::c_void>(),
555 0,
556 )
557 };
558 if rc == 0 {
559 return true;
560 }
561 std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
564}
565
566pub fn resolve_caller(stream: &UnixStream) -> CallerAttribution {
591 resolve_caller_with_pin(
594 stream,
595 #[cfg(target_os = "linux")]
596 peer_pidfd(stream),
597 )
598}
599
600fn resolve_caller_with_pin(
601 stream: &UnixStream,
602 #[cfg(target_os = "linux")] pin: PeerPin,
603) -> CallerAttribution {
604 let (pid, primary_gid, uid) = match stream.peer_cred() {
605 Ok(cred) => {
606 let pid = match cred.pid() {
607 Some(p) if p > 0 => p as u32,
612 _ => {
613 eprintln!(
614 "[sysknife-daemon] WARNING: peer_cred() returned no usable pid; \
615 defaulting to Observer and recording the caller as unattributed"
616 );
617 return CallerAttribution::unattributed();
618 }
619 };
620 (pid, cred.gid(), cred.uid())
621 }
622 Err(e) => {
623 eprintln!(
624 "[sysknife-daemon] WARNING: peer_cred() failed: {e}; defaulting to Observer \
625 and recording the caller as unattributed"
626 );
627 return CallerAttribution::unattributed();
628 }
629 };
630
631 let gid_map = read_gid_map();
634 #[cfg(target_os = "linux")]
635 let mut groups = groups_for_pinned_peer(pid, pin, || groups_for_pid(pid, &gid_map));
636 #[cfg(not(target_os = "linux"))]
637 let mut groups = groups_for_pid(pid, &gid_map);
638
639 if let Some(name) = gid_map.get(&primary_gid) {
642 if !groups.contains(name) {
643 groups.push(name.clone());
644 }
645 }
646 if groups.is_empty() {
647 eprintln!(
648 "[sysknife-daemon] WARNING: could not resolve groups for PID {pid}; defaulting to Observer"
649 );
650 }
651 if uid == overflow_uid() {
664 eprintln!(
665 "[sysknife-daemon] WARNING: SO_PEERCRED reported the overflow uid ({uid}) for pid \
666 {pid}; the peer is not representable in this daemon's user namespace. Recording \
667 the caller as unattributed rather than as uid:{uid}."
668 );
669 return CallerAttribution::unattributed();
670 }
671
672 CallerAttribution::from_peer_uid(uid, highest_role_from_groups(groups))
673}
674
675fn overflow_uid() -> u32 {
684 const DEFAULT_OVERFLOW_UID: u32 = 65534;
686 static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
687 *CACHED.get_or_init(|| {
688 std::fs::read_to_string("/proc/sys/kernel/overflowuid")
689 .ok()
690 .and_then(|raw| raw.trim().parse::<u32>().ok())
691 .unwrap_or(DEFAULT_OVERFLOW_UID)
692 })
693}
694
695fn read_gid_map() -> std::collections::HashMap<u32, String> {
698 let content = match std::fs::read_to_string("/etc/group") {
699 Ok(c) => c,
700 Err(e) => {
701 eprintln!("[sysknife-daemon] WARNING: cannot read /etc/group: {e}; all callers will be demoted to Observer");
704 return std::collections::HashMap::new();
705 }
706 };
707 let mut map = std::collections::HashMap::new();
708 for (line_no, line) in content.lines().enumerate() {
709 let mut parts = line.splitn(4, ':');
710 let name = match parts.next() {
711 Some(n) => n,
712 None => {
713 eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no}: missing name field");
714 continue;
715 }
716 };
717 let _ = parts.next(); let gid_str = match parts.next() {
719 Some(g) => g,
720 None => {
721 eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): missing GID field");
722 continue;
723 }
724 };
725 match gid_str.parse::<u32>() {
726 Ok(gid) => {
727 map.insert(gid, name.to_string());
728 }
729 Err(_) => {
730 eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): GID {gid_str:?} is not a number");
731 }
732 }
733 }
734 map
735}
736
737fn groups_for_pid(pid: u32, gid_map: &std::collections::HashMap<u32, String>) -> Vec<String> {
738 let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
739 Ok(s) => s,
740 Err(e) => {
741 eprintln!(
742 "[sysknife-daemon] could not read /proc/{pid}/status: {e}; treating as no groups"
743 );
744 return vec![];
745 }
746 };
747 for line in status.lines() {
748 if line.starts_with("Groups:") {
749 return line
750 .trim_start_matches("Groups:")
751 .split_whitespace()
752 .filter_map(|s| s.parse::<u32>().ok())
753 .filter_map(|gid| gid_map.get(&gid).cloned())
754 .collect();
755 }
756 }
757 eprintln!(
761 "[sysknife-daemon] WARNING: no Groups: line in /proc/{pid}/status; \
762 supplementary groups unavailable — caller may be demoted to Observer"
763 );
764 vec![]
765}
766
767fn authorize_action(
772 policy: &crate::policy::PolicyTable,
773 caller: &CallerRole,
774 action_name: &str,
775) -> bool {
776 policy.action_allowed(caller, action_name)
777}
778
779fn authorization_failure_message(
787 policy: &crate::policy::PolicyTable,
788 caller: &CallerRole,
789 action_name: &str,
790) -> String {
791 match policy.min_role_for_action(action_name) {
792 Some(required) => crate::auth::denial_message(action_name, *caller, required),
793 None => format!("action '{action_name}' is not allowed for {caller:?} role"),
796 }
797}
798
799async fn caller_owns_transaction(
836 state: &DaemonState,
837 caller: &CallerAttribution,
838 transaction_id: &str,
839) -> Ownership {
840 if matches!(
841 caller.principal(),
842 crate::auth::CallerPrincipal::Unattributed
843 ) {
844 return Ownership::CannotEstablish(
845 "this connection could not be attributed to an account, so it cannot approve, \
846 cancel, inspect or execute a transaction. The daemon reports the peer as \
847 unattributed when SO_PEERCRED fails or when the peer is not representable in \
848 this daemon's user namespace."
849 .to_string(),
850 );
851 }
852 let row = match state.audit.fetch_chain_row(transaction_id).await {
853 Ok(row) => row,
854 Err(e) => {
855 return Ownership::CannotEstablish(format!(
856 "failed to load transaction ownership: {e}"
857 ));
858 }
859 };
860 let Some(row) = row else {
865 return Ownership::NoSuchTransaction;
866 };
867 let Some(owner) = row.caller_principal.as_deref() else {
868 return Ownership::CannotEstablish(format!(
869 "transaction {transaction_id} records no owning account, so ownership cannot be \
870 established"
871 ));
872 };
873 if owner == caller.principal().as_signed_str() {
874 Ownership::Owned
875 } else {
876 Ownership::NotOwned
877 }
878}
879
880enum Ownership {
888 Owned,
889 NotOwned,
890 NoSuchTransaction,
891 CannotEstablish(String),
892}
893
894fn not_your_transaction_message(transaction_id: &str) -> String {
900 format!(
901 "transaction {transaction_id} was created by a different account. An approval receipt \
902 is proof that a specific account confirmed a specific preview, so only the account \
903 that previewed an action can approve, cancel, inspect or execute it."
904 )
905}
906
907async fn authorize_for_transaction(
908 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
909 state: &DaemonState,
910 caller: &CallerAttribution,
911 request_id: &str,
912 transaction_id: &str,
913 missing_category: &str,
914 missing_message: &str,
915) -> Result<bool, HandlerError> {
916 match state.audit.get(transaction_id).await {
917 Ok(Some(transaction)) => {
918 match caller_owns_transaction(state, caller, transaction_id).await {
919 Ownership::Owned | Ownership::NoSuchTransaction => {}
920 Ownership::NotOwned => {
921 send_error(
922 framed,
923 request_id,
924 "authorization_failure",
925 not_your_transaction_message(transaction_id),
926 )
927 .await?;
928 return Ok(false);
929 }
930 Ownership::CannotEstablish(message) => {
931 send_error(framed, request_id, "authorization_failure", message).await?;
932 return Ok(false);
933 }
934 }
935 if authorize_action(&state.policy, &caller.role(), &transaction.action_name) {
936 Ok(true)
937 } else {
938 send_error(
939 framed,
940 request_id,
941 "authorization_failure",
942 authorization_failure_message(
943 &state.policy,
944 &caller.role(),
945 &transaction.action_name,
946 ),
947 )
948 .await?;
949 Ok(false)
950 }
951 }
952 Ok(None) => {
953 send_error(framed, request_id, missing_category, missing_message).await?;
954 Ok(false)
955 }
956 Err(e) => {
957 send_error(
958 framed,
959 request_id,
960 "transient_infrastructure_failure",
961 format!("failed to load transaction: {e}"),
962 )
963 .await?;
964 Ok(false)
965 }
966 }
967}
968
969use sysknife_core::action_family::{action_matches_distro, action_requires_distro};
973
974fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<(), String> {
975 let is_mutating = crate::policy::min_role_for_action(action_name)
983 .is_some_and(|role| role > CallerRole::Observer);
984 if !action_requires_distro(action_name) && !is_mutating {
985 return Ok(());
986 }
987
988 let distro = state.host_distro.as_ref().ok_or_else(|| {
1009 format!("cannot safely run {action_name}: host distribution could not be detected")
1010 })?;
1011 if !distro.is_supported() {
1012 return Err(format!(
1013 "cannot run {action_name} on unsupported host {distro}; see docs/distro-support.md"
1014 ));
1015 }
1016 if !action_matches_distro(action_name, distro) {
1017 return Err(format!(
1018 "action {action_name} is incompatible with supported host {distro}"
1019 ));
1020 }
1021 Ok(())
1022}
1023
1024pub fn compute_request_hash(key: &RequestHashKey, action_name: &str, params: &Value) -> String {
1042 let canonical = canonical_json(params);
1043 key.request_hash(action_name, &canonical)
1044}
1045
1046const AUTOREMOVE_REMOVALS_KEY: &str = "autoremove_removals";
1049
1050const DEPLOYMENT_IDENTITY_KEY: &str = "deployment_identity";
1052
1053fn parse_deployment_identity(status_json: &str, index: u32) -> Result<String, String> {
1062 let doc: Value = serde_json::from_str(status_json)
1063 .map_err(|e| format!("rpm-ostree status --json did not parse: {e}"))?;
1064 let deployments = doc
1065 .get("deployments")
1066 .and_then(Value::as_array)
1067 .ok_or_else(|| "rpm-ostree status --json has no deployments array".to_string())?;
1068 let entry = deployments.get(index as usize).ok_or_else(|| {
1069 format!(
1070 "there is no deployment at index {index}; rpm-ostree lists {}",
1071 deployments.len()
1072 )
1073 })?;
1074 let checksum = entry
1075 .get("checksum")
1076 .and_then(Value::as_str)
1077 .filter(|c| !c.is_empty())
1078 .ok_or_else(|| format!("the deployment at index {index} carries no checksum"))?;
1079 Ok(checksum.to_string())
1080}
1081
1082async fn resolve_deployment_identity(
1084 runner: &Arc<dyn CommandRunner + Send + Sync>,
1085 index: u32,
1086) -> Result<String, String> {
1087 let r = Arc::clone(runner);
1088 match tokio::task::spawn_blocking(move || r.run("rpm-ostree", &["status", "--json"])).await {
1089 Ok(Ok(out)) => parse_deployment_identity(&out, index),
1090 Ok(Err(e)) => Err(format!("rpm-ostree status --json failed: {e}")),
1091 Err(e) => Err(format!("deployment status task panicked: {e}")),
1092 }
1093}
1094
1095fn verify_deployment_binding(
1106 approved_proposed_change: &Value,
1107 live_identity: &str,
1108) -> Result<(), String> {
1109 let Some(captured) = approved_proposed_change
1110 .get(DEPLOYMENT_IDENTITY_KEY)
1111 .and_then(Value::as_str)
1112 else {
1113 return Err(
1114 "the approved preview did not capture which deployment sat at that index; preview again"
1115 .to_string(),
1116 );
1117 };
1118 if captured == live_identity {
1119 return Ok(());
1120 }
1121 Err(format!(
1122 "the deployment at that index changed since you approved it (approved {captured}, now {live_identity}); preview again"
1123 ))
1124}
1125
1126async fn simulate_autoremove_set(
1130 runner: &Arc<dyn CommandRunner + Send + Sync>,
1131) -> Result<std::collections::BTreeSet<String>, String> {
1132 let r = Arc::clone(runner);
1133 match tokio::task::spawn_blocking(move || r.run("apt-get", &["-s", "autoremove"])).await {
1134 Ok(Ok(out)) => Ok(crate::actions::apt::parse_autoremove_removals(&out)),
1135 Ok(Err(e)) => Err(format!("apt-get -s autoremove failed: {e}")),
1136 Err(e) => Err(format!("autoremove simulate task panicked: {e}")),
1137 }
1138}
1139
1140fn autoremove_kernel_warning(set: &std::collections::BTreeSet<String>) -> Option<String> {
1144 let flagged: Vec<&str> = set
1145 .iter()
1146 .map(String::as_str)
1147 .filter(|n| {
1148 let base = n.split(':').next().unwrap_or(n);
1149 base.starts_with("linux-image")
1150 || base.starts_with("linux-modules")
1151 || base.starts_with("linux-headers")
1152 || base.starts_with("grub")
1153 || base.contains("nvidia")
1154 || base.ends_with("-dkms")
1155 })
1156 .collect();
1157 if flagged.is_empty() {
1158 None
1159 } else {
1160 Some(format!(
1161 "autoremove would delete kernel/driver/bootloader packages: {}. Review carefully.",
1162 flagged.join(", ")
1163 ))
1164 }
1165}
1166
1167fn verify_autoremove_binding(
1173 approved_proposed_change: &Value,
1174 live: &std::collections::BTreeSet<String>,
1175) -> Result<(), String> {
1176 let Some(captured) = approved_proposed_change.get(AUTOREMOVE_REMOVALS_KEY) else {
1177 return Err(
1178 "the approved preview did not capture the autoremove deletion set; preview again"
1179 .to_string(),
1180 );
1181 };
1182 let approved: std::collections::BTreeSet<String> = serde_json::from_value(captured.clone())
1183 .map_err(|_| {
1184 "the approved preview's autoremove set is malformed; preview again".to_string()
1185 })?;
1186 if &approved == live {
1187 return Ok(());
1188 }
1189 let added: Vec<&str> = live.difference(&approved).map(String::as_str).collect();
1190 let removed: Vec<&str> = approved.difference(live).map(String::as_str).collect();
1191 Err(format!(
1192 "the set of packages autoremove would delete changed since you approved it \
1193 (now also removes: [{}]; no longer removes: [{}]); preview again",
1194 added.join(", "),
1195 removed.join(", ")
1196 ))
1197}
1198
1199fn canonical_json(v: &Value) -> String {
1200 match v {
1201 Value::Object(map) => {
1202 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1203 keys.sort_unstable();
1204 let pairs = keys
1205 .iter()
1206 .map(|k| {
1207 format!(
1210 "{}:{}",
1211 Value::String((*k).to_string()),
1212 canonical_json(&map[*k])
1213 )
1214 })
1215 .collect::<Vec<_>>()
1216 .join(",");
1217 format!("{{{pairs}}}")
1218 }
1219 Value::Array(arr) => {
1222 let items = arr.iter().map(canonical_json).collect::<Vec<_>>().join(",");
1223 format!("[{items}]")
1224 }
1225 _ => format!("{v}"),
1228 }
1229}
1230
1231pub async fn unix_connection_handler<S>(
1259 stream: S,
1260 state: DaemonState,
1261 runner: Arc<dyn CommandRunner + Send + Sync>,
1262 caller: CallerAttribution,
1263) where
1264 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1265{
1266 let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
1267 connection_handler_with_executor(stream, state, runner, executor, caller).await;
1268}
1269
1270#[cfg(target_os = "linux")]
1275pub async fn vsock_connection_handler<S>(
1276 stream: S,
1277 state: DaemonState,
1278 runner: Arc<dyn CommandRunner + Send + Sync>,
1279) where
1280 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1281{
1282 let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
1283 vsock_connection_handler_with_executor(stream, state, runner, executor).await;
1284}
1285
1286#[cfg(target_os = "linux")]
1288pub async fn vsock_connection_handler_with_executor<S>(
1289 stream: S,
1290 state: DaemonState,
1291 runner: Arc<dyn CommandRunner + Send + Sync>,
1292 executor: Arc<dyn ActionExecutor>,
1293) where
1294 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1295{
1296 let mut framed = FramedStream::new(stream);
1297
1298 let token_path = crate::auth::default_token_path();
1299 let caller = match authenticate_vsock_token(&mut framed, &token_path).await {
1300 Some(role) => CallerAttribution::from_vsock_token(role),
1304 None => {
1305 eprintln!("[sysknife-daemon] vsock auth failed; closing connection");
1306 return;
1307 }
1308 };
1309
1310 dispatch_loop(&mut framed, state, runner, executor, caller).await;
1311}
1312
1313pub async fn connection_handler_with_executor<S>(
1318 stream: S,
1319 state: DaemonState,
1320 runner: Arc<dyn CommandRunner + Send + Sync>,
1321 executor: Arc<dyn ActionExecutor>,
1322 caller: CallerAttribution,
1323) where
1324 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
1325{
1326 let mut framed = FramedStream::new(stream);
1327 dispatch_loop(&mut framed, state, runner, executor, caller).await;
1328}
1329
1330async fn authenticate_vsock_token(
1356 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1357 token_path: &std::path::Path,
1358) -> Option<CallerRole> {
1359 #[derive(serde::Deserialize)]
1360 struct AuthFrame {
1361 #[serde(rename = "type")]
1362 msg_type: String,
1363 token: String,
1364 }
1365
1366 let recv_result = tokio::time::timeout(
1367 std::time::Duration::from_secs(VSOCK_AUTH_FRAME_TIMEOUT_SECS),
1368 framed.recv(),
1369 )
1370 .await;
1371 let Ok(frame_result) = recv_result else {
1372 eprintln!(
1373 "[sysknife-daemon] vsock auth: timed out waiting for auth frame after {VSOCK_AUTH_FRAME_TIMEOUT_SECS}s"
1374 );
1375 return None;
1376 };
1377 let raw = match frame_result {
1378 Ok(raw) => raw,
1379 Err(e) => {
1380 eprintln!("[sysknife-daemon] vsock auth: framing error reading auth frame: {e}");
1381 return None;
1382 }
1383 };
1384 let auth: AuthFrame = match serde_json::from_slice(&raw) {
1385 Ok(auth) => auth,
1386 Err(e) => {
1387 eprintln!("[sysknife-daemon] vsock auth: malformed auth frame JSON: {e}");
1388 return None;
1389 }
1390 };
1391 if auth.msg_type != "auth" {
1392 eprintln!(
1393 "[sysknife-daemon] vsock auth: unexpected frame type {:?} (expected \"auth\")",
1394 auth.msg_type
1395 );
1396 return None;
1397 }
1398 let role = crate::auth::validate_token_against_file(&auth.token, token_path);
1399 if role.is_none() {
1400 eprintln!("[sysknife-daemon] vsock auth: token did not validate against token file");
1401 }
1402 role
1403}
1404
1405const IDLE_CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
1415
1416const FIRST_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
1426
1427fn read_deadline(requests_served: usize) -> std::time::Duration {
1433 if requests_served == 0 {
1434 FIRST_REQUEST_TIMEOUT
1435 } else {
1436 IDLE_CONNECTION_TIMEOUT
1437 }
1438}
1439
1440async fn dispatch_loop<S>(
1441 framed: &mut FramedStream<S>,
1442 state: DaemonState,
1443 runner: Arc<dyn CommandRunner + Send + Sync>,
1444 executor: Arc<dyn ActionExecutor>,
1445 caller: CallerAttribution,
1446) where
1447 S: AsyncRead + AsyncWrite + Unpin,
1448{
1449 let mut requests_served: usize = 0;
1450 loop {
1451 let deadline = read_deadline(requests_served);
1464 let raw = match tokio::time::timeout(deadline, framed.recv()).await {
1465 Ok(Ok(bytes)) => bytes,
1466 Ok(Err(FramingError::Io(_))) => break, Ok(Err(FramingError::MessageTooLarge(_))) => break, Err(_) => {
1469 if requests_served == 0 {
1470 eprintln!(
1471 "[sysknife-daemon] closing connection that sent no request within {}s",
1472 deadline.as_secs()
1473 );
1474 } else {
1475 eprintln!(
1476 "[sysknife-daemon] closing connection idle for more than {}s",
1477 deadline.as_secs()
1478 );
1479 }
1480 break;
1481 }
1482 };
1483 requests_served += 1;
1484
1485 let msg: Value = match serde_json::from_slice(&raw) {
1486 Ok(v) => v,
1487 Err(e) => {
1488 eprintln!("[sysknife-daemon] malformed JSON from client, closing connection: {e}");
1489 break;
1490 }
1491 };
1492
1493 let request: DaemonRequest = match serde_json::from_value(msg) {
1494 Ok(r) => r,
1495 Err(e) => {
1496 if let Err(send_err) = send_error(
1497 framed,
1498 "",
1499 "validation_failure",
1500 format!("unknown message type: {e}"),
1501 )
1502 .await
1503 {
1504 eprintln!(
1505 "[sysknife-daemon] failed to send validation error response: {send_err}"
1506 );
1507 }
1508 continue;
1509 }
1510 };
1511
1512 let result = match &request {
1513 DaemonRequest::QueryState { request_id } => {
1514 handle_query_state(
1515 framed,
1516 Arc::clone(&runner),
1517 state.host_distro.clone(),
1518 request_id,
1519 )
1520 .await
1521 }
1522 DaemonRequest::Preview {
1523 request_id,
1524 action_name,
1525 params,
1526 unattended,
1527 } => {
1528 handle_preview(
1529 framed,
1530 &state,
1531 Arc::clone(&runner),
1532 &caller,
1533 request_id,
1534 action_name,
1535 params,
1536 *unattended,
1537 )
1538 .await
1539 }
1540 DaemonRequest::Execute {
1541 request_id,
1542 transaction_id,
1543 action_name,
1544 params,
1545 approval_receipt,
1546 } => {
1547 handle_execute(
1548 framed,
1549 &state,
1550 Arc::clone(&executor),
1551 Arc::clone(&runner),
1552 &caller,
1553 request_id,
1554 transaction_id,
1555 action_name,
1556 params,
1557 approval_receipt,
1558 )
1559 .await
1560 }
1561 DaemonRequest::Approve {
1562 request_id,
1563 transaction_id,
1564 } => handle_approve(framed, &state, &caller, request_id, transaction_id.as_str()).await,
1565 DaemonRequest::ApprovalDetails {
1566 request_id,
1567 transaction_id,
1568 } => {
1569 handle_approval_details(
1570 framed,
1571 &state,
1572 &caller,
1573 request_id,
1574 transaction_id.as_str(),
1575 )
1576 .await
1577 }
1578 DaemonRequest::QueryAction {
1579 request_id,
1580 action_name,
1581 params,
1582 } => {
1583 handle_query_action(
1584 framed,
1585 Arc::clone(&executor),
1586 &state,
1587 action_name,
1588 params,
1589 request_id,
1590 )
1591 .await
1592 }
1593 DaemonRequest::QueryHistory {
1594 request_id,
1595 limit,
1596 status_filter,
1597 action_filter,
1598 since_hours,
1599 since,
1600 } => {
1601 handle_query_history(
1602 framed,
1603 &state,
1604 request_id,
1605 *limit,
1606 status_filter.clone(),
1607 action_filter.clone(),
1608 *since_hours,
1609 since.as_deref(),
1610 )
1611 .await
1612 }
1613 DaemonRequest::Describe {
1614 request_id,
1615 action_name,
1616 params,
1617 } => handle_describe(framed, &state, &caller, action_name, params, request_id).await,
1618 DaemonRequest::Cancel {
1619 request_id,
1620 transaction_id,
1621 } => handle_cancel(framed, &state, &caller, request_id, transaction_id.as_str()).await,
1622 };
1623
1624 if let Err(e) = result {
1625 eprintln!("[sysknife-daemon] connection handler send error: {e}");
1628 }
1629 }
1630}
1631
1632fn receipt_digest(receipt: &str) -> String {
1633 hex::encode(Sha256::digest(receipt.as_bytes()))
1634}
1635
1636async fn handle_approve(
1637 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1638 state: &DaemonState,
1639 caller: &CallerAttribution,
1640 request_id: &str,
1641 transaction_id: &str,
1642) -> Result<(), HandlerError> {
1643 if !authorize_for_transaction(
1644 framed,
1645 state,
1646 caller,
1647 request_id,
1648 transaction_id,
1649 "stale_approval",
1650 "transaction is missing, expired, already approved, or no longer queued",
1651 )
1652 .await?
1653 {
1654 return Ok(());
1655 }
1656 let receipt = match state
1657 .audit
1658 .approve_transaction(transaction_id, caller.principal())
1659 .await
1660 {
1661 Ok(receipt) => receipt,
1662 Err(e @ crate::transactions::TransactionStoreError::DatabaseInvariant(_)) => {
1668 return send_error(
1669 framed,
1670 request_id,
1671 "integrity_failure",
1672 format!("approval rejected by an integrity check: {e}"),
1673 )
1674 .await;
1675 }
1676 Err(e) => {
1677 return send_error(
1678 framed,
1679 request_id,
1680 "transient_infrastructure_failure",
1681 format!("failed to persist approval: {e}"),
1682 )
1683 .await;
1684 }
1685 };
1686 let Some(receipt) = receipt else {
1687 return send_error(
1688 framed,
1689 request_id,
1690 "stale_approval",
1691 "transaction is missing, expired, already approved, or no longer queued",
1692 )
1693 .await;
1694 };
1695 let response = send_response(
1696 framed,
1697 &DaemonResponse::ApprovalResponse {
1698 request_id: request_id.to_string(),
1699 transaction_id: transaction_id.to_string(),
1700 approval_receipt: receipt,
1701 },
1702 )
1703 .await;
1704 if response.is_err() {
1705 if let Err(e) = state
1706 .audit
1707 .revoke_unconsumed_approval(transaction_id, caller.principal())
1708 .await
1709 {
1710 eprintln!(
1711 "[sysknife-daemon] failed to revoke undelivered approval for \
1712 {transaction_id}: {e}"
1713 );
1714 }
1715 }
1716 response
1717}
1718
1719async fn handle_approval_details(
1720 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1721 state: &DaemonState,
1722 caller: &CallerAttribution,
1723 request_id: &str,
1724 transaction_id: &str,
1725) -> Result<(), HandlerError> {
1726 match caller_owns_transaction(state, caller, transaction_id).await {
1727 Ownership::Owned | Ownership::NoSuchTransaction => {}
1728 Ownership::NotOwned => {
1729 return send_error(
1730 framed,
1731 request_id,
1732 "authorization_failure",
1733 not_your_transaction_message(transaction_id),
1734 )
1735 .await;
1736 }
1737 Ownership::CannotEstablish(message) => {
1738 return send_error(framed, request_id, "authorization_failure", message).await;
1739 }
1740 }
1741 let transaction = match state.audit.get(transaction_id).await {
1742 Ok(Some(transaction)) if transaction.status == JobState::Queued => transaction,
1743 Ok(_) => {
1744 return send_error(
1745 framed,
1746 request_id,
1747 "stale_approval",
1748 "transaction is missing or no longer queued",
1749 )
1750 .await;
1751 }
1752 Err(e) => {
1753 return send_error(
1754 framed,
1755 request_id,
1756 "transient_infrastructure_failure",
1757 format!("failed to load transaction: {e}"),
1758 )
1759 .await;
1760 }
1761 };
1762 if !authorize_action(&state.policy, &caller.role(), &transaction.action_name) {
1763 return send_error(
1764 framed,
1765 request_id,
1766 "authorization_failure",
1767 authorization_failure_message(&state.policy, &caller.role(), &transaction.action_name),
1768 )
1769 .await;
1770 }
1771 let preview = match state.audit.get_preview(transaction_id).await {
1772 Ok(Some(preview)) => preview,
1773 Ok(None) => {
1774 return send_error(
1775 framed,
1776 request_id,
1777 "stale_approval",
1778 "transaction has no persisted preview",
1779 )
1780 .await;
1781 }
1782 Err(e) => {
1783 return send_error(
1784 framed,
1785 request_id,
1786 "transient_infrastructure_failure",
1787 format!("failed to load preview: {e}"),
1788 )
1789 .await;
1790 }
1791 };
1792 send_response(
1793 framed,
1794 &DaemonResponse::ApprovalDetailsResponse {
1795 request_id: request_id.to_string(),
1796 transaction_id: transaction_id.to_string(),
1797 action_name: transaction.action_name,
1798 preview,
1799 },
1800 )
1801 .await
1802}
1803
1804async fn handle_query_state(
1809 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1810 runner: Arc<dyn CommandRunner + Send + Sync>,
1811 host_distro: Option<sysknife_core::distro::DistroId>,
1812 request_id: &str,
1813) -> Result<(), HandlerError> {
1814 let join_result = tokio::task::spawn_blocking(move || collect_state(&*runner))
1817 .await
1818 .map_err(|e| HandlerError::Internal(format!("collect_state task failed: {e}")))?;
1819 let mut collected = match join_result {
1820 Ok(s) => s,
1821 Err(e) => {
1822 return send_error(framed, request_id, "state_collection_failed", e.to_string()).await;
1823 }
1824 };
1825 collected.host_distro = host_distro;
1826 send_response(
1827 framed,
1828 &DaemonResponse::StateResponse {
1829 request_id: request_id.to_string(),
1830 state: collected,
1831 },
1832 )
1833 .await
1834}
1835
1836async fn handle_cancel(
1837 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1838 state: &DaemonState,
1839 caller: &CallerAttribution,
1840 request_id: &str,
1841 transaction_id: &str,
1842) -> Result<(), HandlerError> {
1843 if !authorize_for_transaction(
1844 framed,
1845 state,
1846 caller,
1847 request_id,
1848 transaction_id,
1849 "not_cancelable",
1850 "transaction cannot be canceled: it is missing, already executing, or finished",
1851 )
1852 .await?
1853 {
1854 return Ok(());
1855 }
1856 match state
1857 .audit
1858 .cancel_queued(transaction_id, caller.principal())
1859 .await
1860 {
1861 Ok(true) => {
1862 send_response(
1863 framed,
1864 &DaemonResponse::CancelResponse {
1865 request_id: request_id.to_string(),
1866 transaction_id: transaction_id.to_string(),
1867 },
1868 )
1869 .await
1870 }
1871 Ok(false) => {
1874 send_error(
1875 framed,
1876 request_id,
1877 "not_cancelable",
1878 "transaction cannot be canceled: it is missing, already executing, or finished",
1879 )
1880 .await
1881 }
1882 Err(e) => {
1883 send_error(
1884 framed,
1885 request_id,
1886 "transient_infrastructure_failure",
1887 format!("failed to cancel transaction: {e}"),
1888 )
1889 .await
1890 }
1891 }
1892}
1893
1894fn resolve_history_since(
1899 since_hours: Option<u32>,
1900 since: Option<&str>,
1901) -> Result<Option<HistorySince>, String> {
1902 match (since_hours, since) {
1903 (Some(_), Some(_)) => Err("'since' and 'since_hours' are mutually exclusive".to_string()),
1904 (Some(hours), None) => Ok(Some(HistorySince::Hours(hours))),
1905 (None, Some(raw)) => chrono::DateTime::parse_from_rfc3339(raw)
1906 .map(|at| Some(HistorySince::Cutoff(at.with_timezone(&chrono::Utc))))
1907 .map_err(|e| format!("'since' must be an RFC 3339 datetime, got {raw:?}: {e}")),
1908 (None, None) => Ok(None),
1909 }
1910}
1911
1912#[allow(clippy::too_many_arguments)]
1913async fn handle_query_history(
1914 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1915 state: &DaemonState,
1916 request_id: &str,
1917 limit: Option<u32>,
1918 status_filter: Option<String>,
1919 action_filter: Option<String>,
1920 since_hours: Option<u32>,
1921 since: Option<&str>,
1922) -> Result<(), HandlerError> {
1923 match state.policy.min_role_for_action("ListJobHistory") {
1929 Some(CallerRole::Observer) => {}
1930 Some(_) => {
1931 return send_error(
1932 framed,
1933 request_id,
1934 "authorization_failure",
1935 "history has been restricted above read-only by policy; \
1936 it is not available over the structured query path",
1937 )
1938 .await;
1939 }
1940 None => {
1941 return send_error(
1942 framed,
1943 request_id,
1944 "validation_failure",
1945 "ListJobHistory action is not registered",
1946 )
1947 .await;
1948 }
1949 }
1950
1951 let since = match resolve_history_since(since_hours, since) {
1952 Ok(since) => since,
1953 Err(message) => {
1954 return send_error(framed, request_id, "validation_failure", message).await;
1955 }
1956 };
1957
1958 let entries = match state
1959 .audit
1960 .list_history(
1961 limit.unwrap_or(DEFAULT_HISTORY_LIMIT),
1962 status_filter.as_deref(),
1963 action_filter.as_deref(),
1964 since,
1965 )
1966 .await
1967 {
1968 Ok(entries) => entries,
1969 Err(e) => {
1970 return send_error(
1971 framed,
1972 request_id,
1973 "execution_failure",
1974 format!("failed to query transaction history: {e}"),
1975 )
1976 .await;
1977 }
1978 };
1979 send_response(
1980 framed,
1981 &DaemonResponse::HistoryResponse {
1982 request_id: request_id.to_string(),
1983 entries,
1984 },
1985 )
1986 .await
1987}
1988
1989async fn handle_query_action(
1990 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
1991 executor: Arc<dyn ActionExecutor>,
1992 state: &DaemonState,
1993 action_name: &str,
1994 params: &Value,
1995 request_id: &str,
1996) -> Result<(), HandlerError> {
1997 let audit = &state.audit;
1998
1999 let min_role = match state.policy.min_role_for_action(action_name) {
2003 Some(role) => role,
2004 None => {
2005 return send_error(
2006 framed,
2007 request_id,
2008 "validation_failure",
2009 format!("unknown action: {action_name}"),
2010 )
2011 .await;
2012 }
2013 };
2014
2015 if min_role != CallerRole::Observer {
2016 return send_error(
2017 framed,
2018 request_id,
2019 "authorization_failure",
2020 format!("{action_name} is not a read-only action; use preview+execute instead"),
2021 )
2022 .await;
2023 }
2024
2025 if crate::actions::OBSERVER_MUTATING_ACTIONS.contains(&action_name) {
2032 return send_error(
2033 framed,
2034 request_id,
2035 "authorization_failure",
2036 format!(
2037 "{action_name} mutates the host and is not available through query_action; \
2038 use preview+execute instead"
2039 ),
2040 )
2041 .await;
2042 }
2043
2044 if let Err(message) = validate_action_platform(state, action_name) {
2052 return send_error(framed, request_id, "unsupported_platform", message).await;
2053 }
2054
2055 if action_name == "ListJobHistory" {
2059 if let Err(e) = crate::executor::validate_action_params(action_name, params) {
2060 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2061 }
2062 let limit = match params.get("limit") {
2063 Some(v) => match v.as_u64() {
2064 Some(n) => n as u32,
2065 None => {
2066 return send_error(
2067 framed,
2068 request_id,
2069 "validation_failure",
2070 format!("'limit' must be an integer, got: {v}"),
2071 )
2072 .await;
2073 }
2074 },
2075 None => DEFAULT_HISTORY_LIMIT,
2076 };
2077 let status_filter = params
2078 .get("status_filter")
2079 .and_then(|v| v.as_str())
2080 .map(|s| s.to_string());
2081 let action_filter = params
2082 .get("action_filter")
2083 .and_then(|v| v.as_str())
2084 .map(|s| s.to_string());
2085 let since_hours = match params.get("since_hours") {
2086 Some(v) => match v.as_u64() {
2087 Some(n) => Some(n as u32),
2088 None => {
2089 return send_error(
2090 framed,
2091 request_id,
2092 "validation_failure",
2093 format!("'since_hours' must be an integer, got: {v}"),
2094 )
2095 .await;
2096 }
2097 },
2098 None => None,
2099 };
2100 let since_cutoff = match params.get("since") {
2101 Some(v) => match v.as_str() {
2102 Some(s) => Some(s),
2103 None => {
2104 return send_error(
2105 framed,
2106 request_id,
2107 "validation_failure",
2108 format!("'since' must be an RFC 3339 datetime string, got: {v}"),
2109 )
2110 .await;
2111 }
2112 },
2113 None => None,
2114 };
2115 let since = match resolve_history_since(since_hours, since_cutoff) {
2116 Ok(since) => since,
2117 Err(message) => {
2118 return send_error(framed, request_id, "validation_failure", message).await;
2119 }
2120 };
2121
2122 let records = match audit
2123 .list_transactions(
2124 limit,
2125 status_filter.as_deref(),
2126 action_filter.as_deref(),
2127 since,
2128 )
2129 .await
2130 {
2131 Ok(r) => r,
2132 Err(e) => {
2133 return send_error(
2134 framed,
2135 request_id,
2136 "execution_failure",
2137 format!("failed to query transaction log: {e}"),
2138 )
2139 .await;
2140 }
2141 };
2142
2143 let output = if records.is_empty() {
2144 let mut msg = "No transactions found".to_string();
2145 let mut filters = Vec::new();
2146 if let Some(s) = &status_filter {
2147 filters.push(format!("status={s}"));
2148 }
2149 if let Some(a) = &action_filter {
2150 filters.push(format!("action={a}"));
2151 }
2152 if let Some(h) = since_hours {
2153 filters.push(format!("since_hours={h}"));
2154 }
2155 if let Some(c) = since_cutoff {
2156 filters.push(format!("since={c}"));
2157 }
2158 if !filters.is_empty() {
2159 msg.push_str(&format!(" (filters: {})", filters.join(", ")));
2160 }
2161 msg.push('.');
2162 msg
2163 } else {
2164 format_job_history(&records)
2165 };
2166 return send_response(
2167 framed,
2168 &DaemonResponse::QueryActionResponse {
2169 request_id: request_id.to_string(),
2170 action_name: action_name.to_string(),
2171 output,
2172 },
2173 )
2174 .await;
2175 }
2176
2177 let spec = match build_action_spec(action_name, params) {
2178 Ok(s) => s,
2179 Err(e) => {
2180 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2181 }
2182 };
2183
2184 let output = match executor.execute(&spec).await {
2185 Ok(out) => out,
2186 Err(e) => {
2187 return send_error(framed, request_id, "execution_failure", e.to_string()).await;
2188 }
2189 };
2190
2191 let is_informational_exit =
2202 matches!((action_name, output.exit_code), ("GetServiceStatus", 1..=4));
2203
2204 if output.is_nonzero() && !is_informational_exit {
2205 return send_error(
2206 framed,
2207 request_id,
2208 "execution_failure",
2209 format!(
2210 "{action_name} failed with exit code {}{}",
2211 output.exit_code,
2212 if output.stderr.trim().is_empty() {
2213 String::new()
2214 } else {
2215 format!(": {}", output.stderr.trim())
2216 }
2217 ),
2218 )
2219 .await;
2220 }
2221
2222 let output_text = if output.stderr.is_empty() {
2223 output.stdout
2224 } else {
2225 format!("{}\n[stderr]\n{}", output.stdout, output.stderr)
2226 };
2227
2228 send_response(
2229 framed,
2230 &DaemonResponse::QueryActionResponse {
2231 request_id: request_id.to_string(),
2232 action_name: action_name.to_string(),
2233 output: output_text,
2234 },
2235 )
2236 .await
2237}
2238
2239async fn handle_describe(
2245 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2246 state: &DaemonState,
2247 caller: &CallerAttribution,
2248 action_name: &str,
2249 params: &Value,
2250 request_id: &str,
2251) -> Result<(), HandlerError> {
2252 use crate::actions::ActionMechanism;
2253 use crate::executor::build_action_spec;
2254
2255 if state.policy.min_role_for_action(action_name).is_none() {
2267 return send_error(
2268 framed,
2269 request_id,
2270 "validation_failure",
2271 format!("unknown action: {action_name}"),
2272 )
2273 .await;
2274 }
2275 if !authorize_action(&state.policy, &caller.role(), action_name) {
2276 return send_error(
2277 framed,
2278 request_id,
2279 "authorization_failure",
2280 authorization_failure_message(&state.policy, &caller.role(), action_name),
2281 )
2282 .await;
2283 }
2284 if let Err(message) = validate_action_platform(state, action_name) {
2285 return send_error(framed, request_id, "unsupported_platform", message).await;
2286 }
2287
2288 if action_name == "ListJobHistory" {
2292 return send_response(
2293 framed,
2294 &DaemonResponse::DescribeResponse {
2295 request_id: request_id.to_string(),
2296 command: "query daemon job history (SQLite)".to_string(),
2297 risk_level: "low".to_string(),
2298 reboot_required: false,
2299 },
2300 )
2301 .await;
2302 }
2303
2304 let spec = match build_action_spec(action_name, params) {
2305 Ok(s) => s,
2306 Err(e) => {
2307 return send_error(
2308 framed,
2309 request_id,
2310 "validation_failure",
2311 format!("unknown action: {action_name} ({e})"),
2312 )
2313 .await;
2314 }
2315 };
2316
2317 let command = match &spec.mechanism {
2318 ActionMechanism::Command { program, args } => {
2319 let display_args = redact_argv(action_name, params, args);
2323 if display_args.is_empty() {
2324 program.to_string()
2325 } else {
2326 format!("{} {}", program, display_args.join(" "))
2327 }
2328 }
2329 ActionMechanism::FileScan { path } => format!("read {path}"),
2330 ActionMechanism::FileWrite { path, .. } => format!("write {path}"),
2331 ActionMechanism::FilePatch { path, .. } => format!("patch {path}"),
2332 ActionMechanism::FileDelete { path } => format!("rm {path}"),
2333 };
2334
2335 let risk_level = format!("{:?}", spec.risk_level).to_lowercase();
2336
2337 send_response(
2338 framed,
2339 &DaemonResponse::DescribeResponse {
2340 request_id: request_id.to_string(),
2341 command,
2342 risk_level,
2343 reboot_required: spec.reboot_required,
2344 },
2345 )
2346 .await
2347}
2348
2349pub const UNATTENDED_WARNING: &str =
2357 "Approved without a human: this step was previewed by a client running with \
2358 --dangerously-skip-approval, so no operator confirmed it.";
2359
2360#[allow(clippy::too_many_arguments)]
2365async fn handle_preview(
2366 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2367 state: &DaemonState,
2368 runner: Arc<dyn CommandRunner + Send + Sync>,
2369 caller: &CallerAttribution,
2370 request_id: &str,
2371 action_name: &str,
2372 params: &Value,
2373 unattended: bool,
2374) -> Result<(), HandlerError> {
2375 let spec = match build_action_spec(action_name, params) {
2376 Ok(s) => s,
2377 Err(e) => {
2378 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2379 }
2380 };
2381
2382 if !authorize_action(&state.policy, &caller.role(), action_name) {
2383 return send_error(
2384 framed,
2385 request_id,
2386 "authorization_failure",
2387 authorization_failure_message(&state.policy, &caller.role(), action_name),
2388 )
2389 .await;
2390 }
2391 if let Err(message) = validate_action_platform(state, action_name) {
2392 return send_error(framed, request_id, "unsupported_platform", message).await;
2393 }
2394
2395 let request_hash = compute_request_hash(&state.request_hash_key, action_name, params);
2396
2397 let runner_for_preview = Arc::clone(&runner);
2402 let current_state =
2403 match tokio::task::spawn_blocking(move || collect_state(&*runner_for_preview)).await {
2404 Err(e) => {
2405 eprintln!(
2406 "[sysknife-daemon] handle_preview: collect_state task failed ({e}); \
2407 generating preview with empty state"
2408 );
2409 Value::Null
2410 }
2411 Ok(Err(e)) => {
2412 eprintln!(
2413 "[sysknife-daemon] handle_preview: state collection failed ({e}); \
2414 generating preview with empty state"
2415 );
2416 Value::Null
2417 }
2418 Ok(Ok(s)) => match serde_json::to_value(&s) {
2419 Ok(v) => v,
2420 Err(e) => {
2421 eprintln!(
2422 "[sysknife-daemon] handle_preview: failed to serialize collected state ({e}); \
2423 using empty state"
2424 );
2425 Value::Null
2426 }
2427 },
2428 };
2429 let redacted_params = redact_params(action_name, params);
2436 let mut proposed_change = json!({ "action": action_name, "params": redacted_params });
2437
2438 let mut autoremove_warning: Option<String> = None;
2442 if action_name == "AptAutoremove" {
2443 match simulate_autoremove_set(&runner).await {
2444 Ok(set) => {
2445 autoremove_warning = autoremove_kernel_warning(&set);
2446 proposed_change[AUTOREMOVE_REMOVALS_KEY] =
2447 json!(set.iter().collect::<Vec<&String>>());
2448 }
2449 Err(e) => {
2450 eprintln!("[sysknife-daemon] handle_preview: autoremove simulate failed: {e}");
2454 autoremove_warning = Some(
2455 "Could not compute which packages autoremove would delete; approving this \
2456 preview will not let it run until a preview captures the set."
2457 .to_string(),
2458 );
2459 }
2460 }
2461 }
2462
2463 let mut deployment_warning: Option<String> = None;
2468 if matches!(action_name, "PinDeployment" | "UnpinDeployment") {
2469 match params.get("index").and_then(Value::as_u64) {
2470 Some(index) => match resolve_deployment_identity(&runner, index as u32).await {
2471 Ok(identity) => {
2472 proposed_change[DEPLOYMENT_IDENTITY_KEY] = json!(identity);
2473 }
2474 Err(e) => {
2475 eprintln!(
2476 "[sysknife-daemon] handle_preview: deployment identity lookup failed: {e}"
2477 );
2478 deployment_warning = Some(
2479 "Could not read which deployment sits at that index; approving this \
2480 preview will not let it run until a preview captures one."
2481 .to_string(),
2482 );
2483 }
2484 },
2485 None => {
2486 deployment_warning =
2487 Some("No deployment index was supplied, so nothing could be bound.".to_string())
2488 }
2489 }
2490 }
2491
2492 let envelope = RequestEnvelope {
2493 action_name: action_name.to_string(),
2494 request_id: request_id.to_string(),
2495 params: params.clone(),
2496 caller_role: caller.role(),
2497 request_hash: sysknife_types::RequestHash::new(request_hash.to_string()),
2498 };
2499
2500 let state_unavailable = current_state.is_null();
2505 let mut preview = preview_action(&envelope, current_state, proposed_change);
2506 if let Some(w) = autoremove_warning {
2507 preview.warnings.push(w);
2508 }
2509 if let Some(w) = deployment_warning {
2512 preview.warnings.push(w);
2513 }
2514 if state_unavailable {
2515 preview.warnings.push(
2516 "System state could not be collected; this preview was generated without it. \
2517 Review the action carefully before approving."
2518 .to_string(),
2519 );
2520 }
2521 if unattended {
2526 preview.warnings.push(UNATTENDED_WARNING.to_string());
2527 }
2528
2529 let new_tx = NewTransaction {
2531 request_id: request_id.to_string(),
2532 request_hash,
2533 action_name: action_name.to_string(),
2534 risk_level: spec.risk_level,
2535 summary: preview.summary.clone(),
2536 warnings: preview.warnings.clone(),
2537 caller_role: caller.role(),
2541 caller_principal: caller.principal(),
2542 };
2543
2544 let recorded = match state.audit.record_previewed(new_tx, preview.clone()).await {
2545 Ok(r) => r,
2546 Err(e) => {
2547 return send_error(
2548 framed,
2549 request_id,
2550 "transient_infrastructure_failure",
2551 format!("failed to record preview transaction: {e}"),
2552 )
2553 .await;
2554 }
2555 };
2556
2557 forward_audit_event(state, &recorded.transaction.transaction_id, &caller.role());
2562
2563 send_response(
2564 framed,
2565 &DaemonResponse::PreviewResponse {
2566 request_id: request_id.to_string(),
2567 preview,
2568 transaction_id: recorded.transaction.transaction_id,
2569 },
2570 )
2571 .await
2572}
2573
2574fn forward_audit_event(state: &DaemonState, transaction_id: &str, caller: &CallerRole) {
2582 let Some(forwarder) = state.forwarder.clone() else {
2583 return;
2584 };
2585 let audit = std::sync::Arc::clone(&state.audit);
2586 let transaction_id = transaction_id.to_string();
2587 let caller_label = format!("{caller:?}");
2588 tokio::spawn(async move {
2589 match audit.fetch_chain_row(&transaction_id).await {
2590 Ok(Some(row)) => {
2591 forwarder.submit(crate::audit_forward::AuditEvent {
2592 seq: row.seq,
2593 transaction_id: row.transaction_id,
2594 action_name: row.action_name,
2595 risk_level: row.risk_level,
2596 summary: row.summary,
2597 approval_id: row.approval_id,
2598 created_at: row.created_at,
2599 chain_hash: row.chain_hash,
2600 key_id: row.key_id,
2601 caller_role: Some(caller_label),
2602 caller_principal: row.caller_principal,
2605 final_status: None,
2606 });
2607 }
2608 Ok(None) => {
2609 eprintln!(
2610 "[sysknife-daemon] audit-forward: transaction {transaction_id} \
2611 not found just after insert (race?)"
2612 );
2613 }
2614 Err(e) => {
2615 eprintln!(
2616 "[sysknife-daemon] audit-forward: chain row fetch failed for \
2617 {transaction_id}: {e}"
2618 );
2619 }
2620 }
2621 });
2622}
2623
2624fn forward_status_change_event(
2635 state: &DaemonState,
2636 transaction_id: &str,
2637 caller: &CallerRole,
2638 final_status: JobState,
2639) {
2640 if state.forwarder.is_none() {
2641 return;
2642 }
2643 let audit = std::sync::Arc::clone(&state.audit);
2644 let forwarder = state.forwarder.clone().expect("checked above");
2645 let transaction_id = transaction_id.to_string();
2646 let caller_label = format!("{caller:?}");
2647 let final_status_label = format!("{final_status:?}").to_lowercase();
2648 tokio::spawn(async move {
2649 match audit.fetch_chain_row(&transaction_id).await {
2650 Ok(Some(row)) => {
2651 forwarder.submit(crate::audit_forward::AuditEvent {
2652 seq: row.seq,
2653 transaction_id: row.transaction_id,
2654 action_name: row.action_name,
2655 risk_level: row.risk_level,
2656 summary: row.summary,
2657 approval_id: row.approval_id,
2658 created_at: row.created_at,
2659 chain_hash: row.chain_hash,
2660 key_id: row.key_id,
2661 caller_role: Some(caller_label),
2662 caller_principal: row.caller_principal,
2663 final_status: Some(final_status_label),
2664 });
2665 }
2666 Ok(None) => {
2667 eprintln!(
2668 "[sysknife-daemon] audit-forward: transaction {transaction_id} \
2669 not found for status-change forward (race?)"
2670 );
2671 }
2672 Err(e) => {
2673 eprintln!(
2674 "[sysknife-daemon] audit-forward: chain row fetch failed for \
2675 {transaction_id}: {e}"
2676 );
2677 }
2678 }
2679 });
2680}
2681
2682async fn release_exclusive_slots(
2687 state: &DaemonState,
2688 claimed: &[crate::actions::ExclusiveResource],
2689 request_hash: &str,
2690) {
2691 if claimed.is_empty() {
2692 return;
2693 }
2694
2695 let mut slots = state.running_exclusive.lock().await;
2696 for resource in claimed {
2697 if slots.get(resource).map(String::as_str) == Some(request_hash) {
2698 slots.remove(resource);
2699 }
2700 }
2701}
2702
2703const TERMINAL_STATUS_RETRY_ATTEMPTS: u32 = 3;
2708const TERMINAL_STATUS_RETRY_BACKOFF_MS: u64 = 25;
2712
2713async fn update_terminal_status(
2714 state: &DaemonState,
2715 transaction_id: &str,
2716 target: JobState,
2717) -> Result<(), crate::transactions::TransactionStoreError> {
2718 for attempt in 0..TERMINAL_STATUS_RETRY_ATTEMPTS {
2719 match state.audit.update_status(transaction_id, target).await {
2720 Ok(()) => return Ok(()),
2721 Err(error) => {
2722 if state
2726 .audit
2727 .get(transaction_id)
2728 .await
2729 .ok()
2730 .flatten()
2731 .is_some_and(|record| record.status == target)
2732 {
2733 return Ok(());
2734 }
2735 if attempt == TERMINAL_STATUS_RETRY_ATTEMPTS - 1 {
2736 return Err(error);
2737 }
2738 tokio::time::sleep(std::time::Duration::from_millis(
2739 TERMINAL_STATUS_RETRY_BACKOFF_MS * u64::from(attempt + 1),
2740 ))
2741 .await;
2742 }
2743 }
2744 }
2745 unreachable!("bounded retry loop always returns")
2746}
2747
2748#[allow(clippy::too_many_arguments)]
2749async fn handle_execute(
2750 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
2751 state: &DaemonState,
2752 executor: Arc<dyn ActionExecutor>,
2753 runner: Arc<dyn CommandRunner + Send + Sync>,
2754 caller: &CallerAttribution,
2755 request_id: &str,
2756 transaction_id: &TransactionId,
2757 action_name: &str,
2758 params: &Value,
2759 approval_receipt: &ApprovalReceipt,
2760) -> Result<(), HandlerError> {
2761 let transaction_id = transaction_id.as_str();
2762 let approval_receipt = approval_receipt.as_str();
2763 let spec = match build_action_spec(action_name, params) {
2764 Ok(s) => s,
2765 Err(e) => {
2766 return send_error(framed, request_id, "validation_failure", e.to_string()).await;
2767 }
2768 };
2769
2770 match caller_owns_transaction(state, caller, transaction_id).await {
2774 Ownership::Owned | Ownership::NoSuchTransaction => {}
2775 Ownership::NotOwned => {
2776 return send_error(
2777 framed,
2778 request_id,
2779 "authorization_failure",
2780 not_your_transaction_message(transaction_id),
2781 )
2782 .await;
2783 }
2784 Ownership::CannotEstablish(message) => {
2785 return send_error(framed, request_id, "authorization_failure", message).await;
2786 }
2787 }
2788
2789 if !authorize_action(&state.policy, &caller.role(), action_name) {
2790 return send_error(
2791 framed,
2792 request_id,
2793 "authorization_failure",
2794 authorization_failure_message(&state.policy, &caller.role(), action_name),
2795 )
2796 .await;
2797 }
2798 if let Err(message) = validate_action_platform(state, action_name) {
2799 return send_error(framed, request_id, "unsupported_platform", message).await;
2800 }
2801
2802 let submitted_hash = compute_request_hash(&state.request_hash_key, action_name, params);
2803 let prior_tx = match state.audit.get(transaction_id).await {
2804 Ok(tx) => tx,
2805 Err(e) => {
2806 return send_error(
2807 framed,
2808 request_id,
2809 "transient_infrastructure_failure",
2810 format!("transaction lookup failed: {e}"),
2811 )
2812 .await;
2813 }
2814 };
2815
2816 let prior_tx = match prior_tx {
2817 Some(tx) => tx,
2818 None => {
2819 return send_error(
2820 framed,
2821 request_id,
2822 "stale_approval",
2823 "no prior preview found for this transaction; preview before executing",
2824 )
2825 .await;
2826 }
2827 };
2828
2829 if prior_tx.action_name != action_name || prior_tx.request_hash != submitted_hash {
2830 return send_error(
2831 framed,
2832 request_id,
2833 "stale_approval",
2834 "action or parameters differ from the approved preview",
2835 )
2836 .await;
2837 }
2838 let stored_hash = prior_tx.request_hash;
2839
2840 let is_high_risk_reboot =
2857 spec.risk_level == sysknife_types::RiskLevel::High && spec.reboot_required;
2858
2859 let policy_says_mutating = match state.policy.min_role_for_action(action_name) {
2860 Some(r) => r > CallerRole::Observer,
2861 None => {
2862 eprintln!(
2867 "[sysknife-daemon] WARNING: no policy entry for {action_name:?} at the \
2868 concurrency gate; treating it as mutating"
2869 );
2870 true
2871 }
2872 };
2873
2874 let is_mutating = policy_says_mutating || is_high_risk_reboot;
2881 if is_high_risk_reboot && !policy_says_mutating {
2882 eprintln!(
2883 "[sysknife-daemon] WARNING: {action_name:?} is High-risk + reboot-required but the \
2884 policy table does not require Dev/Admin for it; gating it as mutating anyway. \
2885 The risk and role tables have drifted."
2886 );
2887 }
2888
2889 let mut to_claim: Vec<crate::actions::ExclusiveResource> = Vec::new();
2891 if is_high_risk_reboot {
2892 to_claim.push(crate::actions::ExclusiveResource::System);
2893 }
2894 let own_resource = crate::actions::exclusive_resource(&spec);
2895 if let Some(r) = own_resource {
2896 if !to_claim.contains(&r) {
2897 to_claim.push(r);
2898 }
2899 }
2900 if !is_mutating {
2901 to_claim.clear();
2902 }
2903
2904 if is_mutating {
2906 let mut slots = state.running_exclusive.lock().await;
2907 let blocking = slots
2910 .get(&crate::actions::ExclusiveResource::System)
2911 .map(|h| (crate::actions::ExclusiveResource::System, h.clone()))
2912 .or_else(|| own_resource.and_then(|r| slots.get(&r).map(|h| (r, h.clone()))));
2913 if let Some((blocking_resource, running_hash)) = blocking {
2914 let ttl = crate::transactions::APPROVAL_RECEIPT_TTL_MINUTES;
2932 let held = blocking_resource.label();
2933 let msg = format!(
2934 "another mutating action is already executing and holds {held} (request_hash \
2935 {running_hash}); retry after the current job completes. This approval \
2936 expires {ttl} minutes after it was issued, and that window is anchored to \
2937 when the preview was created — re-running `sysknife approve` on this same \
2938 transaction will not renew it. If the running job is still going once the \
2939 window has passed, re-run the preview (which mints a fresh transaction) and \
2940 approve that new transaction instead"
2941 );
2942 drop(slots); return send_response(
2944 framed,
2945 &DaemonResponse::ConflictResponse {
2946 request_id: request_id.to_string(),
2947 message: msg,
2948 retry_after_seconds: None,
2949 },
2950 )
2951 .await;
2952 }
2953 for resource in &to_claim {
2956 slots.insert(*resource, stored_hash.clone());
2957 }
2958 }
2960
2961 let claimed = match state
2962 .audit
2963 .claim_approved_for_execution(
2964 transaction_id,
2965 &receipt_digest(approval_receipt),
2966 caller.principal(),
2967 )
2968 .await
2969 {
2970 Ok(c) => c,
2971 Err(e) => {
2972 release_exclusive_slots(state, &to_claim, &stored_hash).await;
2973 return send_error(
2974 framed,
2975 request_id,
2976 "transient_infrastructure_failure",
2977 format!("failed to claim transaction: {e}"),
2978 )
2979 .await;
2980 }
2981 };
2982 if !claimed {
2983 release_exclusive_slots(state, &to_claim, &stored_hash).await;
2984 return send_error(
2985 framed,
2986 request_id,
2987 "stale_approval",
2988 "transaction is not approved for this receipt, expired, or already consumed",
2989 )
2990 .await;
2991 }
2992
2993 if action_name == "AptAutoremove" {
3005 let approved_preview = match state.audit.get_preview(transaction_id).await {
3006 Ok(Some(p)) => p,
3007 Ok(None) => {
3008 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3009 return send_error(
3010 framed,
3011 request_id,
3012 "stale_approval",
3013 "no persisted preview for this transaction; preview before executing",
3014 )
3015 .await;
3016 }
3017 Err(e) => {
3018 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3019 return send_error(
3020 framed,
3021 request_id,
3022 "transient_infrastructure_failure",
3023 format!("preview lookup failed: {e}"),
3024 )
3025 .await;
3026 }
3027 };
3028 let live = match simulate_autoremove_set(&runner).await {
3029 Ok(s) => s,
3030 Err(e) => {
3031 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3032 return send_error(
3033 framed,
3034 request_id,
3035 "execution_failure",
3036 format!("could not confirm the autoremove set before executing: {e}"),
3037 )
3038 .await;
3039 }
3040 };
3041 if let Err(reason) = verify_autoremove_binding(&approved_preview.proposed_change, &live) {
3042 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3043 return send_error(framed, request_id, "stale_approval", reason).await;
3044 }
3045 }
3046
3047 if matches!(action_name, "PinDeployment" | "UnpinDeployment") {
3051 let approved_preview = match state.audit.get_preview(transaction_id).await {
3052 Ok(Some(p)) => p,
3053 Ok(None) => {
3054 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3055 return send_error(
3056 framed,
3057 request_id,
3058 "stale_approval",
3059 "no persisted preview for this transaction; preview before executing",
3060 )
3061 .await;
3062 }
3063 Err(e) => {
3064 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3065 return send_error(
3066 framed,
3067 request_id,
3068 "transient_infrastructure_failure",
3069 format!("preview lookup failed: {e}"),
3070 )
3071 .await;
3072 }
3073 };
3074 let Some(index) = params.get("index").and_then(Value::as_u64) else {
3075 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3076 return send_error(
3077 framed,
3078 request_id,
3079 "validation_failure",
3080 "no deployment index was supplied, so the approval cannot be bound to one",
3081 )
3082 .await;
3083 };
3084 let live = match resolve_deployment_identity(&runner, index as u32).await {
3085 Ok(identity) => identity,
3086 Err(e) => {
3087 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3088 return send_error(
3089 framed,
3090 request_id,
3091 "execution_failure",
3092 format!("could not confirm which deployment is at that index: {e}"),
3093 )
3094 .await;
3095 }
3096 };
3097 if let Err(reason) = verify_deployment_binding(&approved_preview.proposed_change, &live) {
3098 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3099 return send_error(framed, request_id, "stale_approval", reason).await;
3100 }
3101 }
3102
3103 let job_id = Uuid::new_v4().to_string();
3104
3105 if let Err(send_error) = send_response(
3106 framed,
3107 &DaemonResponse::JobStarted {
3108 request_id: request_id.to_string(),
3109 job_id: job_id.clone(),
3110 transaction_id: transaction_id.to_string(),
3111 },
3112 )
3113 .await
3114 {
3115 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3116 if let Err(status_error) =
3117 update_terminal_status(state, transaction_id, JobState::Failed).await
3118 {
3119 eprintln!(
3120 "[sysknife-daemon] failed to mark disconnected transaction {transaction_id} \
3121 as Failed: {status_error}"
3122 );
3123 }
3124 return Err(send_error);
3125 }
3126
3127 let _ = send_response(
3128 framed,
3129 &DaemonResponse::JobProgress {
3130 job_id: job_id.clone(),
3131 line: format!("Authorization passed for {action_name}"),
3132 },
3133 )
3134 .await;
3135
3136 let _ = send_response(
3137 framed,
3138 &DaemonResponse::JobProgress {
3139 job_id: job_id.clone(),
3140 line: format!("Executing {action_name}..."),
3141 },
3142 )
3143 .await;
3144
3145 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
3149 let execution = executor.execute_with_progress(&spec, progress_tx);
3150 tokio::pin!(execution);
3151 let mut progress_open = true;
3152 let output = loop {
3153 tokio::select! {
3154 result = &mut execution => break result,
3155 line = progress_rx.recv(), if progress_open => {
3156 let Some(line) = line else {
3157 progress_open = false;
3158 continue;
3159 };
3160 if let Err(e) = send_response(
3161 framed,
3162 &DaemonResponse::JobProgress {
3163 job_id: job_id.clone(),
3164 line,
3165 },
3166 ).await {
3167 eprintln!(
3168 "[sysknife-daemon] progress send failed (client disconnected?): {e}"
3169 );
3170 }
3171 }
3172 }
3173 };
3174
3175 while let Ok(line) = progress_rx.try_recv() {
3179 if let Err(e) = send_response(
3180 framed,
3181 &DaemonResponse::JobProgress {
3182 job_id: job_id.clone(),
3183 line,
3184 },
3185 )
3186 .await
3187 {
3188 eprintln!("[sysknife-daemon] final progress send failed: {e}");
3189 }
3190 }
3191
3192 let (initial_status, initial_summary) = match &output {
3193 Ok(out) if out.is_success() => {
3194 if spec.reboot_required {
3195 (
3196 JobState::NeedsReboot,
3197 format!("{action_name} completed; reboot required"),
3198 )
3199 } else {
3200 (
3201 JobState::Succeeded,
3202 format!("{action_name} completed successfully"),
3203 )
3204 }
3205 }
3206 Ok(out) => (
3207 JobState::Failed,
3208 format!("{action_name} failed with exit code {}", out.exit_code),
3209 ),
3210 Err(e) => (JobState::Failed, format!("{action_name} failed: {e}")),
3211 };
3212
3213 let completion_line = match &output {
3214 Ok(out) => format!("{action_name} completed with exit code {}", out.exit_code),
3215 Err(e) => format!("{action_name} completed with error: {e}"),
3216 };
3217 let _ = send_response(
3218 framed,
3219 &DaemonResponse::JobProgress {
3220 job_id: job_id.clone(),
3221 line: completion_line,
3222 },
3223 )
3224 .await;
3225
3226 let action_may_still_be_running = matches!(
3230 &output,
3231 Err(crate::executor::ExecutorError::ActionNotStopped { .. })
3232 );
3233 let (final_status, summary, rollback_ref) = attempt_rollback_if_needed(
3234 framed,
3235 &executor,
3236 &job_id,
3237 action_name,
3238 &spec,
3239 initial_status,
3240 initial_summary,
3241 action_may_still_be_running,
3242 )
3243 .await;
3244
3245 if action_may_still_be_running {
3254 if !to_claim.is_empty() {
3255 eprintln!(
3256 "[sysknife-daemon] holding the exclusion slot for {action_name}: the action \
3257 could not be confirmed stopped, so no other mutating action may start until \
3258 the daemon is restarted"
3259 );
3260 }
3261 } else {
3262 release_exclusive_slots(state, &to_claim, &stored_hash).await;
3263 }
3264
3265 let mut warnings = Vec::new();
3269 match update_terminal_status(state, transaction_id, final_status).await {
3270 Ok(()) => {
3271 forward_status_change_event(state, transaction_id, &caller.role(), final_status);
3276 }
3277 Err(e) => {
3278 eprintln!(
3279 "[sysknife-daemon] failed to update transaction {transaction_id} to \
3280 {final_status:?}: {e}"
3281 );
3282 warnings.push(format!("audit trail update failed: {e}"));
3283 }
3284 }
3285
3286 send_response(
3287 framed,
3288 &DaemonResponse::JobCompleted {
3289 job_id: job_id.clone(),
3290 result: JobResult {
3291 status: job_state_str(&final_status).to_string(),
3292 summary,
3293 warnings,
3294 job_id: job_id.clone(),
3295 needs_reboot: matches!(final_status, JobState::NeedsReboot),
3296 rollback_ref,
3297 transaction_id: transaction_id.to_string(),
3298 },
3299 },
3300 )
3301 .await
3302}
3303
3304#[allow(clippy::too_many_arguments)]
3317async fn attempt_rollback_if_needed(
3318 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
3319 executor: &Arc<dyn ActionExecutor>,
3320 job_id: &str,
3321 action_name: &str,
3322 spec: &crate::actions::ActionSpec,
3323 status: JobState,
3324 summary: String,
3325 action_may_still_be_running: bool,
3326) -> (JobState, String, Option<String>) {
3327 if !matches!(status, JobState::Failed) || !spec.rollback_available {
3328 return (status, summary, None);
3329 }
3330 if action_may_still_be_running {
3331 eprintln!(
3332 "[sysknife-daemon] {action_name} could not be confirmed stopped; \
3333 skipping automatic rollback"
3334 );
3335 let line = format!(
3336 "{action_name} could not be confirmed stopped, so automatic rollback was \
3337 skipped: rolling back over a running transaction would be worse than \
3338 leaving it. Inspect the host before retrying."
3339 );
3340 let _ = send_response(
3341 framed,
3342 &DaemonResponse::JobProgress {
3343 job_id: job_id.to_string(),
3344 line: line.clone(),
3345 },
3346 )
3347 .await;
3348 return (
3349 status,
3350 format!("{summary} (automatic rollback skipped)"),
3351 None,
3352 );
3353 }
3354 let Some(rb_spec) = rollback_spec_for(action_name) else {
3355 return (status, summary, None);
3356 };
3357
3358 eprintln!("[sysknife-daemon] {action_name} failed; attempting automatic rollback");
3359
3360 let _ = send_response(
3361 framed,
3362 &DaemonResponse::JobProgress {
3363 job_id: job_id.to_string(),
3364 line: format!(
3365 "{action_name} failed — attempting automatic rollback via rpm-ostree rollback"
3366 ),
3367 },
3368 )
3369 .await;
3370
3371 match executor.execute(&rb_spec).await {
3372 Ok(rb_out) if rb_out.is_success() => {
3373 let _ = send_response(
3374 framed,
3375 &DaemonResponse::JobProgress {
3376 job_id: job_id.to_string(),
3377 line: "Rollback succeeded — previous deployment restored".to_string(),
3378 },
3379 )
3380 .await;
3381 (
3382 JobState::RolledBack,
3383 format!(
3384 "{action_name} failed and was automatically rolled back to the previous deployment"
3385 ),
3386 Some("rpm-ostree rollback".to_string()),
3387 )
3388 }
3389 other => {
3390 let detail = match &other {
3391 Ok(o) => format!("exit code {}", o.exit_code),
3392 Err(e) => e.to_string(),
3393 };
3394 eprintln!("[sysknife-daemon] rollback also failed: {detail}");
3395 let _ = send_response(
3396 framed,
3397 &DaemonResponse::JobProgress {
3398 job_id: job_id.to_string(),
3399 line: format!(
3400 "Rollback also failed ({detail}) — system may need manual intervention"
3401 ),
3402 },
3403 )
3404 .await;
3405 (status, summary, None)
3406 }
3407 }
3408}
3409
3410fn job_state_str(state: &JobState) -> &'static str {
3411 match state {
3412 JobState::Queued => "queued",
3413 JobState::Running => "running",
3414 JobState::Succeeded => "succeeded",
3415 JobState::Failed => "failed",
3416 JobState::Canceled => "canceled",
3417 JobState::RolledBack => "rolled_back",
3418 JobState::NeedsReboot => "needs_reboot",
3419 }
3420}
3421
3422#[derive(Debug, thiserror::Error)]
3427enum HandlerError {
3428 #[error("framing error: {0}")]
3429 Framing(#[from] FramingError),
3430 #[error("serialization error: {0}")]
3431 Serialization(#[from] serde_json::Error),
3432 #[error("internal error: {0}")]
3433 Internal(String),
3434}
3435
3436fn format_job_history(records: &[sysknife_types::TransactionRecord]) -> String {
3437 if records.is_empty() {
3438 return "No transactions found.".to_string();
3439 }
3440
3441 let mut output = format!("Transaction history ({} entries):\n\n", records.len());
3442 for r in records {
3443 output.push_str(&format!(
3444 " {} {:30} {:12} {}\n",
3445 r.transaction_id
3446 .chars()
3447 .take(TRANSACTION_ID_DISPLAY_PREFIX_LEN)
3448 .collect::<String>(),
3449 r.action_name,
3450 format!("{:?}", r.status).to_lowercase(),
3451 r.summary,
3452 ));
3453 }
3454 output
3455}
3456
3457async fn send_response(
3458 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
3459 response: &DaemonResponse,
3460) -> Result<(), HandlerError> {
3461 let json = serde_json::to_vec(response)?;
3462 framed.send(&json).await.map_err(HandlerError::Framing)
3463}
3464
3465async fn send_error(
3466 framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
3467 request_id: &str,
3468 category: &str,
3469 message: impl Into<String>,
3470) -> Result<(), HandlerError> {
3471 send_response(
3472 framed,
3473 &DaemonResponse::ErrorResponse {
3474 request_id: request_id.to_string(),
3475 category: category.to_string(),
3476 message: message.into(),
3477 },
3478 )
3479 .await
3480}
3481
3482#[cfg(test)]
3487mod tests {
3488 use super::*;
3489 use crate::auth::CallerPrincipal;
3490
3491 fn uid_caller(role: CallerRole) -> CallerAttribution {
3493 uid_caller_with(1000, role)
3494 }
3495
3496 fn uid_caller_with(uid: u32, role: CallerRole) -> CallerAttribution {
3499 CallerAttribution::from_peer_uid(uid, role)
3500 }
3501 use crate::{
3502 state::{DaemonConfig, DaemonState},
3503 transport::listen::ListenTarget,
3504 };
3505 use std::io;
3506 use tempfile::tempdir;
3507
3508 #[derive(Default)]
3516 struct CountingRollbackExecutor {
3517 calls: std::sync::atomic::AtomicUsize,
3518 }
3519
3520 #[async_trait::async_trait]
3521 impl ActionExecutor for CountingRollbackExecutor {
3522 async fn execute(
3523 &self,
3524 _spec: &crate::actions::ActionSpec,
3525 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
3526 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3527 Ok(crate::executor::ExecutionOutput {
3528 stdout: "rolled back\n".to_string(),
3529 stderr: String::new(),
3530 exit_code: 0,
3531 })
3532 }
3533 }
3534
3535 fn rollback_eligible_spec() -> crate::actions::ActionSpec {
3538 let spec = crate::executor::build_action_spec("UpdateSystem", &serde_json::json!({}))
3539 .expect("UpdateSystem builds without params");
3540 assert!(
3541 spec.rollback_available,
3542 "this test is meaningless unless the action is rollback-eligible"
3543 );
3544 spec
3545 }
3546
3547 async fn rollback_outcome(
3548 may_still_be_running: bool,
3549 ) -> (JobState, String, Option<String>, usize) {
3550 let (a, _b) = UnixStream::pair().unwrap();
3551 let mut framed = FramedStream::new(a);
3552 let counting = Arc::new(CountingRollbackExecutor::default());
3553 let executor: Arc<dyn ActionExecutor> = counting.clone();
3554 let (status, summary, rollback_ref) = attempt_rollback_if_needed(
3555 &mut framed,
3556 &executor,
3557 "job-1",
3558 "UpdateSystem",
3559 &rollback_eligible_spec(),
3560 JobState::Failed,
3561 "UpdateSystem failed".to_string(),
3562 may_still_be_running,
3563 )
3564 .await;
3565 let calls = counting.calls.load(std::sync::atomic::Ordering::SeqCst);
3566 (status, summary, rollback_ref, calls)
3567 }
3568
3569 #[tokio::test]
3572 async fn an_ordinary_failure_still_rolls_back() {
3573 let (status, _summary, rollback_ref, calls) = rollback_outcome(false).await;
3574 assert_eq!(calls, 1, "the rollback action must have been executed");
3575 assert!(matches!(status, JobState::RolledBack));
3576 assert!(rollback_ref.is_some());
3577 }
3578
3579 #[tokio::test]
3584 async fn an_action_that_could_not_be_stopped_is_never_rolled_back_over() {
3585 let (status, summary, rollback_ref, calls) = rollback_outcome(true).await;
3586 assert_eq!(
3587 calls, 0,
3588 "no rollback may be executed while the original action may still be running"
3589 );
3590 assert!(
3591 matches!(status, JobState::Failed),
3592 "the job stays failed rather than claiming it was rolled back"
3593 );
3594 assert!(rollback_ref.is_none());
3595 assert!(
3596 summary.contains("rollback skipped"),
3597 "the operator must be told why nothing was reverted, got: {summary}"
3598 );
3599 }
3600
3601 #[cfg(target_os = "linux")]
3606 #[tokio::test]
3607 async fn peer_pidfd_reports_live_self() {
3608 let (a, _b) = UnixStream::pair().unwrap();
3609 match peer_pidfd(&a) {
3613 PeerPin::Pinned(fd) => assert!(
3614 pidfd_peer_still_live(&fd),
3615 "the connecting (self) process must read as live"
3616 ),
3617 PeerPin::Unsupported => {}
3618 PeerPin::Unpinnable => panic!("a live peer must not silently lose its pin"),
3619 }
3620 }
3621
3622 #[cfg(target_os = "linux")]
3623 #[test]
3624 fn peer_pin_only_unsupported_option_allows_best_effort_groups() {
3625 assert!(matches!(
3626 PeerPin::from_error(&std::io::Error::from_raw_os_error(libc::ENOPROTOOPT)),
3627 PeerPin::Unsupported
3628 ));
3629 for errno in [
3630 libc::EINVAL,
3631 libc::ESRCH,
3632 libc::EMFILE,
3633 libc::ENFILE,
3634 libc::EPERM,
3635 ] {
3636 let pin = PeerPin::from_error(&std::io::Error::from_raw_os_error(errno));
3637 assert!(matches!(pin, PeerPin::Unpinnable), "errno {errno}");
3638 let groups = groups_for_pinned_peer(123, pin, || {
3639 panic!("an unpinnable peer must not read a potentially recycled PID")
3640 });
3641 assert_eq!(highest_role_from_groups(groups), CallerRole::Observer);
3642 }
3643 let groups = groups_for_pinned_peer(123, PeerPin::Unsupported, || {
3644 vec!["sysknife-admin".to_string()]
3645 });
3646 assert_eq!(highest_role_from_groups(groups), CallerRole::Admin);
3647 }
3648
3649 #[cfg(target_os = "linux")]
3650 #[tokio::test]
3651 async fn unpinnable_peer_keeps_only_its_primary_group() {
3652 let (a, _b) = UnixStream::pair().unwrap();
3653 let cred = a.peer_cred().unwrap();
3654 let gid_map = read_gid_map();
3655 let expected = highest_role_from_groups(gid_map.get(&cred.gid()));
3656 let caller = resolve_caller_with_pin(&a, PeerPin::Unpinnable);
3657 assert_eq!(caller.role(), expected);
3658 assert_eq!(caller.principal(), CallerPrincipal::Uid(cred.uid()));
3659 }
3660
3661 #[tokio::test]
3666 async fn resolve_caller_attributes_a_unix_peer_to_its_kernel_reported_uid() {
3667 let (a, _b) = UnixStream::pair().unwrap();
3668 let caller = resolve_caller(&a);
3669
3670 assert_eq!(
3671 caller.principal(),
3672 CallerPrincipal::Uid(unsafe { libc::geteuid() }),
3673 "a Unix-socket caller must be attributed to the peer uid SO_PEERCRED reports"
3674 );
3675 assert_eq!(
3676 caller.principal().as_signed_str(),
3677 format!("uid:{}", unsafe { libc::geteuid() }),
3678 "and the signed form must carry the uid scheme"
3679 );
3680 }
3681
3682 #[test]
3686 fn a_vsock_principal_does_not_claim_an_account() {
3687 assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
3688 assert!(
3689 !CallerPrincipal::VsockToken
3690 .as_signed_str()
3691 .starts_with("uid:"),
3692 "a token must never render as a uid; that would overstate the evidence"
3693 );
3694 }
3695
3696 #[tokio::test]
3697 async fn resolve_caller_matches_the_peers_actual_groups() {
3698 let (a, _b) = UnixStream::pair().unwrap();
3708 let role = resolve_caller(&a).role();
3709
3710 let gid_map = read_gid_map();
3711 let expected =
3712 crate::auth::highest_role_from_groups(groups_for_pid(std::process::id(), &gid_map));
3713
3714 assert_eq!(
3715 role, expected,
3716 "the resolved role must be exactly what this process's groups justify"
3717 );
3718 }
3719
3720 #[tokio::test]
3721 async fn resolve_caller_never_exceeds_observer_without_a_privileged_group() {
3722 let (a, _b) = UnixStream::pair().unwrap();
3725 let role = resolve_caller(&a).role();
3726
3727 let gid_map = read_gid_map();
3728 let groups = groups_for_pid(std::process::id(), &gid_map);
3729 let privileged = groups.iter().any(|g| {
3730 matches!(
3731 g.as_str(),
3732 crate::auth::DEV_GROUP
3733 | crate::auth::ADMIN_GROUP
3734 | crate::auth::BOOT_GROUP
3735 | crate::auth::WHEEL_GROUP
3736 )
3737 });
3738
3739 if !privileged {
3740 assert_eq!(
3741 role,
3742 CallerRole::Observer,
3743 "a process in no privileged group must resolve to Observer"
3744 );
3745 }
3746 }
3747
3748 #[test]
3757 fn first_request_deadline_is_much_tighter_than_the_idle_deadline() {
3758 let first = read_deadline(0);
3759 let idle = read_deadline(1);
3760 assert!(
3761 first < idle,
3762 "pre-request deadline {first:?} must be tighter than the between-request \
3763 deadline {idle:?}; otherwise a silent connection squats a permit for the \
3764 full idle window"
3765 );
3766 assert_eq!(idle, IDLE_CONNECTION_TIMEOUT);
3767 }
3768
3769 #[test]
3773 fn served_connections_keep_the_full_idle_allowance() {
3774 for served in 1..5 {
3775 assert_eq!(
3776 read_deadline(served),
3777 IDLE_CONNECTION_TIMEOUT,
3778 "request {served} must not be held to the pre-request deadline"
3779 );
3780 }
3781 }
3782
3783 #[test]
3787 fn redact_params_replaces_pro_attach_token() {
3788 let params = json!({"token": "super-secret-test-only", "extra": "kept"});
3789 let r = redact_params("ProAttach", ¶ms);
3790 assert_eq!(r["token"].as_str(), Some("<REDACTED>"));
3792 assert_eq!(r["extra"].as_str(), Some("kept"));
3794 let s = serde_json::to_string(&r).unwrap();
3796 assert!(
3797 !s.contains("super-secret-test-only"),
3798 "token leaked into proposed_change JSON: {s}"
3799 );
3800 }
3801
3802 #[test]
3803 fn redact_params_replaces_configure_wifi_password() {
3804 let params = json!({"ssid": "HomeNet", "password": "correct-horse-test-only"});
3805 let r = redact_params("ConfigureWifi", ¶ms);
3806 assert_eq!(r["password"].as_str(), Some("<REDACTED>"));
3807 assert_eq!(r["ssid"].as_str(), Some("HomeNet"));
3809 let s = serde_json::to_string(&r).unwrap();
3810 assert!(
3811 !s.contains("correct-horse-test-only"),
3812 "wifi password leaked into proposed_change JSON: {s}"
3813 );
3814 }
3815
3816 #[test]
3820 fn redact_argv_replaces_wifi_password_after_keyword() {
3821 let params = json!({"ssid": "HomeNet", "password": "correct-horse-test-only"});
3822 let argv = wifi_argv(&["HomeNet", "password", "correct-horse-test-only"]);
3823 let r = redact_argv("ConfigureWifi", ¶ms, &argv);
3824 assert_eq!(r.last().map(String::as_str), Some("<REDACTED>"));
3825 assert!(
3826 !r.iter().any(|a| a == "correct-horse-test-only"),
3827 "wifi password survived argv redaction: {r:?}"
3828 );
3829 }
3830
3831 #[test]
3834 fn redact_argv_leaves_open_network_argv_intact() {
3835 let params = json!({"ssid": "CafeWifi"});
3836 let argv = wifi_argv(&["CafeWifi"]);
3837 let r = redact_argv("ConfigureWifi", ¶ms, &argv);
3838 assert_eq!(r, argv, "open-network argv must be unchanged");
3839 }
3840
3841 #[test]
3844 fn redact_argv_handles_ssid_named_password() {
3845 let params = json!({"ssid": "password", "password": "s3cret-test-only"});
3846 let argv = wifi_argv(&["password", "password", "s3cret-test-only"]);
3847 let r = redact_argv("ConfigureWifi", ¶ms, &argv);
3848 assert!(
3849 !r.iter().any(|a| a == "s3cret-test-only"),
3850 "secret leaked when the SSID is named 'password': {r:?}"
3851 );
3852 assert_eq!(r.iter().filter(|a| *a == "password").count(), 2);
3854 }
3855
3856 #[test]
3860 fn redact_argv_handles_password_valued_password() {
3861 let params = json!({"ssid": "HomeNet", "password": "password"});
3862 let argv = wifi_argv(&["HomeNet", "password", "password"]);
3863 let r = redact_argv("ConfigureWifi", ¶ms, &argv);
3864 assert_eq!(r.last().map(String::as_str), Some("<REDACTED>"));
3865 }
3866
3867 fn wifi_argv(tail: &[&str]) -> Vec<String> {
3870 let mut argv: Vec<String> = ["nmcli", "device", "wifi", "connect"]
3871 .iter()
3872 .map(|s| (*s).to_string())
3873 .collect();
3874 argv.extend(tail.iter().map(|s| (*s).to_string()));
3875 argv
3876 }
3877
3878 #[test]
3879 fn redact_params_passes_through_for_actions_with_no_credentials() {
3880 let params = json!({"package": "vim"});
3881 let r = redact_params("AptInstall", ¶ms);
3882 assert_eq!(r["package"].as_str(), Some("vim"));
3883 }
3884
3885 #[test]
3886 fn redact_argv_replaces_argv_element_matching_token_value() {
3887 let params = json!({"token": "super-secret-test-only"});
3890 let argv = vec![
3891 "pro".to_string(),
3892 "attach".to_string(),
3893 "super-secret-test-only".to_string(),
3894 ];
3895 let r = redact_argv("ProAttach", ¶ms, &argv);
3896 assert_eq!(r, vec!["pro", "attach", "<REDACTED>"]);
3897 }
3898
3899 #[test]
3900 fn redact_argv_passes_through_when_no_credentials() {
3901 let params = json!({"package": "vim"});
3902 let argv = vec![
3903 "apt-get".to_string(),
3904 "install".to_string(),
3905 "vim".to_string(),
3906 ];
3907 let r = redact_argv("AptInstall", ¶ms, &argv);
3908 assert_eq!(r, argv);
3909 }
3910
3911 #[test]
3922 fn redact_argv_token_equal_to_attach_preserves_structural_attach() {
3923 let params = json!({"token": "attach"});
3924 let argv = vec![
3926 "sudo".to_string(),
3927 "pro".to_string(),
3928 "attach".to_string(),
3929 "attach".to_string(), ];
3931 let r = redact_argv("ProAttach", ¶ms, &argv);
3932 assert_eq!(
3937 r,
3938 vec!["sudo", "pro", "attach", "<REDACTED>"],
3939 "structural 'attach' must survive; only the positional token gets redacted"
3940 );
3941 }
3942
3943 struct MockRunner;
3948 impl CommandRunner for MockRunner {
3949 fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
3950 match program {
3951 "hostname" => Ok("testhost\n".to_string()),
3952 _ => Ok(String::new()),
3953 }
3954 }
3955 }
3956
3957 fn test_state(dir: &tempfile::TempDir) -> DaemonState {
3958 let db_path = dir.path().join("sysknife-test.db");
3959 let sock_path = dir.path().join("sysknife-test.sock");
3960 let config = DaemonConfig::new(ListenTarget::Unix(sock_path), db_path);
3961 DaemonState::open(config).unwrap()
3962 }
3963
3964 fn runner() -> Arc<dyn CommandRunner + Send + Sync> {
3965 Arc::new(MockRunner)
3966 }
3967
3968 fn str_set<const N: usize>(items: [&str; N]) -> std::collections::BTreeSet<String> {
3969 items.iter().map(|s| s.to_string()).collect()
3970 }
3971
3972 #[test]
3973 fn autoremove_binding_accepts_the_same_set() {
3974 let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a", "b"] });
3975 assert!(verify_autoremove_binding(&pc, &str_set(["a", "b"])).is_ok());
3976 }
3977
3978 #[test]
3979 fn autoremove_binding_rejects_a_newly_added_package() {
3980 let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a"] });
3981 let err = verify_autoremove_binding(&pc, &str_set(["a", "linux-image-6.1"]))
3982 .expect_err("a package apt would now remove but the operator did not approve");
3983 assert!(err.contains("linux-image-6.1"), "{err}");
3984 }
3985
3986 #[test]
3987 fn autoremove_binding_rejects_a_no_longer_removed_package() {
3988 let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a", "b"] });
3989 let err = verify_autoremove_binding(&pc, &str_set(["a"]))
3990 .expect_err("the approved set no longer matches");
3991 assert!(err.contains('b'), "{err}");
3992 }
3993
3994 #[test]
3995 fn autoremove_binding_refuses_a_preview_that_captured_no_set() {
3996 let pc = json!({ "action": "AptAutoremove" });
3998 assert!(verify_autoremove_binding(&pc, &str_set([])).is_err());
3999 }
4000
4001 #[test]
4013 fn a_deployment_preview_binds_execute_to_the_deployment_not_the_slot() {
4014 let empty = json!({ "action": "PinDeployment", "params": { "index": 1 } });
4017 let err = verify_deployment_binding(&empty, "abc123")
4018 .expect_err("a preview with no captured identity must not execute");
4019 assert!(
4020 err.contains("preview again"),
4021 "the refusal must tell the operator what to do: {err}"
4022 );
4023
4024 let mut pinned = empty.clone();
4026 pinned[DEPLOYMENT_IDENTITY_KEY] = json!("abc123");
4027 assert!(verify_deployment_binding(&pinned, "abc123").is_ok());
4028
4029 let err = verify_deployment_binding(&pinned, "def456")
4032 .expect_err("a reordered list must not execute against the old approval");
4033 assert!(
4034 err.contains("abc123") && err.contains("def456"),
4035 "the refusal must name the approved and the live deployment: {err}"
4036 );
4037 }
4038
4039 #[test]
4040 fn deployment_identity_is_read_from_rpm_ostree_status() {
4041 let json_out = r#"{"deployments":[
4042 {"checksum":"aaa","origin":"fedora/41/x86_64/silverblue"},
4043 {"checksum":"bbb","origin":"fedora/41/x86_64/silverblue"}
4044 ]}"#;
4045 assert_eq!(parse_deployment_identity(json_out, 0).unwrap(), "aaa");
4046 assert_eq!(parse_deployment_identity(json_out, 1).unwrap(), "bbb");
4047 assert!(parse_deployment_identity(json_out, 2).is_err());
4051 assert!(parse_deployment_identity("not json", 0).is_err());
4052 assert!(parse_deployment_identity(r#"{"deployments":[{}]}"#, 0).is_err());
4055 }
4056
4057 #[test]
4058 fn autoremove_kernel_warning_flags_kernel_and_driver_packages() {
4059 let set = str_set([
4060 "linux-image-6.1",
4061 "nvidia-driver-535",
4062 "libfoo:amd64",
4063 "vim",
4064 ]);
4065 let w = autoremove_kernel_warning(&set).expect("kernel/driver packages must warn");
4066 assert!(
4067 w.contains("linux-image-6.1") && w.contains("nvidia-driver-535"),
4068 "{w}"
4069 );
4070 assert!(!w.contains("vim") && !w.contains("libfoo"), "{w}");
4071 }
4072
4073 #[test]
4074 fn autoremove_kernel_warning_is_silent_for_ordinary_packages() {
4075 assert!(autoremove_kernel_warning(&str_set(["libfoo", "oldlib"])).is_none());
4076 }
4077
4078 struct ScriptedAutoremoveRunner {
4084 outputs: Vec<Result<String, String>>,
4085 calls: Arc<std::sync::atomic::AtomicUsize>,
4086 }
4087 impl ScriptedAutoremoveRunner {
4088 fn with_outputs(
4089 outputs: Vec<Result<String, String>>,
4090 ) -> (
4091 Arc<dyn CommandRunner + Send + Sync>,
4092 Arc<std::sync::atomic::AtomicUsize>,
4093 ) {
4094 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4095 let runner = Arc::new(Self {
4096 outputs,
4097 calls: Arc::clone(&calls),
4098 });
4099 (runner, calls)
4100 }
4101 fn ok(
4103 outputs: &[&str],
4104 ) -> (
4105 Arc<dyn CommandRunner + Send + Sync>,
4106 Arc<std::sync::atomic::AtomicUsize>,
4107 ) {
4108 Self::with_outputs(outputs.iter().map(|s| Ok(s.to_string())).collect())
4109 }
4110 }
4111 impl CommandRunner for ScriptedAutoremoveRunner {
4112 fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error> {
4113 if program == "hostname" {
4114 return Ok("testhost\n".to_string());
4115 }
4116 if program == "apt-get" && args == ["-s", "autoremove"] {
4117 let i = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4118 let out = self.outputs.get(i).unwrap_or_else(|| {
4119 panic!(
4120 "ScriptedAutoremoveRunner: unexpected `apt-get -s autoremove` call #{i} \
4121 (only {} scripted)",
4122 self.outputs.len()
4123 )
4124 });
4125 return out.clone().map_err(io::Error::other);
4126 }
4127 Ok(String::new())
4128 }
4129 }
4130
4131 #[tokio::test]
4132 async fn preview_captures_the_autoremove_set_and_warns_on_kernel_packages() {
4133 let dir = tempdir().unwrap();
4134 let state = test_state(&dir);
4135 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4136 let (runner, _calls) =
4137 ScriptedAutoremoveRunner::ok(&["Remv linux-image-6.1 [6.1]\nRemv libfoo [1]\n"]);
4138 tokio::spawn(async move {
4139 unix_connection_handler(
4140 server,
4141 state,
4142 runner,
4143 uid_caller_with(1000, CallerRole::Admin),
4144 )
4145 .await;
4146 });
4147
4148 let mut framed = FramedStream::new(client);
4149 framed
4150 .send(
4151 &serde_json::to_vec(&json!({
4152 "type": "preview",
4153 "request_id": "r1",
4154 "action_name": "AptAutoremove",
4155 "params": {}
4156 }))
4157 .unwrap(),
4158 )
4159 .await
4160 .unwrap();
4161 let resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4162 assert_eq!(resp["type"], "preview_response", "{resp}");
4163
4164 let removals = resp["preview"]["proposed_change"][AUTOREMOVE_REMOVALS_KEY]
4165 .as_array()
4166 .expect("the preview must capture the autoremove deletion set");
4167 let names: Vec<&str> = removals.iter().map(|v| v.as_str().unwrap()).collect();
4168 assert!(
4169 names.contains(&"linux-image-6.1") && names.contains(&"libfoo"),
4170 "captured set wrong: {names:?}"
4171 );
4172 let warnings = resp["preview"]["warnings"].as_array().unwrap();
4173 assert!(
4174 warnings
4175 .iter()
4176 .any(|w| w.as_str().unwrap().contains("linux-image-6.1")),
4177 "the kernel-package warning must be present: {warnings:?}"
4178 );
4179 }
4180
4181 #[tokio::test]
4182 async fn execute_refuses_autoremove_when_the_deletion_set_drifted() {
4183 let dir = tempdir().unwrap();
4184 let state = test_state(&dir);
4185 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4186 let (runner, calls) =
4188 ScriptedAutoremoveRunner::ok(&["Remv pkga [1]\nRemv pkgb [1]\n", "Remv pkga [1]\n"]);
4189 let calls_probe = Arc::clone(&calls);
4190 tokio::spawn(async move {
4191 unix_connection_handler(
4192 server,
4193 state,
4194 runner,
4195 uid_caller_with(1000, CallerRole::Admin),
4196 )
4197 .await;
4198 });
4199
4200 let mut framed = FramedStream::new(client);
4201 framed
4202 .send(
4203 &serde_json::to_vec(&json!({
4204 "type": "preview",
4205 "request_id": "r1",
4206 "action_name": "AptAutoremove",
4207 "params": {}
4208 }))
4209 .unwrap(),
4210 )
4211 .await
4212 .unwrap();
4213 let resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4214 assert_eq!(resp["type"], "preview_response", "{resp}");
4215 let txid = resp["transaction_id"].as_str().unwrap().to_string();
4216 let receipt = approve_preview(&mut framed, &txid).await;
4217
4218 framed
4219 .send(
4220 &serde_json::to_vec(&json!({
4221 "type": "execute",
4222 "request_id": "r2",
4223 "transaction_id": txid,
4224 "action_name": "AptAutoremove",
4225 "params": {},
4226 "approval_receipt": receipt
4227 }))
4228 .unwrap(),
4229 )
4230 .await
4231 .unwrap();
4232 let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4233 assert_eq!(
4235 exec["type"], "error_response",
4236 "drift must be refused: {exec}"
4237 );
4238 assert_eq!(exec["category"], "stale_approval", "{exec}");
4239 assert!(
4240 exec["message"].as_str().unwrap().contains("changed since"),
4241 "message should explain the drift: {exec}"
4242 );
4243 assert_eq!(
4245 calls_probe.load(std::sync::atomic::Ordering::SeqCst),
4246 2,
4247 "apt-get -s autoremove must run once per phase"
4248 );
4249 }
4250
4251 #[derive(Default)]
4255 struct RecordingExecutor {
4256 called: Arc<std::sync::atomic::AtomicUsize>,
4257 }
4258 #[async_trait::async_trait]
4259 impl ActionExecutor for RecordingExecutor {
4260 async fn execute(
4261 &self,
4262 _spec: &crate::actions::ActionSpec,
4263 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
4264 unreachable!("dispatcher uses execute_with_progress")
4265 }
4266 async fn execute_with_progress(
4267 &self,
4268 _spec: &crate::actions::ActionSpec,
4269 progress: tokio::sync::mpsc::UnboundedSender<String>,
4270 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
4271 self.called
4272 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4273 let _ = progress.send("executed".to_string());
4274 Ok(crate::executor::ExecutionOutput {
4275 stdout: String::new(),
4276 stderr: String::new(),
4277 exit_code: 0,
4278 })
4279 }
4280 }
4281
4282 async fn preview_autoremove(framed: &mut FramedStream<tokio::net::UnixStream>) -> Value {
4283 framed
4284 .send(
4285 &serde_json::to_vec(&json!({
4286 "type": "preview",
4287 "request_id": "r1",
4288 "action_name": "AptAutoremove",
4289 "params": {}
4290 }))
4291 .unwrap(),
4292 )
4293 .await
4294 .unwrap();
4295 serde_json::from_slice(&framed.recv().await.unwrap()).unwrap()
4296 }
4297
4298 async fn send_execute_autoremove(
4299 framed: &mut FramedStream<tokio::net::UnixStream>,
4300 txid: &str,
4301 receipt: &str,
4302 ) {
4303 framed
4304 .send(
4305 &serde_json::to_vec(&json!({
4306 "type": "execute",
4307 "request_id": "r2",
4308 "transaction_id": txid,
4309 "action_name": "AptAutoremove",
4310 "params": {},
4311 "approval_receipt": receipt
4312 }))
4313 .unwrap(),
4314 )
4315 .await
4316 .unwrap();
4317 }
4318
4319 #[tokio::test]
4320 async fn execute_proceeds_when_the_autoremove_set_is_unchanged() {
4321 let dir = tempdir().unwrap();
4322 let state = test_state(&dir);
4323 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4324 let (runner, _calls) =
4326 ScriptedAutoremoveRunner::ok(&["Remv pkga [1]\n", "Remv pkga [1]\n"]);
4327 let called = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4328 let executor = Arc::new(RecordingExecutor {
4329 called: Arc::clone(&called),
4330 });
4331 tokio::spawn(async move {
4332 connection_handler_with_executor(
4333 server,
4334 state,
4335 runner,
4336 executor,
4337 uid_caller_with(1000, CallerRole::Admin),
4338 )
4339 .await;
4340 });
4341
4342 let mut framed = FramedStream::new(client);
4343 let resp = preview_autoremove(&mut framed).await;
4344 assert_eq!(resp["type"], "preview_response", "{resp}");
4345 let txid = resp["transaction_id"].as_str().unwrap().to_string();
4346 let receipt = approve_preview(&mut framed, &txid).await;
4347 send_execute_autoremove(&mut framed, &txid, &receipt).await;
4348
4349 loop {
4350 let r: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4351 match r["type"].as_str().unwrap() {
4352 "job_started" | "job_progress" => {}
4353 "job_completed" => break,
4354 other => panic!("an unchanged set must proceed, got {other}: {r}"),
4355 }
4356 }
4357 assert_eq!(
4358 called.load(std::sync::atomic::Ordering::SeqCst),
4359 1,
4360 "executor must run exactly once when the set is unchanged"
4361 );
4362 }
4363
4364 #[tokio::test]
4365 async fn preview_that_cannot_simulate_captures_no_set_and_execute_refuses() {
4366 let dir = tempdir().unwrap();
4367 let state = test_state(&dir);
4368 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4369 let (runner, _calls) = ScriptedAutoremoveRunner::with_outputs(vec![
4372 Err("apt database lock held".to_string()),
4373 Ok("Remv pkga [1]\n".to_string()),
4374 ]);
4375 tokio::spawn(async move {
4376 unix_connection_handler(
4377 server,
4378 state,
4379 runner,
4380 uid_caller_with(1000, CallerRole::Admin),
4381 )
4382 .await;
4383 });
4384
4385 let mut framed = FramedStream::new(client);
4386 let resp = preview_autoremove(&mut framed).await;
4387 assert_eq!(resp["type"], "preview_response", "{resp}");
4388 assert!(
4390 resp["preview"]["proposed_change"]
4391 .get(AUTOREMOVE_REMOVALS_KEY)
4392 .is_none(),
4393 "a failed simulate must not capture a set: {resp}"
4394 );
4395 assert!(
4396 resp["preview"]["warnings"]
4397 .as_array()
4398 .unwrap()
4399 .iter()
4400 .any(|w| w.as_str().unwrap().contains("Could not compute")),
4401 "the operator must be warned: {resp}"
4402 );
4403 let txid = resp["transaction_id"].as_str().unwrap().to_string();
4404 let receipt = approve_preview(&mut framed, &txid).await;
4405 send_execute_autoremove(&mut framed, &txid, &receipt).await;
4406
4407 let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4408 assert_eq!(exec["type"], "error_response", "{exec}");
4409 assert_eq!(exec["category"], "stale_approval", "{exec}");
4410 assert!(
4411 exec["message"]
4412 .as_str()
4413 .unwrap()
4414 .contains("did not capture"),
4415 "must refuse an uncaptured set: {exec}"
4416 );
4417 }
4418
4419 #[tokio::test]
4420 async fn execute_refuses_when_the_autoremove_re_simulate_fails() {
4421 let dir = tempdir().unwrap();
4422 let state = test_state(&dir);
4423 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4424 let (runner, _calls) = ScriptedAutoremoveRunner::with_outputs(vec![
4427 Ok("Remv pkga [1]\n".to_string()),
4428 Err("apt database lock held".to_string()),
4429 ]);
4430 tokio::spawn(async move {
4431 unix_connection_handler(
4432 server,
4433 state,
4434 runner,
4435 uid_caller_with(1000, CallerRole::Admin),
4436 )
4437 .await;
4438 });
4439
4440 let mut framed = FramedStream::new(client);
4441 let resp = preview_autoremove(&mut framed).await;
4442 assert_eq!(resp["type"], "preview_response", "{resp}");
4443 let txid = resp["transaction_id"].as_str().unwrap().to_string();
4444 let receipt = approve_preview(&mut framed, &txid).await;
4445 send_execute_autoremove(&mut framed, &txid, &receipt).await;
4446
4447 let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4448 assert_eq!(exec["type"], "error_response", "{exec}");
4449 assert_eq!(exec["category"], "execution_failure", "{exec}");
4450 assert!(
4451 exec["message"]
4452 .as_str()
4453 .unwrap()
4454 .contains("confirm the autoremove set"),
4455 "{exec}"
4456 );
4457 }
4458
4459 #[test]
4460 fn autoremove_binding_refuses_a_malformed_captured_set() {
4461 let pc = json!({ AUTOREMOVE_REMOVALS_KEY: "not-an-array" });
4463 let err = verify_autoremove_binding(&pc, &str_set(["a"]))
4464 .expect_err("a malformed captured set must be refused");
4465 assert!(err.contains("malformed"), "{err}");
4466 }
4467
4468 async fn approve_preview(
4469 framed: &mut FramedStream<tokio::net::UnixStream>,
4470 transaction_id: &str,
4471 ) -> String {
4472 framed
4473 .send(
4474 &serde_json::to_vec(&json!({
4475 "type": "approve",
4476 "request_id": format!("approve-{transaction_id}"),
4477 "transaction_id": transaction_id,
4478 }))
4479 .unwrap(),
4480 )
4481 .await
4482 .unwrap();
4483 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4484 assert_eq!(response["type"], "approval_response");
4485 response["approval_receipt"]
4486 .as_str()
4487 .expect("approval receipt")
4488 .to_string()
4489 }
4490
4491 async fn preview_and_approve(
4492 framed: &mut FramedStream<tokio::net::UnixStream>,
4493 action_name: &str,
4494 params: Value,
4495 ) -> (String, String) {
4496 framed
4497 .send(
4498 &serde_json::to_vec(&json!({
4499 "type": "preview",
4500 "request_id": format!("preview-{action_name}"),
4501 "action_name": action_name,
4502 "params": params,
4503 }))
4504 .unwrap(),
4505 )
4506 .await
4507 .unwrap();
4508 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4509 assert_eq!(response["type"], "preview_response");
4510 let transaction_id = response["transaction_id"].as_str().unwrap().to_string();
4511 let receipt = approve_preview(framed, &transaction_id).await;
4512 (transaction_id, receipt)
4513 }
4514
4515 #[tokio::test]
4516 async fn approval_details_returns_persisted_preview_before_approval() {
4517 let dir = tempdir().unwrap();
4518 let state = test_state(&dir);
4519 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4520 tokio::spawn(async move {
4521 unix_connection_handler(
4522 server,
4523 state,
4524 runner(),
4525 uid_caller_with(1000, CallerRole::Observer),
4526 )
4527 .await;
4528 });
4529 let mut framed = FramedStream::new(client);
4530 framed
4531 .send(
4532 &serde_json::to_vec(&json!({
4533 "type": "preview",
4534 "request_id": "details-preview",
4535 "action_name": "GetMemoryInfo",
4536 "params": {}
4537 }))
4538 .unwrap(),
4539 )
4540 .await
4541 .unwrap();
4542 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4543 let transaction_id = preview["transaction_id"].as_str().unwrap();
4544
4545 framed
4546 .send(
4547 &serde_json::to_vec(&json!({
4548 "type": "approval_details",
4549 "request_id": "details-read",
4550 "transaction_id": transaction_id
4551 }))
4552 .unwrap(),
4553 )
4554 .await
4555 .unwrap();
4556 let details: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4557 assert_eq!(details["type"], "approval_details_response");
4558 assert_eq!(details["transaction_id"], transaction_id);
4559 assert_eq!(details["action_name"], "GetMemoryInfo");
4560 assert_eq!(details["preview"]["risk_level"], "low");
4561 assert!(!details["preview"]["summary"].as_str().unwrap().is_empty());
4562 }
4563
4564 #[tokio::test]
4565 async fn approve_missing_transaction_maps_to_stale_approval() {
4566 let dir = tempdir().unwrap();
4567 let responses = exchange(
4568 test_state(&dir),
4569 CallerRole::Observer,
4570 vec![json!({
4571 "type": "approve",
4572 "request_id": "approve-missing",
4573 "transaction_id": "missing-transaction",
4574 })],
4575 1,
4576 )
4577 .await;
4578
4579 assert_eq!(responses[0]["type"], "error_response");
4580 assert_eq!(responses[0]["category"], "stale_approval");
4581 }
4582
4583 #[tokio::test]
4584 async fn approving_same_transaction_twice_maps_second_to_stale_approval() {
4585 let dir = tempdir().unwrap();
4586 let state = test_state(&dir);
4587 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4588 tokio::spawn(async move {
4589 unix_connection_handler(
4590 server,
4591 state,
4592 runner(),
4593 uid_caller_with(1000, CallerRole::Observer),
4594 )
4595 .await;
4596 });
4597 let mut framed = FramedStream::new(client);
4598 let (transaction_id, _) =
4599 preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
4600
4601 framed
4602 .send(
4603 &serde_json::to_vec(&json!({
4604 "type": "approve",
4605 "request_id": "approve-again",
4606 "transaction_id": transaction_id,
4607 }))
4608 .unwrap(),
4609 )
4610 .await
4611 .unwrap();
4612 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4613
4614 assert_eq!(response["type"], "error_response");
4615 assert_eq!(response["category"], "stale_approval");
4616 }
4617
4618 #[tokio::test]
4619 async fn approve_with_forged_commitment_maps_to_integrity_failure() {
4620 let dir = tempdir().unwrap();
4625 let db_path = dir.path().join("sysknife-test.db");
4626 let state = test_state(&dir);
4627 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4628 tokio::spawn(async move {
4629 unix_connection_handler(
4630 server,
4631 state,
4632 runner(),
4633 uid_caller_with(1000, CallerRole::Observer),
4634 )
4635 .await;
4636 });
4637 let mut framed = FramedStream::new(client);
4638
4639 framed
4640 .send(
4641 &serde_json::to_vec(&json!({
4642 "type": "preview",
4643 "request_id": "preview-forge",
4644 "action_name": "GetMemoryInfo",
4645 "params": {},
4646 }))
4647 .unwrap(),
4648 )
4649 .await
4650 .unwrap();
4651 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4652 let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
4653
4654 rusqlite::Connection::open(&db_path)
4657 .unwrap()
4658 .execute(
4659 "UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
4660 rusqlite::params![transaction_id],
4661 )
4662 .unwrap();
4663
4664 framed
4665 .send(
4666 &serde_json::to_vec(&json!({
4667 "type": "approve",
4668 "request_id": "approve-forged",
4669 "transaction_id": transaction_id,
4670 }))
4671 .unwrap(),
4672 )
4673 .await
4674 .unwrap();
4675 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4676
4677 assert_eq!(response["type"], "error_response");
4678 assert_eq!(response["category"], "integrity_failure");
4679 }
4680
4681 #[tokio::test]
4682 async fn query_history_returns_structured_rows_with_created_at_and_risk_level() {
4683 let dir = tempdir().unwrap();
4686 let state = test_state(&dir);
4687 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4688 tokio::spawn(async move {
4689 unix_connection_handler(
4690 server,
4691 state,
4692 runner(),
4693 uid_caller_with(1000, CallerRole::Observer),
4694 )
4695 .await;
4696 });
4697 let mut framed = FramedStream::new(client);
4698
4699 framed
4700 .send(
4701 &serde_json::to_vec(&json!({
4702 "type": "preview",
4703 "request_id": "preview-hist",
4704 "action_name": "GetMemoryInfo",
4705 "params": {},
4706 }))
4707 .unwrap(),
4708 )
4709 .await
4710 .unwrap();
4711 let _ = framed.recv().await.unwrap();
4712
4713 framed
4714 .send(
4715 &serde_json::to_vec(&json!({
4716 "type": "query_history",
4717 "request_id": "hist-1",
4718 "limit": 10,
4719 }))
4720 .unwrap(),
4721 )
4722 .await
4723 .unwrap();
4724 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4725
4726 assert_eq!(response["type"], "history_response");
4727 let entries = response["entries"].as_array().expect("entries array");
4728 assert_eq!(entries.len(), 1);
4729 assert_eq!(entries[0]["action_name"], "GetMemoryInfo");
4730 assert!(
4731 !entries[0]["created_at"].as_str().unwrap_or("").is_empty(),
4732 "created_at must be populated over the structured IPC"
4733 );
4734 assert_eq!(
4735 entries[0]["risk_level"], "low",
4736 "GetSystemState is a low-risk action; risk_level must carry the exact typed value"
4737 );
4738 }
4739
4740 async fn history_daemon_with_20_and_75_minute_rows(
4743 dir: &tempfile::TempDir,
4744 ) -> FramedStream<tokio::net::UnixStream> {
4745 let state = test_state(dir);
4746 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4747 tokio::spawn(async move {
4748 unix_connection_handler(
4749 server,
4750 state,
4751 runner(),
4752 uid_caller_with(1000, CallerRole::Observer),
4753 )
4754 .await;
4755 });
4756 let mut framed = FramedStream::new(client);
4757 let db = rusqlite::Connection::open(dir.path().join("sysknife-test.db")).unwrap();
4758 for (i, minutes_ago) in [75, 20].into_iter().enumerate() {
4759 framed
4760 .send(
4761 &serde_json::to_vec(&json!({
4762 "type": "preview",
4763 "request_id": format!("preview-since-{i}"),
4764 "action_name": "GetMemoryInfo",
4765 "params": {},
4766 }))
4767 .unwrap(),
4768 )
4769 .await
4770 .unwrap();
4771 let _ = framed.recv().await.unwrap();
4772 db.execute(
4774 "UPDATE transactions \
4775 SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?1) \
4776 WHERE julianday(created_at) > julianday('now', '-5 minutes')",
4777 rusqlite::params![format!("-{minutes_ago} minutes")],
4778 )
4779 .unwrap();
4780 }
4781 framed
4782 }
4783
4784 fn minutes_ago_rfc3339(minutes: i64) -> String {
4785 (chrono::Utc::now() - chrono::Duration::minutes(minutes))
4786 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4787 }
4788
4789 #[test]
4790 fn resolve_history_since_parses_and_rejects_ambiguity() {
4791 assert_eq!(resolve_history_since(None, None), Ok(None));
4792 assert_eq!(
4793 resolve_history_since(Some(24), None),
4794 Ok(Some(HistorySince::Hours(24)))
4795 );
4796 let expected = chrono::DateTime::parse_from_rfc3339("2026-01-15T10:30:00Z")
4797 .unwrap()
4798 .with_timezone(&chrono::Utc);
4799 assert_eq!(
4800 resolve_history_since(None, Some("2026-01-15T12:30:00+02:00")),
4801 Ok(Some(HistorySince::Cutoff(expected)))
4802 );
4803 assert!(resolve_history_since(None, Some("yesterday")).is_err());
4804 assert!(resolve_history_since(Some(1), Some("2026-01-15T10:30:00Z")).is_err());
4805 }
4806
4807 #[tokio::test]
4808 async fn query_history_since_keeps_sub_hour_precision() {
4809 let dir = tempdir().unwrap();
4814 let mut framed = history_daemon_with_20_and_75_minute_rows(&dir).await;
4815
4816 for (cutoff_minutes, expected) in [(30, 1), (90, 2), (10, 0)] {
4817 framed
4818 .send(
4819 &serde_json::to_vec(&json!({
4820 "type": "query_history",
4821 "request_id": format!("hist-since-{cutoff_minutes}"),
4822 "since": minutes_ago_rfc3339(cutoff_minutes),
4823 }))
4824 .unwrap(),
4825 )
4826 .await
4827 .unwrap();
4828 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4829 assert_eq!(response["type"], "history_response", "{response}");
4830 let entries = response["entries"].as_array().expect("entries array");
4831 assert_eq!(
4832 entries.len(),
4833 expected,
4834 "cutoff {cutoff_minutes} minutes ago: {response}"
4835 );
4836 }
4837 }
4838
4839 #[tokio::test]
4840 async fn list_job_history_since_keeps_sub_hour_precision() {
4841 let dir = tempdir().unwrap();
4843 let mut framed = history_daemon_with_20_and_75_minute_rows(&dir).await;
4844
4845 for (cutoff_minutes, expect_rows) in [(30, true), (10, false)] {
4846 framed
4847 .send(
4848 &serde_json::to_vec(&json!({
4849 "type": "query_action",
4850 "request_id": format!("ljh-since-{cutoff_minutes}"),
4851 "action_name": "ListJobHistory",
4852 "params": { "since": minutes_ago_rfc3339(cutoff_minutes) },
4853 }))
4854 .unwrap(),
4855 )
4856 .await
4857 .unwrap();
4858 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4859 let output = response["output"].as_str().expect("output text");
4860 assert_eq!(
4861 !output.starts_with("No transactions found"),
4862 expect_rows,
4863 "cutoff {cutoff_minutes} minutes ago: {output}"
4864 );
4865 }
4866 }
4867
4868 #[tokio::test]
4869 async fn query_history_honours_policy_override_raising_history_above_observer() {
4870 let dir = tempdir().unwrap();
4874 let real = test_state(&dir);
4875 let overrides =
4878 std::collections::HashMap::from([("ListJobHistory".to_string(), "high".to_string())]);
4879 let policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
4880 let audit_key = crate::audit_chain::AuditKey::load_or_generate(
4883 &crate::audit_chain::resolve_audit_key_path(&dir.path().join("sysknife-test.db")),
4884 )
4885 .unwrap();
4886 let state = crate::state::DaemonState::open_with_audit(
4887 real.config.clone(),
4888 policy,
4889 None,
4890 real.audit.clone(),
4891 &audit_key,
4892 );
4893
4894 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4895 tokio::spawn(async move {
4896 unix_connection_handler(
4897 server,
4898 state,
4899 runner(),
4900 uid_caller_with(1000, CallerRole::Observer),
4901 )
4902 .await;
4903 });
4904 let mut framed = FramedStream::new(client);
4905 framed
4906 .send(
4907 &serde_json::to_vec(&json!({
4908 "type": "query_history",
4909 "request_id": "hist-denied",
4910 "limit": 10,
4911 }))
4912 .unwrap(),
4913 )
4914 .await
4915 .unwrap();
4916 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4917
4918 assert_eq!(response["type"], "error_response");
4919 assert_eq!(response["category"], "authorization_failure");
4920 }
4921
4922 #[tokio::test]
4923 async fn cancel_queued_transaction_succeeds_and_marks_it_canceled() {
4924 let dir = tempdir().unwrap();
4925 let state = test_state(&dir);
4926 let handler_state = state.clone();
4927 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4928 tokio::spawn(async move {
4929 unix_connection_handler(
4930 server,
4931 handler_state,
4932 runner(),
4933 uid_caller_with(1000, CallerRole::Observer),
4934 )
4935 .await;
4936 });
4937 let mut framed = FramedStream::new(client);
4938
4939 framed
4940 .send(
4941 &serde_json::to_vec(&json!({
4942 "type": "preview",
4943 "request_id": "preview-cancel",
4944 "action_name": "GetMemoryInfo",
4945 "params": {},
4946 }))
4947 .unwrap(),
4948 )
4949 .await
4950 .unwrap();
4951 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4952 let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
4953
4954 framed
4955 .send(
4956 &serde_json::to_vec(&json!({
4957 "type": "cancel",
4958 "request_id": "cancel-1",
4959 "transaction_id": transaction_id,
4960 }))
4961 .unwrap(),
4962 )
4963 .await
4964 .unwrap();
4965 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
4966
4967 assert_eq!(response["type"], "cancel_response");
4968 assert_eq!(
4969 state
4970 .audit
4971 .get(&transaction_id)
4972 .await
4973 .unwrap()
4974 .unwrap()
4975 .status,
4976 JobState::Canceled
4977 );
4978 }
4979
4980 #[tokio::test]
4981 async fn cancel_running_transaction_is_rejected_and_left_running() {
4982 let dir = tempdir().unwrap();
4983 let state = test_state(&dir);
4984 let handler_state = state.clone();
4985 let (client, server) = tokio::net::UnixStream::pair().unwrap();
4986 tokio::spawn(async move {
4987 unix_connection_handler(
4988 server,
4989 handler_state,
4990 runner(),
4991 uid_caller_with(1000, CallerRole::Observer),
4992 )
4993 .await;
4994 });
4995 let mut framed = FramedStream::new(client);
4996 let (transaction_id, receipt) =
4997 preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
4998 assert!(state
5000 .audit
5001 .claim_approved_for_execution(
5002 &transaction_id,
5003 &receipt_digest(&receipt),
5004 CallerPrincipal::Uid(1000),
5005 )
5006 .await
5007 .unwrap());
5008
5009 framed
5010 .send(
5011 &serde_json::to_vec(&json!({
5012 "type": "cancel",
5013 "request_id": "cancel-running",
5014 "transaction_id": transaction_id,
5015 }))
5016 .unwrap(),
5017 )
5018 .await
5019 .unwrap();
5020 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5021
5022 assert_eq!(response["type"], "error_response");
5023 assert_eq!(response["category"], "not_cancelable");
5024 assert_eq!(
5025 state
5026 .audit
5027 .get(&transaction_id)
5028 .await
5029 .unwrap()
5030 .unwrap()
5031 .status,
5032 JobState::Running,
5033 "an in-flight action must not be disturbed by cancel"
5034 );
5035 }
5036
5037 #[tokio::test]
5038 async fn cancel_missing_transaction_is_rejected() {
5039 let dir = tempdir().unwrap();
5040 let responses = exchange(
5041 test_state(&dir),
5042 CallerRole::Observer,
5043 vec![json!({
5044 "type": "cancel",
5045 "request_id": "cancel-missing",
5046 "transaction_id": "no-such-transaction",
5047 })],
5048 1,
5049 )
5050 .await;
5051 assert_eq!(responses[0]["type"], "error_response");
5052 assert_eq!(responses[0]["category"], "not_cancelable");
5053 }
5054
5055 #[tokio::test]
5056 async fn undelivered_approval_is_revoked_so_the_user_can_retry() {
5057 let dir = tempdir().unwrap();
5058 let state = test_state(&dir);
5059 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5060 let handler_state = state.clone();
5061 tokio::spawn(async move {
5062 unix_connection_handler(
5063 server,
5064 handler_state,
5065 runner(),
5066 uid_caller_with(1000, CallerRole::Observer),
5067 )
5068 .await;
5069 });
5070 let mut framed = FramedStream::new(client);
5071 framed
5072 .send(
5073 &serde_json::to_vec(&json!({
5074 "type": "preview",
5075 "request_id": "preview-undelivered",
5076 "action_name": "GetMemoryInfo",
5077 "params": {},
5078 }))
5079 .unwrap(),
5080 )
5081 .await
5082 .unwrap();
5083 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5084 let transaction_id = preview["transaction_id"].as_str().unwrap();
5085
5086 let (peer, broken_stream) = tokio::io::duplex(64);
5087 drop(peer);
5088 let mut broken_framed = FramedStream::new(broken_stream);
5089 assert!(handle_approve(
5090 &mut broken_framed,
5091 &state,
5092 &uid_caller(CallerRole::Admin),
5093 "approve-undelivered",
5094 transaction_id,
5095 )
5096 .await
5097 .is_err());
5098
5099 let (retry_stream, receiver) = tokio::io::duplex(4096);
5100 let mut retry_framed = FramedStream::new(retry_stream);
5101 handle_approve(
5102 &mut retry_framed,
5103 &state,
5104 &uid_caller(CallerRole::Admin),
5105 "approve-retry",
5106 transaction_id,
5107 )
5108 .await
5109 .unwrap();
5110 let mut receiver = FramedStream::new(receiver);
5111 let response: Value = serde_json::from_slice(&receiver.recv().await.unwrap()).unwrap();
5112 assert_eq!(response["type"], "approval_response");
5113 }
5114
5115 #[tokio::test]
5116 async fn approval_details_for_running_transaction_maps_to_stale_approval() {
5117 let dir = tempdir().unwrap();
5118 let state = test_state(&dir);
5119 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5120 let handler_state = state.clone();
5121 tokio::spawn(async move {
5122 unix_connection_handler(
5123 server,
5124 handler_state,
5125 runner(),
5126 uid_caller_with(1000, CallerRole::Observer),
5127 )
5128 .await;
5129 });
5130 let mut framed = FramedStream::new(client);
5131 let (transaction_id, receipt) =
5132 preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
5133 assert!(state
5134 .audit
5135 .claim_approved_for_execution(
5136 &transaction_id,
5137 &receipt_digest(&receipt),
5138 CallerPrincipal::Uid(1000),
5139 )
5140 .await
5141 .unwrap());
5142
5143 framed
5144 .send(
5145 &serde_json::to_vec(&json!({
5146 "type": "approval_details",
5147 "request_id": "details-running",
5148 "transaction_id": transaction_id,
5149 }))
5150 .unwrap(),
5151 )
5152 .await
5153 .unwrap();
5154 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5155
5156 assert_eq!(response["type"], "error_response");
5157 assert_eq!(response["category"], "stale_approval");
5158 }
5159
5160 async fn exchange(
5163 state: DaemonState,
5164 role: CallerRole,
5165 requests: Vec<Value>,
5166 want_responses: usize,
5167 ) -> Vec<Value> {
5168 exchange_as(state, uid_caller_with(1000, role), requests, want_responses).await
5169 }
5170
5171 async fn exchange_as(
5174 state: DaemonState,
5175 caller: CallerAttribution,
5176 requests: Vec<Value>,
5177 want_responses: usize,
5178 ) -> Vec<Value> {
5179 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5180 tokio::spawn(async move {
5181 unix_connection_handler(server, state, runner(), caller).await;
5182 });
5183
5184 let mut framed = FramedStream::new(client);
5185 for req in &requests {
5186 let bytes = serde_json::to_vec(req).unwrap();
5187 framed.send(&bytes).await.unwrap();
5188 }
5189
5190 let mut responses = Vec::new();
5191 for _ in 0..want_responses {
5192 let raw = framed.recv().await.unwrap();
5193 responses.push(serde_json::from_slice::<Value>(&raw).unwrap());
5194 }
5195 responses
5196 }
5197
5198 #[tokio::test]
5203 async fn query_state_returns_state_response() {
5204 let dir = tempdir().unwrap();
5205 let mut state = test_state(&dir);
5206 let distro = sysknife_core::distro::DistroId::Ubuntu {
5207 major: 24,
5208 minor: 4,
5209 };
5210 state.host_distro = Some(distro.clone());
5211
5212 let resps = exchange(
5213 state,
5214 CallerRole::Observer,
5215 vec![json!({"type": "query_state", "request_id": "r1"})],
5216 1,
5217 )
5218 .await;
5219
5220 assert_eq!(resps[0]["type"], "state_response");
5221 assert_eq!(resps[0]["request_id"], "r1");
5222 assert_eq!(resps[0]["state"]["host_name"], "testhost");
5223 assert_eq!(
5224 resps[0]["state"]["host_distro"],
5225 json!({"Ubuntu": {"major": 24, "minor": 4}})
5226 );
5227 }
5228
5229 #[tokio::test]
5241 async fn the_recorded_principal_is_the_one_the_connection_was_attributed_to() {
5242 let dir = tempdir().unwrap();
5243 let state = test_state(&dir);
5244 let audit = std::sync::Arc::clone(&state.audit);
5245
5246 let resps = exchange_as(
5247 state,
5248 uid_caller_with(4242, CallerRole::Observer),
5249 vec![json!({
5250 "type": "preview",
5251 "request_id": "r-principal",
5252 "action_name": "GetMemoryInfo",
5253 "params": {}
5254 })],
5255 1,
5256 )
5257 .await;
5258
5259 let transaction_id = resps[0]["transaction_id"].as_str().unwrap();
5260 let row = audit
5261 .fetch_chain_row(transaction_id)
5262 .await
5263 .expect("fetch the row just written")
5264 .expect("preview records a transaction");
5265
5266 assert_eq!(
5267 row.caller_principal.as_deref(),
5268 Some("uid:4242"),
5269 "the row must name the account resolved for this connection, not a default"
5270 );
5271 assert_eq!(row.chain_version, crate::audit_chain::CHAIN_VERSION_CURRENT);
5272 }
5273
5274 #[tokio::test]
5284 async fn handlers_sign_the_connection_account_into_approval_events() {
5285 let dir = tempdir().unwrap();
5286 let state = test_state(&dir);
5287 let audit = std::sync::Arc::clone(&state.audit);
5288 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5289 tokio::spawn(async move {
5290 unix_connection_handler(
5291 server,
5292 state,
5293 runner(),
5294 uid_caller_with(4242, CallerRole::Admin),
5295 )
5296 .await;
5297 });
5298 let mut framed = FramedStream::new(client);
5299 let (txid, receipt) = preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
5300 framed
5301 .send(
5302 &serde_json::to_vec(&json!({
5303 "type": "execute",
5304 "request_id": "r-exec",
5305 "transaction_id": txid,
5306 "action_name": "GetMemoryInfo",
5307 "params": {},
5308 "approval_receipt": receipt
5309 }))
5310 .unwrap(),
5311 )
5312 .await
5313 .unwrap();
5314 let _exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5315 let events = audit.fetch_event_rows().await.unwrap();
5316 let event = |kind: &str| {
5317 events
5318 .iter()
5319 .find(|e| e.kind == kind)
5320 .unwrap_or_else(|| panic!("no {kind} event in {events:?}"))
5321 };
5322 assert_eq!(
5323 event("approval_granted").caller_principal.as_deref(),
5324 Some("uid:4242"),
5325 "handle_approve must sign the account the connection was attributed to"
5326 );
5327 assert_eq!(
5328 event("approval_consumed").caller_principal.as_deref(),
5329 Some("uid:4242"),
5330 "handle_execute must sign the account the connection was attributed to"
5331 );
5332 assert_eq!(
5333 event("approval_granted").chain_version,
5334 crate::audit_chain::EVENT_VERSION_V2
5335 );
5336 assert_eq!(
5337 event("approval_consumed").chain_version,
5338 crate::audit_chain::EVENT_VERSION_V2
5339 );
5340 }
5341
5342 #[tokio::test]
5346 async fn cancelling_an_approved_transaction_signs_the_cancelling_account() {
5347 let dir = tempdir().unwrap();
5348 let state = test_state(&dir);
5349 let audit = std::sync::Arc::clone(&state.audit);
5350 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5351 tokio::spawn(async move {
5352 unix_connection_handler(
5353 server,
5354 state,
5355 runner(),
5356 uid_caller_with(4242, CallerRole::Observer),
5357 )
5358 .await;
5359 });
5360 let mut framed = FramedStream::new(client);
5361 let (txid, _receipt) = preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
5362 framed
5363 .send(
5364 &serde_json::to_vec(&json!({
5365 "type": "cancel",
5366 "request_id": "r-cancel",
5367 "transaction_id": txid,
5368 }))
5369 .unwrap(),
5370 )
5371 .await
5372 .unwrap();
5373 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5374 assert_eq!(response["type"], "cancel_response", "{response}");
5375 let events = audit.fetch_event_rows().await.unwrap();
5376 let revoked = events
5377 .iter()
5378 .find(|e| e.kind == "approval_revoked")
5379 .unwrap_or_else(|| panic!("no approval_revoked event in {events:?}"));
5380 assert_eq!(
5381 revoked.caller_principal.as_deref(),
5382 Some("uid:4242"),
5383 "handle_cancel must sign the account that cancelled"
5384 );
5385 assert_eq!(revoked.chain_version, crate::audit_chain::EVENT_VERSION_V2);
5386 }
5387
5388 #[tokio::test]
5389 async fn preview_returns_hash_and_transaction_id() {
5390 let dir = tempdir().unwrap();
5391 let state = test_state(&dir);
5392
5393 let resps = exchange(
5394 state,
5395 CallerRole::Observer,
5396 vec![json!({
5397 "type": "preview",
5398 "request_id": "r1",
5399 "action_name": "GetMemoryInfo",
5400 "params": {}
5401 })],
5402 1,
5403 )
5404 .await;
5405
5406 assert_eq!(resps[0]["type"], "preview_response");
5407 assert_eq!(resps[0]["request_id"], "r1");
5408 let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
5409 assert_eq!(
5410 hash.len(),
5411 64,
5412 "request_hash should be a 64-char hex SHA-256"
5413 );
5414 assert!(
5415 !resps[0]["transaction_id"].as_str().unwrap().is_empty(),
5416 "transaction_id must be set"
5417 );
5418 }
5419
5420 #[tokio::test]
5428 async fn preview_request_hash_is_keyed_not_a_bare_sha256() {
5429 let dir = tempdir().unwrap();
5430 let state = test_state(&dir);
5431 let key = state.request_hash_key.clone();
5432
5433 let resps = exchange(
5434 state,
5435 CallerRole::Observer,
5436 vec![json!({
5437 "type": "preview",
5438 "request_id": "r1",
5439 "action_name": "GetMemoryInfo",
5440 "params": {}
5441 })],
5442 1,
5443 )
5444 .await;
5445
5446 assert_eq!(resps[0]["type"], "preview_response", "{:?}", resps[0]);
5447 let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
5448
5449 let expected = key.request_hash("GetMemoryInfo", &canonical_json(&json!({})));
5452 assert_eq!(
5453 hash, expected,
5454 "the live preview hash must be the HMAC under the daemon's \
5455 request_hash_key"
5456 );
5457
5458 let mut hasher = Sha256::new();
5460 hasher.update(b"GetMemoryInfo");
5461 hasher.update(b"\x00");
5462 hasher.update(canonical_json(&json!({})).as_bytes());
5463 let unsalted = hex::encode(hasher.finalize());
5464 assert_ne!(
5465 hash, unsalted,
5466 "the live path returned the unsalted SHA-256; #268 is open again"
5467 );
5468 }
5469
5470 #[tokio::test]
5471 async fn preview_hash_is_deterministic() {
5472 let key = crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]).request_hash_key();
5474 let hash1 = compute_request_hash(
5475 &key,
5476 "InstallFlatpak",
5477 &json!({"app_id": "org.gnome.Builder", "remote": "flathub"}),
5478 );
5479 let hash2 = compute_request_hash(
5480 &key,
5481 "InstallFlatpak",
5482 &json!({"remote": "flathub", "app_id": "org.gnome.Builder"}),
5483 );
5484 assert_eq!(hash1, hash2, "canonical JSON must sort keys");
5485 }
5486
5487 #[test]
5488 fn canonical_json_recurses_into_arrays() {
5489 let key = crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]).request_hash_key();
5493 let hash1 = compute_request_hash(
5494 &key,
5495 "InstallPackages",
5496 &json!({"packages": [{"b": 1, "a": 2}]}),
5497 );
5498 let hash2 = compute_request_hash(
5499 &key,
5500 "InstallPackages",
5501 &json!({"packages": [{"a": 2, "b": 1}]}),
5502 );
5503 assert_eq!(hash1, hash2, "nested object keys in arrays must be sorted");
5504 }
5505
5506 #[test]
5507 fn canonical_json_preserves_array_element_order() {
5508 let key = crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]).request_hash_key();
5511 let hash1 = compute_request_hash(&key, "Op", &json!({"items": ["a", "b"]}));
5512 let hash2 = compute_request_hash(&key, "Op", &json!({"items": ["b", "a"]}));
5513 assert_ne!(hash1, hash2, "array element order must be preserved");
5514 }
5515
5516 #[tokio::test]
5521 async fn high_risk_action_rejected_for_observer() {
5522 let dir = tempdir().unwrap();
5523 let state = test_state(&dir);
5524
5525 let resps = exchange(
5526 state,
5527 CallerRole::Observer, vec![json!({
5529 "type": "preview",
5530 "request_id": "r1",
5531 "action_name": "UpdateSystem",
5532 "params": {}
5533 })],
5534 1,
5535 )
5536 .await;
5537
5538 assert_eq!(resps[0]["type"], "error_response");
5539 assert_eq!(resps[0]["category"], "authorization_failure");
5540 }
5541
5542 #[tokio::test]
5543 async fn under_privileged_caller_cannot_act_on_admin_transaction() {
5544 let dir = tempdir().unwrap();
5550 let mut state = test_state(&dir);
5554 state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 });
5555
5556 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5558 let handler_state = state.clone();
5559 tokio::spawn(async move {
5560 unix_connection_handler(
5561 server,
5562 handler_state,
5563 runner(),
5564 uid_caller_with(1000, CallerRole::Admin),
5565 )
5566 .await;
5567 });
5568 let mut framed = FramedStream::new(client);
5569 framed
5570 .send(
5571 &serde_json::to_vec(&json!({
5572 "type": "preview",
5573 "request_id": "preview-admin",
5574 "action_name": "UpdateSystem",
5575 "params": {},
5576 }))
5577 .unwrap(),
5578 )
5579 .await
5580 .unwrap();
5581 let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5582 assert_eq!(preview["type"], "preview_response");
5583 let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
5584
5585 for (request_id, verb) in [
5588 ("obs-approve", "approve"),
5589 ("obs-cancel", "cancel"),
5590 ("obs-details", "details"),
5591 ] {
5592 let (stream, rx) = tokio::io::duplex(4096);
5593 let mut caller = FramedStream::new(stream);
5594 match verb {
5595 "approve" => handle_approve(
5596 &mut caller,
5597 &state,
5598 &uid_caller(CallerRole::Observer),
5599 request_id,
5600 &transaction_id,
5601 )
5602 .await
5603 .unwrap(),
5604 "cancel" => handle_cancel(
5605 &mut caller,
5606 &state,
5607 &uid_caller(CallerRole::Observer),
5608 request_id,
5609 &transaction_id,
5610 )
5611 .await
5612 .unwrap(),
5613 _ => handle_approval_details(
5614 &mut caller,
5615 &state,
5616 &uid_caller(CallerRole::Observer),
5617 request_id,
5618 &transaction_id,
5619 )
5620 .await
5621 .unwrap(),
5622 }
5623 let mut rx = FramedStream::new(rx);
5624 let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
5625 assert_eq!(resp["type"], "error_response", "{verb} must be refused");
5626 assert_eq!(
5627 resp["category"], "authorization_failure",
5628 "{verb} refusal must be an authorization failure"
5629 );
5630 }
5631
5632 let (stream, rx) = tokio::io::duplex(4096);
5635 let mut admin = FramedStream::new(stream);
5636 handle_approve(
5637 &mut admin,
5638 &state,
5639 &uid_caller(CallerRole::Admin),
5640 "admin-approve",
5641 &transaction_id,
5642 )
5643 .await
5644 .unwrap();
5645 let mut rx = FramedStream::new(rx);
5646 let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
5647 assert_eq!(resp["type"], "approval_response");
5648 }
5649
5650 #[tokio::test]
5651 async fn medium_risk_action_rejected_for_observer() {
5652 let dir = tempdir().unwrap();
5653 let state = test_state(&dir);
5654
5655 let resps = exchange(
5656 state,
5657 CallerRole::Observer, vec![json!({
5659 "type": "preview",
5660 "request_id": "r1",
5661 "action_name": "InstallFlatpak",
5662 "params": {"username": "alice", "app_id": "org.gnome.Builder", "remote": "flathub"}
5663 })],
5664 1,
5665 )
5666 .await;
5667
5668 assert_eq!(resps[0]["type"], "error_response");
5669 assert_eq!(resps[0]["category"], "authorization_failure");
5670 }
5671
5672 #[tokio::test]
5673 async fn low_risk_action_allowed_for_observer() {
5674 let dir = tempdir().unwrap();
5675 let state = test_state(&dir);
5676
5677 let resps = exchange(
5678 state,
5679 CallerRole::Observer,
5680 vec![json!({
5681 "type": "preview",
5682 "request_id": "r1",
5683 "action_name": "GetMemoryInfo",
5684 "params": {}
5685 })],
5686 1,
5687 )
5688 .await;
5689
5690 assert_eq!(resps[0]["type"], "preview_response");
5691 }
5692
5693 #[test]
5694 fn unsupported_distros_cannot_reach_mutation_paths() {
5695 let dir = tempdir().unwrap();
5696 let mut state = test_state(&dir);
5697 state.host_distro = Some(sysknife_core::distro::DistroId::UbuntuCore {
5698 major: 24,
5699 minor: 4,
5700 });
5701 assert!(validate_action_platform(&state, "AptInstall").is_err());
5702
5703 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5709 major: 18,
5710 minor: 4,
5711 });
5712 assert!(validate_action_platform(&state, "AptUpgrade").is_err());
5713
5714 for (major, minor) in [(20, 4), (25, 10), (26, 4)] {
5717 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu { major, minor });
5718 assert!(
5719 validate_action_platform(&state, "AptUpgrade").is_ok(),
5720 "Ubuntu {major}.{minor:02} must be able to run mutating actions"
5721 );
5722 }
5723 }
5724
5725 #[test]
5726 fn a_low_risk_action_that_mutates_is_not_treated_as_a_read() {
5727 let dir = tempdir().unwrap();
5733 let mut state = test_state(&dir);
5734 state.host_distro = Some(sysknife_core::distro::DistroId::Debian { version: Some(11) });
5735 assert!(
5736 !state.host_distro.as_ref().unwrap().is_supported(),
5737 "this test needs an ineligible host"
5738 );
5739 assert_eq!(
5740 crate::policy::min_role_for_action("AptUpdate"),
5741 Some(CallerRole::Observer),
5742 "AptUpdate is the action that makes the role a bad proxy for mutation"
5743 );
5744 assert!(
5745 validate_action_platform(&state, "AptUpdate").is_err(),
5746 "a privileged mutation must stay refused on an ineligible host"
5747 );
5748 for version in [None, Some(11), Some(12), Some(13)] {
5749 state.host_distro = Some(sysknife_core::distro::DistroId::Debian { version });
5750 assert_eq!(
5751 validate_action_platform(&state, "AptUpdate").is_ok(),
5752 matches!(version, Some(12 | 13))
5753 );
5754 assert!(validate_action_platform(&state, "AddPpa").is_err());
5755 }
5756 }
5757 #[test]
5758 fn raising_a_read_only_action_via_risk_overrides_does_not_arm_the_platform_fence() {
5759 let dir = tempdir().unwrap();
5765 let mut state = test_state(&dir);
5766 state.host_distro = None;
5767 let overrides =
5768 std::collections::HashMap::from([("GetDiskUsage".to_string(), "high".to_string())]);
5769 state.policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
5770
5771 assert_eq!(
5772 state.policy.min_role_for_action("GetDiskUsage"),
5773 Some(CallerRole::Admin),
5774 "the override still governs authorization"
5775 );
5776 assert!(
5777 validate_action_platform(&state, "GetDiskUsage").is_ok(),
5778 "a read-only action stays exempt from the distro fence"
5779 );
5780 }
5781
5782 #[test]
5783 fn supported_hosts_still_enforce_action_family() {
5784 let dir = tempdir().unwrap();
5785 let mut state = test_state(&dir);
5786 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5787 major: 24,
5788 minor: 4,
5789 });
5790 assert!(validate_action_platform(&state, "AptInstall").is_ok());
5791 assert!(validate_action_platform(&state, "AddLayeredPackage").is_err());
5792
5793 for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS {
5794 assert!(validate_action_platform(&state, action).is_ok(), "{action}");
5795 }
5796 state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 });
5797 for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS {
5798 assert!(
5799 validate_action_platform(&state, action).is_err(),
5800 "{action}"
5801 );
5802 }
5803 for action in sysknife_core::action_family::NON_CANONICAL_ON_FEDORA {
5804 assert!(
5805 validate_action_platform(&state, action).is_ok(),
5806 "portable {action}"
5807 );
5808 }
5809 state.host_distro = None;
5810 for action in sysknife_core::action_family::UBUNTU_ONLY_ACTIONS {
5811 assert!(
5812 validate_action_platform(&state, action).is_err(),
5813 "undetected {action}"
5814 );
5815 }
5816 }
5817
5818 #[tokio::test]
5823 async fn query_action_refuses_an_action_the_host_family_cannot_run() {
5824 let dir = tempdir().unwrap();
5830 let mut state = test_state(&dir);
5831 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5832 major: 24,
5833 minor: 4,
5834 });
5835
5836 let resps = exchange(
5837 state,
5838 CallerRole::Observer,
5839 vec![json!({
5840 "type": "query_action",
5841 "request_id": "r1",
5842 "action_name": "GetLayeredPackages",
5843 "params": {}
5844 })],
5845 1,
5846 )
5847 .await;
5848
5849 assert_eq!(resps[0]["type"], "error_response");
5850 assert_eq!(resps[0]["category"], "unsupported_platform");
5851 }
5852
5853 #[tokio::test]
5854 async fn describe_refuses_an_action_the_caller_is_not_allowed_to_run() {
5855 let dir = tempdir().unwrap();
5859 let state = test_state(&dir);
5860
5861 let resps = exchange(
5862 state,
5863 CallerRole::Observer,
5864 vec![json!({
5865 "type": "describe",
5866 "request_id": "r1",
5867 "action_name": "GrantSudoAccess",
5868 "params": {"username": "alice", "commands": "/usr/bin/ls", "nopasswd": false}
5869 })],
5870 1,
5871 )
5872 .await;
5873
5874 assert_eq!(resps[0]["type"], "error_response");
5875 assert_eq!(resps[0]["category"], "authorization_failure");
5876 }
5877
5878 #[tokio::test]
5879 async fn describe_refuses_an_action_the_host_family_cannot_run() {
5880 let dir = tempdir().unwrap();
5881 let mut state = test_state(&dir);
5882 state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
5883 major: 24,
5884 minor: 4,
5885 });
5886
5887 let resps = exchange(
5888 state,
5889 CallerRole::Admin,
5890 vec![json!({
5891 "type": "describe",
5892 "request_id": "r1",
5893 "action_name": "AddLayeredPackage",
5894 "params": {"package": "vim"}
5895 })],
5896 1,
5897 )
5898 .await;
5899
5900 assert_eq!(resps[0]["type"], "error_response");
5901 assert_eq!(resps[0]["category"], "unsupported_platform");
5902 }
5903
5904 #[tokio::test]
5905 async fn describe_still_answers_for_an_allowed_action() {
5906 let dir = tempdir().unwrap();
5909 let state = test_state(&dir);
5910
5911 let resps = exchange(
5912 state,
5913 CallerRole::Observer,
5914 vec![json!({
5915 "type": "describe",
5916 "request_id": "r1",
5917 "action_name": "GetDiskUsage",
5918 "params": {}
5919 })],
5920 1,
5921 )
5922 .await;
5923
5924 assert_eq!(resps[0]["type"], "describe_response");
5925 }
5926
5927 #[tokio::test]
5932 async fn execute_without_prior_preview_returns_stale_approval() {
5933 let dir = tempdir().unwrap();
5934 let state = test_state(&dir);
5935
5936 let resps = exchange(
5937 state,
5938 CallerRole::Observer,
5939 vec![json!({
5940 "type": "execute",
5941 "request_id": "r1",
5942 "transaction_id": "missing-transaction",
5943 "action_name": "GetMemoryInfo",
5944 "params": {},
5945 "approval_receipt": "unissued-receipt"
5946 })],
5947 1,
5948 )
5949 .await;
5950
5951 assert_eq!(resps[0]["type"], "error_response");
5952 assert_eq!(resps[0]["category"], "stale_approval");
5953 }
5954
5955 #[tokio::test]
5956 async fn execute_with_changed_params_rejects_without_consuming_approval() {
5957 let dir = tempdir().unwrap();
5958 let state = test_state(&dir);
5959 let (client, server) = tokio::net::UnixStream::pair().unwrap();
5960 let handler_state = state.clone();
5961 tokio::spawn(async move {
5962 unix_connection_handler(
5963 server,
5964 handler_state,
5965 runner(),
5966 uid_caller_with(1000, CallerRole::Observer),
5967 )
5968 .await;
5969 });
5970 let mut framed = FramedStream::new(client);
5971 let (transaction_id, receipt) = preview_and_approve(
5972 &mut framed,
5973 "GetServiceStatus",
5974 json!({"unit": "sshd.service"}),
5975 )
5976 .await;
5977
5978 framed
5979 .send(
5980 &serde_json::to_vec(&json!({
5981 "type": "execute",
5982 "request_id": "changed-params",
5983 "transaction_id": transaction_id,
5984 "action_name": "GetServiceStatus",
5985 "params": {"unit": "cron.service"},
5986 "approval_receipt": receipt,
5987 }))
5988 .unwrap(),
5989 )
5990 .await
5991 .unwrap();
5992 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
5993
5994 assert_eq!(response["type"], "error_response");
5995 assert_eq!(response["category"], "stale_approval");
5996 assert_eq!(
5997 state
5998 .audit
5999 .get(&transaction_id)
6000 .await
6001 .unwrap()
6002 .unwrap()
6003 .status,
6004 JobState::Queued
6005 );
6006 assert!(state
6007 .audit
6008 .claim_approved_for_execution(
6009 &transaction_id,
6010 &receipt_digest(&receipt),
6011 CallerPrincipal::Uid(1000),
6012 )
6013 .await
6014 .unwrap());
6015 }
6016
6017 #[tokio::test]
6018 async fn execute_with_changed_action_rejects_without_consuming_approval() {
6019 let dir = tempdir().unwrap();
6020 let state = test_state(&dir);
6021 let (client, server) = tokio::net::UnixStream::pair().unwrap();
6022 let handler_state = state.clone();
6023 tokio::spawn(async move {
6024 unix_connection_handler(
6025 server,
6026 handler_state,
6027 runner(),
6028 uid_caller_with(1000, CallerRole::Observer),
6029 )
6030 .await;
6031 });
6032 let mut framed = FramedStream::new(client);
6033 let (transaction_id, receipt) =
6034 preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
6035
6036 framed
6037 .send(
6038 &serde_json::to_vec(&json!({
6039 "type": "execute",
6040 "request_id": "changed-action",
6041 "transaction_id": transaction_id,
6042 "action_name": "GetDiskUsage",
6043 "params": {},
6044 "approval_receipt": receipt,
6045 }))
6046 .unwrap(),
6047 )
6048 .await
6049 .unwrap();
6050 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
6051
6052 assert_eq!(response["type"], "error_response");
6053 assert_eq!(response["category"], "stale_approval");
6054 assert_eq!(
6055 state
6056 .audit
6057 .get(&transaction_id)
6058 .await
6059 .unwrap()
6060 .unwrap()
6061 .status,
6062 JobState::Queued
6063 );
6064 assert!(state
6065 .audit
6066 .claim_approved_for_execution(
6067 &transaction_id,
6068 &receipt_digest(&receipt),
6069 CallerPrincipal::Uid(1000),
6070 )
6071 .await
6072 .unwrap());
6073 }
6074
6075 #[tokio::test]
6076 async fn job_started_send_failure_releases_slot_and_marks_transaction_failed() {
6077 let dir = tempdir().unwrap();
6078 let mut state = test_state(&dir);
6079 state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 44 });
6080 let (client, server) = tokio::net::UnixStream::pair().unwrap();
6081 let handler_state = state.clone();
6082 tokio::spawn(async move {
6083 unix_connection_handler(
6084 server,
6085 handler_state,
6086 runner(),
6087 uid_caller_with(1000, CallerRole::Admin),
6088 )
6089 .await;
6090 });
6091 let mut preview_stream = FramedStream::new(client);
6092 let params = json!({"package": "vim"});
6093 let (transaction_id, receipt) =
6094 preview_and_approve(&mut preview_stream, "AddLayeredPackage", params.clone()).await;
6095
6096 let (peer, broken_stream) = tokio::io::duplex(64);
6097 drop(peer);
6098 let mut broken_framed = FramedStream::new(broken_stream);
6099 let typed_transaction_id = TransactionId(transaction_id.clone());
6100 let typed_receipt = ApprovalReceipt(receipt);
6101 let result = handle_execute(
6102 &mut broken_framed,
6103 &state,
6104 Arc::new(crate::executor::RealActionExecutor),
6105 runner(),
6106 &uid_caller(CallerRole::Admin),
6107 "execute-disconnected",
6108 &typed_transaction_id,
6109 "AddLayeredPackage",
6110 ¶ms,
6111 &typed_receipt,
6112 )
6113 .await;
6114
6115 assert!(result.is_err(), "the initial response write must fail");
6116 assert!(
6117 state.running_exclusive.lock().await.is_empty(),
6118 "a disconnected client must not retain any exclusion lock"
6119 );
6120 assert_eq!(
6121 state
6122 .audit
6123 .get(&transaction_id)
6124 .await
6125 .unwrap()
6126 .unwrap()
6127 .status,
6128 JobState::Failed,
6129 "a claimed transaction must not remain Running when execution never starts"
6130 );
6131 }
6132
6133 #[tokio::test]
6138 async fn execute_after_preview_with_receipt_returns_job_completed() {
6139 let dir = tempdir().unwrap();
6140 let state = test_state(&dir);
6141 let (client, server) = tokio::net::UnixStream::pair().unwrap();
6142
6143 tokio::spawn(async move {
6144 unix_connection_handler(
6145 server,
6146 state,
6147 runner(),
6148 uid_caller_with(1000, CallerRole::Observer),
6149 )
6150 .await;
6151 });
6152
6153 let mut framed = FramedStream::new(client);
6154
6155 let preview_req = json!({
6157 "type": "preview",
6158 "request_id": "r1",
6159 "action_name": "GetMemoryInfo",
6160 "params": {}
6161 });
6162 framed
6163 .send(&serde_json::to_vec(&preview_req).unwrap())
6164 .await
6165 .unwrap();
6166
6167 let raw = framed.recv().await.unwrap();
6168 let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
6169 assert_eq!(preview_resp["type"], "preview_response");
6170 let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
6171 let receipt = approve_preview(&mut framed, transaction_id).await;
6172
6173 let exec_req = json!({
6175 "type": "execute",
6176 "request_id": "r2",
6177 "transaction_id": transaction_id,
6178 "action_name": "GetMemoryInfo",
6179 "params": {},
6180 "approval_receipt": receipt
6181 });
6182 framed
6183 .send(&serde_json::to_vec(&exec_req).unwrap())
6184 .await
6185 .unwrap();
6186
6187 let raw = framed.recv().await.unwrap();
6189 let started: Value = serde_json::from_slice(&raw).unwrap();
6190 assert_eq!(started["type"], "job_started");
6191 let job_id = started["job_id"].as_str().unwrap().to_string();
6192
6193 loop {
6195 let raw = framed.recv().await.unwrap();
6196 let msg: Value = serde_json::from_slice(&raw).unwrap();
6197 let msg_type = msg["type"].as_str().unwrap();
6198 match msg_type {
6199 "job_progress" => {
6200 assert_eq!(msg["job_id"], job_id);
6201 }
6202 "job_completed" => {
6203 assert_eq!(msg["job_id"], job_id);
6204 let status = msg["result"]["status"].as_str().unwrap();
6205 assert!(
6207 matches!(status, "succeeded" | "failed" | "needs_reboot"),
6208 "unexpected status: {status}"
6209 );
6210 break;
6211 }
6212 other => panic!("unexpected message type: {other}"),
6213 }
6214 }
6215 }
6216
6217 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6229 async fn update_status_failure_surfaces_as_audit_warning_in_job_completed() {
6230 use crate::audit_chain::{AuditKey, ChainRow, VerifyOutcome};
6231 use crate::store::AuditStore;
6232 use crate::transactions::{
6233 NewTransaction as NewTx, RecordedPreviewedTransaction, TransactionStoreError,
6234 };
6235 use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
6236
6237 #[derive(Debug)]
6244 struct UpdateStatusFails(std::sync::Arc<dyn AuditStore>);
6245
6246 #[async_trait::async_trait]
6247 impl AuditStore for UpdateStatusFails {
6248 async fn record(&self, t: NewTx) -> Result<TransactionRecord, TransactionStoreError> {
6249 self.0.record(t).await
6250 }
6251 async fn record_previewed(
6252 &self,
6253 t: NewTx,
6254 p: PreviewEnvelope,
6255 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
6256 self.0.record_previewed(t, p).await
6257 }
6258 async fn get(
6259 &self,
6260 id: &str,
6261 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
6262 self.0.get(id).await
6263 }
6264 async fn get_preview(
6265 &self,
6266 id: &str,
6267 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
6268 self.0.get_preview(id).await
6269 }
6270 async fn update_status(
6271 &self,
6272 id: &str,
6273 _s: JobState,
6274 ) -> Result<(), TransactionStoreError> {
6275 Err(TransactionStoreError::NotFound(id.to_string()))
6276 }
6277 async fn approve_transaction(
6278 &self,
6279 id: &str,
6280 approver: CallerPrincipal,
6281 ) -> Result<Option<String>, TransactionStoreError> {
6282 self.0.approve_transaction(id, approver).await
6283 }
6284 async fn revoke_unconsumed_approval(
6285 &self,
6286 id: &str,
6287 revoker: CallerPrincipal,
6288 ) -> Result<bool, TransactionStoreError> {
6289 self.0.revoke_unconsumed_approval(id, revoker).await
6290 }
6291 async fn claim_approved_for_execution(
6292 &self,
6293 id: &str,
6294 digest: &str,
6295 executor: CallerPrincipal,
6296 ) -> Result<bool, TransactionStoreError> {
6297 self.0
6298 .claim_approved_for_execution(id, digest, executor)
6299 .await
6300 }
6301 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
6302 self.0.cleanup_stale_queued().await
6303 }
6304 async fn cancel_queued(
6305 &self,
6306 id: &str,
6307 canceller: CallerPrincipal,
6308 ) -> Result<bool, TransactionStoreError> {
6309 self.0.cancel_queued(id, canceller).await
6310 }
6311 async fn list_transactions(
6312 &self,
6313 limit: u32,
6314 status: Option<&str>,
6315 action: Option<&str>,
6316 since: Option<HistorySince>,
6317 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
6318 self.0.list_transactions(limit, status, action, since).await
6319 }
6320 async fn list_history(
6321 &self,
6322 limit: u32,
6323 status: Option<&str>,
6324 action: Option<&str>,
6325 since: Option<HistorySince>,
6326 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>
6327 {
6328 self.0.list_history(limit, status, action, since).await
6329 }
6330 async fn fetch_chain_row(
6331 &self,
6332 id: &str,
6333 ) -> Result<Option<ChainRow>, TransactionStoreError> {
6334 self.0.fetch_chain_row(id).await
6335 }
6336 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
6337 self.0.fetch_chain_rows().await
6338 }
6339 async fn fetch_event_rows(
6340 &self,
6341 ) -> Result<Vec<crate::audit_chain::EventRow>, TransactionStoreError> {
6342 self.0.fetch_event_rows().await
6343 }
6344 async fn verify_audit_chain(
6345 &self,
6346 k: &AuditKey,
6347 ) -> Result<VerifyOutcome, TransactionStoreError> {
6348 self.0.verify_audit_chain(k).await
6349 }
6350 }
6351
6352 let dir = tempdir().unwrap();
6353 let real = test_state(&dir);
6354 let faulty: std::sync::Arc<dyn AuditStore> =
6356 std::sync::Arc::new(UpdateStatusFails(real.audit.clone()));
6357 let cfg = real.config.clone();
6358 let audit_key = crate::audit_chain::AuditKey::load_or_generate(
6359 &crate::audit_chain::resolve_audit_key_path(&dir.path().join("sysknife-test.db")),
6360 )
6361 .unwrap();
6362 let state = crate::state::DaemonState::open_with_audit(
6363 cfg,
6364 crate::policy::PolicyTable::empty(),
6365 None,
6366 faulty,
6367 &audit_key,
6368 );
6369
6370 let (client, server) = tokio::net::UnixStream::pair().unwrap();
6371 tokio::spawn(async move {
6372 unix_connection_handler(
6373 server,
6374 state,
6375 runner(),
6376 uid_caller_with(1000, CallerRole::Observer),
6377 )
6378 .await;
6379 });
6380 let mut framed = FramedStream::new(client);
6381
6382 framed
6384 .send(
6385 &serde_json::to_vec(&json!({
6386 "type": "preview",
6387 "request_id": "r1",
6388 "action_name": "GetMemoryInfo",
6389 "params": {}
6390 }))
6391 .unwrap(),
6392 )
6393 .await
6394 .unwrap();
6395 let preview_resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
6396 let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
6397 let receipt = approve_preview(&mut framed, transaction_id).await;
6398
6399 framed
6400 .send(
6401 &serde_json::to_vec(&json!({
6402 "type": "execute",
6403 "request_id": "r2",
6404 "transaction_id": transaction_id,
6405 "action_name": "GetMemoryInfo",
6406 "params": {},
6407 "approval_receipt": receipt
6408 }))
6409 .unwrap(),
6410 )
6411 .await
6412 .unwrap();
6413
6414 let mut completed: Option<Value> = None;
6416 for _ in 0..30 {
6417 let raw = framed.recv().await.unwrap();
6418 let msg: Value = serde_json::from_slice(&raw).unwrap();
6419 if msg["type"] == "job_completed" {
6420 completed = Some(msg);
6421 break;
6422 }
6423 }
6424 let completed = completed.expect("dispatcher must emit job_completed");
6425 let warnings = completed["result"]["warnings"]
6426 .as_array()
6427 .expect("warnings array on job_completed");
6428
6429 assert!(
6430 warnings
6431 .iter()
6432 .any(|w| w.as_str().unwrap_or("").contains("audit trail update failed")),
6433 "expected an `audit trail update failed` warning when update_status returns Err; got: {warnings:#?}"
6434 );
6435 }
6436
6437 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6454 async fn concurrent_executes_against_one_preview_serialise_to_one_winner() {
6455 let dir = tempdir().unwrap();
6456 let state = test_state(&dir);
6457
6458 let (pc, ps) = tokio::net::UnixStream::pair().unwrap();
6460 {
6461 let state = state.clone();
6462 tokio::spawn(async move {
6463 unix_connection_handler(
6464 ps,
6465 state,
6466 runner(),
6467 uid_caller_with(1000, CallerRole::Observer),
6468 )
6469 .await;
6470 });
6471 }
6472 let mut framed_p = FramedStream::new(pc);
6473 framed_p
6474 .send(
6475 &serde_json::to_vec(&json!({
6476 "type": "preview",
6477 "request_id": "preview-once",
6478 "action_name": "GetMemoryInfo",
6479 "params": {}
6480 }))
6481 .unwrap(),
6482 )
6483 .await
6484 .unwrap();
6485 let raw = framed_p.recv().await.unwrap();
6486 let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
6487 let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
6488 let approval_receipt = approve_preview(&mut framed_p, &transaction_id).await;
6489
6490 async fn exec_once(
6492 state: crate::state::DaemonState,
6493 transaction_id: String,
6494 approval_receipt: String,
6495 request_id: &'static str,
6496 ) -> Vec<Value> {
6497 let (c, s) = tokio::net::UnixStream::pair().unwrap();
6498 tokio::spawn(async move {
6499 unix_connection_handler(
6500 s,
6501 state,
6502 runner(),
6503 uid_caller_with(1000, CallerRole::Observer),
6504 )
6505 .await;
6506 });
6507 let mut f = FramedStream::new(c);
6508 f.send(
6509 &serde_json::to_vec(&json!({
6510 "type": "execute",
6511 "request_id": request_id,
6512 "transaction_id": transaction_id,
6513 "action_name": "GetMemoryInfo",
6514 "params": {},
6515 "approval_receipt": approval_receipt
6516 }))
6517 .unwrap(),
6518 )
6519 .await
6520 .unwrap();
6521
6522 let mut frames = Vec::new();
6526 for _ in 0..30 {
6527 let raw = match f.recv().await {
6528 Ok(b) => b,
6529 Err(_) => break,
6530 };
6531 let v: Value = serde_json::from_slice(&raw).unwrap();
6532 let kind = v["type"].as_str().unwrap_or("").to_string();
6533 frames.push(v);
6534 if kind == "job_completed" || kind == "error_response" {
6535 break;
6536 }
6537 }
6538 frames
6539 }
6540
6541 let (a, b) = tokio::join!(
6542 exec_once(
6543 state.clone(),
6544 transaction_id.clone(),
6545 approval_receipt.clone(),
6546 "exec-A"
6547 ),
6548 exec_once(
6549 state.clone(),
6550 transaction_id.clone(),
6551 approval_receipt.clone(),
6552 "exec-B"
6553 ),
6554 );
6555
6556 let terminal = |frames: &[Value]| -> &'static str {
6558 for f in frames {
6559 match f["type"].as_str().unwrap_or("") {
6560 "job_completed" => return "win",
6561 "error_response" => return "lose",
6562 _ => {}
6563 }
6564 }
6565 "neither"
6566 };
6567 let outcomes = [terminal(&a), terminal(&b)];
6568
6569 let mut sorted = outcomes;
6571 sorted.sort();
6572 assert_eq!(
6573 sorted,
6574 ["lose", "win"],
6575 "exactly one execute must win and one must lose; got {outcomes:?}\n A={a:#?}\n B={b:#?}"
6576 );
6577
6578 let loser = if outcomes[0] == "lose" { &a } else { &b };
6581 let err_frame = loser
6582 .iter()
6583 .find(|f| f["type"] == "error_response")
6584 .expect("loser has an error_response");
6585 assert_eq!(
6586 err_frame["category"], "stale_approval",
6587 "loser must be classified stale_approval; got {err_frame:#?}"
6588 );
6589 }
6590
6591 #[tokio::test]
6601 async fn re_executing_a_completed_approval_returns_stale_approval() {
6602 let dir = tempdir().unwrap();
6603 let state = test_state(&dir);
6604 let (client, server) = tokio::net::UnixStream::pair().unwrap();
6605
6606 tokio::spawn(async move {
6607 unix_connection_handler(
6608 server,
6609 state,
6610 runner(),
6611 uid_caller_with(1000, CallerRole::Observer),
6612 )
6613 .await;
6614 });
6615
6616 let mut framed = FramedStream::new(client);
6617
6618 framed
6621 .send(
6622 &serde_json::to_vec(&json!({
6623 "type": "preview",
6624 "request_id": "r1",
6625 "action_name": "GetMemoryInfo",
6626 "params": {}
6627 }))
6628 .unwrap(),
6629 )
6630 .await
6631 .unwrap();
6632
6633 let raw = framed.recv().await.unwrap();
6634 let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
6635 assert_eq!(preview_resp["type"], "preview_response");
6636 let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
6637 let approval_receipt = approve_preview(&mut framed, &transaction_id).await;
6638
6639 framed
6641 .send(
6642 &serde_json::to_vec(&json!({
6643 "type": "execute",
6644 "request_id": "r2",
6645 "transaction_id": transaction_id,
6646 "action_name": "GetMemoryInfo",
6647 "params": {},
6648 "approval_receipt": approval_receipt
6649 }))
6650 .unwrap(),
6651 )
6652 .await
6653 .unwrap();
6654
6655 loop {
6658 let raw = framed.recv().await.unwrap();
6659 let msg: Value = serde_json::from_slice(&raw).unwrap();
6660 match msg["type"].as_str().unwrap() {
6661 "job_started" | "job_progress" => continue,
6662 "job_completed" => break,
6663 other => panic!("unexpected message during first execute: {other}"),
6664 }
6665 }
6666
6667 framed
6670 .send(
6671 &serde_json::to_vec(&json!({
6672 "type": "execute",
6673 "request_id": "r3-replay",
6674 "transaction_id": transaction_id,
6675 "action_name": "GetMemoryInfo",
6676 "params": {},
6677 "approval_receipt": approval_receipt
6678 }))
6679 .unwrap(),
6680 )
6681 .await
6682 .unwrap();
6683
6684 let raw = framed.recv().await.unwrap();
6685 let resp: Value = serde_json::from_slice(&raw).unwrap();
6686 assert_eq!(
6687 resp["type"], "error_response",
6688 "replay must produce error_response, got: {resp}"
6689 );
6690 assert_eq!(
6691 resp["category"], "stale_approval",
6692 "replay must be classified as stale_approval, got: {}",
6693 resp["category"]
6694 );
6695 assert_eq!(resp["request_id"], "r3-replay");
6696 }
6697
6698 #[tokio::test]
6703 async fn describe_returns_command_and_risk_for_known_action() {
6704 let dir = tempdir().unwrap();
6705 let state = test_state(&dir);
6706
6707 let resps = exchange(
6708 state,
6709 CallerRole::Observer,
6710 vec![json!({
6711 "type": "describe",
6712 "request_id": "r1",
6713 "action_name": "GetDateTime",
6714 "params": {}
6715 })],
6716 1,
6717 )
6718 .await;
6719
6720 assert_eq!(resps[0]["type"], "describe_response");
6721 assert_eq!(resps[0]["request_id"], "r1");
6722 assert_eq!(resps[0]["command"], "timedatectl");
6723 assert_eq!(resps[0]["risk_level"], "low");
6724 assert_eq!(resps[0]["reboot_required"], false);
6725 }
6726
6727 #[tokio::test]
6728 async fn describe_returns_error_for_unknown_action() {
6729 let dir = tempdir().unwrap();
6730 let state = test_state(&dir);
6731
6732 let resps = exchange(
6733 state,
6734 CallerRole::Observer,
6735 vec![json!({
6736 "type": "describe",
6737 "request_id": "r1",
6738 "action_name": "NotARealAction",
6739 "params": {}
6740 })],
6741 1,
6742 )
6743 .await;
6744
6745 assert_eq!(resps[0]["type"], "error_response");
6746 assert_eq!(resps[0]["category"], "validation_failure");
6747 }
6748
6749 #[tokio::test]
6754 async fn unknown_message_type_returns_validation_failure() {
6755 let dir = tempdir().unwrap();
6756 let state = test_state(&dir);
6757
6758 let resps = exchange(
6759 state,
6760 CallerRole::Observer,
6761 vec![json!({"type": "does_not_exist", "request_id": "r1"})],
6762 1,
6763 )
6764 .await;
6765
6766 assert_eq!(resps[0]["type"], "error_response");
6767 assert_eq!(resps[0]["category"], "validation_failure");
6768 }
6769
6770 #[tokio::test]
6775 async fn fast_executor_progress_is_drained_before_job_completed() {
6776 struct FastProgressExecutor;
6777
6778 #[async_trait::async_trait]
6779 impl ActionExecutor for FastProgressExecutor {
6780 async fn execute(
6781 &self,
6782 _spec: &crate::actions::ActionSpec,
6783 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
6784 {
6785 unreachable!("dispatcher uses execute_with_progress")
6786 }
6787
6788 async fn execute_with_progress(
6789 &self,
6790 _spec: &crate::actions::ActionSpec,
6791 progress: tokio::sync::mpsc::UnboundedSender<String>,
6792 ) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
6793 {
6794 progress.send("last line before exit".to_string()).unwrap();
6795 Ok(crate::executor::ExecutionOutput {
6796 stdout: "last line before exit\n".to_string(),
6797 stderr: String::new(),
6798 exit_code: 0,
6799 })
6800 }
6801 }
6802
6803 let dir = tempdir().unwrap();
6804 let state = test_state(&dir);
6805 let (client, server) = tokio::net::UnixStream::pair().unwrap();
6806 tokio::spawn(async move {
6807 connection_handler_with_executor(
6808 server,
6809 state,
6810 runner(),
6811 Arc::new(FastProgressExecutor),
6812 uid_caller(CallerRole::Observer),
6813 )
6814 .await;
6815 });
6816 let mut framed = FramedStream::new(client);
6817 let (transaction_id, receipt) =
6818 preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
6819 framed
6820 .send(
6821 &serde_json::to_vec(&json!({
6822 "type": "execute",
6823 "request_id": "execute-fast-progress",
6824 "transaction_id": transaction_id,
6825 "action_name": "GetMemoryInfo",
6826 "params": {},
6827 "approval_receipt": receipt,
6828 }))
6829 .unwrap(),
6830 )
6831 .await
6832 .unwrap();
6833
6834 let mut progress_lines = Vec::new();
6835 loop {
6836 let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
6837 match response["type"].as_str().unwrap() {
6838 "job_progress" => {
6839 progress_lines.push(response["line"].as_str().unwrap().to_string())
6840 }
6841 "job_started" => {}
6842 "job_completed" => break,
6843 other => panic!("unexpected response: {other}"),
6844 }
6845 }
6846 assert!(progress_lines
6847 .iter()
6848 .any(|line| line == "last line before exit"));
6849 }
6850
6851 #[cfg(target_os = "linux")]
6856 mod vsock_auth {
6857 use super::*;
6858 use std::sync::Arc;
6859
6860 fn executor() -> Arc<dyn ActionExecutor> {
6861 Arc::new(crate::executor::RealActionExecutor)
6862 }
6863
6864 fn write_token_mode(path: &std::path::Path, mode: u32) {
6869 use std::os::unix::fs::PermissionsExt;
6870 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
6871 }
6872
6873 #[tokio::test]
6877 async fn group_or_world_readable_token_file_is_rejected() {
6878 let dir = tempdir().unwrap();
6879 let token_path = dir.path().join("token");
6880 std::fs::write(&token_path, "test-secret").unwrap();
6881 write_token_mode(&token_path, 0o644);
6882
6883 assert!(
6884 crate::auth::validate_token_against_file("test-secret", &token_path).is_none(),
6885 "a 0644 token file must be rejected even when the token matches"
6886 );
6887
6888 write_token_mode(&token_path, 0o600);
6890 assert!(
6891 crate::auth::validate_token_against_file("test-secret", &token_path).is_some(),
6892 "a 0600 token file with a matching token must be accepted"
6893 );
6894 }
6895
6896 #[tokio::test]
6898 async fn valid_token_grants_access() {
6899 let dir = tempdir().unwrap();
6900 let token_path = dir.path().join("token");
6901 std::fs::write(&token_path, "test-secret").unwrap();
6902 write_token_mode(&token_path, 0o600);
6903
6904 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
6905 let state = test_state(&dir);
6906
6907 tokio::spawn(async move {
6908 let mut framed = FramedStream::new(server);
6909 let role = authenticate_vsock_token(&mut framed, &token_path).await;
6910 if let Some(r) = role {
6911 dispatch_loop(
6912 &mut framed,
6913 state,
6914 runner(),
6915 executor(),
6916 CallerAttribution::from_vsock_token(r),
6917 )
6918 .await;
6919 }
6920 });
6921
6922 let mut framed = FramedStream::new(client);
6923 let auth = serde_json::json!({"type": "auth", "token": "test-secret"});
6924 framed
6925 .send(&serde_json::to_vec(&auth).unwrap())
6926 .await
6927 .unwrap();
6928 let req = serde_json::json!({"type": "query_state", "request_id": "vsock-r1"});
6929 framed
6930 .send(&serde_json::to_vec(&req).unwrap())
6931 .await
6932 .unwrap();
6933 let raw = framed.recv().await.unwrap();
6934 let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
6935 assert_eq!(resp["type"], "state_response");
6936 }
6937
6938 #[tokio::test]
6940 async fn wrong_token_closes_connection() {
6941 let dir = tempdir().unwrap();
6942 let token_path = dir.path().join("token");
6943 std::fs::write(&token_path, "correct").unwrap();
6944 write_token_mode(&token_path, 0o600);
6945
6946 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
6947 let state = test_state(&dir);
6948
6949 tokio::spawn(async move {
6950 let mut framed = FramedStream::new(server);
6951 let _ = authenticate_vsock_token(&mut framed, &token_path).await;
6952 drop(state);
6954 });
6955
6956 let mut framed = FramedStream::new(client);
6957 let auth = serde_json::json!({"type": "auth", "token": "wrong"});
6958 framed
6959 .send(&serde_json::to_vec(&auth).unwrap())
6960 .await
6961 .unwrap();
6962 let result = framed.recv().await;
6964 assert!(
6965 result.is_err(),
6966 "expected EOF after wrong token, got {:?}",
6967 result
6968 );
6969 }
6970
6971 #[tokio::test]
6973 async fn eof_before_auth_frame_closes_connection() {
6974 let dir = tempdir().unwrap();
6975 let token_path = dir.path().join("token");
6976 std::fs::write(&token_path, "secret").unwrap();
6977 write_token_mode(&token_path, 0o600);
6978
6979 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
6980
6981 tokio::spawn(async move {
6982 let mut framed = FramedStream::new(server);
6983 let result = authenticate_vsock_token(&mut framed, &token_path).await;
6984 assert!(result.is_none());
6985 });
6986
6987 drop(client);
6989 }
6990
6991 #[tokio::test]
6993 async fn malformed_json_frame_closes_connection() {
6994 let dir = tempdir().unwrap();
6995 let token_path = dir.path().join("token");
6996 std::fs::write(&token_path, "secret").unwrap();
6997 write_token_mode(&token_path, 0o600);
6998
6999 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
7000
7001 let handle = tokio::spawn(async move {
7002 let mut framed = FramedStream::new(server);
7003 let result = authenticate_vsock_token(&mut framed, &token_path).await;
7004 assert!(result.is_none());
7005 });
7006
7007 let mut framed = FramedStream::new(client);
7008 framed.send(b"not json at all {{{{").await.unwrap();
7009 drop(framed);
7010 handle.await.unwrap();
7011 }
7012
7013 #[tokio::test]
7015 async fn wrong_msg_type_closes_connection() {
7016 let dir = tempdir().unwrap();
7017 let token_path = dir.path().join("token");
7018 std::fs::write(&token_path, "secret").unwrap();
7019 write_token_mode(&token_path, 0o600);
7020
7021 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
7022
7023 let handle = tokio::spawn(async move {
7024 let mut framed = FramedStream::new(server);
7025 let result = authenticate_vsock_token(&mut framed, &token_path).await;
7026 assert!(result.is_none());
7027 });
7028
7029 let mut framed = FramedStream::new(client);
7030 let bad = serde_json::json!({"type": "query_state", "token": "secret"});
7031 framed
7032 .send(&serde_json::to_vec(&bad).unwrap())
7033 .await
7034 .unwrap();
7035 drop(framed);
7036 handle.await.unwrap();
7037 }
7038 }
7039
7040 #[tokio::test]
7045 async fn connection_handler_accepts_duplex_stream() {
7046 let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
7049 let dir = tempdir().unwrap();
7050 let state = test_state(&dir);
7051 tokio::spawn(unix_connection_handler(
7052 server,
7053 state,
7054 runner(),
7055 uid_caller(CallerRole::Observer),
7056 ));
7057
7058 let mut framed = FramedStream::new(client);
7059 let req = json!({"type": "query_state", "request_id": "duplex-r1"});
7060 framed
7061 .send(&serde_json::to_vec(&req).unwrap())
7062 .await
7063 .unwrap();
7064 let raw = framed.recv().await.unwrap();
7065 let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
7066 assert_eq!(resp["type"], "state_response");
7067 assert_eq!(resp["request_id"], "duplex-r1");
7068 }
7069}