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