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
589impl From<CallerRole> for proto::CallerRole {
590    // Matched directly against the variants rather than round-tripping through
591    // an i32 code table, so a renumbered `.proto` moves this conversion along
592    // with the prost-generated enum instead of drifting behind it. The
593    // conversion is total, so it cannot panic.
594    fn from(value: CallerRole) -> Self {
595        match value {
596            CallerRole::Observer => proto::CallerRole::Observer,
597            CallerRole::Dev => proto::CallerRole::Dev,
598            CallerRole::Admin => proto::CallerRole::Admin,
599            CallerRole::Boot => proto::CallerRole::Boot,
600        }
601    }
602}
603
604impl TryFrom<proto::CallerRole> for CallerRole {
605    type Error = BridgeError;
606
607    fn try_from(value: proto::CallerRole) -> Result<Self, Self::Error> {
608        match i32::from(value) {
609            1 => Ok(CallerRole::Observer),
610            2 => Ok(CallerRole::Dev),
611            3 => Ok(CallerRole::Admin),
612            4 => Ok(CallerRole::Boot),
613            code => Err(BridgeError::InvalidEnum {
614                field: "caller_role",
615                value: code,
616            }),
617        }
618    }
619}
620
621impl From<RiskLevel> for proto::RiskLevel {
622    fn from(value: RiskLevel) -> Self {
623        match value {
624            RiskLevel::Low => proto::RiskLevel::Low,
625            RiskLevel::Medium => proto::RiskLevel::Medium,
626            RiskLevel::High => proto::RiskLevel::High,
627        }
628    }
629}
630
631impl TryFrom<proto::RiskLevel> for RiskLevel {
632    type Error = BridgeError;
633
634    fn try_from(value: proto::RiskLevel) -> Result<Self, Self::Error> {
635        match i32::from(value) {
636            1 => Ok(RiskLevel::Low),
637            2 => Ok(RiskLevel::Medium),
638            3 => Ok(RiskLevel::High),
639            code => Err(BridgeError::InvalidEnum {
640                field: "risk_level",
641                value: code,
642            }),
643        }
644    }
645}
646
647impl From<JobState> for proto::JobState {
648    fn from(value: JobState) -> Self {
649        match value {
650            JobState::Queued => proto::JobState::Queued,
651            JobState::Running => proto::JobState::Running,
652            JobState::Succeeded => proto::JobState::Succeeded,
653            JobState::Failed => proto::JobState::Failed,
654            JobState::Canceled => proto::JobState::Canceled,
655            JobState::RolledBack => proto::JobState::RolledBack,
656            JobState::NeedsReboot => proto::JobState::NeedsReboot,
657        }
658    }
659}
660
661impl TryFrom<proto::JobState> for JobState {
662    type Error = BridgeError;
663
664    fn try_from(value: proto::JobState) -> Result<Self, Self::Error> {
665        match i32::from(value) {
666            1 => Ok(JobState::Queued),
667            2 => Ok(JobState::Running),
668            3 => Ok(JobState::Succeeded),
669            4 => Ok(JobState::Failed),
670            5 => Ok(JobState::Canceled),
671            6 => Ok(JobState::RolledBack),
672            7 => Ok(JobState::NeedsReboot),
673            code => Err(BridgeError::InvalidEnum {
674                field: "job_state",
675                value: code,
676            }),
677        }
678    }
679}
680
681impl From<FailureCategory> for proto::FailureCategory {
682    fn from(value: FailureCategory) -> Self {
683        match value {
684            FailureCategory::ValidationFailure => proto::FailureCategory::ValidationFailure,
685            FailureCategory::AuthorizationFailure => proto::FailureCategory::AuthorizationFailure,
686            FailureCategory::PolicyDenied => proto::FailureCategory::PolicyDenied,
687            FailureCategory::StaleApproval => proto::FailureCategory::StaleApproval,
688            FailureCategory::ExecutionFailure => proto::FailureCategory::ExecutionFailure,
689            FailureCategory::TransientInfrastructureFailure => {
690                proto::FailureCategory::TransientInfrastructureFailure
691            }
692            FailureCategory::Cancellation => proto::FailureCategory::Cancellation,
693            FailureCategory::StuckExecution => proto::FailureCategory::StuckExecution,
694            FailureCategory::RebootRequired => proto::FailureCategory::RebootRequired,
695            FailureCategory::RollbackFailure => proto::FailureCategory::RollbackFailure,
696        }
697    }
698}
699
700impl TryFrom<proto::FailureCategory> for FailureCategory {
701    type Error = BridgeError;
702
703    fn try_from(value: proto::FailureCategory) -> Result<Self, Self::Error> {
704        match i32::from(value) {
705            1 => Ok(FailureCategory::ValidationFailure),
706            2 => Ok(FailureCategory::AuthorizationFailure),
707            3 => Ok(FailureCategory::PolicyDenied),
708            4 => Ok(FailureCategory::StaleApproval),
709            5 => Ok(FailureCategory::ExecutionFailure),
710            6 => Ok(FailureCategory::TransientInfrastructureFailure),
711            7 => Ok(FailureCategory::Cancellation),
712            8 => Ok(FailureCategory::StuckExecution),
713            9 => Ok(FailureCategory::RebootRequired),
714            10 => Ok(FailureCategory::RollbackFailure),
715            code => Err(BridgeError::InvalidEnum {
716                field: "failure_category",
717                value: code,
718            }),
719        }
720    }
721}
722
723impl From<RequestEnvelope> for proto::RequestEnvelope {
724    fn from(value: RequestEnvelope) -> Self {
725        Self {
726            action_name: value.action_name,
727            request_id: value.request_id,
728            params_json: serde_json::to_string(&value.params).expect("json serialization"),
729            caller_role: i32::from(proto::CallerRole::from(value.caller_role)),
730            request_hash: value.request_hash.into_inner(),
731        }
732    }
733}
734
735impl TryFrom<proto::RequestEnvelope> for RequestEnvelope {
736    type Error = BridgeError;
737
738    fn try_from(value: proto::RequestEnvelope) -> Result<Self, Self::Error> {
739        let params = serde_json::from_str(&value.params_json)
740            .map_err(|error| BridgeError::InvalidJson("params_json", error))?;
741
742        Ok(Self {
743            action_name: value.action_name,
744            request_id: value.request_id,
745            params,
746            caller_role: CallerRole::try_from(
747                proto::CallerRole::try_from(value.caller_role).map_err(|_| {
748                    BridgeError::InvalidEnum {
749                        field: "caller_role",
750                        value: value.caller_role,
751                    }
752                })?,
753            )?,
754            request_hash: RequestHash::from(value.request_hash),
755        })
756    }
757}
758
759impl From<PreviewEnvelope> for proto::PreviewEnvelope {
760    fn from(value: PreviewEnvelope) -> Self {
761        Self {
762            summary: value.summary,
763            risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
764            current_state_json: serde_json::to_string(&value.current_state)
765                .expect("json serialization"),
766            proposed_change_json: serde_json::to_string(&value.proposed_change)
767                .expect("json serialization"),
768            expected_side_effects: value.expected_side_effects,
769            reboot_required: value.reboot_required,
770            rollback_available: value.rollback_available,
771            warnings: value.warnings,
772            request_hash: value.request_hash.into_inner(),
773        }
774    }
775}
776
777impl TryFrom<proto::PreviewEnvelope> for PreviewEnvelope {
778    type Error = BridgeError;
779
780    fn try_from(value: proto::PreviewEnvelope) -> Result<Self, Self::Error> {
781        let current_state = serde_json::from_str(&value.current_state_json)
782            .map_err(|error| BridgeError::InvalidJson("current_state_json", error))?;
783        let proposed_change = serde_json::from_str(&value.proposed_change_json)
784            .map_err(|error| BridgeError::InvalidJson("proposed_change_json", error))?;
785
786        Ok(Self {
787            summary: value.summary,
788            risk_level: RiskLevel::try_from(
789                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
790                    BridgeError::InvalidEnum {
791                        field: "risk_level",
792                        value: value.risk_level,
793                    }
794                })?,
795            )?,
796            current_state,
797            proposed_change,
798            expected_side_effects: value.expected_side_effects,
799            reboot_required: value.reboot_required,
800            rollback_available: value.rollback_available,
801            warnings: value.warnings,
802            request_hash: RequestHash::from(value.request_hash),
803        })
804    }
805}
806
807impl From<ResultEnvelope> for proto::ResultEnvelope {
808    fn from(value: ResultEnvelope) -> Self {
809        Self {
810            status: i32::from(proto::JobState::from(value.status)),
811            summary: value.summary,
812            warnings: value.warnings,
813            job_id: value.job_id.unwrap_or_default(),
814            needs_reboot: value.needs_reboot,
815            rollback_ref: value.rollback_ref.unwrap_or_default(),
816            transaction_id: value.transaction_id,
817        }
818    }
819}
820
821impl TryFrom<proto::ResultEnvelope> for ResultEnvelope {
822    type Error = BridgeError;
823
824    fn try_from(value: proto::ResultEnvelope) -> Result<Self, Self::Error> {
825        Ok(Self {
826            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
827                BridgeError::InvalidEnum {
828                    field: "job_state",
829                    value: value.status,
830                }
831            })?)?,
832            summary: value.summary,
833            warnings: value.warnings,
834            job_id: if value.job_id.is_empty() {
835                None
836            } else {
837                Some(value.job_id)
838            },
839            needs_reboot: value.needs_reboot,
840            rollback_ref: if value.rollback_ref.is_empty() {
841                None
842            } else {
843                Some(value.rollback_ref)
844            },
845            transaction_id: value.transaction_id,
846        })
847    }
848}
849
850impl From<TransactionRecord> for proto::TransactionRecord {
851    fn from(value: TransactionRecord) -> Self {
852        Self {
853            transaction_id: value.transaction_id,
854            request_id: value.request_id,
855            request_hash: value.request_hash,
856            action_name: value.action_name,
857            risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
858            status: i32::from(proto::JobState::from(value.status)),
859            approval_id: value.approval_id.unwrap_or_default(),
860            summary: value.summary,
861            warnings: value.warnings,
862        }
863    }
864}
865
866impl TryFrom<proto::TransactionRecord> for TransactionRecord {
867    type Error = BridgeError;
868
869    fn try_from(value: proto::TransactionRecord) -> Result<Self, Self::Error> {
870        Ok(Self {
871            transaction_id: value.transaction_id,
872            request_id: value.request_id,
873            request_hash: value.request_hash,
874            action_name: value.action_name,
875            risk_level: RiskLevel::try_from(
876                proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
877                    BridgeError::InvalidEnum {
878                        field: "risk_level",
879                        value: value.risk_level,
880                    }
881                })?,
882            )?,
883            status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
884                BridgeError::InvalidEnum {
885                    field: "job_state",
886                    value: value.status,
887                }
888            })?)?,
889            approval_id: if value.approval_id.is_empty() {
890                None
891            } else {
892                Some(value.approval_id)
893            },
894            summary: value.summary,
895            warnings: value.warnings,
896        })
897    }
898}
899
900#[cfg(test)]
901mod request_hash_tests {
902    use super::*;
903
904    #[test]
905    fn serde_round_trips_via_transparent_string() {
906        // The newtype must serialise to a bare string so existing JSON IPC
907        // frames continue to deserialise unchanged.
908        let r = RequestHash::new("abc123".to_string());
909        let json = serde_json::to_string(&r).unwrap();
910        assert_eq!(json, "\"abc123\"");
911        let back: RequestHash = serde_json::from_str("\"abc123\"").unwrap();
912        assert_eq!(back, r);
913    }
914}
915
916#[cfg(test)]
917mod caller_role_ordering_tests {
918    use super::CallerRole;
919
920    #[test]
921    fn caller_role_orders_least_to_most_privileged() {
922        // The derived `Ord` IS the authorization comparison, so a variant
923        // reordered during an edit would silently change who can do what.
924        assert!(CallerRole::Observer < CallerRole::Dev);
925        assert!(CallerRole::Dev < CallerRole::Admin);
926        assert!(CallerRole::Admin < CallerRole::Boot);
927
928        // The comparison call sites rely on this shape directly.
929        assert!(CallerRole::Admin >= CallerRole::Dev);
930        assert!(!(CallerRole::Observer >= CallerRole::Dev));
931    }
932
933    #[test]
934    fn max_selects_the_higher_privilege() {
935        // `highest_role_from_groups` folds with `max`; a user in both the
936        // observer and admin groups must end up Admin.
937        assert_eq!(
938            CallerRole::Observer.max(CallerRole::Admin),
939            CallerRole::Admin
940        );
941        assert_eq!(CallerRole::Boot.max(CallerRole::Dev), CallerRole::Boot);
942    }
943}