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 {
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    /// How many distinct backend paths this policy materializes. Most
154    /// policies are 1; keypair policies are 2.
155    #[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// ══════════════════════════════════════════════════════════════════════
165// RotationPolicy — when a secret should be rotated
166// ══════════════════════════════════════════════════════════════════════
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[serde(rename_all = "kebab-case")]
170pub enum RotationPolicy {
171    /// `cofre apply` never auto-rotates this secret. Operator must
172    /// explicitly invoke `cofre apply --rotate <path>`.
173    Manual,
174    /// `cofre plan` flags this secret as overdue once 90 days have
175    /// elapsed since last materialization (per the inventory).
176    Quarterly,
177    /// `cofre plan` flags this secret as overdue once 365 days have
178    /// elapsed.
179    Yearly,
180    /// Root-of-trust material that should NEVER be rotated by cofre.
181    /// `cofre apply --rotate` against a `Never`-marked secret refuses.
182    Never,
183}
184
185impl RotationPolicy {
186    /// Days after materialization beyond which `cofre plan` flags this
187    /// secret as overdue. `None` means never overdue (`Manual` / `Never`).
188    #[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// ══════════════════════════════════════════════════════════════════════
199// BackendKind — where the materialized value lives
200// ══════════════════════════════════════════════════════════════════════
201
202#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, gen_platform::TypedDispatcher)]
203#[serde(rename_all = "kebab-case", tag = "kind")]
204pub enum BackendKind {
205    /// SOPS-encrypted YAML/JSON file, with the secret stored at a
206    /// specific YAML path inside it. cofre uses an EDITOR-mode hijack
207    /// to splice the value in without ever displaying plaintext.
208    Sops {
209        /// Absolute path to the SOPS file on disk.
210        file: String,
211        /// Dotted YAML path within the file, e.g. `cofre.ryn.vnc-password`.
212        yaml_path: String,
213    },
214    /// Akeyless secret at the given absolute path. cofre uploads via
215    /// the Akeyless API (or CLI with FD-passed value, never argv).
216    Akeyless {
217        /// Absolute Akeyless secret path, e.g. `/pleme-io/ryn/...`.
218        path: String,
219    },
220    /// In-memory only. Used by tests; rejected by `validate_backend()`
221    /// for production plans (the validator gates this behind a
222    /// `Plan::test_only` toggle).
223    Mock { name: String },
224}
225
226impl BackendKind {
227    /// Stable identifier for inventory + dedup. For `Sops`, this is
228    /// `sops:<file>:<yaml_path>`; for `Akeyless`, `akeyless:<path>`.
229    #[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
244// Fleet-wide dispatcher-catalog registration for the cofre secret-
245// source surface. Substrate gains typed coverage over the resource
246// side of secret materialization — same algebraic laws apply.
247// See theory/UNIFIED-COMPUTING-MODEL.md §VI.
248gen_platform::register_dispatcher!("cofre.backend-kind", BackendKind);
249
250// ══════════════════════════════════════════════════════════════════════
251// SecretRef — a typed pointer to one secret
252// ══════════════════════════════════════════════════════════════════════
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct SecretRef {
256    /// Logical identifier — used by humans + inventories. Slug-shape
257    /// (`[a-z0-9-]+`). Validators reject anything else.
258    pub name: String,
259    /// Optional human-readable description. Renders into the inventory
260    /// + plan, never into the secret value itself.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub description: Option<String>,
263    /// Where the materialized secret lives.
264    pub backend: BackendKind,
265    /// How the secret is born. `None` ⇒ cofre never generates this
266    /// secret (operator owns its lifecycle); cofre may still verify
267    /// existence via `cofre verify`.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub generation: Option<SecretGenPolicy>,
270    /// When the secret should be rotated.
271    #[serde(default = "default_rotation")]
272    pub rotation: RotationPolicy,
273    /// Free-form labels — used for filtering + grouping in `cofre plan`.
274    #[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    /// Returns the list of concrete backend paths this ref materializes.
284    /// For a singleton policy: `[self.backend.stable_id()]`. For
285    /// keypair policies: two suffixed paths.
286    #[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// ══════════════════════════════════════════════════════════════════════
302// SecretMaterializationPlan — many SecretRefs + metadata
303// ══════════════════════════════════════════════════════════════════════
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct SecretMaterializationPlan {
307    /// Schema version. Currently `pleme.io/v1`.
308    #[serde(rename = "apiVersion")]
309    pub api_version: String,
310    /// Always `SecretMaterializationPlan`. Reject mismatches at parse.
311    pub kind: String,
312    /// Plan-level metadata (operator-friendly identifiers).
313    pub metadata: PlanMetadata,
314    /// The actual secrets.
315    pub secrets: Vec<SecretRef>,
316    /// When `true`, validators allow `BackendKind::Mock` entries.
317    /// Plans rendered from arch-synthesizer always emit `false`.
318    #[serde(default)]
319    pub test_only: bool,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct PlanMetadata {
324    /// Plan name — slug-shape, e.g. `ryn-remote-access`.
325    pub name: String,
326    /// Optional human description.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub description: Option<String>,
329    /// Source typescape that produced this plan, e.g.
330    /// `arch-synthesizer/remote_access`. For attestation breadcrumbs.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub source: Option<String>,
333}
334
335impl SecretMaterializationPlan {
336    /// Canonical `apiVersion` value.
337    pub const API_VERSION: &'static str = "pleme.io/v1";
338    /// Canonical `kind` value.
339    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    /// Read a plan from a YAML string. Validates schema and content.
357    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    /// Render the plan to YAML.
364    pub fn to_yaml(&self) -> Result<String, PlanError> {
365        serde_yaml::to_string(self).map_err(PlanError::Serialize)
366    }
367}
368
369// ══════════════════════════════════════════════════════════════════════
370// Validation
371// ══════════════════════════════════════════════════════════════════════
372
373#[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    /// Validate every structural invariant. Pure, total. Fails fast.
425    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
534// ══════════════════════════════════════════════════════════════════════
535// Convenient builders for common shapes
536// ══════════════════════════════════════════════════════════════════════
537
538impl SecretRef {
539    /// A vanilla random alphanumeric password, no length cap, manual rotation.
540    #[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    /// A capped password — for protocols with hard length limits
557    /// (e.g. macOS legacy VNC at 16 chars).
558    #[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    /// Builder helper.
580    #[must_use]
581    pub fn with_rotation(mut self, r: RotationPolicy) -> Self {
582        self.rotation = r;
583        self
584    }
585
586    /// Builder helper.
587    #[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    /// Builder helper.
594    #[must_use]
595    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
596        self.labels = labels;
597        self
598    }
599}
600
601// ══════════════════════════════════════════════════════════════════════
602// Tests
603// ══════════════════════════════════════════════════════════════════════
604
605#[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    // ── Charsets ──────────────────────────────────────────────────────
646
647    #[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    // ── Plan validation ────────────────────────────────────────────────
666
667    #[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    // ── Generation policy validation ───────────────────────────────────
780
781    #[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        // length == max_length is the BR canonical case for VNC.
810        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    // ── materialization_targets ────────────────────────────────────────
869
870    #[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    // ── Round-trip ─────────────────────────────────────────────────────
911
912    #[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    /// theory/SELF-BOOTSTRAP-AKEYLESS.md's M0 step 1 -- the typed
943    /// SecretMaterializationPlan declaring Camelot's real bootstrap
944    /// secrets (mirroring camelot-bootstrap/secrets.go's exact
945    /// defaultSpec/GenerateSecureSecrets shape) validates against cofre's
946    /// real, unmocked SecretMaterializationPlan::validate() -- the same
947    /// path `cofre plan --manifest <path>` runs. Data-only: this test
948    /// proves the plan is well-formed, it never generates or applies a
949    /// live secret. The plan file lives in the nix repo (not this one)
950    /// since it's fleet config, not a cofre-owned artifact -- read here
951    /// by absolute path as the pragmatic verification seam given the nix
952    /// repo isn't a Cargo workspace.
953    #[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}