Skip to main content

greentic_types/
secrets.rs

1//! Canonical secret requirement primitives shared across Greentic crates.
2//! All repos must use these helpers; local re-implementation is forbidden.
3
4use crate::{ErrorCode, GResult, GreenticError};
5use alloc::{format, string::String, vec::Vec};
6use core::ops::Deref;
7#[cfg(feature = "schemars")]
8use schemars::JsonSchema;
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12/// Canonical secret identifier used across manifests and bindings.
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
14#[cfg_attr(feature = "serde", derive(Serialize))]
15#[cfg_attr(feature = "serde", serde(transparent))]
16#[cfg_attr(feature = "schemars", derive(JsonSchema))]
17pub struct SecretKey(String);
18
19impl SecretKey {
20    /// Constructs a secret key and validates the identifier format.
21    pub fn new(key: impl Into<String>) -> GResult<Self> {
22        let key = key.into();
23        Self::parse(&key).map_err(|err| {
24            GreenticError::new(
25                ErrorCode::InvalidInput,
26                format!("invalid secret key: {err}"),
27            )
28        })
29    }
30
31    /// Returns the key as a string slice.
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// Parses and validates a secret key string.
37    ///
38    /// Validation rules:
39    /// - must be non-empty
40    /// - allowed characters: ASCII `a-zA-Z0-9._-/`
41    /// - must not start with `/`
42    /// - must not contain a `..` path segment
43    pub fn parse(value: &str) -> Result<Self, SecretKeyError> {
44        if value.is_empty() {
45            return Err(SecretKeyError::Empty);
46        }
47        if value.starts_with('/') {
48            return Err(SecretKeyError::LeadingSlash);
49        }
50        for c in value.chars() {
51            if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) {
52                return Err(SecretKeyError::InvalidChar { c });
53            }
54        }
55        if value.split('/').any(|segment| segment == "..") {
56            return Err(SecretKeyError::DotDotSegment);
57        }
58        Ok(Self(value.to_owned()))
59    }
60
61    /// Parses and validates a secret key in **canonical** form.
62    ///
63    /// Canonical keys must satisfy all rules of [`Self::parse`] plus:
64    /// - no ASCII uppercase letters (`A-Z`) — use [`Self::normalize`] to lowercase first
65    /// - must not end with `/`
66    ///
67    /// A single-segment lowercase key (e.g. `"flat_lower"`) is valid; a `/` separator is
68    /// not required.
69    pub fn parse_canonical(value: &str) -> Result<Self, SecretKeyError> {
70        // Run base validation first (handles empty, leading slash, invalid chars, `..`).
71        Self::parse(value)?;
72
73        // Reject any uppercase ASCII letter.
74        if value.chars().any(|c| c.is_ascii_uppercase()) {
75            return Err(SecretKeyError::Uppercase);
76        }
77
78        // Reject trailing `/`.
79        if value.ends_with('/') {
80            return Err(SecretKeyError::TrailingSlash);
81        }
82
83        Ok(Self(value.to_owned()))
84    }
85
86    /// Lowercases the input (ASCII only, lossless apart from case) and then validates it
87    /// with [`Self::parse_canonical`].
88    ///
89    /// This is the preferred entry-point when ingesting externally-sourced keys that may
90    /// use `UPPER_SNAKE_CASE` or `Mixed/Case` conventions.
91    pub fn normalize(value: impl Into<String>) -> Result<Self, SecretKeyError> {
92        let lowered = value.into().to_ascii_lowercase();
93        Self::parse_canonical(&lowered)
94    }
95}
96
97/// Validation errors produced by [`SecretKey::parse`] and [`SecretKey::parse_canonical`].
98#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
99pub enum SecretKeyError {
100    /// Input was empty.
101    #[error("secret key must not be empty")]
102    Empty,
103    /// Input started with `/`.
104    #[error("secret key must not start with '/'")]
105    LeadingSlash,
106    /// Input contained a `..` path segment.
107    #[error("secret key must not contain '..' segments")]
108    DotDotSegment,
109    /// Input contained a disallowed character.
110    #[error("secret key contains invalid character '{c}'")]
111    InvalidChar {
112        /// The offending character.
113        c: char,
114    },
115    /// Canonical key contained an ASCII uppercase letter.
116    ///
117    /// Use [`SecretKey::normalize`] to lowercase the input before parsing canonically.
118    #[error("canonical secret key must not contain uppercase letters")]
119    Uppercase,
120    /// Canonical key ended with `/`.
121    #[error("canonical secret key must not end with '/'")]
122    TrailingSlash,
123}
124
125impl Deref for SecretKey {
126    type Target = str;
127
128    fn deref(&self) -> &Self::Target {
129        &self.0
130    }
131}
132
133impl From<String> for SecretKey {
134    fn from(key: String) -> Self {
135        Self(key)
136    }
137}
138
139impl From<&str> for SecretKey {
140    fn from(key: &str) -> Self {
141        Self(key.to_owned())
142    }
143}
144
145impl From<SecretKey> for String {
146    fn from(key: SecretKey) -> Self {
147        key.0
148    }
149}
150
151#[cfg(feature = "serde")]
152impl<'de> Deserialize<'de> for SecretKey {
153    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
154    where
155        D: serde::Deserializer<'de>,
156    {
157        let value = String::deserialize(deserializer)?;
158        SecretKey::parse(&value).map_err(serde::de::Error::custom)
159    }
160}
161
162/// Canonical secret scope (environment, tenant, team).
163#[derive(Clone, Debug, PartialEq, Eq)]
164#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
165#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
166#[cfg_attr(feature = "schemars", derive(JsonSchema))]
167pub struct SecretScope {
168    /// Environment identifier (e.g., `dev`, `prod`).
169    pub env: String,
170    /// Tenant identifier within the environment.
171    pub tenant: String,
172    /// Optional team for finer-grained isolation.
173    #[cfg_attr(
174        feature = "serde",
175        serde(default, skip_serializing_if = "Option::is_none")
176    )]
177    pub team: Option<String>,
178}
179
180/// Supported secret content formats.
181#[derive(Clone, Debug, PartialEq, Eq)]
182#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
183#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
184#[cfg_attr(feature = "schemars", derive(JsonSchema))]
185pub enum SecretFormat {
186    /// Arbitrary bytes.
187    Bytes,
188    /// UTF-8 text.
189    Text,
190    /// JSON document.
191    Json,
192}
193
194/// Structured secret requirement used in capabilities, bindings, and deployment plans.
195#[non_exhaustive]
196#[derive(Clone, Debug, PartialEq, Eq)]
197#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
198#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
199#[cfg_attr(feature = "schemars", derive(JsonSchema))]
200pub struct SecretRequirement {
201    /// Logical key the runtime should resolve.
202    pub key: SecretKey,
203    /// Whether the secret is mandatory for execution.
204    #[cfg_attr(
205        feature = "serde",
206        serde(default = "SecretRequirement::default_required")
207    )]
208    pub required: bool,
209    /// Optional description for operator-facing tooling.
210    #[cfg_attr(
211        feature = "serde",
212        serde(default, skip_serializing_if = "Option::is_none")
213    )]
214    pub description: Option<String>,
215    /// Expected scope for resolution (environment/tenant/team).
216    #[cfg_attr(
217        feature = "serde",
218        serde(default, skip_serializing_if = "Option::is_none")
219    )]
220    pub scope: Option<SecretScope>,
221    /// Preferred secret format when known.
222    #[cfg_attr(
223        feature = "serde",
224        serde(default, skip_serializing_if = "Option::is_none")
225    )]
226    pub format: Option<SecretFormat>,
227    /// Optional JSON Schema fragment describing the value shape.
228    #[cfg_attr(
229        feature = "serde",
230        serde(default, skip_serializing_if = "Option::is_none")
231    )]
232    pub schema: Option<serde_json::Value>,
233    /// Example payloads for documentation.
234    #[cfg_attr(
235        feature = "serde",
236        serde(default, skip_serializing_if = "Vec::is_empty")
237    )]
238    pub examples: Vec<String>,
239}
240
241impl Default for SecretRequirement {
242    fn default() -> Self {
243        Self {
244            key: SecretKey::default(),
245            required: true,
246            description: None,
247            scope: None,
248            format: None,
249            schema: None,
250            examples: Vec::new(),
251        }
252    }
253}
254
255impl SecretRequirement {
256    const fn default_required() -> bool {
257        true
258    }
259}