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