1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::convert::TryFrom;
4
5use sysknife_proto::sysknife::v1 as proto;
6
7pub const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct DistroHint {
41 pub family: &'static str,
46 pub version: Option<String>,
49}
50
51pub const DISTRO_FAMILY_FEDORA: &str = "fedora";
53
54pub const DISTRO_FAMILY_DEBIAN: &str = "debian";
56
57pub const DISTRO_FAMILY_OTHER: &str = "other";
59
60pub const KNOWN_ACTION_NAMES: &[&str] = &[
85 "GetSystemState",
86 "CollectDiagnostics",
87 "GetDeploymentHistory",
88 "ListDeployments",
89 "UpdateSystem",
90 "CleanupDeployments",
91 "RebootSystem",
92 "RollbackDeployment",
93 "GetKernelArguments",
94 "PinDeployment",
95 "UnpinDeployment",
96 "RebaseSystem",
97 "SetKernelArguments",
98 "InstallFlatpak",
99 "RemoveFlatpak",
100 "UpdateFlatpak",
101 "SearchFlatpakApps",
102 "ListFlatpakRemotes",
103 "ListInstalledFlatpaks",
104 "AddFlatpakRemote",
105 "RemoveFlatpakRemote",
106 "GetFlatpakAppInfo",
107 "ListToolboxes",
108 "CreateToolbox",
109 "RemoveToolbox",
110 "GetLayeredPackages",
111 "ResetLayeredPackageOverride",
112 "GetPendingUpdates",
113 "InstallPackages",
114 "RemovePackages",
115 "AddLayeredPackage",
116 "RemoveLayeredPackage",
117 "ReplaceLayeredPackage",
118 "RemoveBasePackage",
119 "ListServices",
120 "ListTimers",
121 "ReloadDaemon",
122 "CreateScheduledJob",
123 "StartService",
124 "StopService",
125 "RestartService",
126 "ReloadService",
127 "SetServiceEnabled",
128 "MaskService",
129 "UnmaskService",
130 "GetServiceLogs",
131 "GetServiceStatus",
132 "GetServiceResourceLimits",
133 "SetServiceResourceLimits",
134 "GetFirewallState",
135 "GetNetworkStatus",
136 "GetListeningPorts",
137 "ConfigureWifi",
138 "SetDnsServers",
139 "ConfigureFirewall",
140 "GetDiskUsage",
141 "ListProcesses",
142 "SignalProcess",
143 "GetMemoryInfo",
144 "GetHostState",
145 "GetJournalLog",
147 "VacuumJournal",
148 "GetLvmReport",
150 "ExtendLogicalVolume",
151 "CreateLogicalVolume",
152 "CreateLvSnapshot",
153 "GetSysctl",
155 "SetSysctl",
156 "GetMounts",
158 "AddMount",
159 "RemoveMount",
160 "AddSwap",
161 "RemoveSwap",
162 "GetSudoGrants",
164 "GrantSudoAccess",
165 "RevokeSudoAccess",
166 "GetLogrotateStatus",
168 "ConfigureLogRotation",
169 "RemoveLogRotation",
170 "ConfigureRemoteSyslog",
171 "RemoveRemoteSyslog",
172 "GetPasswordAging",
174 "SetPasswordAging",
175 "SetPasswordPolicy",
176 "SetAccountLockout",
177 "GetAuditRules",
179 "AddAuditRule",
180 "RemoveAuditRule",
181 "GetCertificates",
183 "ObtainCertificate",
184 "RenewCertificates",
185 "GetDateTime",
186 "SetHostname",
187 "SetTimezone",
188 "SetLocale",
189 "SetNtp",
190 "ListPackageRepositories",
191 "AddPackageRepository",
192 "RemovePackageRepository",
193 "EnablePackageRepository",
194 "DisablePackageRepository",
195 "ListContainers",
196 "CreateContainer",
197 "StartContainer",
198 "StopContainer",
199 "RemoveContainer",
200 "GetContainerInfo",
201 "ListUsers",
202 "ListGroups",
203 "CreateUser",
204 "DeleteUser",
205 "AddUserToGroup",
206 "RemoveUserFromGroup",
207 "CreateGroup",
208 "DeleteGroup",
209 "LockUserAccount",
210 "UnlockUserAccount",
211 "GetAuthorizedKeys",
212 "AddAuthorizedKey",
213 "RemoveAuthorizedKey",
214 "SetSshdOption",
215 "ListJobHistory",
216 "AptUpdate",
218 "AptUpgrade",
219 "AptInstall",
220 "AptRemove",
221 "AptPurge",
222 "AptAutoremove",
223 "AptHold",
224 "AptUnhold",
225 "AptSearch",
226 "AptListInstalled",
227 "AptShow",
228 "AptListUpgradable",
229 "AptHistoryList",
230 "ConfigureUnattendedUpgrades",
231 "GetAptPins",
232 "SetAptPin",
233 "RemoveAptPin",
234 "AddPpa",
236 "RemovePpa",
237 "SnapInstall",
239 "SnapRemove",
240 "SnapRefresh",
241 "SnapHold",
242 "SnapUnhold",
243 "SnapList",
244 "SnapInfo",
245 "SnapRevert",
246 "SnapClassicInstall",
247 "UfwEnable",
249 "UfwDisable",
250 "UfwAllow",
251 "UfwDeny",
252 "UfwReset",
253 "UfwStatus",
254 "DistroboxList",
256 "DistroboxCreate",
257 "DistroboxRemove",
258 "NetplanGetConfig",
260 "NetplanApply",
261 "NetplanSet",
262 "NetplanGenerate",
263 "GrubGetKargs",
265 "GrubSetKargs",
266 "CheckPendingReboot",
268 "ResolvectlStatus",
270 "ResolvectlSetDns",
271 "AppArmorStatus",
273 "AppArmorEnforce",
274 "AppArmorComplain",
275 "CloudInitStatus",
277 "UbuntuInstallFlatpak",
279 "UbuntuRemoveFlatpak",
280 "UbuntuUpdateFlatpak",
281 "UbuntuListFlatpaks",
282 "Fail2banStatus",
284 "Fail2banBanIp",
285 "Fail2banUnbanIp",
286 "ConfigureFail2banJail",
287 "UbuntuReleaseUpgrade",
289 "ProStatus",
291 "ProAttach",
292 "ProDetach",
293 "EnableProService",
294 "DisableProService",
295 "LivepatchStatus",
297 "MultipassList",
299 "UfwDeleteRule",
301 "UfwLimit",
302];
303
304#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
311#[serde(transparent)]
312pub struct ActionName(String);
313
314impl ActionName {
315 pub fn parse(name: impl Into<String>) -> Result<Self, UnknownActionName> {
317 let name = name.into();
318 if KNOWN_ACTION_NAMES.contains(&name.as_str()) {
319 Ok(Self(name))
320 } else {
321 Err(UnknownActionName(name))
322 }
323 }
324
325 pub fn as_str(&self) -> &str {
327 &self.0
328 }
329}
330
331impl std::fmt::Display for ActionName {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 f.write_str(&self.0)
334 }
335}
336
337impl AsRef<str> for ActionName {
338 fn as_ref(&self) -> &str {
339 &self.0
340 }
341}
342
343#[derive(Clone, Debug, PartialEq, Eq)]
345pub struct UnknownActionName(pub String);
346
347impl std::fmt::Display for UnknownActionName {
348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 write!(f, "unknown action name: '{}'", self.0)
350 }
351}
352
353impl std::error::Error for UnknownActionName {}
354
355#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
364#[serde(transparent)]
365pub struct RequestHash(String);
366
367impl RequestHash {
368 pub fn new(s: impl Into<String>) -> Self {
369 Self(s.into())
370 }
371 pub fn as_str(&self) -> &str {
372 &self.0
373 }
374 pub fn into_inner(self) -> String {
375 self.0
376 }
377}
378
379impl std::fmt::Display for RequestHash {
380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381 f.write_str(&self.0)
382 }
383}
384
385impl AsRef<str> for RequestHash {
386 fn as_ref(&self) -> &str {
387 &self.0
388 }
389}
390
391impl From<String> for RequestHash {
392 fn from(s: String) -> Self {
393 Self(s)
394 }
395}
396
397#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
410#[serde(transparent)]
411pub struct TransactionId(String);
412
413#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
420#[serde(transparent)]
421pub struct ApprovalReceipt(String);
422
423impl std::fmt::Debug for ApprovalReceipt {
424 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425 f.write_str("ApprovalReceipt(<redacted>)")
426 }
427}
428
429macro_rules! opaque_string_newtype {
430 ($name:ident) => {
431 impl $name {
432 pub fn new(s: impl Into<String>) -> Self {
433 Self(s.into())
434 }
435 pub fn as_str(&self) -> &str {
436 &self.0
437 }
438 pub fn into_inner(self) -> String {
439 self.0
440 }
441 }
442
443 impl AsRef<str> for $name {
444 fn as_ref(&self) -> &str {
445 &self.0
446 }
447 }
448
449 impl From<String> for $name {
450 fn from(s: String) -> Self {
451 Self(s)
452 }
453 }
454 };
455}
456
457opaque_string_newtype!(TransactionId);
458opaque_string_newtype!(ApprovalReceipt);
459
460impl std::fmt::Display for TransactionId {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 f.write_str(&self.0)
467 }
468}
469
470#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
481#[serde(rename_all = "snake_case")]
482pub enum CallerRole {
483 Observer,
484 Dev,
485 Admin,
486 Boot,
487}
488
489impl CallerRole {
490 pub fn as_str(&self) -> &'static str {
498 match self {
499 Self::Observer => "observer",
500 Self::Dev => "dev",
501 Self::Admin => "admin",
502 Self::Boot => "boot",
503 }
504 }
505}
506
507#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "snake_case")]
509pub enum RiskLevel {
510 Low,
511 Medium,
512 High,
513}
514
515#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "snake_case")]
517pub enum JobState {
518 Queued,
519 Running,
520 Succeeded,
521 Failed,
522 Canceled,
523 RolledBack,
524 NeedsReboot,
525}
526
527#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum FailureCategory {
536 ValidationFailure,
537 AuthorizationFailure,
538 PolicyDenied,
539 StaleApproval,
540 ExecutionFailure,
541 TransientInfrastructureFailure,
542 Cancellation,
543 StuckExecution,
544 RebootRequired,
545 RollbackFailure,
546}
547
548#[derive(Debug, thiserror::Error)]
549pub enum BridgeError {
550 #[error("invalid json payload for {0}")]
551 InvalidJson(&'static str, #[source] serde_json::Error),
552
553 #[error("invalid enum value {value} for {field}")]
554 InvalidEnum { field: &'static str, value: i32 },
555}
556
557#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
558pub struct RequestEnvelope {
559 pub action_name: String,
560 pub request_id: String,
561 pub params: Value,
562 pub caller_role: CallerRole,
563 pub request_hash: RequestHash,
564}
565
566#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
567pub struct PreviewEnvelope {
568 pub summary: String,
569 pub risk_level: RiskLevel,
570 pub current_state: Value,
571 pub proposed_change: Value,
572 pub expected_side_effects: Vec<String>,
573 pub reboot_required: bool,
574 pub rollback_available: bool,
575 pub warnings: Vec<String>,
576 pub request_hash: RequestHash,
577}
578
579#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
580pub struct ResultEnvelope {
581 pub status: JobState,
582 pub summary: String,
583 pub warnings: Vec<String>,
584 pub job_id: Option<String>,
585 pub needs_reboot: bool,
586 pub rollback_ref: Option<String>,
587 pub transaction_id: String,
588}
589
590#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
591pub struct TransactionRecord {
592 pub transaction_id: String,
593 pub request_id: String,
594 pub request_hash: String,
595 pub action_name: String,
596 pub risk_level: RiskLevel,
597 pub status: JobState,
598 pub approval_id: Option<String>,
599 pub summary: String,
600 pub warnings: Vec<String>,
601}
602
603impl From<CallerRole> for proto::CallerRole {
604 fn from(value: CallerRole) -> Self {
609 match value {
610 CallerRole::Observer => proto::CallerRole::Observer,
611 CallerRole::Dev => proto::CallerRole::Dev,
612 CallerRole::Admin => proto::CallerRole::Admin,
613 CallerRole::Boot => proto::CallerRole::Boot,
614 }
615 }
616}
617
618impl TryFrom<proto::CallerRole> for CallerRole {
619 type Error = BridgeError;
620
621 fn try_from(value: proto::CallerRole) -> Result<Self, Self::Error> {
622 match i32::from(value) {
623 1 => Ok(CallerRole::Observer),
624 2 => Ok(CallerRole::Dev),
625 3 => Ok(CallerRole::Admin),
626 4 => Ok(CallerRole::Boot),
627 code => Err(BridgeError::InvalidEnum {
628 field: "caller_role",
629 value: code,
630 }),
631 }
632 }
633}
634
635impl From<RiskLevel> for proto::RiskLevel {
636 fn from(value: RiskLevel) -> Self {
637 match value {
638 RiskLevel::Low => proto::RiskLevel::Low,
639 RiskLevel::Medium => proto::RiskLevel::Medium,
640 RiskLevel::High => proto::RiskLevel::High,
641 }
642 }
643}
644
645impl TryFrom<proto::RiskLevel> for RiskLevel {
646 type Error = BridgeError;
647
648 fn try_from(value: proto::RiskLevel) -> Result<Self, Self::Error> {
649 match i32::from(value) {
650 1 => Ok(RiskLevel::Low),
651 2 => Ok(RiskLevel::Medium),
652 3 => Ok(RiskLevel::High),
653 code => Err(BridgeError::InvalidEnum {
654 field: "risk_level",
655 value: code,
656 }),
657 }
658 }
659}
660
661impl From<JobState> for proto::JobState {
662 fn from(value: JobState) -> Self {
663 match value {
664 JobState::Queued => proto::JobState::Queued,
665 JobState::Running => proto::JobState::Running,
666 JobState::Succeeded => proto::JobState::Succeeded,
667 JobState::Failed => proto::JobState::Failed,
668 JobState::Canceled => proto::JobState::Canceled,
669 JobState::RolledBack => proto::JobState::RolledBack,
670 JobState::NeedsReboot => proto::JobState::NeedsReboot,
671 }
672 }
673}
674
675impl TryFrom<proto::JobState> for JobState {
676 type Error = BridgeError;
677
678 fn try_from(value: proto::JobState) -> Result<Self, Self::Error> {
679 match i32::from(value) {
680 1 => Ok(JobState::Queued),
681 2 => Ok(JobState::Running),
682 3 => Ok(JobState::Succeeded),
683 4 => Ok(JobState::Failed),
684 5 => Ok(JobState::Canceled),
685 6 => Ok(JobState::RolledBack),
686 7 => Ok(JobState::NeedsReboot),
687 code => Err(BridgeError::InvalidEnum {
688 field: "job_state",
689 value: code,
690 }),
691 }
692 }
693}
694
695impl From<FailureCategory> for proto::FailureCategory {
696 fn from(value: FailureCategory) -> Self {
697 match value {
698 FailureCategory::ValidationFailure => proto::FailureCategory::ValidationFailure,
699 FailureCategory::AuthorizationFailure => proto::FailureCategory::AuthorizationFailure,
700 FailureCategory::PolicyDenied => proto::FailureCategory::PolicyDenied,
701 FailureCategory::StaleApproval => proto::FailureCategory::StaleApproval,
702 FailureCategory::ExecutionFailure => proto::FailureCategory::ExecutionFailure,
703 FailureCategory::TransientInfrastructureFailure => {
704 proto::FailureCategory::TransientInfrastructureFailure
705 }
706 FailureCategory::Cancellation => proto::FailureCategory::Cancellation,
707 FailureCategory::StuckExecution => proto::FailureCategory::StuckExecution,
708 FailureCategory::RebootRequired => proto::FailureCategory::RebootRequired,
709 FailureCategory::RollbackFailure => proto::FailureCategory::RollbackFailure,
710 }
711 }
712}
713
714impl TryFrom<proto::FailureCategory> for FailureCategory {
715 type Error = BridgeError;
716
717 fn try_from(value: proto::FailureCategory) -> Result<Self, Self::Error> {
718 match i32::from(value) {
719 1 => Ok(FailureCategory::ValidationFailure),
720 2 => Ok(FailureCategory::AuthorizationFailure),
721 3 => Ok(FailureCategory::PolicyDenied),
722 4 => Ok(FailureCategory::StaleApproval),
723 5 => Ok(FailureCategory::ExecutionFailure),
724 6 => Ok(FailureCategory::TransientInfrastructureFailure),
725 7 => Ok(FailureCategory::Cancellation),
726 8 => Ok(FailureCategory::StuckExecution),
727 9 => Ok(FailureCategory::RebootRequired),
728 10 => Ok(FailureCategory::RollbackFailure),
729 code => Err(BridgeError::InvalidEnum {
730 field: "failure_category",
731 value: code,
732 }),
733 }
734 }
735}
736
737impl From<RequestEnvelope> for proto::RequestEnvelope {
738 fn from(value: RequestEnvelope) -> Self {
739 Self {
740 action_name: value.action_name,
741 request_id: value.request_id,
742 params_json: serde_json::to_string(&value.params).expect("json serialization"),
743 caller_role: i32::from(proto::CallerRole::from(value.caller_role)),
744 request_hash: value.request_hash.into_inner(),
745 }
746 }
747}
748
749impl TryFrom<proto::RequestEnvelope> for RequestEnvelope {
750 type Error = BridgeError;
751
752 fn try_from(value: proto::RequestEnvelope) -> Result<Self, Self::Error> {
753 let params = serde_json::from_str(&value.params_json)
754 .map_err(|error| BridgeError::InvalidJson("params_json", error))?;
755
756 Ok(Self {
757 action_name: value.action_name,
758 request_id: value.request_id,
759 params,
760 caller_role: CallerRole::try_from(
761 proto::CallerRole::try_from(value.caller_role).map_err(|_| {
762 BridgeError::InvalidEnum {
763 field: "caller_role",
764 value: value.caller_role,
765 }
766 })?,
767 )?,
768 request_hash: RequestHash::from(value.request_hash),
769 })
770 }
771}
772
773impl From<PreviewEnvelope> for proto::PreviewEnvelope {
774 fn from(value: PreviewEnvelope) -> Self {
775 Self {
776 summary: value.summary,
777 risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
778 current_state_json: serde_json::to_string(&value.current_state)
779 .expect("json serialization"),
780 proposed_change_json: serde_json::to_string(&value.proposed_change)
781 .expect("json serialization"),
782 expected_side_effects: value.expected_side_effects,
783 reboot_required: value.reboot_required,
784 rollback_available: value.rollback_available,
785 warnings: value.warnings,
786 request_hash: value.request_hash.into_inner(),
787 }
788 }
789}
790
791impl TryFrom<proto::PreviewEnvelope> for PreviewEnvelope {
792 type Error = BridgeError;
793
794 fn try_from(value: proto::PreviewEnvelope) -> Result<Self, Self::Error> {
795 let current_state = serde_json::from_str(&value.current_state_json)
796 .map_err(|error| BridgeError::InvalidJson("current_state_json", error))?;
797 let proposed_change = serde_json::from_str(&value.proposed_change_json)
798 .map_err(|error| BridgeError::InvalidJson("proposed_change_json", error))?;
799
800 Ok(Self {
801 summary: value.summary,
802 risk_level: RiskLevel::try_from(
803 proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
804 BridgeError::InvalidEnum {
805 field: "risk_level",
806 value: value.risk_level,
807 }
808 })?,
809 )?,
810 current_state,
811 proposed_change,
812 expected_side_effects: value.expected_side_effects,
813 reboot_required: value.reboot_required,
814 rollback_available: value.rollback_available,
815 warnings: value.warnings,
816 request_hash: RequestHash::from(value.request_hash),
817 })
818 }
819}
820
821impl From<ResultEnvelope> for proto::ResultEnvelope {
822 fn from(value: ResultEnvelope) -> Self {
823 Self {
824 status: i32::from(proto::JobState::from(value.status)),
825 summary: value.summary,
826 warnings: value.warnings,
827 job_id: value.job_id.unwrap_or_default(),
828 needs_reboot: value.needs_reboot,
829 rollback_ref: value.rollback_ref.unwrap_or_default(),
830 transaction_id: value.transaction_id,
831 }
832 }
833}
834
835impl TryFrom<proto::ResultEnvelope> for ResultEnvelope {
836 type Error = BridgeError;
837
838 fn try_from(value: proto::ResultEnvelope) -> Result<Self, Self::Error> {
839 Ok(Self {
840 status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
841 BridgeError::InvalidEnum {
842 field: "job_state",
843 value: value.status,
844 }
845 })?)?,
846 summary: value.summary,
847 warnings: value.warnings,
848 job_id: if value.job_id.is_empty() {
849 None
850 } else {
851 Some(value.job_id)
852 },
853 needs_reboot: value.needs_reboot,
854 rollback_ref: if value.rollback_ref.is_empty() {
855 None
856 } else {
857 Some(value.rollback_ref)
858 },
859 transaction_id: value.transaction_id,
860 })
861 }
862}
863
864impl From<TransactionRecord> for proto::TransactionRecord {
865 fn from(value: TransactionRecord) -> Self {
866 Self {
867 transaction_id: value.transaction_id,
868 request_id: value.request_id,
869 request_hash: value.request_hash,
870 action_name: value.action_name,
871 risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
872 status: i32::from(proto::JobState::from(value.status)),
873 approval_id: value.approval_id.unwrap_or_default(),
874 summary: value.summary,
875 warnings: value.warnings,
876 }
877 }
878}
879
880impl TryFrom<proto::TransactionRecord> for TransactionRecord {
881 type Error = BridgeError;
882
883 fn try_from(value: proto::TransactionRecord) -> Result<Self, Self::Error> {
884 Ok(Self {
885 transaction_id: value.transaction_id,
886 request_id: value.request_id,
887 request_hash: value.request_hash,
888 action_name: value.action_name,
889 risk_level: RiskLevel::try_from(
890 proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
891 BridgeError::InvalidEnum {
892 field: "risk_level",
893 value: value.risk_level,
894 }
895 })?,
896 )?,
897 status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
898 BridgeError::InvalidEnum {
899 field: "job_state",
900 value: value.status,
901 }
902 })?)?,
903 approval_id: if value.approval_id.is_empty() {
904 None
905 } else {
906 Some(value.approval_id)
907 },
908 summary: value.summary,
909 warnings: value.warnings,
910 })
911 }
912}
913
914#[cfg(test)]
915mod request_hash_tests {
916 use super::*;
917
918 #[test]
919 fn serde_round_trips_via_transparent_string() {
920 let r = RequestHash::new("abc123".to_string());
923 let json = serde_json::to_string(&r).unwrap();
924 assert_eq!(json, "\"abc123\"");
925 let back: RequestHash = serde_json::from_str("\"abc123\"").unwrap();
926 assert_eq!(back, r);
927 }
928}
929
930#[cfg(test)]
931mod caller_role_ordering_tests {
932 use super::CallerRole;
933
934 #[test]
935 fn caller_role_orders_least_to_most_privileged() {
936 assert!(CallerRole::Observer < CallerRole::Dev);
939 assert!(CallerRole::Dev < CallerRole::Admin);
940 assert!(CallerRole::Admin < CallerRole::Boot);
941
942 assert!(CallerRole::Admin >= CallerRole::Dev);
944 assert!(!(CallerRole::Observer >= CallerRole::Dev));
945 }
946
947 #[test]
948 fn max_selects_the_higher_privilege() {
949 assert_eq!(
952 CallerRole::Observer.max(CallerRole::Admin),
953 CallerRole::Admin
954 );
955 assert_eq!(CallerRole::Boot.max(CallerRole::Dev), CallerRole::Boot);
956 }
957}
958
959#[cfg(test)]
960mod frame_limit_declaration_tests {
961 use std::fs;
962 use std::path::{Path, PathBuf};
963
964 fn workspace_root() -> PathBuf {
967 let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
968 loop {
969 let manifest = dir.join("Cargo.toml");
970 if manifest.is_file() {
971 if let Ok(text) = fs::read_to_string(&manifest) {
972 if text.contains("[workspace]") {
973 return dir;
974 }
975 }
976 }
977 if !dir.pop() {
978 panic!("could not locate the workspace root");
979 }
980 }
981 }
982
983 fn walk_rs(dir: &Path, out: &mut Vec<PathBuf>) {
985 if let Ok(entries) = fs::read_dir(dir) {
986 for entry in entries.flatten() {
987 let path = entry.path();
988 if path.file_name().is_some_and(|n| n == "target") {
989 continue;
990 }
991 if path.is_dir() {
992 walk_rs(&path, out);
993 } else if path.extension().is_some_and(|e| e == "rs") {
994 out.push(path);
995 }
996 }
997 }
998 }
999
1000 #[test]
1021 fn frame_limit_is_declared_once() {
1022 let root = workspace_root();
1023 let mut files: Vec<PathBuf> = Vec::new();
1024 for crate_dir in [
1025 "crates/sysknife-types",
1026 "crates/sysknife-daemon",
1027 "apps/sysknife-cli",
1028 ] {
1029 walk_rs(&root.join(crate_dir), &mut files);
1030 }
1031
1032 let mut declarations: Vec<String> = Vec::new();
1033 for path in &files {
1034 let text = fs::read_to_string(path).unwrap();
1035 for (idx, raw) in text.lines().enumerate() {
1036 let line = raw.trim();
1037 let is_const_decl = [
1038 "const ",
1039 "pub const ",
1040 "pub(crate) const ",
1041 "pub(super) const ",
1042 ]
1043 .iter()
1044 .any(|p| line.starts_with(p));
1045 if is_const_decl && line.contains("4 * 1024 * 1024") {
1048 declarations.push(format!("{}:{}", path.display(), idx + 1));
1049 }
1050 }
1051 }
1052
1053 assert_eq!(
1054 declarations.len(),
1055 1,
1056 "expected exactly one declaration of the 4 MiB frame limit, \
1057 found {}:\n{:#?}",
1058 declarations.len(),
1059 declarations
1060 );
1061 let (decl_path, _line) = declarations[0].rsplit_once(':').unwrap();
1062 let relativized = PathBuf::from(decl_path.replace(&format!("{}/", root.display()), ""));
1063 assert_eq!(
1064 relativized,
1065 Path::new("crates/sysknife-types/src/lib.rs"),
1066 "the single frame-limit declaration must live in sysknife-types"
1067 );
1068 }
1069}