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