1#![warn(clippy::pedantic)]
54
55use serde::{Deserialize, Serialize};
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63pub enum Charset {
64 Alphanumeric,
66 Symbols,
68 Hex,
70 Base32,
72 Base64UrlSafe,
74}
75
76impl Charset {
77 #[must_use]
80 pub fn alphabet(self) -> &'static [u8] {
81 match self {
82 Self::Alphanumeric => {
83 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
84 }
85 Self::Symbols => {
86 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*()-_=+[]{};:,.<>?/"
87 }
88 Self::Hex => b"0123456789abcdef",
89 Self::Base32 => b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
90 Self::Base64UrlSafe => {
91 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
92 }
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case", tag = "kind")]
103pub enum SecretGenPolicy {
104 PasswordRandom {
111 length: u8,
112 charset: Charset,
113 max_length: Option<u8>,
114 },
115 PreSharedKey { length_bytes: u8 },
118 Token { length: u8, prefix: Option<String> },
122 WireguardKeypair,
125 SshKeypair { algo: SshAlgo },
129 TlsKeypair {
132 algo: TlsAlgo,
133 validity_days: u32,
134 },
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum SshAlgo {
140 Ed25519,
141 Rsa4096,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
145#[serde(rename_all = "kebab-case")]
146pub enum TlsAlgo {
147 Ed25519,
148 Rsa4096,
149 EcdsaP256,
150}
151
152impl SecretGenPolicy {
153 #[must_use]
156 pub fn backend_paths(&self) -> usize {
157 match self {
158 Self::WireguardKeypair | Self::SshKeypair { .. } | Self::TlsKeypair { .. } => 2,
159 _ => 1,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[serde(rename_all = "kebab-case")]
170pub enum RotationPolicy {
171 Manual,
174 Quarterly,
177 Yearly,
180 Never,
183}
184
185impl RotationPolicy {
186 #[must_use]
189 pub fn overdue_after_days(self) -> Option<u32> {
190 match self {
191 Self::Manual | Self::Never => None,
192 Self::Quarterly => Some(90),
193 Self::Yearly => Some(365),
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, gen_platform::TypedDispatcher)]
203#[serde(rename_all = "kebab-case", tag = "kind")]
204pub enum BackendKind {
205 Sops {
209 file: String,
211 yaml_path: String,
213 },
214 Akeyless {
217 path: String,
219 },
220 Mock { name: String },
224}
225
226impl BackendKind {
227 #[must_use]
230 pub fn stable_id(&self) -> String {
231 match self {
232 Self::Sops { file, yaml_path } => format!("sops:{file}:{yaml_path}"),
233 Self::Akeyless { path } => format!("akeyless:{path}"),
234 Self::Mock { name } => format!("mock:{name}"),
235 }
236 }
237
238 #[must_use]
239 pub fn is_test_only(&self) -> bool {
240 matches!(self, Self::Mock { .. })
241 }
242}
243
244gen_platform::register_dispatcher!("cofre.backend-kind", BackendKind);
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct SecretRef {
256 pub name: String,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub description: Option<String>,
263 pub backend: BackendKind,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub generation: Option<SecretGenPolicy>,
270 #[serde(default = "default_rotation")]
272 pub rotation: RotationPolicy,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub labels: Vec<String>,
276}
277
278fn default_rotation() -> RotationPolicy {
279 RotationPolicy::Manual
280}
281
282impl SecretRef {
283 #[must_use]
287 pub fn materialization_targets(&self) -> Vec<String> {
288 let base = self.backend.stable_id();
289 match &self.generation {
290 Some(SecretGenPolicy::WireguardKeypair) | Some(SecretGenPolicy::SshKeypair { .. }) => {
291 vec![format!("{base}.private"), format!("{base}.public")]
292 }
293 Some(SecretGenPolicy::TlsKeypair { .. }) => {
294 vec![format!("{base}.key"), format!("{base}.crt")]
295 }
296 _ => vec![base],
297 }
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct SecretMaterializationPlan {
307 #[serde(rename = "apiVersion")]
309 pub api_version: String,
310 pub kind: String,
312 pub metadata: PlanMetadata,
314 pub secrets: Vec<SecretRef>,
316 #[serde(default)]
319 pub test_only: bool,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct PlanMetadata {
324 pub name: String,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub description: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub source: Option<String>,
333}
334
335impl SecretMaterializationPlan {
336 pub const API_VERSION: &'static str = "pleme.io/v1";
338 pub const KIND: &'static str = "SecretMaterializationPlan";
340
341 #[must_use]
342 pub fn new(name: impl Into<String>, secrets: Vec<SecretRef>) -> Self {
343 Self {
344 api_version: Self::API_VERSION.into(),
345 kind: Self::KIND.into(),
346 metadata: PlanMetadata {
347 name: name.into(),
348 description: None,
349 source: None,
350 },
351 secrets,
352 test_only: false,
353 }
354 }
355
356 pub fn from_yaml(s: &str) -> Result<Self, PlanError> {
358 let plan: Self = serde_yaml::from_str(s).map_err(PlanError::Parse)?;
359 plan.validate()?;
360 Ok(plan)
361 }
362
363 pub fn to_yaml(&self) -> Result<String, PlanError> {
365 serde_yaml::to_string(self).map_err(PlanError::Serialize)
366 }
367}
368
369#[derive(Debug, thiserror::Error)]
374pub enum PlanError {
375 #[error("plan parse failure: {0}")]
376 Parse(serde_yaml::Error),
377 #[error("plan serialize failure: {0}")]
378 Serialize(serde_yaml::Error),
379 #[error("unsupported apiVersion: {0:?} (expected {expected:?})", expected = SecretMaterializationPlan::API_VERSION)]
380 UnsupportedApiVersion(String),
381 #[error("unexpected kind: {0:?} (expected {expected:?})", expected = SecretMaterializationPlan::KIND)]
382 UnexpectedKind(String),
383 #[error("plan name must match [a-z0-9-]+ (was {0:?})")]
384 InvalidPlanName(String),
385 #[error("secret name must match [a-z0-9-]+ (was {0:?})")]
386 InvalidSecretName(String),
387 #[error("secret name {0:?} appears more than once in the plan")]
388 DuplicateSecretName(String),
389 #[error("backend stable-id {0:?} appears in more than one secret — would collide on apply")]
390 DuplicateBackend(String),
391 #[error("BackendKind::Mock present in non-test plan — set test_only=true if intentional")]
392 MockBackendInProductionPlan,
393 #[error("PasswordRandom length must be > 0")]
394 ZeroLengthPassword,
395 #[error("PasswordRandom length {requested} exceeds max_length {cap}")]
396 PasswordExceedsMaxLength { requested: u8, cap: u8 },
397 #[error("PreSharedKey length_bytes must be > 0")]
398 ZeroLengthPreSharedKey,
399 #[error("Token length must be > 0")]
400 ZeroLengthToken,
401 #[error("TlsKeypair validity_days must be > 0")]
402 ZeroValidityDays,
403 #[error("Token prefix must match [a-zA-Z0-9_-]* (was {0:?})")]
404 InvalidTokenPrefix(String),
405 #[error("Sops backend file path must be absolute (was {0:?})")]
406 NonAbsoluteSopsFile(String),
407 #[error("Sops backend yaml_path must be non-empty")]
408 EmptySopsYamlPath,
409 #[error("Akeyless backend path must start with '/' (was {0:?})")]
410 InvalidAkeylessPath(String),
411 #[error("plan must contain at least one secret")]
412 EmptyPlan,
413}
414
415fn is_slug(s: &str) -> bool {
416 !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
417}
418
419fn is_token_prefix(s: &str) -> bool {
420 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
421}
422
423impl SecretMaterializationPlan {
424 pub fn validate(&self) -> Result<(), PlanError> {
426 if self.api_version != Self::API_VERSION {
427 return Err(PlanError::UnsupportedApiVersion(self.api_version.clone()));
428 }
429 if self.kind != Self::KIND {
430 return Err(PlanError::UnexpectedKind(self.kind.clone()));
431 }
432 if !is_slug(&self.metadata.name) {
433 return Err(PlanError::InvalidPlanName(self.metadata.name.clone()));
434 }
435 if self.secrets.is_empty() {
436 return Err(PlanError::EmptyPlan);
437 }
438
439 let mut seen_names = std::collections::HashSet::new();
440 let mut seen_backends = std::collections::HashSet::new();
441
442 for s in &self.secrets {
443 if !is_slug(&s.name) {
444 return Err(PlanError::InvalidSecretName(s.name.clone()));
445 }
446 if !seen_names.insert(s.name.clone()) {
447 return Err(PlanError::DuplicateSecretName(s.name.clone()));
448 }
449
450 for tgt in s.materialization_targets() {
451 if !seen_backends.insert(tgt.clone()) {
452 return Err(PlanError::DuplicateBackend(tgt));
453 }
454 }
455
456 if !self.test_only && s.backend.is_test_only() {
457 return Err(PlanError::MockBackendInProductionPlan);
458 }
459
460 validate_backend(&s.backend)?;
461
462 if let Some(g) = &s.generation {
463 validate_generation(g)?;
464 }
465 }
466 Ok(())
467 }
468}
469
470fn validate_backend(b: &BackendKind) -> Result<(), PlanError> {
471 match b {
472 BackendKind::Sops { file, yaml_path } => {
473 if !file.starts_with('/') {
474 return Err(PlanError::NonAbsoluteSopsFile(file.clone()));
475 }
476 if yaml_path.is_empty() {
477 return Err(PlanError::EmptySopsYamlPath);
478 }
479 }
480 BackendKind::Akeyless { path } => {
481 if !path.starts_with('/') {
482 return Err(PlanError::InvalidAkeylessPath(path.clone()));
483 }
484 }
485 BackendKind::Mock { .. } => {}
486 }
487 Ok(())
488}
489
490fn validate_generation(g: &SecretGenPolicy) -> Result<(), PlanError> {
491 match g {
492 SecretGenPolicy::PasswordRandom {
493 length,
494 max_length,
495 charset: _,
496 } => {
497 if *length == 0 {
498 return Err(PlanError::ZeroLengthPassword);
499 }
500 if let Some(cap) = max_length {
501 if length > cap {
502 return Err(PlanError::PasswordExceedsMaxLength {
503 requested: *length,
504 cap: *cap,
505 });
506 }
507 }
508 }
509 SecretGenPolicy::PreSharedKey { length_bytes } => {
510 if *length_bytes == 0 {
511 return Err(PlanError::ZeroLengthPreSharedKey);
512 }
513 }
514 SecretGenPolicy::Token { length, prefix } => {
515 if *length == 0 {
516 return Err(PlanError::ZeroLengthToken);
517 }
518 if let Some(p) = prefix {
519 if !is_token_prefix(p) {
520 return Err(PlanError::InvalidTokenPrefix(p.clone()));
521 }
522 }
523 }
524 SecretGenPolicy::TlsKeypair { validity_days, .. } => {
525 if *validity_days == 0 {
526 return Err(PlanError::ZeroValidityDays);
527 }
528 }
529 SecretGenPolicy::WireguardKeypair | SecretGenPolicy::SshKeypair { .. } => {}
530 }
531 Ok(())
532}
533
534impl SecretRef {
539 #[must_use]
541 pub fn password(name: impl Into<String>, backend: BackendKind, length: u8) -> Self {
542 Self {
543 name: name.into(),
544 description: None,
545 backend,
546 generation: Some(SecretGenPolicy::PasswordRandom {
547 length,
548 charset: Charset::Alphanumeric,
549 max_length: None,
550 }),
551 rotation: RotationPolicy::Manual,
552 labels: vec![],
553 }
554 }
555
556 #[must_use]
559 pub fn capped_password(
560 name: impl Into<String>,
561 backend: BackendKind,
562 length: u8,
563 max_length: u8,
564 ) -> Self {
565 Self {
566 name: name.into(),
567 description: None,
568 backend,
569 generation: Some(SecretGenPolicy::PasswordRandom {
570 length,
571 charset: Charset::Alphanumeric,
572 max_length: Some(max_length),
573 }),
574 rotation: RotationPolicy::Manual,
575 labels: vec![],
576 }
577 }
578
579 #[must_use]
581 pub fn with_rotation(mut self, r: RotationPolicy) -> Self {
582 self.rotation = r;
583 self
584 }
585
586 #[must_use]
588 pub fn with_description(mut self, d: impl Into<String>) -> Self {
589 self.description = Some(d.into());
590 self
591 }
592
593 #[must_use]
595 pub fn with_labels(mut self, labels: Vec<String>) -> Self {
596 self.labels = labels;
597 self
598 }
599}
600
601#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn akeyless_password(name: &str, length: u8) -> SecretRef {
610 SecretRef::password(
611 name,
612 BackendKind::Akeyless {
613 path: format!("/test/{name}"),
614 },
615 length,
616 )
617 }
618
619 fn ryn_plan() -> SecretMaterializationPlan {
620 SecretMaterializationPlan::new(
621 "ryn-remote-access",
622 vec![
623 SecretRef::capped_password(
624 "vnc-password",
625 BackendKind::Akeyless {
626 path: "/pleme-io/ryn/remote-access/vnc-password".into(),
627 },
628 16,
629 16,
630 )
631 .with_rotation(RotationPolicy::Quarterly)
632 .with_description("Apple Screen Sharing VNC password (capped at 16 by ARD-XOR)"),
633 SecretRef::password(
634 "rustdesk-password",
635 BackendKind::Akeyless {
636 path: "/pleme-io/ryn/remote-access/rustdesk-password".into(),
637 },
638 24,
639 )
640 .with_rotation(RotationPolicy::Quarterly),
641 ],
642 )
643 }
644
645 #[test]
648 fn every_charset_is_non_empty() {
649 for c in [
650 Charset::Alphanumeric,
651 Charset::Symbols,
652 Charset::Hex,
653 Charset::Base32,
654 Charset::Base64UrlSafe,
655 ] {
656 assert!(!c.alphabet().is_empty());
657 }
658 }
659
660 #[test]
661 fn hex_alphabet_is_lowercase() {
662 assert_eq!(Charset::Hex.alphabet(), b"0123456789abcdef");
663 }
664
665 #[test]
668 fn ryn_plan_validates() {
669 assert!(ryn_plan().validate().is_ok());
670 }
671
672 #[test]
673 fn empty_plan_rejected() {
674 let p = SecretMaterializationPlan::new("empty", vec![]);
675 assert!(matches!(p.validate(), Err(PlanError::EmptyPlan)));
676 }
677
678 #[test]
679 fn duplicate_secret_name_rejected() {
680 let p = SecretMaterializationPlan::new(
681 "dup",
682 vec![akeyless_password("foo", 16), akeyless_password("foo", 16)],
683 );
684 assert!(matches!(p.validate(), Err(PlanError::DuplicateSecretName(_))));
685 }
686
687 #[test]
688 fn duplicate_backend_rejected() {
689 let mut a = akeyless_password("foo", 16);
690 let mut b = akeyless_password("bar", 16);
691 a.backend = BackendKind::Akeyless { path: "/x".into() };
692 b.backend = BackendKind::Akeyless { path: "/x".into() };
693 let p = SecretMaterializationPlan::new("dup-backend", vec![a, b]);
694 assert!(matches!(p.validate(), Err(PlanError::DuplicateBackend(_))));
695 }
696
697 #[test]
698 fn invalid_plan_name_rejected() {
699 let p = SecretMaterializationPlan::new("Bad Name!", vec![akeyless_password("x", 16)]);
700 assert!(matches!(p.validate(), Err(PlanError::InvalidPlanName(_))));
701 }
702
703 #[test]
704 fn invalid_secret_name_rejected() {
705 let p = SecretMaterializationPlan::new("ok", vec![akeyless_password("Bad Name", 16)]);
706 assert!(matches!(p.validate(), Err(PlanError::InvalidSecretName(_))));
707 }
708
709 #[test]
710 fn unsupported_apiversion_rejected() {
711 let mut p = ryn_plan();
712 p.api_version = "wrong/v0".into();
713 assert!(matches!(p.validate(), Err(PlanError::UnsupportedApiVersion(_))));
714 }
715
716 #[test]
717 fn unexpected_kind_rejected() {
718 let mut p = ryn_plan();
719 p.kind = "Whatever".into();
720 assert!(matches!(p.validate(), Err(PlanError::UnexpectedKind(_))));
721 }
722
723 #[test]
724 fn mock_backend_in_prod_plan_rejected() {
725 let mut s = akeyless_password("foo", 16);
726 s.backend = BackendKind::Mock { name: "x".into() };
727 let p = SecretMaterializationPlan::new("prod", vec![s]);
728 assert!(matches!(p.validate(), Err(PlanError::MockBackendInProductionPlan)));
729 }
730
731 #[test]
732 fn mock_backend_in_test_plan_allowed() {
733 let mut s = akeyless_password("foo", 16);
734 s.backend = BackendKind::Mock { name: "x".into() };
735 let mut p = SecretMaterializationPlan::new("test", vec![s]);
736 p.test_only = true;
737 assert!(p.validate().is_ok());
738 }
739
740 #[test]
741 fn nonabsolute_sops_file_rejected() {
742 let s = SecretRef::password(
743 "foo",
744 BackendKind::Sops {
745 file: "relative/path.yaml".into(),
746 yaml_path: "x.y".into(),
747 },
748 16,
749 );
750 let p = SecretMaterializationPlan::new("nonabs", vec![s]);
751 assert!(matches!(p.validate(), Err(PlanError::NonAbsoluteSopsFile(_))));
752 }
753
754 #[test]
755 fn empty_sops_yaml_path_rejected() {
756 let s = SecretRef::password(
757 "foo",
758 BackendKind::Sops {
759 file: "/abs.yaml".into(),
760 yaml_path: String::new(),
761 },
762 16,
763 );
764 let p = SecretMaterializationPlan::new("emptyyp", vec![s]);
765 assert!(matches!(p.validate(), Err(PlanError::EmptySopsYamlPath)));
766 }
767
768 #[test]
769 fn invalid_akeyless_path_rejected() {
770 let s = SecretRef::password(
771 "foo",
772 BackendKind::Akeyless { path: "no-slash".into() },
773 16,
774 );
775 let p = SecretMaterializationPlan::new("invak", vec![s]);
776 assert!(matches!(p.validate(), Err(PlanError::InvalidAkeylessPath(_))));
777 }
778
779 #[test]
782 fn zero_length_password_rejected() {
783 let s = SecretRef::password(
784 "foo",
785 BackendKind::Akeyless { path: "/x".into() },
786 0,
787 );
788 let p = SecretMaterializationPlan::new("zerolen", vec![s]);
789 assert!(matches!(p.validate(), Err(PlanError::ZeroLengthPassword)));
790 }
791
792 #[test]
793 fn password_exceeds_max_length_rejected() {
794 let s = SecretRef::capped_password(
795 "vnc",
796 BackendKind::Akeyless { path: "/x".into() },
797 32,
798 16,
799 );
800 let p = SecretMaterializationPlan::new("toolong", vec![s]);
801 assert!(matches!(
802 p.validate(),
803 Err(PlanError::PasswordExceedsMaxLength { requested: 32, cap: 16 })
804 ));
805 }
806
807 #[test]
808 fn vnc_at_max_length_allowed() {
809 let s = SecretRef::capped_password(
811 "vnc",
812 BackendKind::Akeyless { path: "/x".into() },
813 16,
814 16,
815 );
816 let p = SecretMaterializationPlan::new("vnc", vec![s]);
817 assert!(p.validate().is_ok());
818 }
819
820 #[test]
821 fn zero_byte_psk_rejected() {
822 let s = SecretRef {
823 name: "psk".into(),
824 description: None,
825 backend: BackendKind::Akeyless { path: "/x".into() },
826 generation: Some(SecretGenPolicy::PreSharedKey { length_bytes: 0 }),
827 rotation: RotationPolicy::Manual,
828 labels: vec![],
829 };
830 let p = SecretMaterializationPlan::new("zeropsk", vec![s]);
831 assert!(matches!(p.validate(), Err(PlanError::ZeroLengthPreSharedKey)));
832 }
833
834 #[test]
835 fn zero_validity_tls_rejected() {
836 let s = SecretRef {
837 name: "tls".into(),
838 description: None,
839 backend: BackendKind::Akeyless { path: "/x".into() },
840 generation: Some(SecretGenPolicy::TlsKeypair {
841 algo: TlsAlgo::Ed25519,
842 validity_days: 0,
843 }),
844 rotation: RotationPolicy::Manual,
845 labels: vec![],
846 };
847 let p = SecretMaterializationPlan::new("notvalid", vec![s]);
848 assert!(matches!(p.validate(), Err(PlanError::ZeroValidityDays)));
849 }
850
851 #[test]
852 fn invalid_token_prefix_rejected() {
853 let s = SecretRef {
854 name: "tok".into(),
855 description: None,
856 backend: BackendKind::Akeyless { path: "/x".into() },
857 generation: Some(SecretGenPolicy::Token {
858 length: 32,
859 prefix: Some("bad space".into()),
860 }),
861 rotation: RotationPolicy::Manual,
862 labels: vec![],
863 };
864 let p = SecretMaterializationPlan::new("badprefix", vec![s]);
865 assert!(matches!(p.validate(), Err(PlanError::InvalidTokenPrefix(_))));
866 }
867
868 #[test]
871 fn singleton_password_has_one_target() {
872 let s = akeyless_password("foo", 16);
873 assert_eq!(s.materialization_targets().len(), 1);
874 }
875
876 #[test]
877 fn wireguard_keypair_has_two_targets() {
878 let s = SecretRef {
879 name: "wg".into(),
880 description: None,
881 backend: BackendKind::Akeyless { path: "/x".into() },
882 generation: Some(SecretGenPolicy::WireguardKeypair),
883 rotation: RotationPolicy::Manual,
884 labels: vec![],
885 };
886 let t = s.materialization_targets();
887 assert_eq!(t.len(), 2);
888 assert!(t[0].ends_with(".private"));
889 assert!(t[1].ends_with(".public"));
890 }
891
892 #[test]
893 fn tls_keypair_targets_are_key_and_crt() {
894 let s = SecretRef {
895 name: "tls".into(),
896 description: None,
897 backend: BackendKind::Akeyless { path: "/x".into() },
898 generation: Some(SecretGenPolicy::TlsKeypair {
899 algo: TlsAlgo::Ed25519,
900 validity_days: 365,
901 }),
902 rotation: RotationPolicy::Yearly,
903 labels: vec![],
904 };
905 let t = s.materialization_targets();
906 assert!(t[0].ends_with(".key"));
907 assert!(t[1].ends_with(".crt"));
908 }
909
910 #[test]
913 fn yaml_round_trip_is_total() {
914 let p = ryn_plan();
915 let s = p.to_yaml().unwrap();
916 let q = SecretMaterializationPlan::from_yaml(&s).unwrap();
917 assert_eq!(p, q);
918 }
919
920 #[test]
921 fn json_round_trip_is_total() {
922 let p = ryn_plan();
923 let s = serde_json::to_string(&p).unwrap();
924 let q: SecretMaterializationPlan = serde_json::from_str(&s).unwrap();
925 assert_eq!(p, q);
926 }
927
928 #[test]
929 fn yaml_is_deterministic() {
930 let p = ryn_plan();
931 assert_eq!(p.to_yaml().unwrap(), p.to_yaml().unwrap());
932 }
933
934 #[test]
935 fn rotation_overdue_table() {
936 assert_eq!(RotationPolicy::Manual.overdue_after_days(), None);
937 assert_eq!(RotationPolicy::Quarterly.overdue_after_days(), Some(90));
938 assert_eq!(RotationPolicy::Yearly.overdue_after_days(), Some(365));
939 assert_eq!(RotationPolicy::Never.overdue_after_days(), None);
940 }
941
942 #[test]
954 fn camelot_bootstrap_plan_validates() {
955 let path = "/Users/luis.d/code/github/pleme-io/nix/cofre-plans/camelot-bootstrap.yaml";
956 let body = std::fs::read_to_string(path)
957 .unwrap_or_else(|e| panic!("read {path}: {e}"));
958 let plan = SecretMaterializationPlan::from_yaml(&body)
959 .unwrap_or_else(|e| panic!("camelot-bootstrap.yaml failed validation: {e}"));
960 assert_eq!(plan.metadata.name, "camelot-dev-bootstrap");
961 assert_eq!(plan.secrets.len(), 7);
962 assert!(!plan.test_only);
963 for s in &plan.secrets {
964 assert!(matches!(s.backend, BackendKind::Sops { .. }));
965 assert_eq!(s.rotation, RotationPolicy::Quarterly);
966 }
967 }
968}