Skip to main content

sysknife_types/
lib.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::convert::TryFrom;
4
5use sysknife_proto::sysknife::v1 as proto;
6
7// ---------------------------------------------------------------------------
8// Framing / wire protocol
9// ---------------------------------------------------------------------------
10
11/// Maximum size of a single message exchanged over a sysknife socket, in bytes
12/// (4 MiB).
13///
14/// As a wire fact, the frame limit belongs to the protocol crate that every
15/// side already depends on, rather than to any one executor (the daemon), so
16/// that the daemon and the CLI agree on it without a dependency edge from a
17/// consumer up to a privileged producer. Duplicate declarations of the limit
18/// elsewhere are guarded against by `tests::frame_limit_is_declared_once`.
19pub const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
20
21// ---------------------------------------------------------------------------
22// DistroHint — planner-facing snapshot of the running distro
23// ---------------------------------------------------------------------------
24
25/// Planner-facing distro snapshot injected into the system prompt.
26///
27/// This is a deliberately lightweight type: it captures only what the planner
28/// needs to pick the right action family (`family`), distinguish Ubuntu-only
29/// mechanisms (`id`), and produce human-readable output (`version`). Detection and the full
30/// `DistroId` enum stay in `sysknife-core`; the CLI converts `DistroId` →
31/// `DistroHint` at startup so the brain never depends on `sysknife-core`.
32///
33/// # Design rationale
34///
35/// Moving `DistroId` to `sysknife-types` would add proto-bridge boilerplate
36/// and force every crate that imports types to compile the detection logic.
37/// A thin snapshot type is smaller-diff and still gives the planner everything
38/// it needs.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct DistroHint {
41    /// Detected distribution ID, e.g. `ubuntu` or `debian`. Family membership
42    /// alone cannot authorize an Ubuntu-only mechanism. Never derive this from
43    /// the human-readable version string.
44    pub id: String,
45    /// Broad distro family: `"fedora"`, `"debian"`, or `"other"`.
46    ///
47    /// Use `DISTRO_FAMILY_FEDORA`, `DISTRO_FAMILY_DEBIAN`, and
48    /// `DISTRO_FAMILY_OTHER` for comparison to avoid magic strings.
49    pub family: &'static str,
50    /// Human-readable version string, e.g. `"Fedora 41"`, `"Ubuntu 24.04"`.
51    /// `None` when the version could not be determined.
52    pub version: Option<String>,
53}
54
55/// Family label for Fedora-family distros (Fedora, FedoraSilverblue, etc.).
56pub const DISTRO_FAMILY_FEDORA: &str = "fedora";
57
58/// Family label for Debian-family distros (Ubuntu, Debian, etc.).
59pub const DISTRO_FAMILY_DEBIAN: &str = "debian";
60
61/// Family label for distros that do not fit into a known family.
62pub const DISTRO_FAMILY_OTHER: &str = "other";
63
64// ---------------------------------------------------------------------------
65// ActionName — validated action catalogue primitive
66// ---------------------------------------------------------------------------
67
68/// Canonical names of every action in the SysKnife catalogue.
69///
70/// This is the single source of truth for "is this string a valid action
71/// name?" — kept in `sysknife-types` (not `sysknife-brain`) so every crate
72/// that validates an action name (the daemon's `policy::min_role_for_action`,
73/// the CLI's approval flow, the proto bridge) sees the same list without
74/// depending on the brain.
75///
76/// Note: [`RequestEnvelope::action_name`] itself is a plain `String` — it is
77/// **not** validated against this list at deserialization time; that would
78/// require a custom `Deserialize` impl this type does not have. Validation
79/// happens downstream, via [`ActionName::parse`] against the value once it
80/// has been pulled out of the envelope (the daemon does this before acting
81/// on a request).
82///
83/// **Cross-module invariant:** `sysknife_brain::propose_plan::KNOWN_ACTIONS`
84/// (which pairs each name with an LLM-facing description) MUST list every
85/// name that appears here, in either order.  The brain has a unit test
86/// (`every_known_action_name_is_in_types`) that pins this — adding an
87/// action requires a coordinated update to both lists.
88pub const KNOWN_ACTION_NAMES: &[&str] = &[
89    "GetSystemState",
90    "CollectDiagnostics",
91    "GetDeploymentHistory",
92    "ListDeployments",
93    "UpdateSystem",
94    "CleanupDeployments",
95    "RebootSystem",
96    "RollbackDeployment",
97    "GetKernelArguments",
98    "PinDeployment",
99    "UnpinDeployment",
100    "RebaseSystem",
101    "SetKernelArguments",
102    "InstallFlatpak",
103    "RemoveFlatpak",
104    "UpdateFlatpak",
105    "SearchFlatpakApps",
106    "ListFlatpakRemotes",
107    "ListInstalledFlatpaks",
108    "AddFlatpakRemote",
109    "RemoveFlatpakRemote",
110    "GetFlatpakAppInfo",
111    "ListToolboxes",
112    "CreateToolbox",
113    "RemoveToolbox",
114    "GetLayeredPackages",
115    "ResetLayeredPackageOverride",
116    "GetPendingUpdates",
117    "InstallPackages",
118    "RemovePackages",
119    "AddLayeredPackage",
120    "RemoveLayeredPackage",
121    "ReplaceLayeredPackage",
122    "RemoveBasePackage",
123    "ListServices",
124    "ListTimers",
125    "ReloadDaemon",
126    "CreateScheduledJob",
127    "StartService",
128    "StopService",
129    "RestartService",
130    "ReloadService",
131    "SetServiceEnabled",
132    "MaskService",
133    "UnmaskService",
134    "GetServiceLogs",
135    "GetServiceStatus",
136    "GetServiceResourceLimits",
137    "SetServiceResourceLimits",
138    "GetFirewallState",
139    "GetNetworkStatus",
140    "GetListeningPorts",
141    "GetNftablesRuleset",
142    "GetFirewallBackendState",
143    "ConfigureWifi",
144    "SetDnsServers",
145    "ConfigureFirewall",
146    "GetDiskUsage",
147    "ListProcesses",
148    "SignalProcess",
149    "GetMemoryInfo",
150    "GetHostState",
151    // Observability / journald (cross-distro)
152    "GetJournalLog",
153    "VacuumJournal",
154    // Storage / LVM (cross-distro)
155    "GetLvmReport",
156    "ExtendLogicalVolume",
157    "CreateLogicalVolume",
158    "CreateLvSnapshot",
159    // Kernel / sysctl (cross-distro)
160    "GetSysctl",
161    "SetSysctl",
162    // Filesystem mounts / swap (cross-distro)
163    "GetMounts",
164    "AddMount",
165    "RemoveMount",
166    "AddSwap",
167    "RemoveSwap",
168    // Scoped sudoers.d (cross-distro)
169    "GetSudoGrants",
170    "GrantSudoAccess",
171    "RevokeSudoAccess",
172    // Log management (cross-distro)
173    "GetLogrotateStatus",
174    "ConfigureLogRotation",
175    "RemoveLogRotation",
176    "ConfigureRemoteSyslog",
177    "RemoveRemoteSyslog",
178    // PAM password policy (cross-distro)
179    "GetPasswordAging",
180    "SetPasswordAging",
181    "SetPasswordPolicy",
182    "SetAccountLockout",
183    // auditd file-watch rules (cross-distro)
184    "GetAuditRules",
185    "AddAuditRule",
186    "RemoveAuditRule",
187    // certbot / ACME (cross-distro)
188    "GetCertificates",
189    "ObtainCertificate",
190    "RenewCertificates",
191    "GetDateTime",
192    "SetHostname",
193    "SetTimezone",
194    "SetLocale",
195    "SetNtp",
196    "ListPackageRepositories",
197    "AddPackageRepository",
198    "RemovePackageRepository",
199    "EnablePackageRepository",
200    "DisablePackageRepository",
201    "ListContainers",
202    "CreateContainer",
203    "StartContainer",
204    "StopContainer",
205    "RemoveContainer",
206    "GetContainerInfo",
207    "ListUsers",
208    "ListGroups",
209    "CreateUser",
210    "DeleteUser",
211    "AddUserToGroup",
212    "RemoveUserFromGroup",
213    "CreateGroup",
214    "DeleteGroup",
215    "LockUserAccount",
216    "UnlockUserAccount",
217    "GetAuthorizedKeys",
218    "AddAuthorizedKey",
219    "RemoveAuthorizedKey",
220    "SetSshdOption",
221    "ListJobHistory",
222    // Ubuntu / apt
223    "AptUpdate",
224    "AptUpgrade",
225    "AptInstall",
226    "AptRemove",
227    "AptPurge",
228    "AptAutoremove",
229    "AptHold",
230    "AptUnhold",
231    "AptSearch",
232    "AptListInstalled",
233    "AptShow",
234    "AptListUpgradable",
235    "AptHistoryList",
236    "ConfigureUnattendedUpgrades",
237    "GetAptPins",
238    "SetAptPin",
239    "RemoveAptPin",
240    // Ubuntu / ppa
241    "AddPpa",
242    "RemovePpa",
243    // Ubuntu / snap
244    "SnapInstall",
245    "SnapRemove",
246    "SnapRefresh",
247    "SnapHold",
248    "SnapUnhold",
249    "SnapList",
250    "SnapInfo",
251    "SnapRevert",
252    "SnapClassicInstall",
253    // Ubuntu / ufw
254    "UfwEnable",
255    "UfwDisable",
256    "UfwAllow",
257    "UfwDeny",
258    "UfwReset",
259    "UfwStatus",
260    // Ubuntu / distrobox
261    "DistroboxList",
262    "DistroboxCreate",
263    "DistroboxRemove",
264    // Ubuntu / netplan
265    "NetplanGetConfig",
266    "NetplanApply",
267    "NetplanSet",
268    "NetplanGenerate",
269    // Ubuntu / grub
270    "GrubGetKargs",
271    "GrubSetKargs",
272    // Ubuntu / reboot
273    "CheckPendingReboot",
274    // Cross-distro / resolvectl (systemd-resolved)
275    "ResolvectlStatus",
276    "ResolvectlSetDns",
277    // Ubuntu / apparmor
278    "AppArmorStatus",
279    "AppArmorEnforce",
280    "AppArmorComplain",
281    // Ubuntu / cloud-init
282    "CloudInitStatus",
283    // Ubuntu / flatpak (Ubuntu-specific routing)
284    "UbuntuInstallFlatpak",
285    "UbuntuRemoveFlatpak",
286    "UbuntuUpdateFlatpak",
287    "UbuntuListFlatpaks",
288    // Ubuntu / fail2ban
289    "Fail2banStatus",
290    "Fail2banBanIp",
291    "Fail2banUnbanIp",
292    "ConfigureFail2banJail",
293    // Ubuntu / release upgrade (Tier 3)
294    "UbuntuReleaseUpgrade",
295    // Ubuntu / Ubuntu Pro (Tier 3)
296    "ProStatus",
297    "ProAttach",
298    "ProDetach",
299    "EnableProService",
300    "DisableProService",
301    // Ubuntu / Livepatch (Tier 3)
302    "LivepatchStatus",
303    // Ubuntu / Multipass (Tier 3)
304    "MultipassList",
305    // Ubuntu / ufw Tier 3
306    "UfwDeleteRule",
307    "UfwLimit",
308];
309
310/// A validated action name from the approved SysKnife catalogue.
311///
312/// Constructed via [`ActionName::parse`], which rejects any string not in
313/// [`KNOWN_ACTION_NAMES`].  Holding an `ActionName` is a compile-time
314/// guarantee that the contained string is in the catalogue — downstream
315/// code does not need to re-validate.
316#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
317#[serde(transparent)]
318pub struct ActionName(String);
319
320impl ActionName {
321    /// Parse a string into a validated `ActionName`.
322    pub fn parse(name: impl Into<String>) -> Result<Self, UnknownActionName> {
323        let name = name.into();
324        if KNOWN_ACTION_NAMES.contains(&name.as_str()) {
325            Ok(Self(name))
326        } else {
327            Err(UnknownActionName(name))
328        }
329    }
330
331    /// Return the inner string slice.
332    pub fn as_str(&self) -> &str {
333        &self.0
334    }
335}
336
337impl std::fmt::Display for ActionName {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.write_str(&self.0)
340    }
341}
342
343impl AsRef<str> for ActionName {
344    fn as_ref(&self) -> &str {
345        &self.0
346    }
347}
348
349/// Returned by [`ActionName::parse`] when the string is not a known action.
350#[derive(Clone, Debug, PartialEq, Eq)]
351pub struct UnknownActionName(pub String);
352
353impl std::fmt::Display for UnknownActionName {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        write!(f, "unknown action name: '{}'", self.0)
356    }
357}
358
359impl std::error::Error for UnknownActionName {}
360
361// ---------------------------------------------------------------------------
362// Request hash newtype
363// ---------------------------------------------------------------------------
364
365/// Hex-encoded SHA-256 hash of a [`RequestEnvelope`].
366///
367/// `serde(transparent)` keeps the wire format identical to a bare string so
368/// existing JSON IPC frames deserialize unchanged.
369#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
370#[serde(transparent)]
371pub struct RequestHash(String);
372
373impl RequestHash {
374    pub fn new(s: impl Into<String>) -> Self {
375        Self(s.into())
376    }
377    pub fn as_str(&self) -> &str {
378        &self.0
379    }
380    pub fn into_inner(self) -> String {
381        self.0
382    }
383}
384
385impl std::fmt::Display for RequestHash {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.write_str(&self.0)
388    }
389}
390
391impl AsRef<str> for RequestHash {
392    fn as_ref(&self) -> &str {
393        &self.0
394    }
395}
396
397impl From<String> for RequestHash {
398    fn from(s: String) -> Self {
399        Self(s)
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Approval-flow newtypes
405// ---------------------------------------------------------------------------
406
407/// Identifier for one preview/approve/execute round-trip.
408///
409/// Distinct from [`ApprovalReceipt`] at the type level because the two are
410/// indistinguishable at the value level: both are opaque strings passed side by
411/// side into the same calls. `execute(transaction_id, action_name, params,
412/// approval_receipt, …)` took three bare `&str` in a row, so transposing any
413/// two of them compiled and failed at runtime as a stale-approval error, which
414/// reads like an expiry rather than a call-site bug.
415#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
416#[serde(transparent)]
417pub struct TransactionId(String);
418
419/// A one-time bearer receipt minted by `sysknife approve`.
420///
421/// This is a **credential**: whoever holds it can execute the approved step
422/// once. `Debug` is redacted so a `tracing::debug!("{req:?}")` on any struct
423/// carrying one cannot spill it into a log. Use [`ApprovalReceipt::as_str`]
424/// where the real value is genuinely needed.
425#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
426#[serde(transparent)]
427pub struct ApprovalReceipt(String);
428
429impl std::fmt::Debug for ApprovalReceipt {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.write_str("ApprovalReceipt(<redacted>)")
432    }
433}
434
435macro_rules! opaque_string_newtype {
436    ($name:ident) => {
437        impl $name {
438            pub fn new(s: impl Into<String>) -> Self {
439                Self(s.into())
440            }
441            pub fn as_str(&self) -> &str {
442                &self.0
443            }
444            pub fn into_inner(self) -> String {
445                self.0
446            }
447        }
448
449        impl AsRef<str> for $name {
450            fn as_ref(&self) -> &str {
451                &self.0
452            }
453        }
454
455        impl From<String> for $name {
456            fn from(s: String) -> Self {
457                Self(s)
458            }
459        }
460    };
461}
462
463opaque_string_newtype!(TransactionId);
464opaque_string_newtype!(ApprovalReceipt);
465
466// `Display` only for the identifier. `ApprovalReceipt` deliberately has none:
467// a credential that formats itself into any `{}` is one interpolation away
468// from a log line, and the explicit `as_str()` marks the places that really
469// need the secret.
470impl std::fmt::Display for TransactionId {
471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472        f.write_str(&self.0)
473    }
474}
475
476/// Caller privilege tier, ordered least to most privileged.
477///
478/// **Variant order is the privilege order** and `Ord` is derived from it, so
479/// `caller >= required` is the authorization comparison. This mirrors what
480/// v0.2.10 did for `PlanRiskLevel`; `CallerRole` kept a hand-written
481/// `role_rank` match until now, which is a second encoding of the same
482/// ordering that could drift from this declaration.
483///
484/// Adding a variant means placing it at the correct privilege position, not
485/// appending it.
486#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
487#[serde(rename_all = "snake_case")]
488pub enum CallerRole {
489    Observer,
490    Dev,
491    Admin,
492    Boot,
493}
494
495impl CallerRole {
496    /// Stable lowercase spelling, identical to the serde representation.
497    ///
498    /// The audit chain signs this string, so it is a wire format: renaming a
499    /// variant must not silently change what past signatures were made over.
500    /// `format!("{role:?}")` cannot be used for that — `Debug` carries no
501    /// stability promise and tracks the identifier. `caller_role_as_str_matches_serde`
502    /// pins the two encodings together.
503    pub fn as_str(&self) -> &'static str {
504        match self {
505            Self::Observer => "observer",
506            Self::Dev => "dev",
507            Self::Admin => "admin",
508            Self::Boot => "boot",
509        }
510    }
511}
512
513#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum RiskLevel {
516    Low,
517    Medium,
518    High,
519}
520
521#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(rename_all = "snake_case")]
523pub enum JobState {
524    Queued,
525    Running,
526    Succeeded,
527    Failed,
528    Canceled,
529    RolledBack,
530    NeedsReboot,
531}
532
533/// Structured failure categories for the IPC error protocol.
534///
535/// Currently used only in the proto bridge layer (sysknife-types <-> sysknife-proto
536/// conversions) and their tests. The daemon dispatcher uses string category
537/// names on the wire today; these variants will replace those strings when the
538/// daemon adopts the proto layer end-to-end.
539#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum FailureCategory {
542    ValidationFailure,
543    AuthorizationFailure,
544    PolicyDenied,
545    StaleApproval,
546    ExecutionFailure,
547    TransientInfrastructureFailure,
548    Cancellation,
549    StuckExecution,
550    RebootRequired,
551    RollbackFailure,
552}
553
554#[derive(Debug, thiserror::Error)]
555pub enum BridgeError {
556    #[error("invalid json payload for {0}")]
557    InvalidJson(&'static str, #[source] serde_json::Error),
558
559    #[error("invalid enum value {value} for {field}")]
560    InvalidEnum { field: &'static str, value: i32 },
561}
562
563#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
564pub struct RequestEnvelope {
565    pub action_name: String,
566    pub request_id: String,
567    pub params: Value,
568    pub caller_role: CallerRole,
569    pub request_hash: RequestHash,
570}
571
572#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
573pub struct PreviewEnvelope {
574    pub summary: String,
575    pub risk_level: RiskLevel,
576    pub current_state: Value,
577    pub proposed_change: Value,
578    pub expected_side_effects: Vec<String>,
579    pub reboot_required: bool,
580    pub rollback_available: bool,
581    pub warnings: Vec<String>,
582    pub request_hash: RequestHash,
583}
584
585#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
586pub struct ResultEnvelope {
587    pub status: JobState,
588    pub summary: String,
589    pub warnings: Vec<String>,
590    pub job_id: Option<String>,
591    pub needs_reboot: bool,
592    pub rollback_ref: Option<String>,
593    pub transaction_id: String,
594}
595
596#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
597pub struct TransactionRecord {
598    pub transaction_id: String,
599    pub request_id: String,
600    pub request_hash: String,
601    pub action_name: String,
602    pub risk_level: RiskLevel,
603    pub status: JobState,
604    pub approval_id: Option<String>,
605    pub summary: String,
606    pub warnings: Vec<String>,
607}
608
609impl From<CallerRole> for proto::CallerRole {
610    // Matched directly against the variants rather than round-tripping through
611    // an i32 code table, so a renumbered `.proto` moves this conversion along
612    // with the prost-generated enum instead of drifting behind it. The
613    // conversion is total, so it cannot panic.
614    fn from(value: CallerRole) -> Self {
615        match value {
616            CallerRole::Observer => proto::CallerRole::Observer,
617            CallerRole::Dev => proto::CallerRole::Dev,
618            CallerRole::Admin => proto::CallerRole::Admin,
619            CallerRole::Boot => proto::CallerRole::Boot,
620        }
621    }
622}
623
624impl TryFrom<proto::CallerRole> for CallerRole {
625    type Error = BridgeError;
626
627    fn try_from(value: proto::CallerRole) -> Result<Self, Self::Error> {
628        match i32::from(value) {
629            1 => Ok(CallerRole::Observer),
630            2 => Ok(CallerRole::Dev),
631            3 => Ok(CallerRole::Admin),
632            4 => Ok(CallerRole::Boot),
633            code => Err(BridgeError::InvalidEnum {
634                field: "caller_role",
635                value: code,
636            }),
637        }
638    }
639}
640
641impl From<RiskLevel> for proto::RiskLevel {
642    fn from(value: RiskLevel) -> Self {
643        match value {
644            RiskLevel::Low => proto::RiskLevel::Low,
645            RiskLevel::Medium => proto::RiskLevel::Medium,
646            RiskLevel::High => proto::RiskLevel::High,
647        }
648    }
649}
650
651impl TryFrom<proto::RiskLevel> for RiskLevel {
652    type Error = BridgeError;
653
654    fn try_from(value: proto::RiskLevel) -> Result<Self, Self::Error> {
655        match i32::from(value) {
656            1 => Ok(RiskLevel::Low),
657            2 => Ok(RiskLevel::Medium),
658            3 => Ok(RiskLevel::High),
659            code => Err(BridgeError::InvalidEnum {
660                field: "risk_level",
661                value: code,
662            }),
663        }
664    }
665}
666
667impl From<JobState> for proto::JobState {
668    fn from(value: JobState) -> Self {
669        match value {
670            JobState::Queued => proto::JobState::Queued,
671            JobState::Running => proto::JobState::Running,
672            JobState::Succeeded => proto::JobState::Succeeded,
673            JobState::Failed => proto::JobState::Failed,
674            JobState::Canceled => proto::JobState::Canceled,
675            JobState::RolledBack => proto::JobState::RolledBack,
676            JobState::NeedsReboot => proto::JobState::NeedsReboot,
677        }
678    }
679}
680
681impl TryFrom<proto::JobState> for JobState {
682    type Error = BridgeError;
683
684    fn try_from(value: proto::JobState) -> Result<Self, Self::Error> {
685        match i32::from(value) {
686            1 => Ok(JobState::Queued),
687            2 => Ok(JobState::Running),
688            3 => Ok(JobState::Succeeded),
689            4 => Ok(JobState::Failed),
690            5 => Ok(JobState::Canceled),
691            6 => Ok(JobState::RolledBack),
692            7 => Ok(JobState::NeedsReboot),
693            code => Err(BridgeError::InvalidEnum {
694                field: "job_state",
695                value: code,
696            }),
697        }
698    }
699}
700
701impl From<FailureCategory> for proto::FailureCategory {
702    fn from(value: FailureCategory) -> Self {
703        match value {
704            FailureCategory::ValidationFailure => proto::FailureCategory::ValidationFailure,
705            FailureCategory::AuthorizationFailure => proto::FailureCategory::AuthorizationFailure,
706            FailureCategory::PolicyDenied => proto::FailureCategory::PolicyDenied,
707            FailureCategory::StaleApproval => proto::FailureCategory::StaleApproval,
708            FailureCategory::ExecutionFailure => proto::FailureCategory::ExecutionFailure,
709            FailureCategory::TransientInfrastructureFailure => {
710                proto::FailureCategory::TransientInfrastructureFailure
711            }
712            FailureCategory::Cancellation => proto::FailureCategory::Cancellation,
713            FailureCategory::StuckExecution => proto::FailureCategory::StuckExecution,
714            FailureCategory::RebootRequired => proto::FailureCategory::RebootRequired,
715            FailureCategory::RollbackFailure => proto::FailureCategory::RollbackFailure,
716        }
717    }
718}
719
720impl TryFrom<proto::FailureCategory> for FailureCategory {
721    type Error = BridgeError;
722
723    fn try_from(value: proto::FailureCategory) -> Result<Self, Self::Error> {
724        match i32::from(value) {
725            1 => Ok(FailureCategory::ValidationFailure),
726            2 => Ok(FailureCategory::AuthorizationFailure),
727            3 => Ok(FailureCategory::PolicyDenied),
728            4 => Ok(FailureCategory::StaleApproval),
729            5 => Ok(FailureCategory::ExecutionFailure),
730            6 => Ok(FailureCategory::TransientInfrastructureFailure),
731            7 => Ok(FailureCategory::Cancellation),
732            8 => Ok(FailureCategory::StuckExecution),
733            9 => Ok(FailureCategory::RebootRequired),
734            10 => Ok(FailureCategory::RollbackFailure),
735            code => Err(BridgeError::InvalidEnum {
736                field: "failure_category",
737                value: code,
738            }),
739        }
740    }
741}
742
743impl From<RequestEnvelope> for proto::RequestEnvelope {
744    fn from(value: RequestEnvelope) -> Self {
745        Self {
746            action_name: value.action_name,
747            request_id: value.request_id,
748            params_json: serde_json::to_string(&value.params).expect("json serialization"),
749            caller_role: i32::from(proto::CallerRole::from(value.caller_role)),
750            request_hash: value.request_hash.into_inner(),
751        }
752    }
753}
754
755impl TryFrom<proto::RequestEnvelope> for RequestEnvelope {
756    type Error = BridgeError;
757
758    fn try_from(value: proto::RequestEnvelope) -> Result<Self, Self::Error> {
759        let params = serde_json::from_str(&value.params_json)
760            .map_err(|error| BridgeError::InvalidJson("params_json", error))?;
761
762        Ok(Self {
763            action_name: value.action_name,
764            request_id: value.request_id,
765            params,
766            caller_role: CallerRole::try_from(
767                proto::CallerRole::try_from(value.caller_role).map_err(|_| {
768                    BridgeError::InvalidEnum {
769                        field: "caller_role",
770                        value: value.caller_role,
771                    }
772                })?,
773            )?,
774            request_hash: RequestHash::from(value.request_hash),
775        })
776    }
777}
778
779impl From<PreviewEnvelope> for proto::PreviewEnvelope {
780    fn from(value: PreviewEnvelope) -> Self {
781        Self {
782            summary: value.summary,
783            risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
784            current_state_json: serde_json::to_string(&value.current_state)
785                .expect("json serialization"),
786            proposed_change_json: serde_json::to_string(&value.proposed_change)
787                .expect("json serialization"),
788            expected_side_effects: value.expected_side_effects,
789            reboot_required: value.reboot_required,
790            rollback_available: value.rollback_available,
791            warnings: value.warnings,
792            request_hash: value.request_hash.into_inner(),
793        }
794    }
795}
796
797impl TryFrom<proto::PreviewEnvelope> for PreviewEnvelope {
798    type Error = BridgeError;
799
800    fn try_from(value: proto::PreviewEnvelope) -> Result<Self, Self::Error> {
801        let current_state = serde_json::from_str(&value.current_state_json)
802            .map_err(|error| BridgeError::InvalidJson("current_state_json", error))?;
803        let proposed_change = serde_json::from_str(&value.proposed_change_json)
804            .map_err(|error| BridgeError::InvalidJson("proposed_change_json", error))?;
805
806        Ok(Self {
807            summary: value.summary,
808            risk_level: RiskLevel::try_from(
809                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
810                    BridgeError::InvalidEnum {
811                        field: "risk_level",
812                        value: value.risk_level,
813                    }
814                })?,
815            )?,
816            current_state,
817            proposed_change,
818            expected_side_effects: value.expected_side_effects,
819            reboot_required: value.reboot_required,
820            rollback_available: value.rollback_available,
821            warnings: value.warnings,
822            request_hash: RequestHash::from(value.request_hash),
823        })
824    }
825}
826
827impl From<ResultEnvelope> for proto::ResultEnvelope {
828    fn from(value: ResultEnvelope) -> Self {
829        Self {
830            status: i32::from(proto::JobState::from(value.status)),
831            summary: value.summary,
832            warnings: value.warnings,
833            job_id: value.job_id.unwrap_or_default(),
834            needs_reboot: value.needs_reboot,
835            rollback_ref: value.rollback_ref.unwrap_or_default(),
836            transaction_id: value.transaction_id,
837        }
838    }
839}
840
841impl TryFrom<proto::ResultEnvelope> for ResultEnvelope {
842    type Error = BridgeError;
843
844    fn try_from(value: proto::ResultEnvelope) -> Result<Self, Self::Error> {
845        Ok(Self {
846            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
847                BridgeError::InvalidEnum {
848                    field: "job_state",
849                    value: value.status,
850                }
851            })?)?,
852            summary: value.summary,
853            warnings: value.warnings,
854            job_id: if value.job_id.is_empty() {
855                None
856            } else {
857                Some(value.job_id)
858            },
859            needs_reboot: value.needs_reboot,
860            rollback_ref: if value.rollback_ref.is_empty() {
861                None
862            } else {
863                Some(value.rollback_ref)
864            },
865            transaction_id: value.transaction_id,
866        })
867    }
868}
869
870impl From<TransactionRecord> for proto::TransactionRecord {
871    fn from(value: TransactionRecord) -> Self {
872        Self {
873            transaction_id: value.transaction_id,
874            request_id: value.request_id,
875            request_hash: value.request_hash,
876            action_name: value.action_name,
877            risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
878            status: i32::from(proto::JobState::from(value.status)),
879            approval_id: value.approval_id.unwrap_or_default(),
880            summary: value.summary,
881            warnings: value.warnings,
882        }
883    }
884}
885
886impl TryFrom<proto::TransactionRecord> for TransactionRecord {
887    type Error = BridgeError;
888
889    fn try_from(value: proto::TransactionRecord) -> Result<Self, Self::Error> {
890        Ok(Self {
891            transaction_id: value.transaction_id,
892            request_id: value.request_id,
893            request_hash: value.request_hash,
894            action_name: value.action_name,
895            risk_level: RiskLevel::try_from(
896                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
897                    BridgeError::InvalidEnum {
898                        field: "risk_level",
899                        value: value.risk_level,
900                    }
901                })?,
902            )?,
903            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
904                BridgeError::InvalidEnum {
905                    field: "job_state",
906                    value: value.status,
907                }
908            })?)?,
909            approval_id: if value.approval_id.is_empty() {
910                None
911            } else {
912                Some(value.approval_id)
913            },
914            summary: value.summary,
915            warnings: value.warnings,
916        })
917    }
918}
919
920#[cfg(test)]
921mod request_hash_tests {
922    use super::*;
923
924    #[test]
925    fn serde_round_trips_via_transparent_string() {
926        // The newtype must serialise to a bare string so existing JSON IPC
927        // frames continue to deserialise unchanged.
928        let r = RequestHash::new("abc123".to_string());
929        let json = serde_json::to_string(&r).unwrap();
930        assert_eq!(json, "\"abc123\"");
931        let back: RequestHash = serde_json::from_str("\"abc123\"").unwrap();
932        assert_eq!(back, r);
933    }
934}
935
936#[cfg(test)]
937mod caller_role_ordering_tests {
938    use super::CallerRole;
939
940    #[test]
941    fn caller_role_orders_least_to_most_privileged() {
942        // The derived `Ord` IS the authorization comparison, so a variant
943        // reordered during an edit would silently change who can do what.
944        assert!(CallerRole::Observer < CallerRole::Dev);
945        assert!(CallerRole::Dev < CallerRole::Admin);
946        assert!(CallerRole::Admin < CallerRole::Boot);
947
948        // The comparison call sites rely on this shape directly.
949        assert!(CallerRole::Admin >= CallerRole::Dev);
950        assert!(!(CallerRole::Observer >= CallerRole::Dev));
951    }
952
953    #[test]
954    fn max_selects_the_higher_privilege() {
955        // `highest_role_from_groups` folds with `max`; a user in both the
956        // observer and admin groups must end up Admin.
957        assert_eq!(
958            CallerRole::Observer.max(CallerRole::Admin),
959            CallerRole::Admin
960        );
961        assert_eq!(CallerRole::Boot.max(CallerRole::Dev), CallerRole::Boot);
962    }
963}
964
965#[cfg(test)]
966mod frame_limit_declaration_tests {
967    use std::fs;
968    use std::path::{Path, PathBuf};
969
970    /// Walk the workspace root: climb from this crate's manifest dir until a
971    /// `Cargo.toml` declaring `[workspace]` is found.
972    fn workspace_root() -> PathBuf {
973        let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
974        loop {
975            let manifest = dir.join("Cargo.toml");
976            if manifest.is_file() {
977                if let Ok(text) = fs::read_to_string(&manifest) {
978                    if text.contains("[workspace]") {
979                        return dir;
980                    }
981                }
982            }
983            if !dir.pop() {
984                panic!("could not locate the workspace root");
985            }
986        }
987    }
988
989    /// Collect every `.rs` file under `dir`, skipping build output.
990    fn walk_rs(dir: &Path, out: &mut Vec<PathBuf>) {
991        if let Ok(entries) = fs::read_dir(dir) {
992            for entry in entries.flatten() {
993                let path = entry.path();
994                if path.file_name().is_some_and(|n| n == "target") {
995                    continue;
996                }
997                if path.is_dir() {
998                    walk_rs(&path, out);
999                } else if path.extension().is_some_and(|e| e == "rs") {
1000                    out.push(path);
1001                }
1002            }
1003        }
1004    }
1005
1006    /// The 4 MiB frame limit must be declared in exactly one place.
1007    ///
1008    /// `MAX_MESSAGE_BYTES` is a wire fact and therefore lives only in
1009    /// `sysknife-types` (the protocol crate every side depends on). The daemon
1010    /// and CLI import it rather than redeclare it. If a second `4 MiB`
1011    /// *constant* is added anywhere in the daemon or CLI (a re-introduced
1012    /// `MAX_FRAME_BYTES`), the limit can silently diverge and one peer can
1013    /// reject frames the other side already accepted. That regression is what
1014    /// this test catches: it scans the workspace for constant *declarations*
1015    /// carrying the 4 MiB initialiser and requires exactly one, in this crate.
1016    ///
1017    /// Lines that merely use the literal as a buffer size (e.g. a
1018    /// `tokio::io::duplex(...)` in a test) or that reference the imported
1019    /// constant are not declarations and are intentionally ignored.
1020    ///
1021    /// The scan matches the `4 * 1024 * 1024` literal *form*, not the value:
1022    /// a re-declaration written as `4194304` or `4 << 20` would slip past it,
1023    /// as would one split across two lines. That is a known, accepted gap —
1024    /// the duplicates it must catch arrived as copy-pastes of the existing
1025    /// literal. No reader should over-trust it as a value-graph check.
1026    #[test]
1027    fn frame_limit_is_declared_once() {
1028        let root = workspace_root();
1029        let mut files: Vec<PathBuf> = Vec::new();
1030        for crate_dir in [
1031            "crates/sysknife-types",
1032            "crates/sysknife-daemon",
1033            "apps/sysknife-cli",
1034        ] {
1035            walk_rs(&root.join(crate_dir), &mut files);
1036        }
1037
1038        let mut declarations: Vec<String> = Vec::new();
1039        for path in &files {
1040            let text = fs::read_to_string(path).unwrap();
1041            for (idx, raw) in text.lines().enumerate() {
1042                let line = raw.trim();
1043                let is_const_decl = [
1044                    "const ",
1045                    "pub const ",
1046                    "pub(crate) const ",
1047                    "pub(super) const ",
1048                ]
1049                .iter()
1050                .any(|p| line.starts_with(p));
1051                // A declaration names a constant AND carries the 4 MiB value.
1052                // Doc comments (start with `///`) and usages never both match.
1053                if is_const_decl && line.contains("4 * 1024 * 1024") {
1054                    declarations.push(format!("{}:{}", path.display(), idx + 1));
1055                }
1056            }
1057        }
1058
1059        assert_eq!(
1060            declarations.len(),
1061            1,
1062            "expected exactly one declaration of the 4 MiB frame limit, \
1063             found {}:\n{:#?}",
1064            declarations.len(),
1065            declarations
1066        );
1067        let (decl_path, _line) = declarations[0].rsplit_once(':').unwrap();
1068        let relativized = PathBuf::from(decl_path.replace(&format!("{}/", root.display()), ""));
1069        assert_eq!(
1070            relativized,
1071            Path::new("crates/sysknife-types/src/lib.rs"),
1072            "the single frame-limit declaration must live in sysknife-types"
1073        );
1074    }
1075}