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 id: String,
45 pub family: &'static str,
50 pub version: Option<String>,
53}
54
55pub const DISTRO_FAMILY_FEDORA: &str = "fedora";
57
58pub const DISTRO_FAMILY_DEBIAN: &str = "debian";
60
61pub const DISTRO_FAMILY_OTHER: &str = "other";
63
64pub 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 "GetJournalLog",
151 "VacuumJournal",
152 "GetLvmReport",
154 "ExtendLogicalVolume",
155 "CreateLogicalVolume",
156 "CreateLvSnapshot",
157 "GetSysctl",
159 "SetSysctl",
160 "GetMounts",
162 "AddMount",
163 "RemoveMount",
164 "AddSwap",
165 "RemoveSwap",
166 "GetSudoGrants",
168 "GrantSudoAccess",
169 "RevokeSudoAccess",
170 "GetLogrotateStatus",
172 "ConfigureLogRotation",
173 "RemoveLogRotation",
174 "ConfigureRemoteSyslog",
175 "RemoveRemoteSyslog",
176 "GetPasswordAging",
178 "SetPasswordAging",
179 "SetPasswordPolicy",
180 "SetAccountLockout",
181 "GetAuditRules",
183 "AddAuditRule",
184 "RemoveAuditRule",
185 "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 "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 "AddPpa",
240 "RemovePpa",
241 "SnapInstall",
243 "SnapRemove",
244 "SnapRefresh",
245 "SnapHold",
246 "SnapUnhold",
247 "SnapList",
248 "SnapInfo",
249 "SnapRevert",
250 "SnapClassicInstall",
251 "UfwEnable",
253 "UfwDisable",
254 "UfwAllow",
255 "UfwDeny",
256 "UfwReset",
257 "UfwStatus",
258 "DistroboxList",
260 "DistroboxCreate",
261 "DistroboxRemove",
262 "NetplanGetConfig",
264 "NetplanApply",
265 "NetplanSet",
266 "NetplanGenerate",
267 "GrubGetKargs",
269 "GrubSetKargs",
270 "CheckPendingReboot",
272 "ResolvectlStatus",
274 "ResolvectlSetDns",
275 "AppArmorStatus",
277 "AppArmorEnforce",
278 "AppArmorComplain",
279 "CloudInitStatus",
281 "UbuntuInstallFlatpak",
283 "UbuntuRemoveFlatpak",
284 "UbuntuUpdateFlatpak",
285 "UbuntuListFlatpaks",
286 "Fail2banStatus",
288 "Fail2banBanIp",
289 "Fail2banUnbanIp",
290 "ConfigureFail2banJail",
291 "UbuntuReleaseUpgrade",
293 "ProStatus",
295 "ProAttach",
296 "ProDetach",
297 "EnableProService",
298 "DisableProService",
299 "LivepatchStatus",
301 "MultipassList",
303 "UfwDeleteRule",
305 "UfwLimit",
306];
307
308#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
315#[serde(transparent)]
316pub struct ActionName(String);
317
318impl ActionName {
319 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 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#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
414#[serde(transparent)]
415pub struct TransactionId(String);
416
417#[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
464impl 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#[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 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#[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 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 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 assert!(CallerRole::Observer < CallerRole::Dev);
943 assert!(CallerRole::Dev < CallerRole::Admin);
944 assert!(CallerRole::Admin < CallerRole::Boot);
945
946 assert!(CallerRole::Admin >= CallerRole::Dev);
948 assert!(!(CallerRole::Observer >= CallerRole::Dev));
949 }
950
951 #[test]
952 fn max_selects_the_higher_privilege() {
953 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 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 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 #[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 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}