Skip to main content

cofre_types/
lib.rs

1//! `cofre-types` — typed secret-materialization primitives.
2//!
3//! ══════════════════════════════════════════════════════════════════════
4//! What this crate is
5//! ══════════════════════════════════════════════════════════════════════
6//!
7//! The pure-types half of the **cofre** toolchain. Defines:
8//!
9//!   - `SecretGenPolicy`   — how a secret is born (random, keypair, ...)
10//!   - `RotationPolicy`    — when it should be rotated
11//!   - `Charset`           — which alphabet random-generated values draw from
12//!   - `BackendKind`       — where the materialized value lives
13//!   - `SecretRef`         — a typed pointer to one secret, with policy
14//!   - `SecretMaterializationPlan` — many `SecretRef`s plus metadata,
15//!                                    serializable to YAML/JSON for a
16//!                                    `cofre apply` invocation
17//!
18//! No I/O, no randomness, no backend code lives here — those concerns
19//! belong in the `cofre` binary or in third-party `SecretBackend` impls.
20//! This crate is **pure types**: load it, manipulate, validate, render.
21//!
22//! ══════════════════════════════════════════════════════════════════════
23//! Why this crate exists
24//! ══════════════════════════════════════════════════════════════════════
25//!
26//! Many tools generate secrets. Most of them put the plaintext on stdout
27//! at some point. Most of them hard-code one backend. Most of them have
28//! no notion of "this kind of password is constrained to ≤16 characters
29//! by the consuming protocol".
30//!
31//! cofre's typed pipeline solves all three. By the time a generated
32//! secret exists, the typescape has already proven (at compile time +
33//! `cargo test`) that:
34//!
35//!   - the chosen length is compatible with the consumer (e.g. VNC's
36//!     16-char ARD-XOR cap is structurally enforced via `max_length`)
37//!   - the requested charset is non-empty
38//!   - the rotation policy is consistent (e.g. `Never` rejects pairing
39//!     with a `Manual` rotation)
40//!
41//! Validation is total before generation begins. Once cofre starts
42//! materializing, the only remaining failure modes are I/O.
43//!
44//! ══════════════════════════════════════════════════════════════════════
45//! Compatibility
46//! ══════════════════════════════════════════════════════════════════════
47//!
48//! Plans serialize to a stable YAML (and JSON) shape — the schema is part
49//! of cofre's public contract and is versioned via `apiVersion`. Adding
50//! new variants to `SecretGenPolicy` is non-breaking; renaming or
51//! removing them bumps the schema.
52
53#![warn(clippy::pedantic)]
54
55use serde::{Deserialize, Serialize};
56
57// ══════════════════════════════════════════════════════════════════════
58// Charset — which alphabet random-generated values draw from
59// ══════════════════════════════════════════════════════════════════════
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63pub enum Charset {
64    /// `[A-Za-z0-9]` — safest for legacy systems with weird quoting.
65    Alphanumeric,
66    /// `[A-Za-z0-9~!@#$%^&*()-_=+\[\]{};:,.<>?/]` — strong but commonly safe.
67    Symbols,
68    /// `[0-9a-f]` — fixed-width hex, e.g. for token-like material.
69    Hex,
70    /// `[A-Z2-7]` — RFC 4648 base32 alphabet (no padding semantics here).
71    Base32,
72    /// URL-safe base64 (`-` and `_`, no padding).
73    Base64UrlSafe,
74}
75
76impl Charset {
77    /// The set of allowed bytes. Used by the generator at runtime; here
78    /// we expose it for validators to assert non-emptiness.
79    #[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// ══════════════════════════════════════════════════════════════════════
98// SecretGenPolicy — how a secret is born
99// ══════════════════════════════════════════════════════════════════════
100
101#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case", tag = "kind")]
103pub enum SecretGenPolicy {
104    /// A random password drawn from `charset`.
105    ///
106    /// `length` is the *requested* length; `max_length`, when set, is a
107    /// **structural cap** imposed by the consumer (e.g. macOS's legacy
108    /// VNC ARD-XOR scheme silently truncates beyond 16 bytes — pairing
109    /// `length: 32` with `max_length: Some(16)` is a validator failure).
110    PasswordRandom {
111        length: u8,
112        charset: Charset,
113        max_length: Option<u8>,
114    },
115    /// A pre-shared key — random bytes, `length_bytes` long, encoded as
116    /// base64 url-safe. Used for WireGuard PSKs and similar.
117    PreSharedKey { length_bytes: u8 },
118    /// A bearer token — random alphanumeric, `length` chars, with an
119    /// optional human-readable prefix (`prefix: Some("pat_")` →
120    /// `pat_AbCdEf...`).
121    Token { length: u8, prefix: Option<String> },
122    /// A WireGuard X25519 keypair. Materializes TWO secrets at once:
123    /// `<path>.private` and `<path>.public`, both base64 (32 bytes each).
124    WireguardKeypair,
125    /// An SSH keypair (Ed25519 or RSA). Same dual-path materialization
126    /// as WireGuard: `<path>.private` (OpenSSH PEM), `<path>.public`
127    /// (`ssh-ed25519 AAAA... comment` line).
128    SshKeypair { algo: SshAlgo },
129    /// A self-signed TLS keypair, valid `validity_days` from generation.
130    /// Materializes `<path>.key` (PEM) + `<path>.crt` (PEM).
131    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    /// How many distinct backend paths this policy materializes. Most
151    /// policies are 1; keypair policies are 2.
152    #[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// ══════════════════════════════════════════════════════════════════════
162// RotationPolicy — when a secret should be rotated
163// ══════════════════════════════════════════════════════════════════════
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
166#[serde(rename_all = "kebab-case")]
167pub enum RotationPolicy {
168    /// `cofre apply` never auto-rotates this secret. Operator must
169    /// explicitly invoke `cofre apply --rotate <path>`.
170    Manual,
171    /// `cofre plan` flags this secret as overdue once 90 days have
172    /// elapsed since last materialization (per the inventory).
173    Quarterly,
174    /// `cofre plan` flags this secret as overdue once 365 days have
175    /// elapsed.
176    Yearly,
177    /// Root-of-trust material that should NEVER be rotated by cofre.
178    /// `cofre apply --rotate` against a `Never`-marked secret refuses.
179    Never,
180}
181
182impl RotationPolicy {
183    /// Days after materialization beyond which `cofre plan` flags this
184    /// secret as overdue. `None` means never overdue (`Manual` / `Never`).
185    #[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// ══════════════════════════════════════════════════════════════════════
196// BackendKind — where the materialized value lives
197// ══════════════════════════════════════════════════════════════════════
198
199#[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-encrypted YAML/JSON file, with the secret stored at a
205    /// specific YAML path inside it. cofre uses an EDITOR-mode hijack
206    /// to splice the value in without ever displaying plaintext.
207    Sops {
208        /// Absolute path to the SOPS file on disk.
209        file: String,
210        /// Dotted YAML path within the file, e.g. `cofre.ryn.vnc-password`.
211        yaml_path: String,
212    },
213    /// Akeyless secret at the given absolute path. cofre uploads via
214    /// the Akeyless API (or CLI with FD-passed value, never argv).
215    Akeyless {
216        /// Absolute Akeyless secret path, e.g. `/pleme-io/ryn/...`.
217        path: String,
218    },
219    /// In-memory only. Used by tests; rejected by `validate_backend()`
220    /// for production plans (the validator gates this behind a
221    /// `Plan::test_only` toggle).
222    Mock { name: String },
223}
224
225impl BackendKind {
226    /// Stable identifier for inventory + dedup. For `Sops`, this is
227    /// `sops:<file>:<yaml_path>`; for `Akeyless`, `akeyless:<path>`.
228    #[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
243// Fleet-wide dispatcher-catalog registration for the cofre secret-
244// source surface. Substrate gains typed coverage over the resource
245// side of secret materialization — same algebraic laws apply.
246// See theory/UNIFIED-COMPUTING-MODEL.md §VI.
247gen_platform::register_dispatcher!("cofre.backend-kind", BackendKind);
248
249// ══════════════════════════════════════════════════════════════════════
250// SecretRef — a typed pointer to one secret
251// ══════════════════════════════════════════════════════════════════════
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct SecretRef {
255    /// Logical identifier — used by humans + inventories. Slug-shape
256    /// (`[a-z0-9-]+`). Validators reject anything else.
257    pub name: String,
258    /// Optional human-readable description. Renders into the inventory
259    /// + plan, never into the secret value itself.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub description: Option<String>,
262    /// Where the materialized secret lives.
263    pub backend: BackendKind,
264    /// How the secret is born. `None` ⇒ cofre never generates this
265    /// secret (operator owns its lifecycle); cofre may still verify
266    /// existence via `cofre verify`.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub generation: Option<SecretGenPolicy>,
269    /// When the secret should be rotated.
270    #[serde(default = "default_rotation")]
271    pub rotation: RotationPolicy,
272    /// Free-form labels — used for filtering + grouping in `cofre plan`.
273    #[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    /// Returns the list of concrete backend paths this ref materializes.
283    /// For a singleton policy: `[self.backend.stable_id()]`. For
284    /// keypair policies: two suffixed paths.
285    #[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// ══════════════════════════════════════════════════════════════════════
301// SecretMaterializationPlan — many SecretRefs + metadata
302// ══════════════════════════════════════════════════════════════════════
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct SecretMaterializationPlan {
306    /// Schema version. Currently `pleme.io/v1`.
307    #[serde(rename = "apiVersion")]
308    pub api_version: String,
309    /// Always `SecretMaterializationPlan`. Reject mismatches at parse.
310    pub kind: String,
311    /// Plan-level metadata (operator-friendly identifiers).
312    pub metadata: PlanMetadata,
313    /// The actual secrets.
314    pub secrets: Vec<SecretRef>,
315    /// When `true`, validators allow `BackendKind::Mock` entries.
316    /// Plans rendered from arch-synthesizer always emit `false`.
317    #[serde(default)]
318    pub test_only: bool,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct PlanMetadata {
323    /// Plan name — slug-shape, e.g. `ryn-remote-access`.
324    pub name: String,
325    /// Optional human description.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub description: Option<String>,
328    /// Source typescape that produced this plan, e.g.
329    /// `arch-synthesizer/remote_access`. For attestation breadcrumbs.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub source: Option<String>,
332}
333
334impl SecretMaterializationPlan {
335    /// Canonical `apiVersion` value.
336    pub const API_VERSION: &'static str = "pleme.io/v1";
337    /// Canonical `kind` value.
338    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    /// Read a plan from a YAML string. Validates schema and content.
356    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    /// Render the plan to YAML.
363    pub fn to_yaml(&self) -> Result<String, PlanError> {
364        serde_yaml::to_string(self).map_err(PlanError::Serialize)
365    }
366}
367
368// ══════════════════════════════════════════════════════════════════════
369// Validation
370// ══════════════════════════════════════════════════════════════════════
371
372#[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    /// Validate every structural invariant. Pure, total. Fails fast.
427    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
536// ══════════════════════════════════════════════════════════════════════
537// Convenient builders for common shapes
538// ══════════════════════════════════════════════════════════════════════
539
540impl SecretRef {
541    /// A vanilla random alphanumeric password, no length cap, manual rotation.
542    #[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    /// A capped password — for protocols with hard length limits
559    /// (e.g. macOS legacy VNC at 16 chars).
560    #[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    /// Builder helper.
582    #[must_use]
583    pub fn with_rotation(mut self, r: RotationPolicy) -> Self {
584        self.rotation = r;
585        self
586    }
587
588    /// Builder helper.
589    #[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    /// Builder helper.
596    #[must_use]
597    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
598        self.labels = labels;
599        self
600    }
601}
602
603// ══════════════════════════════════════════════════════════════════════
604// Tests
605// ══════════════════════════════════════════════════════════════════════
606
607#[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    // ── Charsets ──────────────────────────────────────────────────────
648
649    #[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    // ── Plan validation ────────────────────────────────────────────────
668
669    #[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    // ── Generation policy validation ───────────────────────────────────
799
800    #[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        // length == max_length is the BR canonical case for VNC.
824        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    // ── materialization_targets ────────────────────────────────────────
885
886    #[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    // ── Round-trip ─────────────────────────────────────────────────────
927
928    #[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    /// theory/SELF-BOOTSTRAP-AKEYLESS.md's M0 step 1 -- the typed
959    /// SecretMaterializationPlan declaring Camelot's real bootstrap
960    /// secrets (mirroring camelot-bootstrap/secrets.go's exact
961    /// defaultSpec/GenerateSecureSecrets shape) validates against cofre's
962    /// real, unmocked SecretMaterializationPlan::validate() -- the same
963    /// path `cofre plan --manifest <path>` runs. Data-only: this test
964    /// proves the plan is well-formed, it never generates or applies a
965    /// live secret. The plan file lives in the nix repo (not this one)
966    /// since it's fleet config, not a cofre-owned artifact -- read here
967    /// by absolute path as the pragmatic verification seam given the nix
968    /// repo isn't a Cargo workspace.
969    /// ── ★ THE PATH IS DERIVED, AND ITS ABSENCE IS A SKIP, NOT A PANIC ─────
970    /// This read a hardcoded `/Users/luis.d/code/github/pleme-io/nix/...`, so it
971    /// could only ever pass on ONE operator's machine. It went unnoticed for a
972    /// different reason than usual: the workspace manifest had
973    /// `optional = true` on a `[workspace.dependencies]` entry, which Cargo
974    /// rejects outright, so NO test in this repo had ever run. Fixing the
975    /// manifest on 2026-08-05 made this the first-ever execution — and it
976    /// failed immediately, on another operator's home directory.
977    ///
978    /// The plan file genuinely lives in the sibling `nix` repo (fleet config,
979    /// not a cofre artifact), so the cross-repo read stays. What changes is how
980    /// it is located: derived from this crate's own manifest directory, which
981    /// tracks the checkout instead of one machine.
982    ///
983    /// And a missing sibling is now a SKIP with a printed reason rather than a
984    /// panic. A cross-repo fixture that is legitimately absent (a CI checkout of
985    /// cofre alone) is not a cofre defect, and turning it into a red test trains
986    /// people to ignore the suite — the failure mode this repo's own docs call
987    /// out elsewhere.
988    #[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}