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