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 "GetNftablesRuleset",
142 "GetFirewallBackendState",
143 "ConfigureWifi",
144 "SetDnsServers",
145 "ConfigureFirewall",
146 "GetDiskUsage",
147 "ListProcesses",
148 "SignalProcess",
149 "GetMemoryInfo",
150 "GetHostState",
151 "GetJournalLog",
153 "VacuumJournal",
154 "GetLvmReport",
156 "ExtendLogicalVolume",
157 "CreateLogicalVolume",
158 "CreateLvSnapshot",
159 "GetSysctl",
161 "SetSysctl",
162 "GetMounts",
164 "AddMount",
165 "RemoveMount",
166 "AddSwap",
167 "RemoveSwap",
168 "GetSudoGrants",
170 "GrantSudoAccess",
171 "RevokeSudoAccess",
172 "GetLogrotateStatus",
174 "ConfigureLogRotation",
175 "RemoveLogRotation",
176 "ConfigureRemoteSyslog",
177 "RemoveRemoteSyslog",
178 "GetPasswordAging",
180 "SetPasswordAging",
181 "SetPasswordPolicy",
182 "SetAccountLockout",
183 "GetAuditRules",
185 "AddAuditRule",
186 "RemoveAuditRule",
187 "GetCertificates",
189 "ObtainCertificate",
190 "RenewCertificates",
191 "GetDateTime",
192 "SetHostname",
193 "SetTimezone",
194 "SetLocale",
195 "SetNtp",
196 "ListPackageRepositories",
197 "AddPackageRepository",
198 "RemovePackageRepository",
199 "EnablePackageRepository",
200 "DisablePackageRepository",
201 "ListContainers",
202 "CreateContainer",
203 "StartContainer",
204 "StopContainer",
205 "RemoveContainer",
206 "GetContainerInfo",
207 "ListUsers",
208 "ListGroups",
209 "CreateUser",
210 "DeleteUser",
211 "AddUserToGroup",
212 "RemoveUserFromGroup",
213 "CreateGroup",
214 "DeleteGroup",
215 "LockUserAccount",
216 "UnlockUserAccount",
217 "GetAuthorizedKeys",
218 "AddAuthorizedKey",
219 "RemoveAuthorizedKey",
220 "SetSshdOption",
221 "ListJobHistory",
222 "AptUpdate",
224 "AptUpgrade",
225 "AptInstall",
226 "AptRemove",
227 "AptPurge",
228 "AptAutoremove",
229 "AptHold",
230 "AptUnhold",
231 "AptSearch",
232 "AptListInstalled",
233 "AptShow",
234 "AptListUpgradable",
235 "AptHistoryList",
236 "ConfigureUnattendedUpgrades",
237 "GetAptPins",
238 "SetAptPin",
239 "RemoveAptPin",
240 "AddPpa",
242 "RemovePpa",
243 "SnapInstall",
245 "SnapRemove",
246 "SnapRefresh",
247 "SnapHold",
248 "SnapUnhold",
249 "SnapList",
250 "SnapInfo",
251 "SnapRevert",
252 "SnapClassicInstall",
253 "UfwEnable",
255 "UfwDisable",
256 "UfwAllow",
257 "UfwDeny",
258 "UfwReset",
259 "UfwStatus",
260 "DistroboxList",
262 "DistroboxCreate",
263 "DistroboxRemove",
264 "NetplanGetConfig",
266 "NetplanApply",
267 "NetplanSet",
268 "NetplanGenerate",
269 "GrubGetKargs",
271 "GrubSetKargs",
272 "CheckPendingReboot",
274 "ResolvectlStatus",
276 "ResolvectlSetDns",
277 "AppArmorStatus",
279 "AppArmorEnforce",
280 "AppArmorComplain",
281 "CloudInitStatus",
283 "UbuntuInstallFlatpak",
285 "UbuntuRemoveFlatpak",
286 "UbuntuUpdateFlatpak",
287 "UbuntuListFlatpaks",
288 "Fail2banStatus",
290 "Fail2banBanIp",
291 "Fail2banUnbanIp",
292 "ConfigureFail2banJail",
293 "UbuntuReleaseUpgrade",
295 "ProStatus",
297 "ProAttach",
298 "ProDetach",
299 "EnableProService",
300 "DisableProService",
301 "LivepatchStatus",
303 "MultipassList",
305 "UfwDeleteRule",
307 "UfwLimit",
308];
309
310#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
317#[serde(transparent)]
318pub struct ActionName(String);
319
320impl ActionName {
321 pub fn parse(name: impl Into<String>) -> Result<Self, UnknownActionName> {
323 let name = name.into();
324 if KNOWN_ACTION_NAMES.contains(&name.as_str()) {
325 Ok(Self(name))
326 } else {
327 Err(UnknownActionName(name))
328 }
329 }
330
331 pub fn as_str(&self) -> &str {
333 &self.0
334 }
335}
336
337impl std::fmt::Display for ActionName {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 f.write_str(&self.0)
340 }
341}
342
343impl AsRef<str> for ActionName {
344 fn as_ref(&self) -> &str {
345 &self.0
346 }
347}
348
349#[derive(Clone, Debug, PartialEq, Eq)]
351pub struct UnknownActionName(pub String);
352
353impl std::fmt::Display for UnknownActionName {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 write!(f, "unknown action name: '{}'", self.0)
356 }
357}
358
359impl std::error::Error for UnknownActionName {}
360
361#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
370#[serde(transparent)]
371pub struct RequestHash(String);
372
373impl RequestHash {
374 pub fn new(s: impl Into<String>) -> Self {
375 Self(s.into())
376 }
377 pub fn as_str(&self) -> &str {
378 &self.0
379 }
380 pub fn into_inner(self) -> String {
381 self.0
382 }
383}
384
385impl std::fmt::Display for RequestHash {
386 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387 f.write_str(&self.0)
388 }
389}
390
391impl AsRef<str> for RequestHash {
392 fn as_ref(&self) -> &str {
393 &self.0
394 }
395}
396
397impl From<String> for RequestHash {
398 fn from(s: String) -> Self {
399 Self(s)
400 }
401}
402
403#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
416#[serde(transparent)]
417pub struct TransactionId(String);
418
419#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
426#[serde(transparent)]
427pub struct ApprovalReceipt(String);
428
429impl std::fmt::Debug for ApprovalReceipt {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 f.write_str("ApprovalReceipt(<redacted>)")
432 }
433}
434
435macro_rules! opaque_string_newtype {
436 ($name:ident) => {
437 impl $name {
438 pub fn new(s: impl Into<String>) -> Self {
439 Self(s.into())
440 }
441 pub fn as_str(&self) -> &str {
442 &self.0
443 }
444 pub fn into_inner(self) -> String {
445 self.0
446 }
447 }
448
449 impl AsRef<str> for $name {
450 fn as_ref(&self) -> &str {
451 &self.0
452 }
453 }
454
455 impl From<String> for $name {
456 fn from(s: String) -> Self {
457 Self(s)
458 }
459 }
460 };
461}
462
463opaque_string_newtype!(TransactionId);
464opaque_string_newtype!(ApprovalReceipt);
465
466impl std::fmt::Display for TransactionId {
471 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472 f.write_str(&self.0)
473 }
474}
475
476#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
487#[serde(rename_all = "snake_case")]
488pub enum CallerRole {
489 Observer,
490 Dev,
491 Admin,
492 Boot,
493}
494
495impl CallerRole {
496 pub fn as_str(&self) -> &'static str {
504 match self {
505 Self::Observer => "observer",
506 Self::Dev => "dev",
507 Self::Admin => "admin",
508 Self::Boot => "boot",
509 }
510 }
511}
512
513#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum RiskLevel {
516 Low,
517 Medium,
518 High,
519}
520
521#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(rename_all = "snake_case")]
523pub enum JobState {
524 Queued,
525 Running,
526 Succeeded,
527 Failed,
528 Canceled,
529 RolledBack,
530 NeedsReboot,
531}
532
533#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum FailureCategory {
542 ValidationFailure,
543 AuthorizationFailure,
544 PolicyDenied,
545 StaleApproval,
546 ExecutionFailure,
547 TransientInfrastructureFailure,
548 Cancellation,
549 StuckExecution,
550 RebootRequired,
551 RollbackFailure,
552}
553
554#[derive(Debug, thiserror::Error)]
555pub enum BridgeError {
556 #[error("invalid json payload for {0}")]
557 InvalidJson(&'static str, #[source] serde_json::Error),
558
559 #[error("invalid enum value {value} for {field}")]
560 InvalidEnum { field: &'static str, value: i32 },
561}
562
563#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
564pub struct RequestEnvelope {
565 pub action_name: String,
566 pub request_id: String,
567 pub params: Value,
568 pub caller_role: CallerRole,
569 pub request_hash: RequestHash,
570}
571
572#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
573pub struct PreviewEnvelope {
574 pub summary: String,
575 pub risk_level: RiskLevel,
576 pub current_state: Value,
577 pub proposed_change: Value,
578 pub expected_side_effects: Vec<String>,
579 pub reboot_required: bool,
580 pub rollback_available: bool,
581 pub warnings: Vec<String>,
582 pub request_hash: RequestHash,
583}
584
585#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
586pub struct ResultEnvelope {
587 pub status: JobState,
588 pub summary: String,
589 pub warnings: Vec<String>,
590 pub job_id: Option<String>,
591 pub needs_reboot: bool,
592 pub rollback_ref: Option<String>,
593 pub transaction_id: String,
594}
595
596#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
597pub struct TransactionRecord {
598 pub transaction_id: String,
599 pub request_id: String,
600 pub request_hash: String,
601 pub action_name: String,
602 pub risk_level: RiskLevel,
603 pub status: JobState,
604 pub approval_id: Option<String>,
605 pub summary: String,
606 pub warnings: Vec<String>,
607}
608
609impl From<CallerRole> for proto::CallerRole {
610 fn from(value: CallerRole) -> Self {
615 match value {
616 CallerRole::Observer => proto::CallerRole::Observer,
617 CallerRole::Dev => proto::CallerRole::Dev,
618 CallerRole::Admin => proto::CallerRole::Admin,
619 CallerRole::Boot => proto::CallerRole::Boot,
620 }
621 }
622}
623
624impl TryFrom<proto::CallerRole> for CallerRole {
625 type Error = BridgeError;
626
627 fn try_from(value: proto::CallerRole) -> Result<Self, Self::Error> {
628 match i32::from(value) {
629 1 => Ok(CallerRole::Observer),
630 2 => Ok(CallerRole::Dev),
631 3 => Ok(CallerRole::Admin),
632 4 => Ok(CallerRole::Boot),
633 code => Err(BridgeError::InvalidEnum {
634 field: "caller_role",
635 value: code,
636 }),
637 }
638 }
639}
640
641impl From<RiskLevel> for proto::RiskLevel {
642 fn from(value: RiskLevel) -> Self {
643 match value {
644 RiskLevel::Low => proto::RiskLevel::Low,
645 RiskLevel::Medium => proto::RiskLevel::Medium,
646 RiskLevel::High => proto::RiskLevel::High,
647 }
648 }
649}
650
651impl TryFrom<proto::RiskLevel> for RiskLevel {
652 type Error = BridgeError;
653
654 fn try_from(value: proto::RiskLevel) -> Result<Self, Self::Error> {
655 match i32::from(value) {
656 1 => Ok(RiskLevel::Low),
657 2 => Ok(RiskLevel::Medium),
658 3 => Ok(RiskLevel::High),
659 code => Err(BridgeError::InvalidEnum {
660 field: "risk_level",
661 value: code,
662 }),
663 }
664 }
665}
666
667impl From<JobState> for proto::JobState {
668 fn from(value: JobState) -> Self {
669 match value {
670 JobState::Queued => proto::JobState::Queued,
671 JobState::Running => proto::JobState::Running,
672 JobState::Succeeded => proto::JobState::Succeeded,
673 JobState::Failed => proto::JobState::Failed,
674 JobState::Canceled => proto::JobState::Canceled,
675 JobState::RolledBack => proto::JobState::RolledBack,
676 JobState::NeedsReboot => proto::JobState::NeedsReboot,
677 }
678 }
679}
680
681impl TryFrom<proto::JobState> for JobState {
682 type Error = BridgeError;
683
684 fn try_from(value: proto::JobState) -> Result<Self, Self::Error> {
685 match i32::from(value) {
686 1 => Ok(JobState::Queued),
687 2 => Ok(JobState::Running),
688 3 => Ok(JobState::Succeeded),
689 4 => Ok(JobState::Failed),
690 5 => Ok(JobState::Canceled),
691 6 => Ok(JobState::RolledBack),
692 7 => Ok(JobState::NeedsReboot),
693 code => Err(BridgeError::InvalidEnum {
694 field: "job_state",
695 value: code,
696 }),
697 }
698 }
699}
700
701impl From<FailureCategory> for proto::FailureCategory {
702 fn from(value: FailureCategory) -> Self {
703 match value {
704 FailureCategory::ValidationFailure => proto::FailureCategory::ValidationFailure,
705 FailureCategory::AuthorizationFailure => proto::FailureCategory::AuthorizationFailure,
706 FailureCategory::PolicyDenied => proto::FailureCategory::PolicyDenied,
707 FailureCategory::StaleApproval => proto::FailureCategory::StaleApproval,
708 FailureCategory::ExecutionFailure => proto::FailureCategory::ExecutionFailure,
709 FailureCategory::TransientInfrastructureFailure => {
710 proto::FailureCategory::TransientInfrastructureFailure
711 }
712 FailureCategory::Cancellation => proto::FailureCategory::Cancellation,
713 FailureCategory::StuckExecution => proto::FailureCategory::StuckExecution,
714 FailureCategory::RebootRequired => proto::FailureCategory::RebootRequired,
715 FailureCategory::RollbackFailure => proto::FailureCategory::RollbackFailure,
716 }
717 }
718}
719
720impl TryFrom<proto::FailureCategory> for FailureCategory {
721 type Error = BridgeError;
722
723 fn try_from(value: proto::FailureCategory) -> Result<Self, Self::Error> {
724 match i32::from(value) {
725 1 => Ok(FailureCategory::ValidationFailure),
726 2 => Ok(FailureCategory::AuthorizationFailure),
727 3 => Ok(FailureCategory::PolicyDenied),
728 4 => Ok(FailureCategory::StaleApproval),
729 5 => Ok(FailureCategory::ExecutionFailure),
730 6 => Ok(FailureCategory::TransientInfrastructureFailure),
731 7 => Ok(FailureCategory::Cancellation),
732 8 => Ok(FailureCategory::StuckExecution),
733 9 => Ok(FailureCategory::RebootRequired),
734 10 => Ok(FailureCategory::RollbackFailure),
735 code => Err(BridgeError::InvalidEnum {
736 field: "failure_category",
737 value: code,
738 }),
739 }
740 }
741}
742
743impl From<RequestEnvelope> for proto::RequestEnvelope {
744 fn from(value: RequestEnvelope) -> Self {
745 Self {
746 action_name: value.action_name,
747 request_id: value.request_id,
748 params_json: serde_json::to_string(&value.params).expect("json serialization"),
749 caller_role: i32::from(proto::CallerRole::from(value.caller_role)),
750 request_hash: value.request_hash.into_inner(),
751 }
752 }
753}
754
755impl TryFrom<proto::RequestEnvelope> for RequestEnvelope {
756 type Error = BridgeError;
757
758 fn try_from(value: proto::RequestEnvelope) -> Result<Self, Self::Error> {
759 let params = serde_json::from_str(&value.params_json)
760 .map_err(|error| BridgeError::InvalidJson("params_json", error))?;
761
762 Ok(Self {
763 action_name: value.action_name,
764 request_id: value.request_id,
765 params,
766 caller_role: CallerRole::try_from(
767 proto::CallerRole::try_from(value.caller_role).map_err(|_| {
768 BridgeError::InvalidEnum {
769 field: "caller_role",
770 value: value.caller_role,
771 }
772 })?,
773 )?,
774 request_hash: RequestHash::from(value.request_hash),
775 })
776 }
777}
778
779impl From<PreviewEnvelope> for proto::PreviewEnvelope {
780 fn from(value: PreviewEnvelope) -> Self {
781 Self {
782 summary: value.summary,
783 risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
784 current_state_json: serde_json::to_string(&value.current_state)
785 .expect("json serialization"),
786 proposed_change_json: serde_json::to_string(&value.proposed_change)
787 .expect("json serialization"),
788 expected_side_effects: value.expected_side_effects,
789 reboot_required: value.reboot_required,
790 rollback_available: value.rollback_available,
791 warnings: value.warnings,
792 request_hash: value.request_hash.into_inner(),
793 }
794 }
795}
796
797impl TryFrom<proto::PreviewEnvelope> for PreviewEnvelope {
798 type Error = BridgeError;
799
800 fn try_from(value: proto::PreviewEnvelope) -> Result<Self, Self::Error> {
801 let current_state = serde_json::from_str(&value.current_state_json)
802 .map_err(|error| BridgeError::InvalidJson("current_state_json", error))?;
803 let proposed_change = serde_json::from_str(&value.proposed_change_json)
804 .map_err(|error| BridgeError::InvalidJson("proposed_change_json", error))?;
805
806 Ok(Self {
807 summary: value.summary,
808 risk_level: RiskLevel::try_from(
809 proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
810 BridgeError::InvalidEnum {
811 field: "risk_level",
812 value: value.risk_level,
813 }
814 })?,
815 )?,
816 current_state,
817 proposed_change,
818 expected_side_effects: value.expected_side_effects,
819 reboot_required: value.reboot_required,
820 rollback_available: value.rollback_available,
821 warnings: value.warnings,
822 request_hash: RequestHash::from(value.request_hash),
823 })
824 }
825}
826
827impl From<ResultEnvelope> for proto::ResultEnvelope {
828 fn from(value: ResultEnvelope) -> Self {
829 Self {
830 status: i32::from(proto::JobState::from(value.status)),
831 summary: value.summary,
832 warnings: value.warnings,
833 job_id: value.job_id.unwrap_or_default(),
834 needs_reboot: value.needs_reboot,
835 rollback_ref: value.rollback_ref.unwrap_or_default(),
836 transaction_id: value.transaction_id,
837 }
838 }
839}
840
841impl TryFrom<proto::ResultEnvelope> for ResultEnvelope {
842 type Error = BridgeError;
843
844 fn try_from(value: proto::ResultEnvelope) -> Result<Self, Self::Error> {
845 Ok(Self {
846 status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
847 BridgeError::InvalidEnum {
848 field: "job_state",
849 value: value.status,
850 }
851 })?)?,
852 summary: value.summary,
853 warnings: value.warnings,
854 job_id: if value.job_id.is_empty() {
855 None
856 } else {
857 Some(value.job_id)
858 },
859 needs_reboot: value.needs_reboot,
860 rollback_ref: if value.rollback_ref.is_empty() {
861 None
862 } else {
863 Some(value.rollback_ref)
864 },
865 transaction_id: value.transaction_id,
866 })
867 }
868}
869
870impl From<TransactionRecord> for proto::TransactionRecord {
871 fn from(value: TransactionRecord) -> Self {
872 Self {
873 transaction_id: value.transaction_id,
874 request_id: value.request_id,
875 request_hash: value.request_hash,
876 action_name: value.action_name,
877 risk_level: i32::from(proto::RiskLevel::from(value.risk_level)),
878 status: i32::from(proto::JobState::from(value.status)),
879 approval_id: value.approval_id.unwrap_or_default(),
880 summary: value.summary,
881 warnings: value.warnings,
882 }
883 }
884}
885
886impl TryFrom<proto::TransactionRecord> for TransactionRecord {
887 type Error = BridgeError;
888
889 fn try_from(value: proto::TransactionRecord) -> Result<Self, Self::Error> {
890 Ok(Self {
891 transaction_id: value.transaction_id,
892 request_id: value.request_id,
893 request_hash: value.request_hash,
894 action_name: value.action_name,
895 risk_level: RiskLevel::try_from(
896 proto::RiskLevel::try_from(value.risk_level).map_err(|_| {
897 BridgeError::InvalidEnum {
898 field: "risk_level",
899 value: value.risk_level,
900 }
901 })?,
902 )?,
903 status: JobState::try_from(proto::JobState::try_from(value.status).map_err(|_| {
904 BridgeError::InvalidEnum {
905 field: "job_state",
906 value: value.status,
907 }
908 })?)?,
909 approval_id: if value.approval_id.is_empty() {
910 None
911 } else {
912 Some(value.approval_id)
913 },
914 summary: value.summary,
915 warnings: value.warnings,
916 })
917 }
918}
919
920#[cfg(test)]
921mod request_hash_tests {
922 use super::*;
923
924 #[test]
925 fn serde_round_trips_via_transparent_string() {
926 let r = RequestHash::new("abc123".to_string());
929 let json = serde_json::to_string(&r).unwrap();
930 assert_eq!(json, "\"abc123\"");
931 let back: RequestHash = serde_json::from_str("\"abc123\"").unwrap();
932 assert_eq!(back, r);
933 }
934}
935
936#[cfg(test)]
937mod caller_role_ordering_tests {
938 use super::CallerRole;
939
940 #[test]
941 fn caller_role_orders_least_to_most_privileged() {
942 assert!(CallerRole::Observer < CallerRole::Dev);
945 assert!(CallerRole::Dev < CallerRole::Admin);
946 assert!(CallerRole::Admin < CallerRole::Boot);
947
948 assert!(CallerRole::Admin >= CallerRole::Dev);
950 assert!(!(CallerRole::Observer >= CallerRole::Dev));
951 }
952
953 #[test]
954 fn max_selects_the_higher_privilege() {
955 assert_eq!(
958 CallerRole::Observer.max(CallerRole::Admin),
959 CallerRole::Admin
960 );
961 assert_eq!(CallerRole::Boot.max(CallerRole::Dev), CallerRole::Boot);
962 }
963}
964
965#[cfg(test)]
966mod frame_limit_declaration_tests {
967 use std::fs;
968 use std::path::{Path, PathBuf};
969
970 fn workspace_root() -> PathBuf {
973 let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
974 loop {
975 let manifest = dir.join("Cargo.toml");
976 if manifest.is_file() {
977 if let Ok(text) = fs::read_to_string(&manifest) {
978 if text.contains("[workspace]") {
979 return dir;
980 }
981 }
982 }
983 if !dir.pop() {
984 panic!("could not locate the workspace root");
985 }
986 }
987 }
988
989 fn walk_rs(dir: &Path, out: &mut Vec<PathBuf>) {
991 if let Ok(entries) = fs::read_dir(dir) {
992 for entry in entries.flatten() {
993 let path = entry.path();
994 if path.file_name().is_some_and(|n| n == "target") {
995 continue;
996 }
997 if path.is_dir() {
998 walk_rs(&path, out);
999 } else if path.extension().is_some_and(|e| e == "rs") {
1000 out.push(path);
1001 }
1002 }
1003 }
1004 }
1005
1006 #[test]
1027 fn frame_limit_is_declared_once() {
1028 let root = workspace_root();
1029 let mut files: Vec<PathBuf> = Vec::new();
1030 for crate_dir in [
1031 "crates/sysknife-types",
1032 "crates/sysknife-daemon",
1033 "apps/sysknife-cli",
1034 ] {
1035 walk_rs(&root.join(crate_dir), &mut files);
1036 }
1037
1038 let mut declarations: Vec<String> = Vec::new();
1039 for path in &files {
1040 let text = fs::read_to_string(path).unwrap();
1041 for (idx, raw) in text.lines().enumerate() {
1042 let line = raw.trim();
1043 let is_const_decl = [
1044 "const ",
1045 "pub const ",
1046 "pub(crate) const ",
1047 "pub(super) const ",
1048 ]
1049 .iter()
1050 .any(|p| line.starts_with(p));
1051 if is_const_decl && line.contains("4 * 1024 * 1024") {
1054 declarations.push(format!("{}:{}", path.display(), idx + 1));
1055 }
1056 }
1057 }
1058
1059 assert_eq!(
1060 declarations.len(),
1061 1,
1062 "expected exactly one declaration of the 4 MiB frame limit, \
1063 found {}:\n{:#?}",
1064 declarations.len(),
1065 declarations
1066 );
1067 let (decl_path, _line) = declarations[0].rsplit_once(':').unwrap();
1068 let relativized = PathBuf::from(decl_path.replace(&format!("{}/", root.display()), ""));
1069 assert_eq!(
1070 relativized,
1071 Path::new("crates/sysknife-types/src/lib.rs"),
1072 "the single frame-limit declaration must live in sysknife-types"
1073 );
1074 }
1075}