use std::sync::Arc;
#[cfg(target_os = "linux")]
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::UnixStream;
use uuid::Uuid;
use sysknife_types::{CallerRole, JobState, PreviewEnvelope, RequestEnvelope};
fn credential_keys_for(action_name: &str) -> &'static [&'static str] {
match action_name {
"ProAttach" => &["token"],
"ConfigureWifi" => &["password"],
_ => &[],
}
}
fn redact_params(action_name: &str, params: &Value) -> Value {
let keys = credential_keys_for(action_name);
if keys.is_empty() {
return params.clone();
}
if let Value::Object(map) = params {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
if keys.iter().any(|ck| *ck == k) {
out.insert(k.clone(), Value::String("<REDACTED>".to_string()));
} else {
out.insert(k.clone(), v.clone());
}
}
Value::Object(out)
} else {
params.clone()
}
}
fn credential_argv_spec(action_name: &str) -> Option<CredentialArgvSpec> {
match action_name {
"ProAttach" => Some(CredentialArgvSpec::Last),
"ConfigureWifi" => Some(CredentialArgvSpec::AfterKeyword("password")),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CredentialArgvSpec {
Last,
AfterKeyword(&'static str),
}
fn redact_argv(action_name: &str, params: &Value, args: &[String]) -> Vec<String> {
let keys = credential_keys_for(action_name);
if keys.is_empty() {
return args.to_vec();
}
let secrets: Vec<&str> = keys
.iter()
.filter_map(|k| params.get(k).and_then(|v| v.as_str()))
.collect();
if secrets.is_empty() {
return args.to_vec();
}
let mut out: Vec<String> = args.to_vec();
if let Some(spec) = credential_argv_spec(action_name) {
let idx = match spec {
CredentialArgvSpec::Last => Some(out.len().saturating_sub(1)),
CredentialArgvSpec::AfterKeyword(keyword) => out
.iter()
.enumerate()
.rev()
.find(|(i, arg)| arg.as_str() == keyword && i + 1 < out.len())
.map(|(i, _)| i + 1),
};
if let Some(idx) = idx.filter(|i| *i < out.len()) {
out[idx] = "<REDACTED>".to_string();
}
return out;
}
out.iter_mut().for_each(|a| {
if secrets.iter().any(|s| s == &a.as_str()) {
*a = "<REDACTED>".to_string();
}
});
out
}
use crate::{
auth::{highest_role_from_groups, CallerAttribution},
executor::{build_action_spec, rollback_spec_for, ActionExecutor},
preview::preview_action,
state::DaemonState,
state_collector::{collect_state, CollectedState, CommandRunner},
transactions::NewTransaction,
transport::framing::{FramedStream, FramingError},
};
const VSOCK_AUTH_FRAME_TIMEOUT_SECS: u64 = 10;
const DEFAULT_HISTORY_LIMIT: u32 = 20;
const TRANSACTION_ID_DISPLAY_PREFIX_LEN: usize = 8;
#[derive(Debug, Deserialize)]
#[serde(transparent)]
struct TransactionId(String);
impl TransactionId {
fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
struct ApprovalReceipt(String);
impl ApprovalReceipt {
fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum DaemonRequest {
QueryState {
request_id: String,
},
Preview {
request_id: String,
action_name: String,
params: Value,
#[serde(default)]
unattended: bool,
},
Approve {
request_id: String,
transaction_id: TransactionId,
},
ApprovalDetails {
request_id: String,
transaction_id: TransactionId,
},
Execute {
request_id: String,
transaction_id: TransactionId,
action_name: String,
params: Value,
approval_receipt: ApprovalReceipt,
},
Cancel {
request_id: String,
transaction_id: TransactionId,
},
QueryAction {
request_id: String,
action_name: String,
params: Value,
},
QueryHistory {
request_id: String,
limit: Option<u32>,
status_filter: Option<String>,
action_filter: Option<String>,
since_hours: Option<u32>,
},
Describe {
request_id: String,
action_name: String,
params: Value,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum DaemonResponse {
StateResponse {
request_id: String,
state: CollectedState,
},
PreviewResponse {
request_id: String,
preview: PreviewEnvelope,
transaction_id: String,
},
ApprovalResponse {
request_id: String,
transaction_id: String,
approval_receipt: String,
},
ApprovalDetailsResponse {
request_id: String,
transaction_id: String,
action_name: String,
preview: PreviewEnvelope,
},
JobStarted {
request_id: String,
job_id: String,
transaction_id: String,
},
JobProgress {
job_id: String,
line: String,
},
JobCompleted {
job_id: String,
result: JobResult,
},
QueryActionResponse {
request_id: String,
action_name: String,
output: String,
},
HistoryResponse {
request_id: String,
entries: Vec<crate::transactions::JobHistoryEntry>,
},
CancelResponse {
request_id: String,
transaction_id: String,
},
DescribeResponse {
request_id: String,
command: String,
risk_level: String,
reboot_required: bool,
},
ErrorResponse {
request_id: String,
category: String,
message: String,
},
ConflictResponse {
request_id: String,
message: String,
retry_after_seconds: Option<u32>,
},
}
#[derive(Debug, Serialize)]
struct JobResult {
status: String,
summary: String,
warnings: Vec<String>,
job_id: String,
needs_reboot: bool,
rollback_ref: Option<String>,
transaction_id: String,
}
#[cfg(target_os = "linux")]
const SO_PEERPIDFD: libc::c_int = 77;
#[cfg(target_os = "linux")]
enum PeerPin {
Pinned(OwnedFd),
Unsupported,
Unpinnable,
}
#[cfg(target_os = "linux")]
impl PeerPin {
fn from_error(error: &std::io::Error) -> Self {
if error.raw_os_error() == Some(libc::ENOPROTOOPT) {
Self::Unsupported
} else {
Self::Unpinnable
}
}
}
#[cfg(target_os = "linux")]
fn peer_pidfd(stream: &UnixStream) -> PeerPin {
let mut fd: libc::c_int = -1;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
SO_PEERPIDFD,
(&mut fd as *mut libc::c_int).cast::<libc::c_void>(),
&mut len,
)
};
if rc != 0 {
return PeerPin::from_error(&std::io::Error::last_os_error());
}
if fd < 0 {
return PeerPin::Unpinnable;
}
PeerPin::Pinned(unsafe { OwnedFd::from_raw_fd(fd) })
}
#[cfg(target_os = "linux")]
fn groups_for_pinned_peer(
pid: u32,
pin: PeerPin,
read_groups: impl FnOnce() -> Vec<String>,
) -> Vec<String> {
match pin {
PeerPin::Unsupported => read_groups(),
PeerPin::Unpinnable => {
eprintln!(
"[sysknife-daemon] WARNING: cannot pin peer PID {pid}; ignoring supplementary \
groups and using the SO_PEERCRED primary GID only"
);
Vec::new()
}
PeerPin::Pinned(fd) => {
let groups = read_groups();
if pidfd_peer_still_live(&fd) {
groups
} else {
eprintln!(
"[sysknife-daemon] WARNING: peer PID {pid} was reaped while resolving its \
groups (possible PID reuse); ignoring supplementary groups and using the \
SO_PEERCRED primary GID only"
);
Vec::new()
}
}
}
}
#[cfg(target_os = "linux")]
fn pidfd_peer_still_live(pidfd: &OwnedFd) -> bool {
let rc = unsafe {
libc::syscall(
libc::SYS_pidfd_send_signal,
pidfd.as_raw_fd(),
0,
std::ptr::null::<libc::c_void>(),
0,
)
};
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
pub fn resolve_caller(stream: &UnixStream) -> CallerAttribution {
resolve_caller_with_pin(
stream,
#[cfg(target_os = "linux")]
peer_pidfd(stream),
)
}
fn resolve_caller_with_pin(
stream: &UnixStream,
#[cfg(target_os = "linux")] pin: PeerPin,
) -> CallerAttribution {
let (pid, primary_gid, uid) = match stream.peer_cred() {
Ok(cred) => {
let pid = match cred.pid() {
Some(p) if p > 0 => p as u32,
_ => {
eprintln!(
"[sysknife-daemon] WARNING: peer_cred() returned no usable pid; \
defaulting to Observer and recording the caller as unattributed"
);
return CallerAttribution::unattributed();
}
};
(pid, cred.gid(), cred.uid())
}
Err(e) => {
eprintln!(
"[sysknife-daemon] WARNING: peer_cred() failed: {e}; defaulting to Observer \
and recording the caller as unattributed"
);
return CallerAttribution::unattributed();
}
};
let gid_map = read_gid_map();
#[cfg(target_os = "linux")]
let mut groups = groups_for_pinned_peer(pid, pin, || groups_for_pid(pid, &gid_map));
#[cfg(not(target_os = "linux"))]
let mut groups = groups_for_pid(pid, &gid_map);
if let Some(name) = gid_map.get(&primary_gid) {
if !groups.contains(name) {
groups.push(name.clone());
}
}
if groups.is_empty() {
eprintln!(
"[sysknife-daemon] WARNING: could not resolve groups for PID {pid}; defaulting to Observer"
);
}
if uid == overflow_uid() {
eprintln!(
"[sysknife-daemon] WARNING: SO_PEERCRED reported the overflow uid ({uid}) for pid \
{pid}; the peer is not representable in this daemon's user namespace. Recording \
the caller as unattributed rather than as uid:{uid}."
);
return CallerAttribution::unattributed();
}
CallerAttribution::from_peer_uid(uid, highest_role_from_groups(groups))
}
fn overflow_uid() -> u32 {
const DEFAULT_OVERFLOW_UID: u32 = 65534;
static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
*CACHED.get_or_init(|| {
std::fs::read_to_string("/proc/sys/kernel/overflowuid")
.ok()
.and_then(|raw| raw.trim().parse::<u32>().ok())
.unwrap_or(DEFAULT_OVERFLOW_UID)
})
}
fn read_gid_map() -> std::collections::HashMap<u32, String> {
let content = match std::fs::read_to_string("/etc/group") {
Ok(c) => c,
Err(e) => {
eprintln!("[sysknife-daemon] WARNING: cannot read /etc/group: {e}; all callers will be demoted to Observer");
return std::collections::HashMap::new();
}
};
let mut map = std::collections::HashMap::new();
for (line_no, line) in content.lines().enumerate() {
let mut parts = line.splitn(4, ':');
let name = match parts.next() {
Some(n) => n,
None => {
eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no}: missing name field");
continue;
}
};
let _ = parts.next(); let gid_str = match parts.next() {
Some(g) => g,
None => {
eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): missing GID field");
continue;
}
};
match gid_str.parse::<u32>() {
Ok(gid) => {
map.insert(gid, name.to_string());
}
Err(_) => {
eprintln!("[sysknife-daemon] WARNING: malformed /etc/group line {line_no} (group={name:?}): GID {gid_str:?} is not a number");
}
}
}
map
}
fn groups_for_pid(pid: u32, gid_map: &std::collections::HashMap<u32, String>) -> Vec<String> {
let status = match std::fs::read_to_string(format!("/proc/{pid}/status")) {
Ok(s) => s,
Err(e) => {
eprintln!(
"[sysknife-daemon] could not read /proc/{pid}/status: {e}; treating as no groups"
);
return vec![];
}
};
for line in status.lines() {
if line.starts_with("Groups:") {
return line
.trim_start_matches("Groups:")
.split_whitespace()
.filter_map(|s| s.parse::<u32>().ok())
.filter_map(|gid| gid_map.get(&gid).cloned())
.collect();
}
}
eprintln!(
"[sysknife-daemon] WARNING: no Groups: line in /proc/{pid}/status; \
supplementary groups unavailable — caller may be demoted to Observer"
);
vec![]
}
fn authorize_action(
policy: &crate::policy::PolicyTable,
caller: &CallerRole,
action_name: &str,
) -> bool {
policy.action_allowed(caller, action_name)
}
fn authorization_failure_message(
policy: &crate::policy::PolicyTable,
caller: &CallerRole,
action_name: &str,
) -> String {
match policy.min_role_for_action(action_name) {
Some(required) => crate::auth::denial_message(action_name, *caller, required),
None => format!("action '{action_name}' is not allowed for {caller:?} role"),
}
}
async fn caller_owns_transaction(
state: &DaemonState,
caller: &CallerAttribution,
transaction_id: &str,
) -> Ownership {
if matches!(
caller.principal(),
crate::auth::CallerPrincipal::Unattributed
) {
return Ownership::CannotEstablish(
"this connection could not be attributed to an account, so it cannot approve, \
cancel, inspect or execute a transaction. The daemon reports the peer as \
unattributed when SO_PEERCRED fails or when the peer is not representable in \
this daemon's user namespace."
.to_string(),
);
}
let row = match state.audit.fetch_chain_row(transaction_id).await {
Ok(row) => row,
Err(e) => {
return Ownership::CannotEstablish(format!(
"failed to load transaction ownership: {e}"
));
}
};
let Some(row) = row else {
return Ownership::NoSuchTransaction;
};
let Some(owner) = row.caller_principal.as_deref() else {
return Ownership::CannotEstablish(format!(
"transaction {transaction_id} records no owning account, so ownership cannot be \
established"
));
};
if owner == caller.principal().as_signed_str() {
Ownership::Owned
} else {
Ownership::NotOwned
}
}
enum Ownership {
Owned,
NotOwned,
NoSuchTransaction,
CannotEstablish(String),
}
fn not_your_transaction_message(transaction_id: &str) -> String {
format!(
"transaction {transaction_id} was created by a different account. An approval receipt \
is proof that a specific account confirmed a specific preview, so only the account \
that previewed an action can approve, cancel, inspect or execute it."
)
}
async fn authorize_for_transaction(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
caller: &CallerAttribution,
request_id: &str,
transaction_id: &str,
missing_category: &str,
missing_message: &str,
) -> Result<bool, HandlerError> {
match state.audit.get(transaction_id).await {
Ok(Some(transaction)) => {
match caller_owns_transaction(state, caller, transaction_id).await {
Ownership::Owned | Ownership::NoSuchTransaction => {}
Ownership::NotOwned => {
send_error(
framed,
request_id,
"authorization_failure",
not_your_transaction_message(transaction_id),
)
.await?;
return Ok(false);
}
Ownership::CannotEstablish(message) => {
send_error(framed, request_id, "authorization_failure", message).await?;
return Ok(false);
}
}
if authorize_action(&state.policy, &caller.role(), &transaction.action_name) {
Ok(true)
} else {
send_error(
framed,
request_id,
"authorization_failure",
authorization_failure_message(
&state.policy,
&caller.role(),
&transaction.action_name,
),
)
.await?;
Ok(false)
}
}
Ok(None) => {
send_error(framed, request_id, missing_category, missing_message).await?;
Ok(false)
}
Err(e) => {
send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to load transaction: {e}"),
)
.await?;
Ok(false)
}
}
}
use sysknife_core::action_family::{DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS};
fn validate_action_platform(state: &DaemonState, action_name: &str) -> Result<(), String> {
use sysknife_core::distro::DistroFamily;
let required_family = if DEBIAN_ONLY_ACTIONS.contains(&action_name) {
Some(DistroFamily::Debian)
} else if FEDORA_ONLY_ACTIONS.contains(&action_name) {
Some(DistroFamily::Fedora)
} else {
None
};
let is_mutating = crate::policy::min_role_for_action(action_name)
.is_some_and(|role| role > CallerRole::Observer);
if required_family.is_none() && !is_mutating {
return Ok(());
}
let distro = state.host_distro.as_ref().ok_or_else(|| {
format!("cannot safely run {action_name}: host distribution could not be detected")
})?;
if !distro.is_supported() {
return Err(format!(
"cannot run {action_name} on unsupported host {distro}; see docs/distro-support.md"
));
}
if required_family.is_some_and(|family| distro.family() != family) {
return Err(format!(
"action {action_name} is incompatible with supported host {distro}"
));
}
Ok(())
}
pub fn compute_request_hash(action_name: &str, params: &Value) -> String {
let canonical = canonical_json(params);
let mut hasher = Sha256::new();
hasher.update(action_name.as_bytes());
hasher.update(b"\x00");
hasher.update(canonical.as_bytes());
let bytes = hasher.finalize();
bytes.iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})
}
const AUTOREMOVE_REMOVALS_KEY: &str = "autoremove_removals";
async fn simulate_autoremove_set(
runner: &Arc<dyn CommandRunner + Send + Sync>,
) -> Result<std::collections::BTreeSet<String>, String> {
let r = Arc::clone(runner);
match tokio::task::spawn_blocking(move || r.run("apt-get", &["-s", "autoremove"])).await {
Ok(Ok(out)) => Ok(crate::actions::apt::parse_autoremove_removals(&out)),
Ok(Err(e)) => Err(format!("apt-get -s autoremove failed: {e}")),
Err(e) => Err(format!("autoremove simulate task panicked: {e}")),
}
}
fn autoremove_kernel_warning(set: &std::collections::BTreeSet<String>) -> Option<String> {
let flagged: Vec<&str> = set
.iter()
.map(String::as_str)
.filter(|n| {
let base = n.split(':').next().unwrap_or(n);
base.starts_with("linux-image")
|| base.starts_with("linux-modules")
|| base.starts_with("linux-headers")
|| base.starts_with("grub")
|| base.contains("nvidia")
|| base.ends_with("-dkms")
})
.collect();
if flagged.is_empty() {
None
} else {
Some(format!(
"autoremove would delete kernel/driver/bootloader packages: {}. Review carefully.",
flagged.join(", ")
))
}
}
fn verify_autoremove_binding(
approved_proposed_change: &Value,
live: &std::collections::BTreeSet<String>,
) -> Result<(), String> {
let Some(captured) = approved_proposed_change.get(AUTOREMOVE_REMOVALS_KEY) else {
return Err(
"the approved preview did not capture the autoremove deletion set; preview again"
.to_string(),
);
};
let approved: std::collections::BTreeSet<String> = serde_json::from_value(captured.clone())
.map_err(|_| {
"the approved preview's autoremove set is malformed; preview again".to_string()
})?;
if &approved == live {
return Ok(());
}
let added: Vec<&str> = live.difference(&approved).map(String::as_str).collect();
let removed: Vec<&str> = approved.difference(live).map(String::as_str).collect();
Err(format!(
"the set of packages autoremove would delete changed since you approved it \
(now also removes: [{}]; no longer removes: [{}]); preview again",
added.join(", "),
removed.join(", ")
))
}
fn canonical_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
let pairs = keys
.iter()
.map(|k| {
format!(
"{}:{}",
Value::String((*k).to_string()),
canonical_json(&map[*k])
)
})
.collect::<Vec<_>>()
.join(",");
format!("{{{pairs}}}")
}
Value::Array(arr) => {
let items = arr.iter().map(canonical_json).collect::<Vec<_>>().join(",");
format!("[{items}]")
}
_ => format!("{v}"),
}
}
pub async fn unix_connection_handler<S>(
stream: S,
state: DaemonState,
runner: Arc<dyn CommandRunner + Send + Sync>,
caller: CallerAttribution,
) where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
connection_handler_with_executor(stream, state, runner, executor, caller).await;
}
#[cfg(target_os = "linux")]
pub async fn vsock_connection_handler<S>(
stream: S,
state: DaemonState,
runner: Arc<dyn CommandRunner + Send + Sync>,
) where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let executor: Arc<dyn ActionExecutor> = Arc::new(crate::executor::RealActionExecutor);
vsock_connection_handler_with_executor(stream, state, runner, executor).await;
}
#[cfg(target_os = "linux")]
pub async fn vsock_connection_handler_with_executor<S>(
stream: S,
state: DaemonState,
runner: Arc<dyn CommandRunner + Send + Sync>,
executor: Arc<dyn ActionExecutor>,
) where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut framed = FramedStream::new(stream);
let token_path = crate::auth::default_token_path();
let caller = match authenticate_vsock_token(&mut framed, &token_path).await {
Some(role) => CallerAttribution::from_vsock_token(role),
None => {
eprintln!("[sysknife-daemon] vsock auth failed; closing connection");
return;
}
};
dispatch_loop(&mut framed, state, runner, executor, caller).await;
}
pub async fn connection_handler_with_executor<S>(
stream: S,
state: DaemonState,
runner: Arc<dyn CommandRunner + Send + Sync>,
executor: Arc<dyn ActionExecutor>,
caller: CallerAttribution,
) where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut framed = FramedStream::new(stream);
dispatch_loop(&mut framed, state, runner, executor, caller).await;
}
async fn authenticate_vsock_token(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
token_path: &std::path::Path,
) -> Option<CallerRole> {
#[derive(serde::Deserialize)]
struct AuthFrame {
#[serde(rename = "type")]
msg_type: String,
token: String,
}
let recv_result = tokio::time::timeout(
std::time::Duration::from_secs(VSOCK_AUTH_FRAME_TIMEOUT_SECS),
framed.recv(),
)
.await;
let Ok(frame_result) = recv_result else {
eprintln!(
"[sysknife-daemon] vsock auth: timed out waiting for auth frame after {VSOCK_AUTH_FRAME_TIMEOUT_SECS}s"
);
return None;
};
let raw = match frame_result {
Ok(raw) => raw,
Err(e) => {
eprintln!("[sysknife-daemon] vsock auth: framing error reading auth frame: {e}");
return None;
}
};
let auth: AuthFrame = match serde_json::from_slice(&raw) {
Ok(auth) => auth,
Err(e) => {
eprintln!("[sysknife-daemon] vsock auth: malformed auth frame JSON: {e}");
return None;
}
};
if auth.msg_type != "auth" {
eprintln!(
"[sysknife-daemon] vsock auth: unexpected frame type {:?} (expected \"auth\")",
auth.msg_type
);
return None;
}
let role = crate::auth::validate_token_against_file(&auth.token, token_path);
if role.is_none() {
eprintln!("[sysknife-daemon] vsock auth: token did not validate against token file");
}
role
}
const IDLE_CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
const FIRST_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
fn read_deadline(requests_served: usize) -> std::time::Duration {
if requests_served == 0 {
FIRST_REQUEST_TIMEOUT
} else {
IDLE_CONNECTION_TIMEOUT
}
}
async fn dispatch_loop<S>(
framed: &mut FramedStream<S>,
state: DaemonState,
runner: Arc<dyn CommandRunner + Send + Sync>,
executor: Arc<dyn ActionExecutor>,
caller: CallerAttribution,
) where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut requests_served: usize = 0;
loop {
let deadline = read_deadline(requests_served);
let raw = match tokio::time::timeout(deadline, framed.recv()).await {
Ok(Ok(bytes)) => bytes,
Ok(Err(FramingError::Io(_))) => break, Ok(Err(FramingError::MessageTooLarge(_))) => break, Err(_) => {
if requests_served == 0 {
eprintln!(
"[sysknife-daemon] closing connection that sent no request within {}s",
deadline.as_secs()
);
} else {
eprintln!(
"[sysknife-daemon] closing connection idle for more than {}s",
deadline.as_secs()
);
}
break;
}
};
requests_served += 1;
let msg: Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => {
eprintln!("[sysknife-daemon] malformed JSON from client, closing connection: {e}");
break;
}
};
let request: DaemonRequest = match serde_json::from_value(msg) {
Ok(r) => r,
Err(e) => {
if let Err(send_err) = send_error(
framed,
"",
"validation_failure",
format!("unknown message type: {e}"),
)
.await
{
eprintln!(
"[sysknife-daemon] failed to send validation error response: {send_err}"
);
}
continue;
}
};
let result = match &request {
DaemonRequest::QueryState { request_id } => {
handle_query_state(framed, Arc::clone(&runner), request_id).await
}
DaemonRequest::Preview {
request_id,
action_name,
params,
unattended,
} => {
handle_preview(
framed,
&state,
Arc::clone(&runner),
&caller,
request_id,
action_name,
params,
*unattended,
)
.await
}
DaemonRequest::Execute {
request_id,
transaction_id,
action_name,
params,
approval_receipt,
} => {
handle_execute(
framed,
&state,
Arc::clone(&executor),
Arc::clone(&runner),
&caller,
request_id,
transaction_id,
action_name,
params,
approval_receipt,
)
.await
}
DaemonRequest::Approve {
request_id,
transaction_id,
} => handle_approve(framed, &state, &caller, request_id, transaction_id.as_str()).await,
DaemonRequest::ApprovalDetails {
request_id,
transaction_id,
} => {
handle_approval_details(
framed,
&state,
&caller,
request_id,
transaction_id.as_str(),
)
.await
}
DaemonRequest::QueryAction {
request_id,
action_name,
params,
} => {
handle_query_action(
framed,
Arc::clone(&executor),
&state,
action_name,
params,
request_id,
)
.await
}
DaemonRequest::QueryHistory {
request_id,
limit,
status_filter,
action_filter,
since_hours,
} => {
handle_query_history(
framed,
&state,
request_id,
*limit,
status_filter.clone(),
action_filter.clone(),
*since_hours,
)
.await
}
DaemonRequest::Describe {
request_id,
action_name,
params,
} => handle_describe(framed, &state, &caller, action_name, params, request_id).await,
DaemonRequest::Cancel {
request_id,
transaction_id,
} => handle_cancel(framed, &state, &caller, request_id, transaction_id.as_str()).await,
};
if let Err(e) = result {
eprintln!("[sysknife-daemon] connection handler send error: {e}");
}
}
}
fn receipt_digest(receipt: &str) -> String {
hex::encode(Sha256::digest(receipt.as_bytes()))
}
async fn handle_approve(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
caller: &CallerAttribution,
request_id: &str,
transaction_id: &str,
) -> Result<(), HandlerError> {
if !authorize_for_transaction(
framed,
state,
caller,
request_id,
transaction_id,
"stale_approval",
"transaction is missing, expired, already approved, or no longer queued",
)
.await?
{
return Ok(());
}
let receipt = match state.audit.approve_transaction(transaction_id).await {
Ok(receipt) => receipt,
Err(e @ crate::transactions::TransactionStoreError::DatabaseInvariant(_)) => {
return send_error(
framed,
request_id,
"integrity_failure",
format!("approval rejected by an integrity check: {e}"),
)
.await;
}
Err(e) => {
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to persist approval: {e}"),
)
.await;
}
};
let Some(receipt) = receipt else {
return send_error(
framed,
request_id,
"stale_approval",
"transaction is missing, expired, already approved, or no longer queued",
)
.await;
};
let response = send_response(
framed,
&DaemonResponse::ApprovalResponse {
request_id: request_id.to_string(),
transaction_id: transaction_id.to_string(),
approval_receipt: receipt,
},
)
.await;
if response.is_err() {
if let Err(e) = state.audit.revoke_unconsumed_approval(transaction_id).await {
eprintln!(
"[sysknife-daemon] failed to revoke undelivered approval for \
{transaction_id}: {e}"
);
}
}
response
}
async fn handle_approval_details(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
caller: &CallerAttribution,
request_id: &str,
transaction_id: &str,
) -> Result<(), HandlerError> {
match caller_owns_transaction(state, caller, transaction_id).await {
Ownership::Owned | Ownership::NoSuchTransaction => {}
Ownership::NotOwned => {
return send_error(
framed,
request_id,
"authorization_failure",
not_your_transaction_message(transaction_id),
)
.await;
}
Ownership::CannotEstablish(message) => {
return send_error(framed, request_id, "authorization_failure", message).await;
}
}
let transaction = match state.audit.get(transaction_id).await {
Ok(Some(transaction)) if transaction.status == JobState::Queued => transaction,
Ok(_) => {
return send_error(
framed,
request_id,
"stale_approval",
"transaction is missing or no longer queued",
)
.await;
}
Err(e) => {
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to load transaction: {e}"),
)
.await;
}
};
if !authorize_action(&state.policy, &caller.role(), &transaction.action_name) {
return send_error(
framed,
request_id,
"authorization_failure",
authorization_failure_message(&state.policy, &caller.role(), &transaction.action_name),
)
.await;
}
let preview = match state.audit.get_preview(transaction_id).await {
Ok(Some(preview)) => preview,
Ok(None) => {
return send_error(
framed,
request_id,
"stale_approval",
"transaction has no persisted preview",
)
.await;
}
Err(e) => {
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to load preview: {e}"),
)
.await;
}
};
send_response(
framed,
&DaemonResponse::ApprovalDetailsResponse {
request_id: request_id.to_string(),
transaction_id: transaction_id.to_string(),
action_name: transaction.action_name,
preview,
},
)
.await
}
async fn handle_query_state(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
runner: Arc<dyn CommandRunner + Send + Sync>,
request_id: &str,
) -> Result<(), HandlerError> {
let join_result = tokio::task::spawn_blocking(move || collect_state(&*runner))
.await
.map_err(|e| HandlerError::Internal(format!("collect_state task failed: {e}")))?;
let collected = match join_result {
Ok(s) => s,
Err(e) => {
return send_error(framed, request_id, "state_collection_failed", e.to_string()).await;
}
};
send_response(
framed,
&DaemonResponse::StateResponse {
request_id: request_id.to_string(),
state: collected,
},
)
.await
}
async fn handle_cancel(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
caller: &CallerAttribution,
request_id: &str,
transaction_id: &str,
) -> Result<(), HandlerError> {
if !authorize_for_transaction(
framed,
state,
caller,
request_id,
transaction_id,
"not_cancelable",
"transaction cannot be canceled: it is missing, already executing, or finished",
)
.await?
{
return Ok(());
}
match state.audit.cancel_queued(transaction_id).await {
Ok(true) => {
send_response(
framed,
&DaemonResponse::CancelResponse {
request_id: request_id.to_string(),
transaction_id: transaction_id.to_string(),
},
)
.await
}
Ok(false) => {
send_error(
framed,
request_id,
"not_cancelable",
"transaction cannot be canceled: it is missing, already executing, or finished",
)
.await
}
Err(e) => {
send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to cancel transaction: {e}"),
)
.await
}
}
}
async fn handle_query_history(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
request_id: &str,
limit: Option<u32>,
status_filter: Option<String>,
action_filter: Option<String>,
since_hours: Option<u32>,
) -> Result<(), HandlerError> {
match state.policy.min_role_for_action("ListJobHistory") {
Some(CallerRole::Observer) => {}
Some(_) => {
return send_error(
framed,
request_id,
"authorization_failure",
"history has been restricted above read-only by policy; \
it is not available over the structured query path",
)
.await;
}
None => {
return send_error(
framed,
request_id,
"validation_failure",
"ListJobHistory action is not registered",
)
.await;
}
}
let entries = match state
.audit
.list_history(
limit.unwrap_or(DEFAULT_HISTORY_LIMIT),
status_filter.as_deref(),
action_filter.as_deref(),
since_hours,
)
.await
{
Ok(entries) => entries,
Err(e) => {
return send_error(
framed,
request_id,
"execution_failure",
format!("failed to query transaction history: {e}"),
)
.await;
}
};
send_response(
framed,
&DaemonResponse::HistoryResponse {
request_id: request_id.to_string(),
entries,
},
)
.await
}
async fn handle_query_action(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
executor: Arc<dyn ActionExecutor>,
state: &DaemonState,
action_name: &str,
params: &Value,
request_id: &str,
) -> Result<(), HandlerError> {
let audit = &state.audit;
let min_role = match state.policy.min_role_for_action(action_name) {
Some(role) => role,
None => {
return send_error(
framed,
request_id,
"validation_failure",
format!("unknown action: {action_name}"),
)
.await;
}
};
if min_role != CallerRole::Observer {
return send_error(
framed,
request_id,
"authorization_failure",
format!("{action_name} is not a read-only action; use preview+execute instead"),
)
.await;
}
if crate::actions::OBSERVER_MUTATING_ACTIONS.contains(&action_name) {
return send_error(
framed,
request_id,
"authorization_failure",
format!(
"{action_name} mutates the host and is not available through query_action; \
use preview+execute instead"
),
)
.await;
}
if let Err(message) = validate_action_platform(state, action_name) {
return send_error(framed, request_id, "unsupported_platform", message).await;
}
if action_name == "ListJobHistory" {
let limit = match params.get("limit") {
Some(v) => match v.as_u64() {
Some(n) => n as u32,
None => {
return send_error(
framed,
request_id,
"validation_failure",
format!("'limit' must be an integer, got: {v}"),
)
.await;
}
},
None => DEFAULT_HISTORY_LIMIT,
};
let status_filter = params
.get("status_filter")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let action_filter = params
.get("action_filter")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let since_hours = match params.get("since_hours") {
Some(v) => match v.as_u64() {
Some(n) => Some(n as u32),
None => {
return send_error(
framed,
request_id,
"validation_failure",
format!("'since_hours' must be an integer, got: {v}"),
)
.await;
}
},
None => None,
};
let records = match audit
.list_transactions(
limit,
status_filter.as_deref(),
action_filter.as_deref(),
since_hours,
)
.await
{
Ok(r) => r,
Err(e) => {
return send_error(
framed,
request_id,
"execution_failure",
format!("failed to query transaction log: {e}"),
)
.await;
}
};
let output = if records.is_empty() {
let mut msg = "No transactions found".to_string();
let mut filters = Vec::new();
if let Some(s) = &status_filter {
filters.push(format!("status={s}"));
}
if let Some(a) = &action_filter {
filters.push(format!("action={a}"));
}
if let Some(h) = since_hours {
filters.push(format!("since_hours={h}"));
}
if !filters.is_empty() {
msg.push_str(&format!(" (filters: {})", filters.join(", ")));
}
msg.push('.');
msg
} else {
format_job_history(&records)
};
return send_response(
framed,
&DaemonResponse::QueryActionResponse {
request_id: request_id.to_string(),
action_name: action_name.to_string(),
output,
},
)
.await;
}
let spec = match build_action_spec(action_name, params) {
Ok(s) => s,
Err(e) => {
return send_error(framed, request_id, "validation_failure", e.to_string()).await;
}
};
let output = match executor.execute(&spec).await {
Ok(out) => out,
Err(e) => {
return send_error(framed, request_id, "execution_failure", e.to_string()).await;
}
};
let is_informational_exit =
matches!((action_name, output.exit_code), ("GetServiceStatus", 1..=4));
if output.is_nonzero() && !is_informational_exit {
return send_error(
framed,
request_id,
"execution_failure",
format!(
"{action_name} failed with exit code {}{}",
output.exit_code,
if output.stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", output.stderr.trim())
}
),
)
.await;
}
let output_text = if output.stderr.is_empty() {
output.stdout
} else {
format!("{}\n[stderr]\n{}", output.stdout, output.stderr)
};
send_response(
framed,
&DaemonResponse::QueryActionResponse {
request_id: request_id.to_string(),
action_name: action_name.to_string(),
output: output_text,
},
)
.await
}
async fn handle_describe(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
caller: &CallerAttribution,
action_name: &str,
params: &Value,
request_id: &str,
) -> Result<(), HandlerError> {
use crate::actions::ActionMechanism;
use crate::executor::build_action_spec;
if state.policy.min_role_for_action(action_name).is_none() {
return send_error(
framed,
request_id,
"validation_failure",
format!("unknown action: {action_name}"),
)
.await;
}
if !authorize_action(&state.policy, &caller.role(), action_name) {
return send_error(
framed,
request_id,
"authorization_failure",
authorization_failure_message(&state.policy, &caller.role(), action_name),
)
.await;
}
if let Err(message) = validate_action_platform(state, action_name) {
return send_error(framed, request_id, "unsupported_platform", message).await;
}
if action_name == "ListJobHistory" {
return send_response(
framed,
&DaemonResponse::DescribeResponse {
request_id: request_id.to_string(),
command: "query daemon job history (SQLite)".to_string(),
risk_level: "low".to_string(),
reboot_required: false,
},
)
.await;
}
let spec = match build_action_spec(action_name, params) {
Ok(s) => s,
Err(e) => {
return send_error(
framed,
request_id,
"validation_failure",
format!("unknown action: {action_name} ({e})"),
)
.await;
}
};
let command = match &spec.mechanism {
ActionMechanism::Command { program, args } => {
let display_args = redact_argv(action_name, params, args);
if display_args.is_empty() {
program.to_string()
} else {
format!("{} {}", program, display_args.join(" "))
}
}
ActionMechanism::FileScan { path } => format!("read {path}"),
ActionMechanism::FileWrite { path, .. } => format!("write {path}"),
ActionMechanism::FilePatch { path, .. } => format!("patch {path}"),
ActionMechanism::FileDelete { path } => format!("rm {path}"),
};
let risk_level = format!("{:?}", spec.risk_level).to_lowercase();
send_response(
framed,
&DaemonResponse::DescribeResponse {
request_id: request_id.to_string(),
command,
risk_level,
reboot_required: spec.reboot_required,
},
)
.await
}
pub const UNATTENDED_WARNING: &str =
"Approved without a human: this step was previewed by a client running with \
--dangerously-skip-approval, so no operator confirmed it.";
#[allow(clippy::too_many_arguments)]
async fn handle_preview(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
runner: Arc<dyn CommandRunner + Send + Sync>,
caller: &CallerAttribution,
request_id: &str,
action_name: &str,
params: &Value,
unattended: bool,
) -> Result<(), HandlerError> {
let spec = match build_action_spec(action_name, params) {
Ok(s) => s,
Err(e) => {
return send_error(framed, request_id, "validation_failure", e.to_string()).await;
}
};
if !authorize_action(&state.policy, &caller.role(), action_name) {
return send_error(
framed,
request_id,
"authorization_failure",
authorization_failure_message(&state.policy, &caller.role(), action_name),
)
.await;
}
if let Err(message) = validate_action_platform(state, action_name) {
return send_error(framed, request_id, "unsupported_platform", message).await;
}
let request_hash = compute_request_hash(action_name, params);
let runner_for_preview = Arc::clone(&runner);
let current_state =
match tokio::task::spawn_blocking(move || collect_state(&*runner_for_preview)).await {
Err(e) => {
eprintln!(
"[sysknife-daemon] handle_preview: collect_state task failed ({e}); \
generating preview with empty state"
);
Value::Null
}
Ok(Err(e)) => {
eprintln!(
"[sysknife-daemon] handle_preview: state collection failed ({e}); \
generating preview with empty state"
);
Value::Null
}
Ok(Ok(s)) => match serde_json::to_value(&s) {
Ok(v) => v,
Err(e) => {
eprintln!(
"[sysknife-daemon] handle_preview: failed to serialize collected state ({e}); \
using empty state"
);
Value::Null
}
},
};
let redacted_params = redact_params(action_name, params);
let mut proposed_change = json!({ "action": action_name, "params": redacted_params });
let mut autoremove_warning: Option<String> = None;
if action_name == "AptAutoremove" {
match simulate_autoremove_set(&runner).await {
Ok(set) => {
autoremove_warning = autoremove_kernel_warning(&set);
proposed_change[AUTOREMOVE_REMOVALS_KEY] =
json!(set.iter().collect::<Vec<&String>>());
}
Err(e) => {
eprintln!("[sysknife-daemon] handle_preview: autoremove simulate failed: {e}");
autoremove_warning = Some(
"Could not compute which packages autoremove would delete; approving this \
preview will not let it run until a preview captures the set."
.to_string(),
);
}
}
}
let envelope = RequestEnvelope {
action_name: action_name.to_string(),
request_id: request_id.to_string(),
params: params.clone(),
caller_role: caller.role(),
request_hash: sysknife_types::RequestHash::new(request_hash.to_string()),
};
let state_unavailable = current_state.is_null();
let mut preview = preview_action(&envelope, current_state, proposed_change);
if let Some(w) = autoremove_warning {
preview.warnings.push(w);
}
if state_unavailable {
preview.warnings.push(
"System state could not be collected; this preview was generated without it. \
Review the action carefully before approving."
.to_string(),
);
}
if unattended {
preview.warnings.push(UNATTENDED_WARNING.to_string());
}
let new_tx = NewTransaction {
request_id: request_id.to_string(),
request_hash,
action_name: action_name.to_string(),
risk_level: spec.risk_level,
summary: preview.summary.clone(),
warnings: preview.warnings.clone(),
caller_role: caller.role(),
caller_principal: caller.principal(),
};
let recorded = match state.audit.record_previewed(new_tx, preview.clone()).await {
Ok(r) => r,
Err(e) => {
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to record preview transaction: {e}"),
)
.await;
}
};
forward_audit_event(state, &recorded.transaction.transaction_id, &caller.role());
send_response(
framed,
&DaemonResponse::PreviewResponse {
request_id: request_id.to_string(),
preview,
transaction_id: recorded.transaction.transaction_id,
},
)
.await
}
fn forward_audit_event(state: &DaemonState, transaction_id: &str, caller: &CallerRole) {
let Some(forwarder) = state.forwarder.clone() else {
return;
};
let audit = std::sync::Arc::clone(&state.audit);
let transaction_id = transaction_id.to_string();
let caller_label = format!("{caller:?}");
tokio::spawn(async move {
match audit.fetch_chain_row(&transaction_id).await {
Ok(Some(row)) => {
forwarder.submit(crate::audit_forward::AuditEvent {
seq: row.seq,
transaction_id: row.transaction_id,
action_name: row.action_name,
risk_level: row.risk_level,
summary: row.summary,
approval_id: row.approval_id,
created_at: row.created_at,
chain_hash: row.chain_hash,
key_id: row.key_id,
caller_role: Some(caller_label),
caller_principal: row.caller_principal,
final_status: None,
});
}
Ok(None) => {
eprintln!(
"[sysknife-daemon] audit-forward: transaction {transaction_id} \
not found just after insert (race?)"
);
}
Err(e) => {
eprintln!(
"[sysknife-daemon] audit-forward: chain row fetch failed for \
{transaction_id}: {e}"
);
}
}
});
}
fn forward_status_change_event(
state: &DaemonState,
transaction_id: &str,
caller: &CallerRole,
final_status: JobState,
) {
if state.forwarder.is_none() {
return;
}
let audit = std::sync::Arc::clone(&state.audit);
let forwarder = state.forwarder.clone().expect("checked above");
let transaction_id = transaction_id.to_string();
let caller_label = format!("{caller:?}");
let final_status_label = format!("{final_status:?}").to_lowercase();
tokio::spawn(async move {
match audit.fetch_chain_row(&transaction_id).await {
Ok(Some(row)) => {
forwarder.submit(crate::audit_forward::AuditEvent {
seq: row.seq,
transaction_id: row.transaction_id,
action_name: row.action_name,
risk_level: row.risk_level,
summary: row.summary,
approval_id: row.approval_id,
created_at: row.created_at,
chain_hash: row.chain_hash,
key_id: row.key_id,
caller_role: Some(caller_label),
caller_principal: row.caller_principal,
final_status: Some(final_status_label),
});
}
Ok(None) => {
eprintln!(
"[sysknife-daemon] audit-forward: transaction {transaction_id} \
not found for status-change forward (race?)"
);
}
Err(e) => {
eprintln!(
"[sysknife-daemon] audit-forward: chain row fetch failed for \
{transaction_id}: {e}"
);
}
}
});
}
async fn release_exclusive_slots(
state: &DaemonState,
claimed: &[crate::actions::ExclusiveResource],
request_hash: &str,
) {
if claimed.is_empty() {
return;
}
let mut slots = state.running_exclusive.lock().await;
for resource in claimed {
if slots.get(resource).map(String::as_str) == Some(request_hash) {
slots.remove(resource);
}
}
}
const TERMINAL_STATUS_RETRY_ATTEMPTS: u32 = 3;
const TERMINAL_STATUS_RETRY_BACKOFF_MS: u64 = 25;
async fn update_terminal_status(
state: &DaemonState,
transaction_id: &str,
target: JobState,
) -> Result<(), crate::transactions::TransactionStoreError> {
for attempt in 0..TERMINAL_STATUS_RETRY_ATTEMPTS {
match state.audit.update_status(transaction_id, target).await {
Ok(()) => return Ok(()),
Err(error) => {
if state
.audit
.get(transaction_id)
.await
.ok()
.flatten()
.is_some_and(|record| record.status == target)
{
return Ok(());
}
if attempt == TERMINAL_STATUS_RETRY_ATTEMPTS - 1 {
return Err(error);
}
tokio::time::sleep(std::time::Duration::from_millis(
TERMINAL_STATUS_RETRY_BACKOFF_MS * u64::from(attempt + 1),
))
.await;
}
}
}
unreachable!("bounded retry loop always returns")
}
#[allow(clippy::too_many_arguments)]
async fn handle_execute(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
state: &DaemonState,
executor: Arc<dyn ActionExecutor>,
runner: Arc<dyn CommandRunner + Send + Sync>,
caller: &CallerAttribution,
request_id: &str,
transaction_id: &TransactionId,
action_name: &str,
params: &Value,
approval_receipt: &ApprovalReceipt,
) -> Result<(), HandlerError> {
let transaction_id = transaction_id.as_str();
let approval_receipt = approval_receipt.as_str();
let spec = match build_action_spec(action_name, params) {
Ok(s) => s,
Err(e) => {
return send_error(framed, request_id, "validation_failure", e.to_string()).await;
}
};
match caller_owns_transaction(state, caller, transaction_id).await {
Ownership::Owned | Ownership::NoSuchTransaction => {}
Ownership::NotOwned => {
return send_error(
framed,
request_id,
"authorization_failure",
not_your_transaction_message(transaction_id),
)
.await;
}
Ownership::CannotEstablish(message) => {
return send_error(framed, request_id, "authorization_failure", message).await;
}
}
if !authorize_action(&state.policy, &caller.role(), action_name) {
return send_error(
framed,
request_id,
"authorization_failure",
authorization_failure_message(&state.policy, &caller.role(), action_name),
)
.await;
}
if let Err(message) = validate_action_platform(state, action_name) {
return send_error(framed, request_id, "unsupported_platform", message).await;
}
let submitted_hash = compute_request_hash(action_name, params);
let prior_tx = match state.audit.get(transaction_id).await {
Ok(tx) => tx,
Err(e) => {
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("transaction lookup failed: {e}"),
)
.await;
}
};
let prior_tx = match prior_tx {
Some(tx) => tx,
None => {
return send_error(
framed,
request_id,
"stale_approval",
"no prior preview found for this transaction; preview before executing",
)
.await;
}
};
if prior_tx.action_name != action_name || prior_tx.request_hash != submitted_hash {
return send_error(
framed,
request_id,
"stale_approval",
"action or parameters differ from the approved preview",
)
.await;
}
let stored_hash = prior_tx.request_hash;
let is_high_risk_reboot =
spec.risk_level == sysknife_types::RiskLevel::High && spec.reboot_required;
let policy_says_mutating = match state.policy.min_role_for_action(action_name) {
Some(r) => r > CallerRole::Observer,
None => {
eprintln!(
"[sysknife-daemon] WARNING: no policy entry for {action_name:?} at the \
concurrency gate; treating it as mutating"
);
true
}
};
let is_mutating = policy_says_mutating || is_high_risk_reboot;
if is_high_risk_reboot && !policy_says_mutating {
eprintln!(
"[sysknife-daemon] WARNING: {action_name:?} is High-risk + reboot-required but the \
policy table does not require Dev/Admin for it; gating it as mutating anyway. \
The risk and role tables have drifted."
);
}
let mut to_claim: Vec<crate::actions::ExclusiveResource> = Vec::new();
if is_high_risk_reboot {
to_claim.push(crate::actions::ExclusiveResource::System);
}
let own_resource = crate::actions::exclusive_resource(&spec);
if let Some(r) = own_resource {
if !to_claim.contains(&r) {
to_claim.push(r);
}
}
if !is_mutating {
to_claim.clear();
}
if is_mutating {
let mut slots = state.running_exclusive.lock().await;
let blocking = slots
.get(&crate::actions::ExclusiveResource::System)
.map(|h| (crate::actions::ExclusiveResource::System, h.clone()))
.or_else(|| own_resource.and_then(|r| slots.get(&r).map(|h| (r, h.clone()))));
if let Some((blocking_resource, running_hash)) = blocking {
let ttl = crate::transactions::APPROVAL_RECEIPT_TTL_MINUTES;
let held = blocking_resource.label();
let msg = format!(
"another mutating action is already executing and holds {held} (request_hash \
{running_hash}); retry after the current job completes. This approval \
expires {ttl} minutes after it was issued, and that window is anchored to \
when the preview was created — re-running `sysknife approve` on this same \
transaction will not renew it. If the running job is still going once the \
window has passed, re-run the preview (which mints a fresh transaction) and \
approve that new transaction instead"
);
drop(slots); return send_response(
framed,
&DaemonResponse::ConflictResponse {
request_id: request_id.to_string(),
message: msg,
retry_after_seconds: None,
},
)
.await;
}
for resource in &to_claim {
slots.insert(*resource, stored_hash.clone());
}
}
let claimed = match state
.audit
.claim_approved_for_execution(transaction_id, &receipt_digest(approval_receipt))
.await
{
Ok(c) => c,
Err(e) => {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("failed to claim transaction: {e}"),
)
.await;
}
};
if !claimed {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
return send_error(
framed,
request_id,
"stale_approval",
"transaction is not approved for this receipt, expired, or already consumed",
)
.await;
}
if action_name == "AptAutoremove" {
let approved_preview = match state.audit.get_preview(transaction_id).await {
Ok(Some(p)) => p,
Ok(None) => {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
return send_error(
framed,
request_id,
"stale_approval",
"no persisted preview for this transaction; preview before executing",
)
.await;
}
Err(e) => {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
return send_error(
framed,
request_id,
"transient_infrastructure_failure",
format!("preview lookup failed: {e}"),
)
.await;
}
};
let live = match simulate_autoremove_set(&runner).await {
Ok(s) => s,
Err(e) => {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
return send_error(
framed,
request_id,
"execution_failure",
format!("could not confirm the autoremove set before executing: {e}"),
)
.await;
}
};
if let Err(reason) = verify_autoremove_binding(&approved_preview.proposed_change, &live) {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
return send_error(framed, request_id, "stale_approval", reason).await;
}
}
let job_id = Uuid::new_v4().to_string();
if let Err(send_error) = send_response(
framed,
&DaemonResponse::JobStarted {
request_id: request_id.to_string(),
job_id: job_id.clone(),
transaction_id: transaction_id.to_string(),
},
)
.await
{
release_exclusive_slots(state, &to_claim, &stored_hash).await;
if let Err(status_error) =
update_terminal_status(state, transaction_id, JobState::Failed).await
{
eprintln!(
"[sysknife-daemon] failed to mark disconnected transaction {transaction_id} \
as Failed: {status_error}"
);
}
return Err(send_error);
}
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.clone(),
line: format!("Authorization passed for {action_name}"),
},
)
.await;
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.clone(),
line: format!("Executing {action_name}..."),
},
)
.await;
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
let execution = executor.execute_with_progress(&spec, progress_tx);
tokio::pin!(execution);
let mut progress_open = true;
let output = loop {
tokio::select! {
result = &mut execution => break result,
line = progress_rx.recv(), if progress_open => {
let Some(line) = line else {
progress_open = false;
continue;
};
if let Err(e) = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.clone(),
line,
},
).await {
eprintln!(
"[sysknife-daemon] progress send failed (client disconnected?): {e}"
);
}
}
}
};
while let Ok(line) = progress_rx.try_recv() {
if let Err(e) = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.clone(),
line,
},
)
.await
{
eprintln!("[sysknife-daemon] final progress send failed: {e}");
}
}
let (initial_status, initial_summary) = match &output {
Ok(out) if out.is_success() => {
if spec.reboot_required {
(
JobState::NeedsReboot,
format!("{action_name} completed; reboot required"),
)
} else {
(
JobState::Succeeded,
format!("{action_name} completed successfully"),
)
}
}
Ok(out) => (
JobState::Failed,
format!("{action_name} failed with exit code {}", out.exit_code),
),
Err(e) => (JobState::Failed, format!("{action_name} failed: {e}")),
};
let completion_line = match &output {
Ok(out) => format!("{action_name} completed with exit code {}", out.exit_code),
Err(e) => format!("{action_name} completed with error: {e}"),
};
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.clone(),
line: completion_line,
},
)
.await;
let action_may_still_be_running = matches!(
&output,
Err(crate::executor::ExecutorError::ActionNotStopped { .. })
);
let (final_status, summary, rollback_ref) = attempt_rollback_if_needed(
framed,
&executor,
&job_id,
action_name,
&spec,
initial_status,
initial_summary,
action_may_still_be_running,
)
.await;
if action_may_still_be_running {
if !to_claim.is_empty() {
eprintln!(
"[sysknife-daemon] holding the exclusion slot for {action_name}: the action \
could not be confirmed stopped, so no other mutating action may start until \
the daemon is restarted"
);
}
} else {
release_exclusive_slots(state, &to_claim, &stored_hash).await;
}
let mut warnings = Vec::new();
match update_terminal_status(state, transaction_id, final_status).await {
Ok(()) => {
forward_status_change_event(state, transaction_id, &caller.role(), final_status);
}
Err(e) => {
eprintln!(
"[sysknife-daemon] failed to update transaction {transaction_id} to \
{final_status:?}: {e}"
);
warnings.push(format!("audit trail update failed: {e}"));
}
}
send_response(
framed,
&DaemonResponse::JobCompleted {
job_id: job_id.clone(),
result: JobResult {
status: job_state_str(&final_status).to_string(),
summary,
warnings,
job_id: job_id.clone(),
needs_reboot: matches!(final_status, JobState::NeedsReboot),
rollback_ref,
transaction_id: transaction_id.to_string(),
},
},
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn attempt_rollback_if_needed(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
executor: &Arc<dyn ActionExecutor>,
job_id: &str,
action_name: &str,
spec: &crate::actions::ActionSpec,
status: JobState,
summary: String,
action_may_still_be_running: bool,
) -> (JobState, String, Option<String>) {
if !matches!(status, JobState::Failed) || !spec.rollback_available {
return (status, summary, None);
}
if action_may_still_be_running {
eprintln!(
"[sysknife-daemon] {action_name} could not be confirmed stopped; \
skipping automatic rollback"
);
let line = format!(
"{action_name} could not be confirmed stopped, so automatic rollback was \
skipped: rolling back over a running transaction would be worse than \
leaving it. Inspect the host before retrying."
);
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.to_string(),
line: line.clone(),
},
)
.await;
return (
status,
format!("{summary} (automatic rollback skipped)"),
None,
);
}
let Some(rb_spec) = rollback_spec_for(action_name) else {
return (status, summary, None);
};
eprintln!("[sysknife-daemon] {action_name} failed; attempting automatic rollback");
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.to_string(),
line: format!(
"{action_name} failed — attempting automatic rollback via rpm-ostree rollback"
),
},
)
.await;
match executor.execute(&rb_spec).await {
Ok(rb_out) if rb_out.is_success() => {
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.to_string(),
line: "Rollback succeeded — previous deployment restored".to_string(),
},
)
.await;
(
JobState::RolledBack,
format!(
"{action_name} failed and was automatically rolled back to the previous deployment"
),
Some("rpm-ostree rollback".to_string()),
)
}
other => {
let detail = match &other {
Ok(o) => format!("exit code {}", o.exit_code),
Err(e) => e.to_string(),
};
eprintln!("[sysknife-daemon] rollback also failed: {detail}");
let _ = send_response(
framed,
&DaemonResponse::JobProgress {
job_id: job_id.to_string(),
line: format!(
"Rollback also failed ({detail}) — system may need manual intervention"
),
},
)
.await;
(status, summary, None)
}
}
}
fn job_state_str(state: &JobState) -> &'static str {
match state {
JobState::Queued => "queued",
JobState::Running => "running",
JobState::Succeeded => "succeeded",
JobState::Failed => "failed",
JobState::Canceled => "canceled",
JobState::RolledBack => "rolled_back",
JobState::NeedsReboot => "needs_reboot",
}
}
#[derive(Debug, thiserror::Error)]
enum HandlerError {
#[error("framing error: {0}")]
Framing(#[from] FramingError),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("internal error: {0}")]
Internal(String),
}
fn format_job_history(records: &[sysknife_types::TransactionRecord]) -> String {
if records.is_empty() {
return "No transactions found.".to_string();
}
let mut output = format!("Transaction history ({} entries):\n\n", records.len());
for r in records {
output.push_str(&format!(
" {} {:30} {:12} {}\n",
r.transaction_id
.chars()
.take(TRANSACTION_ID_DISPLAY_PREFIX_LEN)
.collect::<String>(),
r.action_name,
format!("{:?}", r.status).to_lowercase(),
r.summary,
));
}
output
}
async fn send_response(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
response: &DaemonResponse,
) -> Result<(), HandlerError> {
let json = serde_json::to_vec(response)?;
framed.send(&json).await.map_err(HandlerError::Framing)
}
async fn send_error(
framed: &mut FramedStream<impl AsyncRead + AsyncWrite + Unpin>,
request_id: &str,
category: &str,
message: impl Into<String>,
) -> Result<(), HandlerError> {
send_response(
framed,
&DaemonResponse::ErrorResponse {
request_id: request_id.to_string(),
category: category.to_string(),
message: message.into(),
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::CallerPrincipal;
fn uid_caller(role: CallerRole) -> CallerAttribution {
uid_caller_with(1000, role)
}
fn uid_caller_with(uid: u32, role: CallerRole) -> CallerAttribution {
CallerAttribution::from_peer_uid(uid, role)
}
use crate::{
state::{DaemonConfig, DaemonState},
transport::listen::ListenTarget,
};
use std::io;
use tempfile::tempdir;
#[derive(Default)]
struct CountingRollbackExecutor {
calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl ActionExecutor for CountingRollbackExecutor {
async fn execute(
&self,
_spec: &crate::actions::ActionSpec,
) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(crate::executor::ExecutionOutput {
stdout: "rolled back\n".to_string(),
stderr: String::new(),
exit_code: 0,
})
}
}
fn rollback_eligible_spec() -> crate::actions::ActionSpec {
let spec = crate::executor::build_action_spec("UpdateSystem", &serde_json::json!({}))
.expect("UpdateSystem builds without params");
assert!(
spec.rollback_available,
"this test is meaningless unless the action is rollback-eligible"
);
spec
}
async fn rollback_outcome(
may_still_be_running: bool,
) -> (JobState, String, Option<String>, usize) {
let (a, _b) = UnixStream::pair().unwrap();
let mut framed = FramedStream::new(a);
let counting = Arc::new(CountingRollbackExecutor::default());
let executor: Arc<dyn ActionExecutor> = counting.clone();
let (status, summary, rollback_ref) = attempt_rollback_if_needed(
&mut framed,
&executor,
"job-1",
"UpdateSystem",
&rollback_eligible_spec(),
JobState::Failed,
"UpdateSystem failed".to_string(),
may_still_be_running,
)
.await;
let calls = counting.calls.load(std::sync::atomic::Ordering::SeqCst);
(status, summary, rollback_ref, calls)
}
#[tokio::test]
async fn an_ordinary_failure_still_rolls_back() {
let (status, _summary, rollback_ref, calls) = rollback_outcome(false).await;
assert_eq!(calls, 1, "the rollback action must have been executed");
assert!(matches!(status, JobState::RolledBack));
assert!(rollback_ref.is_some());
}
#[tokio::test]
async fn an_action_that_could_not_be_stopped_is_never_rolled_back_over() {
let (status, summary, rollback_ref, calls) = rollback_outcome(true).await;
assert_eq!(
calls, 0,
"no rollback may be executed while the original action may still be running"
);
assert!(
matches!(status, JobState::Failed),
"the job stays failed rather than claiming it was rolled back"
);
assert!(rollback_ref.is_none());
assert!(
summary.contains("rollback skipped"),
"the operator must be told why nothing was reverted, got: {summary}"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn peer_pidfd_reports_live_self() {
let (a, _b) = UnixStream::pair().unwrap();
match peer_pidfd(&a) {
PeerPin::Pinned(fd) => assert!(
pidfd_peer_still_live(&fd),
"the connecting (self) process must read as live"
),
PeerPin::Unsupported => {}
PeerPin::Unpinnable => panic!("a live peer must not silently lose its pin"),
}
}
#[cfg(target_os = "linux")]
#[test]
fn peer_pin_only_unsupported_option_allows_best_effort_groups() {
assert!(matches!(
PeerPin::from_error(&std::io::Error::from_raw_os_error(libc::ENOPROTOOPT)),
PeerPin::Unsupported
));
for errno in [
libc::EINVAL,
libc::ESRCH,
libc::EMFILE,
libc::ENFILE,
libc::EPERM,
] {
let pin = PeerPin::from_error(&std::io::Error::from_raw_os_error(errno));
assert!(matches!(pin, PeerPin::Unpinnable), "errno {errno}");
let groups = groups_for_pinned_peer(123, pin, || {
panic!("an unpinnable peer must not read a potentially recycled PID")
});
assert_eq!(highest_role_from_groups(groups), CallerRole::Observer);
}
let groups = groups_for_pinned_peer(123, PeerPin::Unsupported, || {
vec!["sysknife-admin".to_string()]
});
assert_eq!(highest_role_from_groups(groups), CallerRole::Admin);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn unpinnable_peer_keeps_only_its_primary_group() {
let (a, _b) = UnixStream::pair().unwrap();
let cred = a.peer_cred().unwrap();
let gid_map = read_gid_map();
let expected = highest_role_from_groups(gid_map.get(&cred.gid()));
let caller = resolve_caller_with_pin(&a, PeerPin::Unpinnable);
assert_eq!(caller.role(), expected);
assert_eq!(caller.principal(), CallerPrincipal::Uid(cred.uid()));
}
#[tokio::test]
async fn resolve_caller_attributes_a_unix_peer_to_its_kernel_reported_uid() {
let (a, _b) = UnixStream::pair().unwrap();
let caller = resolve_caller(&a);
assert_eq!(
caller.principal(),
CallerPrincipal::Uid(unsafe { libc::geteuid() }),
"a Unix-socket caller must be attributed to the peer uid SO_PEERCRED reports"
);
assert_eq!(
caller.principal().as_signed_str(),
format!("uid:{}", unsafe { libc::geteuid() }),
"and the signed form must carry the uid scheme"
);
}
#[test]
fn a_vsock_principal_does_not_claim_an_account() {
assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
assert!(
!CallerPrincipal::VsockToken
.as_signed_str()
.starts_with("uid:"),
"a token must never render as a uid; that would overstate the evidence"
);
}
#[tokio::test]
async fn resolve_caller_matches_the_peers_actual_groups() {
let (a, _b) = UnixStream::pair().unwrap();
let role = resolve_caller(&a).role();
let gid_map = read_gid_map();
let expected =
crate::auth::highest_role_from_groups(groups_for_pid(std::process::id(), &gid_map));
assert_eq!(
role, expected,
"the resolved role must be exactly what this process's groups justify"
);
}
#[tokio::test]
async fn resolve_caller_never_exceeds_observer_without_a_privileged_group() {
let (a, _b) = UnixStream::pair().unwrap();
let role = resolve_caller(&a).role();
let gid_map = read_gid_map();
let groups = groups_for_pid(std::process::id(), &gid_map);
let privileged = groups.iter().any(|g| {
matches!(
g.as_str(),
crate::auth::DEV_GROUP
| crate::auth::ADMIN_GROUP
| crate::auth::BOOT_GROUP
| crate::auth::WHEEL_GROUP
)
});
if !privileged {
assert_eq!(
role,
CallerRole::Observer,
"a process in no privileged group must resolve to Observer"
);
}
}
#[test]
fn first_request_deadline_is_much_tighter_than_the_idle_deadline() {
let first = read_deadline(0);
let idle = read_deadline(1);
assert!(
first < idle,
"pre-request deadline {first:?} must be tighter than the between-request \
deadline {idle:?}; otherwise a silent connection squats a permit for the \
full idle window"
);
assert_eq!(idle, IDLE_CONNECTION_TIMEOUT);
}
#[test]
fn served_connections_keep_the_full_idle_allowance() {
for served in 1..5 {
assert_eq!(
read_deadline(served),
IDLE_CONNECTION_TIMEOUT,
"request {served} must not be held to the pre-request deadline"
);
}
}
#[test]
fn redact_params_replaces_pro_attach_token() {
let params = json!({"token": "super-secret-test-only", "extra": "kept"});
let r = redact_params("ProAttach", ¶ms);
assert_eq!(r["token"].as_str(), Some("<REDACTED>"));
assert_eq!(r["extra"].as_str(), Some("kept"));
let s = serde_json::to_string(&r).unwrap();
assert!(
!s.contains("super-secret-test-only"),
"token leaked into proposed_change JSON: {s}"
);
}
#[test]
fn redact_params_replaces_configure_wifi_password() {
let params = json!({"ssid": "HomeNet", "password": "correct-horse-test-only"});
let r = redact_params("ConfigureWifi", ¶ms);
assert_eq!(r["password"].as_str(), Some("<REDACTED>"));
assert_eq!(r["ssid"].as_str(), Some("HomeNet"));
let s = serde_json::to_string(&r).unwrap();
assert!(
!s.contains("correct-horse-test-only"),
"wifi password leaked into proposed_change JSON: {s}"
);
}
#[test]
fn redact_argv_replaces_wifi_password_after_keyword() {
let params = json!({"ssid": "HomeNet", "password": "correct-horse-test-only"});
let argv = wifi_argv(&["HomeNet", "password", "correct-horse-test-only"]);
let r = redact_argv("ConfigureWifi", ¶ms, &argv);
assert_eq!(r.last().map(String::as_str), Some("<REDACTED>"));
assert!(
!r.iter().any(|a| a == "correct-horse-test-only"),
"wifi password survived argv redaction: {r:?}"
);
}
#[test]
fn redact_argv_leaves_open_network_argv_intact() {
let params = json!({"ssid": "CafeWifi"});
let argv = wifi_argv(&["CafeWifi"]);
let r = redact_argv("ConfigureWifi", ¶ms, &argv);
assert_eq!(r, argv, "open-network argv must be unchanged");
}
#[test]
fn redact_argv_handles_ssid_named_password() {
let params = json!({"ssid": "password", "password": "s3cret-test-only"});
let argv = wifi_argv(&["password", "password", "s3cret-test-only"]);
let r = redact_argv("ConfigureWifi", ¶ms, &argv);
assert!(
!r.iter().any(|a| a == "s3cret-test-only"),
"secret leaked when the SSID is named 'password': {r:?}"
);
assert_eq!(r.iter().filter(|a| *a == "password").count(), 2);
}
#[test]
fn redact_argv_handles_password_valued_password() {
let params = json!({"ssid": "HomeNet", "password": "password"});
let argv = wifi_argv(&["HomeNet", "password", "password"]);
let r = redact_argv("ConfigureWifi", ¶ms, &argv);
assert_eq!(r.last().map(String::as_str), Some("<REDACTED>"));
}
fn wifi_argv(tail: &[&str]) -> Vec<String> {
let mut argv: Vec<String> = ["nmcli", "device", "wifi", "connect"]
.iter()
.map(|s| (*s).to_string())
.collect();
argv.extend(tail.iter().map(|s| (*s).to_string()));
argv
}
#[test]
fn redact_params_passes_through_for_actions_with_no_credentials() {
let params = json!({"package": "vim"});
let r = redact_params("AptInstall", ¶ms);
assert_eq!(r["package"].as_str(), Some("vim"));
}
#[test]
fn redact_argv_replaces_argv_element_matching_token_value() {
let params = json!({"token": "super-secret-test-only"});
let argv = vec![
"pro".to_string(),
"attach".to_string(),
"super-secret-test-only".to_string(),
];
let r = redact_argv("ProAttach", ¶ms, &argv);
assert_eq!(r, vec!["pro", "attach", "<REDACTED>"]);
}
#[test]
fn redact_argv_passes_through_when_no_credentials() {
let params = json!({"package": "vim"});
let argv = vec![
"apt-get".to_string(),
"install".to_string(),
"vim".to_string(),
];
let r = redact_argv("AptInstall", ¶ms, &argv);
assert_eq!(r, argv);
}
#[test]
fn redact_argv_token_equal_to_attach_preserves_structural_attach() {
let params = json!({"token": "attach"});
let argv = vec![
"sudo".to_string(),
"pro".to_string(),
"attach".to_string(),
"attach".to_string(), ];
let r = redact_argv("ProAttach", ¶ms, &argv);
assert_eq!(
r,
vec!["sudo", "pro", "attach", "<REDACTED>"],
"structural 'attach' must survive; only the positional token gets redacted"
);
}
struct MockRunner;
impl CommandRunner for MockRunner {
fn run(&self, program: &str, _args: &[&str]) -> Result<String, io::Error> {
match program {
"hostname" => Ok("testhost\n".to_string()),
_ => Ok(String::new()),
}
}
}
fn test_state(dir: &tempfile::TempDir) -> DaemonState {
let db_path = dir.path().join("sysknife-test.db");
let sock_path = dir.path().join("sysknife-test.sock");
let config = DaemonConfig::new(ListenTarget::Unix(sock_path), db_path);
DaemonState::open(config).unwrap()
}
fn runner() -> Arc<dyn CommandRunner + Send + Sync> {
Arc::new(MockRunner)
}
fn str_set<const N: usize>(items: [&str; N]) -> std::collections::BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn autoremove_binding_accepts_the_same_set() {
let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a", "b"] });
assert!(verify_autoremove_binding(&pc, &str_set(["a", "b"])).is_ok());
}
#[test]
fn autoremove_binding_rejects_a_newly_added_package() {
let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a"] });
let err = verify_autoremove_binding(&pc, &str_set(["a", "linux-image-6.1"]))
.expect_err("a package apt would now remove but the operator did not approve");
assert!(err.contains("linux-image-6.1"), "{err}");
}
#[test]
fn autoremove_binding_rejects_a_no_longer_removed_package() {
let pc = json!({ AUTOREMOVE_REMOVALS_KEY: ["a", "b"] });
let err = verify_autoremove_binding(&pc, &str_set(["a"]))
.expect_err("the approved set no longer matches");
assert!(err.contains('b'), "{err}");
}
#[test]
fn autoremove_binding_refuses_a_preview_that_captured_no_set() {
let pc = json!({ "action": "AptAutoremove" });
assert!(verify_autoremove_binding(&pc, &str_set([])).is_err());
}
#[test]
fn autoremove_kernel_warning_flags_kernel_and_driver_packages() {
let set = str_set([
"linux-image-6.1",
"nvidia-driver-535",
"libfoo:amd64",
"vim",
]);
let w = autoremove_kernel_warning(&set).expect("kernel/driver packages must warn");
assert!(
w.contains("linux-image-6.1") && w.contains("nvidia-driver-535"),
"{w}"
);
assert!(!w.contains("vim") && !w.contains("libfoo"), "{w}");
}
#[test]
fn autoremove_kernel_warning_is_silent_for_ordinary_packages() {
assert!(autoremove_kernel_warning(&str_set(["libfoo", "oldlib"])).is_none());
}
struct ScriptedAutoremoveRunner {
outputs: Vec<Result<String, String>>,
calls: Arc<std::sync::atomic::AtomicUsize>,
}
impl ScriptedAutoremoveRunner {
fn with_outputs(
outputs: Vec<Result<String, String>>,
) -> (
Arc<dyn CommandRunner + Send + Sync>,
Arc<std::sync::atomic::AtomicUsize>,
) {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let runner = Arc::new(Self {
outputs,
calls: Arc::clone(&calls),
});
(runner, calls)
}
fn ok(
outputs: &[&str],
) -> (
Arc<dyn CommandRunner + Send + Sync>,
Arc<std::sync::atomic::AtomicUsize>,
) {
Self::with_outputs(outputs.iter().map(|s| Ok(s.to_string())).collect())
}
}
impl CommandRunner for ScriptedAutoremoveRunner {
fn run(&self, program: &str, args: &[&str]) -> Result<String, io::Error> {
if program == "hostname" {
return Ok("testhost\n".to_string());
}
if program == "apt-get" && args == ["-s", "autoremove"] {
let i = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let out = self.outputs.get(i).unwrap_or_else(|| {
panic!(
"ScriptedAutoremoveRunner: unexpected `apt-get -s autoremove` call #{i} \
(only {} scripted)",
self.outputs.len()
)
});
return out.clone().map_err(io::Error::other);
}
Ok(String::new())
}
}
#[tokio::test]
async fn preview_captures_the_autoremove_set_and_warns_on_kernel_packages() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let (runner, _calls) =
ScriptedAutoremoveRunner::ok(&["Remv linux-image-6.1 [6.1]\nRemv libfoo [1]\n"]);
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner,
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "r1",
"action_name": "AptAutoremove",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
let resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(resp["type"], "preview_response", "{resp}");
let removals = resp["preview"]["proposed_change"][AUTOREMOVE_REMOVALS_KEY]
.as_array()
.expect("the preview must capture the autoremove deletion set");
let names: Vec<&str> = removals.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
names.contains(&"linux-image-6.1") && names.contains(&"libfoo"),
"captured set wrong: {names:?}"
);
let warnings = resp["preview"]["warnings"].as_array().unwrap();
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap().contains("linux-image-6.1")),
"the kernel-package warning must be present: {warnings:?}"
);
}
#[tokio::test]
async fn execute_refuses_autoremove_when_the_deletion_set_drifted() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let (runner, calls) =
ScriptedAutoremoveRunner::ok(&["Remv pkga [1]\nRemv pkgb [1]\n", "Remv pkga [1]\n"]);
let calls_probe = Arc::clone(&calls);
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner,
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "r1",
"action_name": "AptAutoremove",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
let resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(resp["type"], "preview_response", "{resp}");
let txid = resp["transaction_id"].as_str().unwrap().to_string();
let receipt = approve_preview(&mut framed, &txid).await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "r2",
"transaction_id": txid,
"action_name": "AptAutoremove",
"params": {},
"approval_receipt": receipt
}))
.unwrap(),
)
.await
.unwrap();
let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(
exec["type"], "error_response",
"drift must be refused: {exec}"
);
assert_eq!(exec["category"], "stale_approval", "{exec}");
assert!(
exec["message"].as_str().unwrap().contains("changed since"),
"message should explain the drift: {exec}"
);
assert_eq!(
calls_probe.load(std::sync::atomic::Ordering::SeqCst),
2,
"apt-get -s autoremove must run once per phase"
);
}
#[derive(Default)]
struct RecordingExecutor {
called: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait::async_trait]
impl ActionExecutor for RecordingExecutor {
async fn execute(
&self,
_spec: &crate::actions::ActionSpec,
) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
unreachable!("dispatcher uses execute_with_progress")
}
async fn execute_with_progress(
&self,
_spec: &crate::actions::ActionSpec,
progress: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError> {
self.called
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _ = progress.send("executed".to_string());
Ok(crate::executor::ExecutionOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
})
}
}
async fn preview_autoremove(framed: &mut FramedStream<tokio::net::UnixStream>) -> Value {
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "r1",
"action_name": "AptAutoremove",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
serde_json::from_slice(&framed.recv().await.unwrap()).unwrap()
}
async fn send_execute_autoremove(
framed: &mut FramedStream<tokio::net::UnixStream>,
txid: &str,
receipt: &str,
) {
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "r2",
"transaction_id": txid,
"action_name": "AptAutoremove",
"params": {},
"approval_receipt": receipt
}))
.unwrap(),
)
.await
.unwrap();
}
#[tokio::test]
async fn execute_proceeds_when_the_autoremove_set_is_unchanged() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let (runner, _calls) =
ScriptedAutoremoveRunner::ok(&["Remv pkga [1]\n", "Remv pkga [1]\n"]);
let called = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let executor = Arc::new(RecordingExecutor {
called: Arc::clone(&called),
});
tokio::spawn(async move {
connection_handler_with_executor(
server,
state,
runner,
executor,
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut framed = FramedStream::new(client);
let resp = preview_autoremove(&mut framed).await;
assert_eq!(resp["type"], "preview_response", "{resp}");
let txid = resp["transaction_id"].as_str().unwrap().to_string();
let receipt = approve_preview(&mut framed, &txid).await;
send_execute_autoremove(&mut framed, &txid, &receipt).await;
loop {
let r: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
match r["type"].as_str().unwrap() {
"job_started" | "job_progress" => {}
"job_completed" => break,
other => panic!("an unchanged set must proceed, got {other}: {r}"),
}
}
assert_eq!(
called.load(std::sync::atomic::Ordering::SeqCst),
1,
"executor must run exactly once when the set is unchanged"
);
}
#[tokio::test]
async fn preview_that_cannot_simulate_captures_no_set_and_execute_refuses() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let (runner, _calls) = ScriptedAutoremoveRunner::with_outputs(vec![
Err("apt database lock held".to_string()),
Ok("Remv pkga [1]\n".to_string()),
]);
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner,
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut framed = FramedStream::new(client);
let resp = preview_autoremove(&mut framed).await;
assert_eq!(resp["type"], "preview_response", "{resp}");
assert!(
resp["preview"]["proposed_change"]
.get(AUTOREMOVE_REMOVALS_KEY)
.is_none(),
"a failed simulate must not capture a set: {resp}"
);
assert!(
resp["preview"]["warnings"]
.as_array()
.unwrap()
.iter()
.any(|w| w.as_str().unwrap().contains("Could not compute")),
"the operator must be warned: {resp}"
);
let txid = resp["transaction_id"].as_str().unwrap().to_string();
let receipt = approve_preview(&mut framed, &txid).await;
send_execute_autoremove(&mut framed, &txid, &receipt).await;
let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(exec["type"], "error_response", "{exec}");
assert_eq!(exec["category"], "stale_approval", "{exec}");
assert!(
exec["message"]
.as_str()
.unwrap()
.contains("did not capture"),
"must refuse an uncaptured set: {exec}"
);
}
#[tokio::test]
async fn execute_refuses_when_the_autoremove_re_simulate_fails() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let (runner, _calls) = ScriptedAutoremoveRunner::with_outputs(vec![
Ok("Remv pkga [1]\n".to_string()),
Err("apt database lock held".to_string()),
]);
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner,
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut framed = FramedStream::new(client);
let resp = preview_autoremove(&mut framed).await;
assert_eq!(resp["type"], "preview_response", "{resp}");
let txid = resp["transaction_id"].as_str().unwrap().to_string();
let receipt = approve_preview(&mut framed, &txid).await;
send_execute_autoremove(&mut framed, &txid, &receipt).await;
let exec: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(exec["type"], "error_response", "{exec}");
assert_eq!(exec["category"], "execution_failure", "{exec}");
assert!(
exec["message"]
.as_str()
.unwrap()
.contains("confirm the autoremove set"),
"{exec}"
);
}
#[test]
fn autoremove_binding_refuses_a_malformed_captured_set() {
let pc = json!({ AUTOREMOVE_REMOVALS_KEY: "not-an-array" });
let err = verify_autoremove_binding(&pc, &str_set(["a"]))
.expect_err("a malformed captured set must be refused");
assert!(err.contains("malformed"), "{err}");
}
async fn approve_preview(
framed: &mut FramedStream<tokio::net::UnixStream>,
transaction_id: &str,
) -> String {
framed
.send(
&serde_json::to_vec(&json!({
"type": "approve",
"request_id": format!("approve-{transaction_id}"),
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "approval_response");
response["approval_receipt"]
.as_str()
.expect("approval receipt")
.to_string()
}
async fn preview_and_approve(
framed: &mut FramedStream<tokio::net::UnixStream>,
action_name: &str,
params: Value,
) -> (String, String) {
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": format!("preview-{action_name}"),
"action_name": action_name,
"params": params,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "preview_response");
let transaction_id = response["transaction_id"].as_str().unwrap().to_string();
let receipt = approve_preview(framed, &transaction_id).await;
(transaction_id, receipt)
}
#[tokio::test]
async fn approval_details_returns_persisted_preview_before_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "details-preview",
"action_name": "GetMemoryInfo",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
let transaction_id = preview["transaction_id"].as_str().unwrap();
framed
.send(
&serde_json::to_vec(&json!({
"type": "approval_details",
"request_id": "details-read",
"transaction_id": transaction_id
}))
.unwrap(),
)
.await
.unwrap();
let details: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(details["type"], "approval_details_response");
assert_eq!(details["transaction_id"], transaction_id);
assert_eq!(details["action_name"], "GetMemoryInfo");
assert_eq!(details["preview"]["risk_level"], "low");
assert!(!details["preview"]["summary"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn approve_missing_transaction_maps_to_stale_approval() {
let dir = tempdir().unwrap();
let responses = exchange(
test_state(&dir),
CallerRole::Observer,
vec![json!({
"type": "approve",
"request_id": "approve-missing",
"transaction_id": "missing-transaction",
})],
1,
)
.await;
assert_eq!(responses[0]["type"], "error_response");
assert_eq!(responses[0]["category"], "stale_approval");
}
#[tokio::test]
async fn approving_same_transaction_twice_maps_second_to_stale_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let (transaction_id, _) =
preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "approve",
"request_id": "approve-again",
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "stale_approval");
}
#[tokio::test]
async fn approve_with_forged_commitment_maps_to_integrity_failure() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("sysknife-test.db");
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "preview-forge",
"action_name": "GetMemoryInfo",
"params": {},
}))
.unwrap(),
)
.await
.unwrap();
let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
rusqlite::Connection::open(&db_path)
.unwrap()
.execute(
"UPDATE transactions SET approval_id = 'forged' WHERE transaction_id = ?1",
rusqlite::params![transaction_id],
)
.unwrap();
framed
.send(
&serde_json::to_vec(&json!({
"type": "approve",
"request_id": "approve-forged",
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "integrity_failure");
}
#[tokio::test]
async fn query_history_returns_structured_rows_with_created_at_and_risk_level() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "preview-hist",
"action_name": "GetMemoryInfo",
"params": {},
}))
.unwrap(),
)
.await
.unwrap();
let _ = framed.recv().await.unwrap();
framed
.send(
&serde_json::to_vec(&json!({
"type": "query_history",
"request_id": "hist-1",
"limit": 10,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "history_response");
let entries = response["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["action_name"], "GetMemoryInfo");
assert!(
!entries[0]["created_at"].as_str().unwrap_or("").is_empty(),
"created_at must be populated over the structured IPC"
);
assert_eq!(
entries[0]["risk_level"], "low",
"GetSystemState is a low-risk action; risk_level must carry the exact typed value"
);
}
#[tokio::test]
async fn query_history_honours_policy_override_raising_history_above_observer() {
let dir = tempdir().unwrap();
let real = test_state(&dir);
let overrides =
std::collections::HashMap::from([("ListJobHistory".to_string(), "high".to_string())]);
let policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
let state = crate::state::DaemonState::open_with_audit(
real.config.clone(),
policy,
None,
real.audit.clone(),
);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "query_history",
"request_id": "hist-denied",
"limit": 10,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "authorization_failure");
}
#[tokio::test]
async fn cancel_queued_transaction_succeeds_and_marks_it_canceled() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let handler_state = state.clone();
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "preview-cancel",
"action_name": "GetMemoryInfo",
"params": {},
}))
.unwrap(),
)
.await
.unwrap();
let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
framed
.send(
&serde_json::to_vec(&json!({
"type": "cancel",
"request_id": "cancel-1",
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "cancel_response");
assert_eq!(
state
.audit
.get(&transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Canceled
);
}
#[tokio::test]
async fn cancel_running_transaction_is_rejected_and_left_running() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let handler_state = state.clone();
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let (transaction_id, receipt) =
preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
assert!(state
.audit
.claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
.await
.unwrap());
framed
.send(
&serde_json::to_vec(&json!({
"type": "cancel",
"request_id": "cancel-running",
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "not_cancelable");
assert_eq!(
state
.audit
.get(&transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Running,
"an in-flight action must not be disturbed by cancel"
);
}
#[tokio::test]
async fn cancel_missing_transaction_is_rejected() {
let dir = tempdir().unwrap();
let responses = exchange(
test_state(&dir),
CallerRole::Observer,
vec![json!({
"type": "cancel",
"request_id": "cancel-missing",
"transaction_id": "no-such-transaction",
})],
1,
)
.await;
assert_eq!(responses[0]["type"], "error_response");
assert_eq!(responses[0]["category"], "not_cancelable");
}
#[tokio::test]
async fn undelivered_approval_is_revoked_so_the_user_can_retry() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let handler_state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "preview-undelivered",
"action_name": "GetMemoryInfo",
"params": {},
}))
.unwrap(),
)
.await
.unwrap();
let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
let transaction_id = preview["transaction_id"].as_str().unwrap();
let (peer, broken_stream) = tokio::io::duplex(64);
drop(peer);
let mut broken_framed = FramedStream::new(broken_stream);
assert!(handle_approve(
&mut broken_framed,
&state,
&uid_caller(CallerRole::Admin),
"approve-undelivered",
transaction_id,
)
.await
.is_err());
let (retry_stream, receiver) = tokio::io::duplex(4096);
let mut retry_framed = FramedStream::new(retry_stream);
handle_approve(
&mut retry_framed,
&state,
&uid_caller(CallerRole::Admin),
"approve-retry",
transaction_id,
)
.await
.unwrap();
let mut receiver = FramedStream::new(receiver);
let response: Value = serde_json::from_slice(&receiver.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "approval_response");
}
#[tokio::test]
async fn approval_details_for_running_transaction_maps_to_stale_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let handler_state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let (transaction_id, receipt) =
preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
assert!(state
.audit
.claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
.await
.unwrap());
framed
.send(
&serde_json::to_vec(&json!({
"type": "approval_details",
"request_id": "details-running",
"transaction_id": transaction_id,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "stale_approval");
}
async fn exchange(
state: DaemonState,
role: CallerRole,
requests: Vec<Value>,
want_responses: usize,
) -> Vec<Value> {
exchange_as(state, uid_caller_with(1000, role), requests, want_responses).await
}
async fn exchange_as(
state: DaemonState,
caller: CallerAttribution,
requests: Vec<Value>,
want_responses: usize,
) -> Vec<Value> {
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(server, state, runner(), caller).await;
});
let mut framed = FramedStream::new(client);
for req in &requests {
let bytes = serde_json::to_vec(req).unwrap();
framed.send(&bytes).await.unwrap();
}
let mut responses = Vec::new();
for _ in 0..want_responses {
let raw = framed.recv().await.unwrap();
responses.push(serde_json::from_slice::<Value>(&raw).unwrap());
}
responses
}
#[tokio::test]
async fn query_state_returns_state_response() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({"type": "query_state", "request_id": "r1"})],
1,
)
.await;
assert_eq!(resps[0]["type"], "state_response");
assert_eq!(resps[0]["request_id"], "r1");
assert_eq!(resps[0]["state"]["host_name"], "testhost");
}
#[tokio::test]
async fn the_recorded_principal_is_the_one_the_connection_was_attributed_to() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let audit = std::sync::Arc::clone(&state.audit);
let resps = exchange_as(
state,
uid_caller_with(4242, CallerRole::Observer),
vec![json!({
"type": "preview",
"request_id": "r-principal",
"action_name": "GetMemoryInfo",
"params": {}
})],
1,
)
.await;
let transaction_id = resps[0]["transaction_id"].as_str().unwrap();
let row = audit
.fetch_chain_row(transaction_id)
.await
.expect("fetch the row just written")
.expect("preview records a transaction");
assert_eq!(
row.caller_principal.as_deref(),
Some("uid:4242"),
"the row must name the account resolved for this connection, not a default"
);
assert_eq!(row.chain_version, crate::audit_chain::CHAIN_VERSION_CURRENT);
}
#[tokio::test]
async fn preview_returns_hash_and_transaction_id() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "preview",
"request_id": "r1",
"action_name": "GetMemoryInfo",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "preview_response");
assert_eq!(resps[0]["request_id"], "r1");
let hash = resps[0]["preview"]["request_hash"].as_str().unwrap();
assert_eq!(
hash.len(),
64,
"request_hash should be a 64-char hex SHA-256"
);
assert!(
!resps[0]["transaction_id"].as_str().unwrap().is_empty(),
"transaction_id must be set"
);
}
#[tokio::test]
async fn preview_hash_is_deterministic() {
let hash1 = compute_request_hash(
"InstallFlatpak",
&json!({"app_id": "org.gnome.Builder", "remote": "flathub"}),
);
let hash2 = compute_request_hash(
"InstallFlatpak",
&json!({"remote": "flathub", "app_id": "org.gnome.Builder"}),
);
assert_eq!(hash1, hash2, "canonical JSON must sort keys");
}
#[test]
fn canonical_json_recurses_into_arrays() {
let hash1 =
compute_request_hash("InstallPackages", &json!({"packages": [{"b": 1, "a": 2}]}));
let hash2 =
compute_request_hash("InstallPackages", &json!({"packages": [{"a": 2, "b": 1}]}));
assert_eq!(hash1, hash2, "nested object keys in arrays must be sorted");
}
#[test]
fn canonical_json_preserves_array_element_order() {
let hash1 = compute_request_hash("Op", &json!({"items": ["a", "b"]}));
let hash2 = compute_request_hash("Op", &json!({"items": ["b", "a"]}));
assert_ne!(hash1, hash2, "array element order must be preserved");
}
#[tokio::test]
async fn high_risk_action_rejected_for_observer() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer, vec![json!({
"type": "preview",
"request_id": "r1",
"action_name": "UpdateSystem",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "authorization_failure");
}
#[tokio::test]
async fn under_privileged_caller_cannot_act_on_admin_transaction() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 41 });
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let handler_state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "preview-admin",
"action_name": "UpdateSystem",
"params": {},
}))
.unwrap(),
)
.await
.unwrap();
let preview: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(preview["type"], "preview_response");
let transaction_id = preview["transaction_id"].as_str().unwrap().to_string();
for (request_id, verb) in [
("obs-approve", "approve"),
("obs-cancel", "cancel"),
("obs-details", "details"),
] {
let (stream, rx) = tokio::io::duplex(4096);
let mut caller = FramedStream::new(stream);
match verb {
"approve" => handle_approve(
&mut caller,
&state,
&uid_caller(CallerRole::Observer),
request_id,
&transaction_id,
)
.await
.unwrap(),
"cancel" => handle_cancel(
&mut caller,
&state,
&uid_caller(CallerRole::Observer),
request_id,
&transaction_id,
)
.await
.unwrap(),
_ => handle_approval_details(
&mut caller,
&state,
&uid_caller(CallerRole::Observer),
request_id,
&transaction_id,
)
.await
.unwrap(),
}
let mut rx = FramedStream::new(rx);
let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
assert_eq!(resp["type"], "error_response", "{verb} must be refused");
assert_eq!(
resp["category"], "authorization_failure",
"{verb} refusal must be an authorization failure"
);
}
let (stream, rx) = tokio::io::duplex(4096);
let mut admin = FramedStream::new(stream);
handle_approve(
&mut admin,
&state,
&uid_caller(CallerRole::Admin),
"admin-approve",
&transaction_id,
)
.await
.unwrap();
let mut rx = FramedStream::new(rx);
let resp: Value = serde_json::from_slice(&rx.recv().await.unwrap()).unwrap();
assert_eq!(resp["type"], "approval_response");
}
#[tokio::test]
async fn medium_risk_action_rejected_for_observer() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer, vec![json!({
"type": "preview",
"request_id": "r1",
"action_name": "InstallFlatpak",
"params": {"username": "alice", "app_id": "org.gnome.Builder", "remote": "flathub"}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "authorization_failure");
}
#[tokio::test]
async fn low_risk_action_allowed_for_observer() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "preview",
"request_id": "r1",
"action_name": "GetMemoryInfo",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "preview_response");
}
#[test]
fn unsupported_distros_cannot_reach_mutation_paths() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::UbuntuCore {
major: 24,
minor: 4,
});
assert!(validate_action_platform(&state, "AptInstall").is_err());
state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
major: 18,
minor: 4,
});
assert!(validate_action_platform(&state, "AptUpgrade").is_err());
for (major, minor) in [(20, 4), (25, 10), (26, 4)] {
state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu { major, minor });
assert!(
validate_action_platform(&state, "AptUpgrade").is_ok(),
"Ubuntu {major}.{minor:02} must be able to run mutating actions"
);
}
}
#[test]
fn a_low_risk_action_that_mutates_is_not_treated_as_a_read() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::Debian { version: Some(12) });
assert!(
!state.host_distro.as_ref().unwrap().is_supported(),
"this test needs an ineligible host"
);
assert_eq!(
crate::policy::min_role_for_action("AptUpdate"),
Some(CallerRole::Observer),
"AptUpdate is the action that makes the role a bad proxy for mutation"
);
assert!(
validate_action_platform(&state, "AptUpdate").is_err(),
"a privileged mutation must stay refused on an ineligible host"
);
}
#[test]
fn raising_a_read_only_action_via_risk_overrides_does_not_arm_the_platform_fence() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = None;
let overrides =
std::collections::HashMap::from([("GetDiskUsage".to_string(), "high".to_string())]);
state.policy = crate::policy::PolicyTable::from_overrides(&overrides).unwrap();
assert_eq!(
state.policy.min_role_for_action("GetDiskUsage"),
Some(CallerRole::Admin),
"the override still governs authorization"
);
assert!(
validate_action_platform(&state, "GetDiskUsage").is_ok(),
"a read-only action stays exempt from the distro fence"
);
}
#[test]
fn supported_hosts_still_enforce_action_family() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
major: 24,
minor: 4,
});
assert!(validate_action_platform(&state, "AptInstall").is_ok());
assert!(validate_action_platform(&state, "AddLayeredPackage").is_err());
}
#[tokio::test]
async fn query_action_refuses_an_action_the_host_family_cannot_run() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
major: 24,
minor: 4,
});
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "query_action",
"request_id": "r1",
"action_name": "GetLayeredPackages",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "unsupported_platform");
}
#[tokio::test]
async fn describe_refuses_an_action_the_caller_is_not_allowed_to_run() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "describe",
"request_id": "r1",
"action_name": "GrantSudoAccess",
"params": {"username": "alice", "commands": "/usr/bin/ls", "nopasswd": false}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "authorization_failure");
}
#[tokio::test]
async fn describe_refuses_an_action_the_host_family_cannot_run() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::Ubuntu {
major: 24,
minor: 4,
});
let resps = exchange(
state,
CallerRole::Admin,
vec![json!({
"type": "describe",
"request_id": "r1",
"action_name": "AddLayeredPackage",
"params": {"package": "vim"}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "unsupported_platform");
}
#[tokio::test]
async fn describe_still_answers_for_an_allowed_action() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "describe",
"request_id": "r1",
"action_name": "GetDiskUsage",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "describe_response");
}
#[tokio::test]
async fn execute_without_prior_preview_returns_stale_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "execute",
"request_id": "r1",
"transaction_id": "missing-transaction",
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": "unissued-receipt"
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "stale_approval");
}
#[tokio::test]
async fn execute_with_changed_params_rejects_without_consuming_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let handler_state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let (transaction_id, receipt) = preview_and_approve(
&mut framed,
"GetServiceStatus",
json!({"unit": "sshd.service"}),
)
.await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "changed-params",
"transaction_id": transaction_id,
"action_name": "GetServiceStatus",
"params": {"unit": "cron.service"},
"approval_receipt": receipt,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "stale_approval");
assert_eq!(
state
.audit
.get(&transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Queued
);
assert!(state
.audit
.claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
.await
.unwrap());
}
#[tokio::test]
async fn execute_with_changed_action_rejects_without_consuming_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let handler_state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let (transaction_id, receipt) =
preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "changed-action",
"transaction_id": transaction_id,
"action_name": "GetDiskUsage",
"params": {},
"approval_receipt": receipt,
}))
.unwrap(),
)
.await
.unwrap();
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
assert_eq!(response["type"], "error_response");
assert_eq!(response["category"], "stale_approval");
assert_eq!(
state
.audit
.get(&transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Queued
);
assert!(state
.audit
.claim_approved_for_execution(&transaction_id, &receipt_digest(&receipt))
.await
.unwrap());
}
#[tokio::test]
async fn job_started_send_failure_releases_slot_and_marks_transaction_failed() {
let dir = tempdir().unwrap();
let mut state = test_state(&dir);
state.host_distro = Some(sysknife_core::distro::DistroId::FedoraSilverblue { version: 44 });
let (client, server) = tokio::net::UnixStream::pair().unwrap();
let handler_state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
server,
handler_state,
runner(),
uid_caller_with(1000, CallerRole::Admin),
)
.await;
});
let mut preview_stream = FramedStream::new(client);
let params = json!({"package": "vim"});
let (transaction_id, receipt) =
preview_and_approve(&mut preview_stream, "AddLayeredPackage", params.clone()).await;
let (peer, broken_stream) = tokio::io::duplex(64);
drop(peer);
let mut broken_framed = FramedStream::new(broken_stream);
let typed_transaction_id = TransactionId(transaction_id.clone());
let typed_receipt = ApprovalReceipt(receipt);
let result = handle_execute(
&mut broken_framed,
&state,
Arc::new(crate::executor::RealActionExecutor),
runner(),
&uid_caller(CallerRole::Admin),
"execute-disconnected",
&typed_transaction_id,
"AddLayeredPackage",
¶ms,
&typed_receipt,
)
.await;
assert!(result.is_err(), "the initial response write must fail");
assert!(
state.running_exclusive.lock().await.is_empty(),
"a disconnected client must not retain any exclusion lock"
);
assert_eq!(
state
.audit
.get(&transaction_id)
.await
.unwrap()
.unwrap()
.status,
JobState::Failed,
"a claimed transaction must not remain Running when execution never starts"
);
}
#[tokio::test]
async fn execute_after_preview_with_receipt_returns_job_completed() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let preview_req = json!({
"type": "preview",
"request_id": "r1",
"action_name": "GetMemoryInfo",
"params": {}
});
framed
.send(&serde_json::to_vec(&preview_req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(preview_resp["type"], "preview_response");
let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
let receipt = approve_preview(&mut framed, transaction_id).await;
let exec_req = json!({
"type": "execute",
"request_id": "r2",
"transaction_id": transaction_id,
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": receipt
});
framed
.send(&serde_json::to_vec(&exec_req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let started: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(started["type"], "job_started");
let job_id = started["job_id"].as_str().unwrap().to_string();
loop {
let raw = framed.recv().await.unwrap();
let msg: Value = serde_json::from_slice(&raw).unwrap();
let msg_type = msg["type"].as_str().unwrap();
match msg_type {
"job_progress" => {
assert_eq!(msg["job_id"], job_id);
}
"job_completed" => {
assert_eq!(msg["job_id"], job_id);
let status = msg["result"]["status"].as_str().unwrap();
assert!(
matches!(status, "succeeded" | "failed" | "needs_reboot"),
"unexpected status: {status}"
);
break;
}
other => panic!("unexpected message type: {other}"),
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_status_failure_surfaces_as_audit_warning_in_job_completed() {
use crate::audit_chain::{AuditKey, ChainRow, VerifyOutcome};
use crate::store::AuditStore;
use crate::transactions::{
NewTransaction as NewTx, RecordedPreviewedTransaction, TransactionStoreError,
};
use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
#[derive(Debug)]
struct UpdateStatusFails(std::sync::Arc<dyn AuditStore>);
#[async_trait::async_trait]
impl AuditStore for UpdateStatusFails {
async fn record(&self, t: NewTx) -> Result<TransactionRecord, TransactionStoreError> {
self.0.record(t).await
}
async fn record_previewed(
&self,
t: NewTx,
p: PreviewEnvelope,
) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
self.0.record_previewed(t, p).await
}
async fn get(
&self,
id: &str,
) -> Result<Option<TransactionRecord>, TransactionStoreError> {
self.0.get(id).await
}
async fn get_preview(
&self,
id: &str,
) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
self.0.get_preview(id).await
}
async fn update_status(
&self,
id: &str,
_s: JobState,
) -> Result<(), TransactionStoreError> {
Err(TransactionStoreError::NotFound(id.to_string()))
}
async fn approve_transaction(
&self,
id: &str,
) -> Result<Option<String>, TransactionStoreError> {
self.0.approve_transaction(id).await
}
async fn revoke_unconsumed_approval(
&self,
id: &str,
) -> Result<bool, TransactionStoreError> {
self.0.revoke_unconsumed_approval(id).await
}
async fn claim_approved_for_execution(
&self,
id: &str,
digest: &str,
) -> Result<bool, TransactionStoreError> {
self.0.claim_approved_for_execution(id, digest).await
}
async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
self.0.cleanup_stale_queued().await
}
async fn cancel_queued(&self, id: &str) -> Result<bool, TransactionStoreError> {
self.0.cancel_queued(id).await
}
async fn list_transactions(
&self,
limit: u32,
status: Option<&str>,
action: Option<&str>,
since: Option<u32>,
) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
self.0.list_transactions(limit, status, action, since).await
}
async fn list_history(
&self,
limit: u32,
status: Option<&str>,
action: Option<&str>,
since: Option<u32>,
) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>
{
self.0.list_history(limit, status, action, since).await
}
async fn fetch_chain_row(
&self,
id: &str,
) -> Result<Option<ChainRow>, TransactionStoreError> {
self.0.fetch_chain_row(id).await
}
async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
self.0.fetch_chain_rows().await
}
async fn fetch_event_rows(
&self,
) -> Result<Vec<crate::audit_chain::EventRow>, TransactionStoreError> {
self.0.fetch_event_rows().await
}
async fn verify_audit_chain(
&self,
k: &AuditKey,
) -> Result<VerifyOutcome, TransactionStoreError> {
self.0.verify_audit_chain(k).await
}
}
let dir = tempdir().unwrap();
let real = test_state(&dir);
let faulty: std::sync::Arc<dyn AuditStore> =
std::sync::Arc::new(UpdateStatusFails(real.audit.clone()));
let cfg = real.config.clone();
let state = crate::state::DaemonState::open_with_audit(
cfg,
crate::policy::PolicyTable::empty(),
None,
faulty,
);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "r1",
"action_name": "GetMemoryInfo",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
let preview_resp: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
let transaction_id = preview_resp["transaction_id"].as_str().unwrap();
let receipt = approve_preview(&mut framed, transaction_id).await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "r2",
"transaction_id": transaction_id,
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": receipt
}))
.unwrap(),
)
.await
.unwrap();
let mut completed: Option<Value> = None;
for _ in 0..30 {
let raw = framed.recv().await.unwrap();
let msg: Value = serde_json::from_slice(&raw).unwrap();
if msg["type"] == "job_completed" {
completed = Some(msg);
break;
}
}
let completed = completed.expect("dispatcher must emit job_completed");
let warnings = completed["result"]["warnings"]
.as_array()
.expect("warnings array on job_completed");
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap_or("").contains("audit trail update failed")),
"expected an `audit trail update failed` warning when update_status returns Err; got: {warnings:#?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_executes_against_one_preview_serialise_to_one_winner() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (pc, ps) = tokio::net::UnixStream::pair().unwrap();
{
let state = state.clone();
tokio::spawn(async move {
unix_connection_handler(
ps,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
}
let mut framed_p = FramedStream::new(pc);
framed_p
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "preview-once",
"action_name": "GetMemoryInfo",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
let raw = framed_p.recv().await.unwrap();
let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
let approval_receipt = approve_preview(&mut framed_p, &transaction_id).await;
async fn exec_once(
state: crate::state::DaemonState,
transaction_id: String,
approval_receipt: String,
request_id: &'static str,
) -> Vec<Value> {
let (c, s) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
s,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut f = FramedStream::new(c);
f.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": request_id,
"transaction_id": transaction_id,
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": approval_receipt
}))
.unwrap(),
)
.await
.unwrap();
let mut frames = Vec::new();
for _ in 0..30 {
let raw = match f.recv().await {
Ok(b) => b,
Err(_) => break,
};
let v: Value = serde_json::from_slice(&raw).unwrap();
let kind = v["type"].as_str().unwrap_or("").to_string();
frames.push(v);
if kind == "job_completed" || kind == "error_response" {
break;
}
}
frames
}
let (a, b) = tokio::join!(
exec_once(
state.clone(),
transaction_id.clone(),
approval_receipt.clone(),
"exec-A"
),
exec_once(
state.clone(),
transaction_id.clone(),
approval_receipt.clone(),
"exec-B"
),
);
let terminal = |frames: &[Value]| -> &'static str {
for f in frames {
match f["type"].as_str().unwrap_or("") {
"job_completed" => return "win",
"error_response" => return "lose",
_ => {}
}
}
"neither"
};
let outcomes = [terminal(&a), terminal(&b)];
let mut sorted = outcomes;
sorted.sort();
assert_eq!(
sorted,
["lose", "win"],
"exactly one execute must win and one must lose; got {outcomes:?}\n A={a:#?}\n B={b:#?}"
);
let loser = if outcomes[0] == "lose" { &a } else { &b };
let err_frame = loser
.iter()
.find(|f| f["type"] == "error_response")
.expect("loser has an error_response");
assert_eq!(
err_frame["category"], "stale_approval",
"loser must be classified stale_approval; got {err_frame:#?}"
);
}
#[tokio::test]
async fn re_executing_a_completed_approval_returns_stale_approval() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
unix_connection_handler(
server,
state,
runner(),
uid_caller_with(1000, CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
framed
.send(
&serde_json::to_vec(&json!({
"type": "preview",
"request_id": "r1",
"action_name": "GetMemoryInfo",
"params": {}
}))
.unwrap(),
)
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let preview_resp: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(preview_resp["type"], "preview_response");
let transaction_id = preview_resp["transaction_id"].as_str().unwrap().to_string();
let approval_receipt = approve_preview(&mut framed, &transaction_id).await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "r2",
"transaction_id": transaction_id,
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": approval_receipt
}))
.unwrap(),
)
.await
.unwrap();
loop {
let raw = framed.recv().await.unwrap();
let msg: Value = serde_json::from_slice(&raw).unwrap();
match msg["type"].as_str().unwrap() {
"job_started" | "job_progress" => continue,
"job_completed" => break,
other => panic!("unexpected message during first execute: {other}"),
}
}
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "r3-replay",
"transaction_id": transaction_id,
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": approval_receipt
}))
.unwrap(),
)
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let resp: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(
resp["type"], "error_response",
"replay must produce error_response, got: {resp}"
);
assert_eq!(
resp["category"], "stale_approval",
"replay must be classified as stale_approval, got: {}",
resp["category"]
);
assert_eq!(resp["request_id"], "r3-replay");
}
#[tokio::test]
async fn describe_returns_command_and_risk_for_known_action() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "describe",
"request_id": "r1",
"action_name": "GetDateTime",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "describe_response");
assert_eq!(resps[0]["request_id"], "r1");
assert_eq!(resps[0]["command"], "timedatectl");
assert_eq!(resps[0]["risk_level"], "low");
assert_eq!(resps[0]["reboot_required"], false);
}
#[tokio::test]
async fn describe_returns_error_for_unknown_action() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({
"type": "describe",
"request_id": "r1",
"action_name": "NotARealAction",
"params": {}
})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "validation_failure");
}
#[tokio::test]
async fn unknown_message_type_returns_validation_failure() {
let dir = tempdir().unwrap();
let state = test_state(&dir);
let resps = exchange(
state,
CallerRole::Observer,
vec![json!({"type": "does_not_exist", "request_id": "r1"})],
1,
)
.await;
assert_eq!(resps[0]["type"], "error_response");
assert_eq!(resps[0]["category"], "validation_failure");
}
#[tokio::test]
async fn fast_executor_progress_is_drained_before_job_completed() {
struct FastProgressExecutor;
#[async_trait::async_trait]
impl ActionExecutor for FastProgressExecutor {
async fn execute(
&self,
_spec: &crate::actions::ActionSpec,
) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
{
unreachable!("dispatcher uses execute_with_progress")
}
async fn execute_with_progress(
&self,
_spec: &crate::actions::ActionSpec,
progress: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<crate::executor::ExecutionOutput, crate::executor::ExecutorError>
{
progress.send("last line before exit".to_string()).unwrap();
Ok(crate::executor::ExecutionOutput {
stdout: "last line before exit\n".to_string(),
stderr: String::new(),
exit_code: 0,
})
}
}
let dir = tempdir().unwrap();
let state = test_state(&dir);
let (client, server) = tokio::net::UnixStream::pair().unwrap();
tokio::spawn(async move {
connection_handler_with_executor(
server,
state,
runner(),
Arc::new(FastProgressExecutor),
uid_caller(CallerRole::Observer),
)
.await;
});
let mut framed = FramedStream::new(client);
let (transaction_id, receipt) =
preview_and_approve(&mut framed, "GetMemoryInfo", json!({})).await;
framed
.send(
&serde_json::to_vec(&json!({
"type": "execute",
"request_id": "execute-fast-progress",
"transaction_id": transaction_id,
"action_name": "GetMemoryInfo",
"params": {},
"approval_receipt": receipt,
}))
.unwrap(),
)
.await
.unwrap();
let mut progress_lines = Vec::new();
loop {
let response: Value = serde_json::from_slice(&framed.recv().await.unwrap()).unwrap();
match response["type"].as_str().unwrap() {
"job_progress" => {
progress_lines.push(response["line"].as_str().unwrap().to_string())
}
"job_started" => {}
"job_completed" => break,
other => panic!("unexpected response: {other}"),
}
}
assert!(progress_lines
.iter()
.any(|line| line == "last line before exit"));
}
#[cfg(target_os = "linux")]
mod vsock_auth {
use super::*;
use std::sync::Arc;
fn executor() -> Arc<dyn ActionExecutor> {
Arc::new(crate::executor::RealActionExecutor)
}
fn write_token_mode(path: &std::path::Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
#[tokio::test]
async fn group_or_world_readable_token_file_is_rejected() {
let dir = tempdir().unwrap();
let token_path = dir.path().join("token");
std::fs::write(&token_path, "test-secret").unwrap();
write_token_mode(&token_path, 0o644);
assert!(
crate::auth::validate_token_against_file("test-secret", &token_path).is_none(),
"a 0644 token file must be rejected even when the token matches"
);
write_token_mode(&token_path, 0o600);
assert!(
crate::auth::validate_token_against_file("test-secret", &token_path).is_some(),
"a 0600 token file with a matching token must be accepted"
);
}
#[tokio::test]
async fn valid_token_grants_access() {
let dir = tempdir().unwrap();
let token_path = dir.path().join("token");
std::fs::write(&token_path, "test-secret").unwrap();
write_token_mode(&token_path, 0o600);
let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
let state = test_state(&dir);
tokio::spawn(async move {
let mut framed = FramedStream::new(server);
let role = authenticate_vsock_token(&mut framed, &token_path).await;
if let Some(r) = role {
dispatch_loop(
&mut framed,
state,
runner(),
executor(),
CallerAttribution::from_vsock_token(r),
)
.await;
}
});
let mut framed = FramedStream::new(client);
let auth = serde_json::json!({"type": "auth", "token": "test-secret"});
framed
.send(&serde_json::to_vec(&auth).unwrap())
.await
.unwrap();
let req = serde_json::json!({"type": "query_state", "request_id": "vsock-r1"});
framed
.send(&serde_json::to_vec(&req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(resp["type"], "state_response");
}
#[tokio::test]
async fn wrong_token_closes_connection() {
let dir = tempdir().unwrap();
let token_path = dir.path().join("token");
std::fs::write(&token_path, "correct").unwrap();
write_token_mode(&token_path, 0o600);
let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
let state = test_state(&dir);
tokio::spawn(async move {
let mut framed = FramedStream::new(server);
let _ = authenticate_vsock_token(&mut framed, &token_path).await;
drop(state);
});
let mut framed = FramedStream::new(client);
let auth = serde_json::json!({"type": "auth", "token": "wrong"});
framed
.send(&serde_json::to_vec(&auth).unwrap())
.await
.unwrap();
let result = framed.recv().await;
assert!(
result.is_err(),
"expected EOF after wrong token, got {:?}",
result
);
}
#[tokio::test]
async fn eof_before_auth_frame_closes_connection() {
let dir = tempdir().unwrap();
let token_path = dir.path().join("token");
std::fs::write(&token_path, "secret").unwrap();
write_token_mode(&token_path, 0o600);
let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
tokio::spawn(async move {
let mut framed = FramedStream::new(server);
let result = authenticate_vsock_token(&mut framed, &token_path).await;
assert!(result.is_none());
});
drop(client);
}
#[tokio::test]
async fn malformed_json_frame_closes_connection() {
let dir = tempdir().unwrap();
let token_path = dir.path().join("token");
std::fs::write(&token_path, "secret").unwrap();
write_token_mode(&token_path, 0o600);
let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
let handle = tokio::spawn(async move {
let mut framed = FramedStream::new(server);
let result = authenticate_vsock_token(&mut framed, &token_path).await;
assert!(result.is_none());
});
let mut framed = FramedStream::new(client);
framed.send(b"not json at all {{{{").await.unwrap();
drop(framed);
handle.await.unwrap();
}
#[tokio::test]
async fn wrong_msg_type_closes_connection() {
let dir = tempdir().unwrap();
let token_path = dir.path().join("token");
std::fs::write(&token_path, "secret").unwrap();
write_token_mode(&token_path, 0o600);
let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
let handle = tokio::spawn(async move {
let mut framed = FramedStream::new(server);
let result = authenticate_vsock_token(&mut framed, &token_path).await;
assert!(result.is_none());
});
let mut framed = FramedStream::new(client);
let bad = serde_json::json!({"type": "query_state", "token": "secret"});
framed
.send(&serde_json::to_vec(&bad).unwrap())
.await
.unwrap();
drop(framed);
handle.await.unwrap();
}
}
#[tokio::test]
async fn connection_handler_accepts_duplex_stream() {
let (client, server) = tokio::io::duplex(4 * 1024 * 1024);
let dir = tempdir().unwrap();
let state = test_state(&dir);
tokio::spawn(unix_connection_handler(
server,
state,
runner(),
uid_caller(CallerRole::Observer),
));
let mut framed = FramedStream::new(client);
let req = json!({"type": "query_state", "request_id": "duplex-r1"});
framed
.send(&serde_json::to_vec(&req).unwrap())
.await
.unwrap();
let raw = framed.recv().await.unwrap();
let resp: serde_json::Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(resp["type"], "state_response");
assert_eq!(resp["request_id"], "duplex-r1");
}
}