Skip to main content

kernel/secret/
span.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Secret references and spans: the shapes that name secrets.
7//! The part `crates/kernel/spec/Secret.lean` specifies this module with the
8//! rest of `kernel::secret`.
9
10use crate::error::{AxCode, AxError};
11
12/// `secret:<realm>/<name>`. Deliberately a separate parser from Locator:
13/// sharing the grammar would mean a read path could resolve a secret
14/// (7.1's type-level answer). Malformed shapes are `E_CONFIG_INVALID`.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct SecretRef {
17    realm: String,
18    name: String,
19}
20
21/// Whether a segment is one of the two the grammar admits, which is also
22/// why a reference needs no escaping: everything outside this alphabet is
23/// refused where the reference is built.
24fn segment_ok(segment: &str) -> bool {
25    !segment.is_empty()
26        && segment
27            .chars()
28            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
29}
30
31/// The one refusal for text outside the grammar. The action names what
32/// was being made of it; the subject is the text itself, so a person sees
33/// what was rejected and not only that something was.
34fn outside_the_grammar(raw: &str) -> AxError {
35    AxError::failure(AxCode::ConfigInvalid, "build a secret reference", raw).with_recovery(
36        "write secret:<realm>/<name>, where each segment is non-empty and uses only letters, \
37         digits, '-', '_' or '.'",
38    )
39}
40
41impl SecretRef {
42    /// The one place a reference is built from its two segments, and so
43    /// the one place the grammar is spelled. Every writer of a vault
44    /// place calls this: a caller that assembled `secret:<realm>/<name>`
45    /// from a format string could name a place no reader resolves, and
46    /// the two spellings would drift the first time either is changed.
47    ///
48    /// # Errors
49    /// `E_CONFIG_INVALID` when a segment is empty or carries a character
50    /// outside the reference alphabet.
51    pub fn new(realm: &str, name: &str) -> Result<SecretRef, AxError> {
52        if !segment_ok(realm) || !segment_ok(name) {
53            return Err(outside_the_grammar(&format!("secret:{realm}/{name}")));
54        }
55        Ok(SecretRef {
56            realm: realm.to_owned(),
57            name: name.to_owned(),
58        })
59    }
60
61    /// Reads a reference back out of its text. Text that carries the
62    /// prefix and a `/` is handed to [`SecretRef::new`], whose refusal
63    /// names this same text: the two halves of the grammar answer with
64    /// one error.
65    ///
66    /// # Errors
67    /// `E_CONFIG_INVALID` for text that is not a reference.
68    pub fn parse(raw: &str) -> Result<SecretRef, AxError> {
69        let Some(rest) = raw.strip_prefix("secret:") else {
70            return Err(outside_the_grammar(raw));
71        };
72        let Some((realm, name)) = rest.split_once('/') else {
73            return Err(outside_the_grammar(raw));
74        };
75        SecretRef::new(realm, name)
76    }
77
78    pub fn realm(&self) -> &str {
79        &self.realm
80    }
81
82    pub fn name(&self) -> &str {
83        &self.name
84    }
85}
86
87impl std::fmt::Display for SecretRef {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "secret:{}/{}", self.realm, self.name)
90    }
91}
92
93impl serde::Serialize for SecretRef {
94    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
95        serializer.collect_str(self)
96    }
97}
98
99impl<'de> serde::Deserialize<'de> for SecretRef {
100    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        let raw = String::deserialize(deserializer)?;
102        SecretRef::parse(&raw).map_err(serde::de::Error::custom)
103    }
104}
105
106/// One detected span. `provider` names the shape-table entry; `None` is
107/// an entropy hit. The span carries offsets only — never the bytes (a
108/// finding that quoted its match would itself be a leak surface).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SecretSpan {
111    pub start: usize,
112    pub len: usize,
113    pub provider: Option<&'static str>,
114}
115
116#[cfg(test)]
117#[allow(
118    clippy::unwrap_used,
119    clippy::expect_used,
120    clippy::panic,
121    clippy::indexing_slicing,
122    reason = "test code"
123)]
124mod tests {
125    use super::*;
126
127    /// The builder and the reader are two halves of one grammar: whatever
128    /// `new` refuses, `parse` refuses, and the text either accepts is the
129    /// same text. The refusal names what was rejected, because a person
130    /// holding a 422 has to see which realm or name the city would not
131    /// take.
132    #[test]
133    fn secret_ref_grammar_is_fail_closed() {
134        let ok = SecretRef::new("anthropic", "api-key").unwrap();
135        assert_eq!(ok.realm(), "anthropic");
136        assert_eq!(ok.name(), "api-key");
137        assert_eq!(ok.to_string(), "secret:anthropic/api-key");
138        assert_eq!(SecretRef::parse("secret:anthropic/api-key").unwrap(), ok);
139        for (realm, name) in [
140            ("", "key"),
141            ("realm", ""),
142            ("a b", "key"),
143            ("realm", "a/b"),
144            ("realm", "key:"),
145            ("realm", "key "),
146        ] {
147            let text = format!("secret:{realm}/{name}");
148            let err = SecretRef::new(realm, name).unwrap_err();
149            assert_eq!(err.code(), &AxCode::ConfigInvalid, "{text}");
150            assert_eq!(err.subject(), text.as_str(), "the refusal names {text}");
151        }
152        for bad in [
153            "secret:",
154            "secret:/x",
155            "secret:a/",
156            "secret:a b/c",
157            "cas:b3-ab",
158            "secret:a/b/c",
159            "SECRET:a/b",
160        ] {
161            let err = SecretRef::parse(bad).unwrap_err();
162            assert_eq!(err.code(), &AxCode::ConfigInvalid, "{bad:?}");
163            assert_eq!(err.subject(), bad, "the refusal names {bad:?}");
164        }
165    }
166}