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/// Caller privilege tier, ordered least to most privileged.
383///
384/// **Variant order is the privilege order** and `Ord` is derived from it, so
385/// `caller >= required` is the authorization comparison. This mirrors what
386/// v0.2.10 did for `PlanRiskLevel`; `CallerRole` kept a hand-written
387/// `role_rank` match until now, which is a second encoding of the same
388/// ordering that could drift from this declaration.
389///
390/// Adding a variant means placing it at the correct privilege position, not
391/// appending it.
392#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum CallerRole {
395    Observer,
396    Dev,
397    Admin,
398    Boot,
399}
400
401#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum RiskLevel {
404    Low,
405    Medium,
406    High,
407}
408
409#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum JobState {
412    Queued,
413    Running,
414    Succeeded,
415    Failed,
416    Canceled,
417    RolledBack,
418    NeedsReboot,
419}
420
421/// Structured failure categories for the IPC error protocol.
422///
423/// Currently used only in the proto bridge layer (sysknife-types <-> sysknife-proto
424/// conversions) and their tests. The daemon dispatcher uses string category
425/// names on the wire today; these variants will replace those strings when the
426/// daemon adopts the proto layer end-to-end.
427#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(rename_all = "snake_case")]
429pub enum FailureCategory {
430    ValidationFailure,
431    AuthorizationFailure,
432    PolicyDenied,
433    StaleApproval,
434    ExecutionFailure,
435    TransientInfrastructureFailure,
436    Cancellation,
437    StuckExecution,
438    RebootRequired,
439    RollbackFailure,
440}
441
442#[derive(Debug, thiserror::Error)]
443pub enum BridgeError {
444    #[error("invalid json payload for {0}")]
445    InvalidJson(&'static str, #[source] serde_json::Error),
446
447    #[error("invalid enum value {value} for {field}")]
448    InvalidEnum { field: &'static str, value: i32 },
449}
450
451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
452pub struct RequestEnvelope {
453    pub action_name: String,
454    pub request_id: String,
455    pub params: Value,
456    pub caller_role: CallerRole,
457    pub request_hash: RequestHash,
458}
459
460#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
461pub struct PreviewEnvelope {
462    pub summary: String,
463    pub risk_level: RiskLevel,
464    pub current_state: Value,
465    pub proposed_change: Value,
466    pub expected_side_effects: Vec<String>,
467    pub reboot_required: bool,
468    pub rollback_available: bool,
469    pub warnings: Vec<String>,
470    pub request_hash: RequestHash,
471}
472
473#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
474pub struct ResultEnvelope {
475    pub status: JobState,
476    pub summary: String,
477    pub warnings: Vec<String>,
478    pub job_id: Option<String>,
479    pub needs_reboot: bool,
480    pub rollback_ref: Option<String>,
481    pub transaction_id: String,
482}
483
484#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
485pub struct TransactionRecord {
486    pub transaction_id: String,
487    pub request_id: String,
488    pub request_hash: String,
489    pub action_name: String,
490    pub risk_level: RiskLevel,
491    pub status: JobState,
492    pub approval_id: Option<String>,
493    pub summary: String,
494    pub warnings: Vec<String>,
495}
496
497fn caller_role_code(value: CallerRole) -> i32 {
498    match value {
499        CallerRole::Observer => 1,
500        CallerRole::Dev => 2,
501        CallerRole::Admin => 3,
502        CallerRole::Boot => 4,
503    }
504}
505
506fn risk_level_code(value: RiskLevel) -> i32 {
507    match value {
508        RiskLevel::Low => 1,
509        RiskLevel::Medium => 2,
510        RiskLevel::High => 3,
511    }
512}
513
514fn job_state_code(value: JobState) -> i32 {
515    match value {
516        JobState::Queued => 1,
517        JobState::Running => 2,
518        JobState::Succeeded => 3,
519        JobState::Failed => 4,
520        JobState::Canceled => 5,
521        JobState::RolledBack => 6,
522        JobState::NeedsReboot => 7,
523    }
524}
525
526impl From<CallerRole> for proto::CallerRole {
527    // Matched directly rather than round-tripping through the i32 code table
528    // and unwrapping. The conversion is total, so it should not be able to
529    // panic: this bridge is not on the live request path today, but once the
530    // daemon speaks proto it would be a panic-on-drift inside a root process.
531    fn from(value: CallerRole) -> Self {
532        match value {
533            CallerRole::Observer => proto::CallerRole::Observer,
534            CallerRole::Dev => proto::CallerRole::Dev,
535            CallerRole::Admin => proto::CallerRole::Admin,
536            CallerRole::Boot => proto::CallerRole::Boot,
537        }
538    }
539}
540
541impl TryFrom<proto::CallerRole> for CallerRole {
542    type Error = BridgeError;
543
544    fn try_from(value: proto::CallerRole) -> Result<Self, Self::Error> {
545        match i32::from(value) {
546            1 => Ok(CallerRole::Observer),
547            2 => Ok(CallerRole::Dev),
548            3 => Ok(CallerRole::Admin),
549            4 => Ok(CallerRole::Boot),
550            code => Err(BridgeError::InvalidEnum {
551                field: "caller_role",
552                value: code,
553            }),
554        }
555    }
556}
557
558impl From<RiskLevel> for proto::RiskLevel {
559    fn from(value: RiskLevel) -> Self {
560        match value {
561            RiskLevel::Low => proto::RiskLevel::Low,
562            RiskLevel::Medium => proto::RiskLevel::Medium,
563            RiskLevel::High => proto::RiskLevel::High,
564        }
565    }
566}
567
568impl TryFrom<proto::RiskLevel> for RiskLevel {
569    type Error = BridgeError;
570
571    fn try_from(value: proto::RiskLevel) -> Result<Self, Self::Error> {
572        match i32::from(value) {
573            1 => Ok(RiskLevel::Low),
574            2 => Ok(RiskLevel::Medium),
575            3 => Ok(RiskLevel::High),
576            code => Err(BridgeError::InvalidEnum {
577                field: "risk_level",
578                value: code,
579            }),
580        }
581    }
582}
583
584impl From<JobState> for proto::JobState {
585    fn from(value: JobState) -> Self {
586        match value {
587            JobState::Queued => proto::JobState::Queued,
588            JobState::Running => proto::JobState::Running,
589            JobState::Succeeded => proto::JobState::Succeeded,
590            JobState::Failed => proto::JobState::Failed,
591            JobState::Canceled => proto::JobState::Canceled,
592            JobState::RolledBack => proto::JobState::RolledBack,
593            JobState::NeedsReboot => proto::JobState::NeedsReboot,
594        }
595    }
596}
597
598impl TryFrom<proto::JobState> for JobState {
599    type Error = BridgeError;
600
601    fn try_from(value: proto::JobState) -> Result<Self, Self::Error> {
602        match i32::from(value) {
603            1 => Ok(JobState::Queued),
604            2 => Ok(JobState::Running),
605            3 => Ok(JobState::Succeeded),
606            4 => Ok(JobState::Failed),
607            5 => Ok(JobState::Canceled),
608            6 => Ok(JobState::RolledBack),
609            7 => Ok(JobState::NeedsReboot),
610            code => Err(BridgeError::InvalidEnum {
611                field: "job_state",
612                value: code,
613            }),
614        }
615    }
616}
617
618impl From<FailureCategory> for proto::FailureCategory {
619    fn from(value: FailureCategory) -> Self {
620        match value {
621            FailureCategory::ValidationFailure => proto::FailureCategory::ValidationFailure,
622            FailureCategory::AuthorizationFailure => proto::FailureCategory::AuthorizationFailure,
623            FailureCategory::PolicyDenied => proto::FailureCategory::PolicyDenied,
624            FailureCategory::StaleApproval => proto::FailureCategory::StaleApproval,
625            FailureCategory::ExecutionFailure => proto::FailureCategory::ExecutionFailure,
626            FailureCategory::TransientInfrastructureFailure => {
627                proto::FailureCategory::TransientInfrastructureFailure
628            }
629            FailureCategory::Cancellation => proto::FailureCategory::Cancellation,
630            FailureCategory::StuckExecution => proto::FailureCategory::StuckExecution,
631            FailureCategory::RebootRequired => proto::FailureCategory::RebootRequired,
632            FailureCategory::RollbackFailure => proto::FailureCategory::RollbackFailure,
633        }
634    }
635}
636
637impl TryFrom<proto::FailureCategory> for FailureCategory {
638    type Error = BridgeError;
639
640    fn try_from(value: proto::FailureCategory) -> Result<Self, Self::Error> {
641        match i32::from(value) {
642            1 => Ok(FailureCategory::ValidationFailure),
643            2 => Ok(FailureCategory::AuthorizationFailure),
644            3 => Ok(FailureCategory::PolicyDenied),
645            4 => Ok(FailureCategory::StaleApproval),
646            5 => Ok(FailureCategory::ExecutionFailure),
647            6 => Ok(FailureCategory::TransientInfrastructureFailure),
648            7 => Ok(FailureCategory::Cancellation),
649            8 => Ok(FailureCategory::StuckExecution),
650            9 => Ok(FailureCategory::RebootRequired),
651            10 => Ok(FailureCategory::RollbackFailure),
652            code => Err(BridgeError::InvalidEnum {
653                field: "failure_category",
654                value: code,
655            }),
656        }
657    }
658}
659
660impl From<RequestEnvelope> for proto::RequestEnvelope {
661    fn from(value: RequestEnvelope) -> Self {
662        Self {
663            action_name: value.action_name,
664            request_id: value.request_id,
665            params_json: serde_json::to_string(&value.params).expect("json serialization"),
666            caller_role: caller_role_code(value.caller_role),
667            request_hash: value.request_hash.into_inner(),
668        }
669    }
670}
671
672impl TryFrom<proto::RequestEnvelope> for RequestEnvelope {
673    type Error = BridgeError;
674
675    fn try_from(value: proto::RequestEnvelope) -> Result<Self, Self::Error> {
676        let params = serde_json::from_str(&value.params_json)
677            .map_err(|error| BridgeError::InvalidJson("params_json", error))?;
678
679        Ok(Self {
680            action_name: value.action_name,
681            request_id: value.request_id,
682            params,
683            caller_role: CallerRole::try_from(
684                proto::CallerRole::try_from(value.caller_role).map_err(|_| {
685                    BridgeError::InvalidEnum {
686                        field: "caller_role",
687                        value: value.caller_role,
688                    }
689                })?,
690            )?,
691            request_hash: RequestHash::from(value.request_hash),
692        })
693    }
694}
695
696impl From<PreviewEnvelope> for proto::PreviewEnvelope {
697    fn from(value: PreviewEnvelope) -> Self {
698        Self {
699            summary: value.summary,
700            risk_level: risk_level_code(value.risk_level),
701            current_state_json: serde_json::to_string(&value.current_state)
702                .expect("json serialization"),
703            proposed_change_json: serde_json::to_string(&value.proposed_change)
704                .expect("json serialization"),
705            expected_side_effects: value.expected_side_effects,
706            reboot_required: value.reboot_required,
707            rollback_available: value.rollback_available,
708            warnings: value.warnings,
709            request_hash: value.request_hash.into_inner(),
710        }
711    }
712}
713
714impl TryFrom<proto::PreviewEnvelope> for PreviewEnvelope {
715    type Error = BridgeError;
716
717    fn try_from(value: proto::PreviewEnvelope) -> Result<Self, Self::Error> {
718        let current_state = serde_json::from_str(&value.current_state_json)
719            .map_err(|error| BridgeError::InvalidJson("current_state_json", error))?;
720        let proposed_change = serde_json::from_str(&value.proposed_change_json)
721            .map_err(|error| BridgeError::InvalidJson("proposed_change_json", error))?;
722
723        Ok(Self {
724            summary: value.summary,
725            risk_level: RiskLevel::try_from(
726                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
727                    BridgeError::InvalidEnum {
728                        field: "risk_level",
729                        value: value.risk_level,
730                    }
731                })?,
732            )?,
733            current_state,
734            proposed_change,
735            expected_side_effects: value.expected_side_effects,
736            reboot_required: value.reboot_required,
737            rollback_available: value.rollback_available,
738            warnings: value.warnings,
739            request_hash: RequestHash::from(value.request_hash),
740        })
741    }
742}
743
744impl From<ResultEnvelope> for proto::ResultEnvelope {
745    fn from(value: ResultEnvelope) -> Self {
746        Self {
747            status: job_state_code(value.status),
748            summary: value.summary,
749            warnings: value.warnings,
750            job_id: value.job_id.unwrap_or_default(),
751            needs_reboot: value.needs_reboot,
752            rollback_ref: value.rollback_ref.unwrap_or_default(),
753            transaction_id: value.transaction_id,
754        }
755    }
756}
757
758impl TryFrom<proto::ResultEnvelope> for ResultEnvelope {
759    type Error = BridgeError;
760
761    fn try_from(value: proto::ResultEnvelope) -> Result<Self, Self::Error> {
762        Ok(Self {
763            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
764                BridgeError::InvalidEnum {
765                    field: "job_state",
766                    value: value.status,
767                }
768            })?)?,
769            summary: value.summary,
770            warnings: value.warnings,
771            job_id: if value.job_id.is_empty() {
772                None
773            } else {
774                Some(value.job_id)
775            },
776            needs_reboot: value.needs_reboot,
777            rollback_ref: if value.rollback_ref.is_empty() {
778                None
779            } else {
780                Some(value.rollback_ref)
781            },
782            transaction_id: value.transaction_id,
783        })
784    }
785}
786
787impl From<TransactionRecord> for proto::TransactionRecord {
788    fn from(value: TransactionRecord) -> Self {
789        Self {
790            transaction_id: value.transaction_id,
791            request_id: value.request_id,
792            request_hash: value.request_hash,
793            action_name: value.action_name,
794            risk_level: risk_level_code(value.risk_level),
795            status: job_state_code(value.status),
796            approval_id: value.approval_id.unwrap_or_default(),
797            summary: value.summary,
798            warnings: value.warnings,
799        }
800    }
801}
802
803impl TryFrom<proto::TransactionRecord> for TransactionRecord {
804    type Error = BridgeError;
805
806    fn try_from(value: proto::TransactionRecord) -> Result<Self, Self::Error> {
807        Ok(Self {
808            transaction_id: value.transaction_id,
809            request_id: value.request_id,
810            request_hash: value.request_hash,
811            action_name: value.action_name,
812            risk_level: RiskLevel::try_from(
813                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
814                    BridgeError::InvalidEnum {
815                        field: "risk_level",
816                        value: value.risk_level,
817                    }
818                })?,
819            )?,
820            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
821                BridgeError::InvalidEnum {
822                    field: "job_state",
823                    value: value.status,
824                }
825            })?)?,
826            approval_id: if value.approval_id.is_empty() {
827                None
828            } else {
829                Some(value.approval_id)
830            },
831            summary: value.summary,
832            warnings: value.warnings,
833        })
834    }
835}
836
837#[cfg(test)]
838mod request_hash_tests {
839    use super::*;
840
841    #[test]
842    fn serde_round_trips_via_transparent_string() {
843        // The newtype must serialise to a bare string so existing JSON IPC
844        // frames continue to deserialise unchanged.
845        let r = RequestHash::new("abc123".to_string());
846        let json = serde_json::to_string(&r).unwrap();
847        assert_eq!(json, "\"abc123\"");
848        let back: RequestHash = serde_json::from_str("\"abc123\"").unwrap();
849        assert_eq!(back, r);
850    }
851}
852
853#[cfg(test)]
854mod caller_role_ordering_tests {
855    use super::CallerRole;
856
857    #[test]
858    fn caller_role_orders_least_to_most_privileged() {
859        // The derived `Ord` IS the authorization comparison, so a variant
860        // reordered during an edit would silently change who can do what.
861        assert!(CallerRole::Observer < CallerRole::Dev);
862        assert!(CallerRole::Dev < CallerRole::Admin);
863        assert!(CallerRole::Admin < CallerRole::Boot);
864
865        // The comparison call sites rely on this shape directly.
866        assert!(CallerRole::Admin >= CallerRole::Dev);
867        assert!(!(CallerRole::Observer >= CallerRole::Dev));
868    }
869
870    #[test]
871    fn max_selects_the_higher_privilege() {
872        // `highest_role_from_groups` folds with `max`; a user in both the
873        // observer and admin groups must end up Admin.
874        assert_eq!(
875            CallerRole::Observer.max(CallerRole::Admin),
876            CallerRole::Admin
877        );
878        assert_eq!(CallerRole::Boot.max(CallerRole::Dev), CallerRole::Boot);
879    }
880}