Skip to main content

sysknife_types/
lib.rs

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