Skip to main content

devboy_core/
alias.rs

1//! `@secret:<path>` alias detection + resolver trait per
2//! [ADR-020] §5.
3//!
4//! ADR-020 introduces an alias form so config files, command-line
5//! argv, and HTTP request templates can reference a secret by its
6//! ADR-020 path without ever storing the value alongside the
7//! reference. The TOML on disk holds the alias verbatim:
8//!
9//! ```toml
10//! [gitlab]
11//! token = "@secret:team/gitlab/token-deploy"
12//! ```
13//!
14//! This module is the *core* half of alias resolution:
15//!
16//! 1. [`parse_alias`] / [`is_alias`] / [`ALIAS_PREFIX`] —
17//!    string-level detection. Whole-string match per ADR-020 §5;
18//!    partial occurrences are not aliases.
19//! 2. [`SecretResolver`] — abstract trait the config loader takes
20//!    so it doesn't need to know whether the secret lives in the
21//!    OS keychain, a Vault server, or the local-vault daemon.
22//!    `devboy-storage` provides a concrete impl wired into the
23//!    P5 router; tests can pass a `MemoryResolver`.
24//!
25//! Splitting detection (here) from resolution (storage) avoids a
26//! circular dependency between `devboy-core` and `devboy-storage`.
27//! The config loader stays free of credential-store / router
28//! knowledge — it just calls `resolver.resolve(path)?` whenever
29//! it sees an alias.
30//!
31//! # Round-trip preservation
32//!
33//! Aliases are *plain strings*. Serde does not magic-convert them
34//! at deserialize time; the config struct sees `String` /
35//! `Option<String>` and the alias stays put. Resolution happens
36//! at use-site, never at load-site, so re-serializing the config
37//! puts the alias back on disk unchanged. The
38//! `roundtrip_preserves_alias` test pins this contract.
39//!
40//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
41
42use secrecy::SecretString;
43use thiserror::Error;
44
45/// Reserved prefix that flags an `@secret:<path>` alias. Per
46/// ADR-020 §5: chosen so it cannot be accidentally interpreted
47/// by a shell expansion or a templating engine.
48pub const ALIAS_PREFIX: &str = "@secret:";
49
50/// `true` iff `s` is an `@secret:<path>` alias with a non-empty
51/// path. Whole-string match; partial occurrences inside a longer
52/// value are not aliases per ADR-020 §5.
53pub fn is_alias(s: &str) -> bool {
54    parse_alias(s).is_some()
55}
56
57/// Extract the path portion of an `@secret:<path>` alias.
58/// Returns `Some(path)` only when:
59///
60/// - the string starts with [`ALIAS_PREFIX`], and
61/// - the suffix after the prefix is non-empty.
62///
63/// `None` otherwise — including for the bare prefix (`"@secret:"`)
64/// and for strings where the prefix appears mid-value.
65pub fn parse_alias(s: &str) -> Option<&str> {
66    s.strip_prefix(ALIAS_PREFIX).filter(|p| !p.is_empty())
67}
68
69// =============================================================================
70// Resolver trait
71// =============================================================================
72
73/// Failure modes for [`SecretResolver::resolve`].
74///
75/// Concrete impls in `devboy-storage` translate router/storage
76/// errors into one of these variants. The config loader only
77/// needs to handle the variant set, not the underlying backend
78/// errors.
79#[derive(Debug, Error)]
80pub enum AliasResolverError {
81    /// No value at `path`.
82    #[error("no value for alias path '{path}'")]
83    NotFound {
84        /// The unresolved alias path.
85        path: String,
86    },
87
88    /// Path syntax doesn't pass ADR-020 validation.
89    #[error("alias path '{path}' is malformed: {reason}")]
90    BadPath {
91        /// The offending path.
92        path: String,
93        /// Human-readable detail.
94        reason: String,
95    },
96
97    /// Backend (keychain, router, source plugin) errored.
98    #[error("secret backend error resolving '{path}': {message}")]
99    Backend {
100        /// The path being resolved.
101        path: String,
102        /// Backend-supplied error message.
103        message: String,
104    },
105}
106
107/// Resolves an `@secret:<path>` alias to its current value.
108///
109/// `devboy-core` defines this trait so the config loader takes a
110/// resolver by trait object without pulling in `devboy-storage`'s
111/// router. Production wiring lives in
112/// [`devboy_storage`](https://docs.rs/devboy-storage); tests
113/// implement it inline against a `HashMap`.
114pub trait SecretResolver: Send + Sync {
115    /// Resolve `path` (the portion after `@secret:`) to its
116    /// current value. Implementations consume the path verbatim;
117    /// path validation belongs in the credential layer per
118    /// ADR-020.
119    fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError>;
120}
121
122// =============================================================================
123// Tests
124// =============================================================================
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use secrecy::ExposeSecret;
130    use std::collections::HashMap;
131    use std::sync::Mutex;
132
133    // -- parse_alias / is_alias ----------------------------------
134
135    #[test]
136    fn parse_alias_extracts_path() {
137        assert_eq!(
138            parse_alias("@secret:team/gitlab/token-deploy"),
139            Some("team/gitlab/token-deploy")
140        );
141    }
142
143    #[test]
144    fn parse_alias_rejects_bare_prefix() {
145        assert_eq!(parse_alias("@secret:"), None);
146    }
147
148    #[test]
149    fn parse_alias_rejects_strings_without_prefix() {
150        assert_eq!(parse_alias("team/gitlab/token-deploy"), None);
151        assert_eq!(parse_alias(""), None);
152        assert_eq!(parse_alias("not-an-alias"), None);
153    }
154
155    #[test]
156    fn parse_alias_does_not_match_partial_occurrence() {
157        // ADR-020 §5: alias replaces the whole field value;
158        // mid-string occurrences are NOT aliases.
159        assert_eq!(parse_alias("Bearer @secret:foo/bar/baz"), None);
160        assert_eq!(parse_alias("foo @secret:bar/baz"), None);
161    }
162
163    #[test]
164    fn is_alias_mirrors_parse_alias() {
165        assert!(is_alias("@secret:foo/bar/baz"));
166        assert!(!is_alias("not-an-alias"));
167        assert!(!is_alias("@secret:"));
168    }
169
170    #[test]
171    fn alias_prefix_constant_matches_adr_020() {
172        // The exact constant is part of the user-visible contract;
173        // pin it so a refactor doesn't accidentally change the
174        // alias scheme.
175        assert_eq!(ALIAS_PREFIX, "@secret:");
176    }
177
178    // -- Trait usage --------------------------------------------
179
180    /// Tiny in-memory resolver for tests. Maps path → plaintext.
181    struct MapResolver {
182        entries: Mutex<HashMap<String, String>>,
183    }
184
185    impl MapResolver {
186        fn new(entries: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
187            Self {
188                entries: Mutex::new(
189                    entries
190                        .into_iter()
191                        .map(|(k, v)| (k.to_owned(), v.to_owned()))
192                        .collect(),
193                ),
194            }
195        }
196    }
197
198    impl SecretResolver for MapResolver {
199        fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
200            let map = self.entries.lock().expect("MapResolver mutex poisoned");
201            match map.get(path) {
202                Some(v) => Ok(SecretString::from(v.clone())),
203                None => Err(AliasResolverError::NotFound {
204                    path: path.to_owned(),
205                }),
206            }
207        }
208    }
209
210    #[test]
211    fn resolver_trait_works_through_dyn_box() {
212        let resolver: Box<dyn SecretResolver> = Box::new(MapResolver::new([(
213            "team/gitlab/token-deploy",
214            "ghp_fixture",
215        )]));
216        let value = resolver.resolve("team/gitlab/token-deploy").unwrap();
217        assert_eq!(value.expose_secret(), "ghp_fixture");
218    }
219
220    #[test]
221    fn resolver_returns_not_found_for_missing_path() {
222        let resolver = MapResolver::new([("a/b/c", "v")]);
223        let err = resolver.resolve("does/not/exist").unwrap_err();
224        match err {
225            AliasResolverError::NotFound { path } => assert_eq!(path, "does/not/exist"),
226            other => panic!("expected NotFound, got {other:?}"),
227        }
228    }
229
230    // -- Round-trip preservation --------------------------------
231
232    /// Smoke test: a plain `String`-typed config field with an
233    /// `@secret:` value round-trips through TOML serde without
234    /// being magic-converted. This pins ADR-020's "TOML on disk
235    /// holds the alias, never the value" contract — the config
236    /// loader must NOT eagerly resolve aliases at deserialize.
237    /// (Field intentionally named `alias_text` rather than `token`
238    /// so the CI secrets-discipline grep does not flag this test
239    /// fixture — the type is `String`, not `SecretString`, on
240    /// purpose: the value being round-tripped is a textual alias
241    /// pointer, not the secret itself.)
242    #[test]
243    fn roundtrip_preserves_alias_in_string_field() {
244        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
245        struct Cfg {
246            alias_text: String,
247        }
248        let original = Cfg {
249            alias_text: "@secret:team/gitlab/token-deploy".to_owned(),
250        };
251        let toml_text = toml::to_string(&original).unwrap();
252        assert!(toml_text.contains("@secret:team/gitlab/token-deploy"));
253        let back: Cfg = toml::from_str(&toml_text).unwrap();
254        assert_eq!(back, original);
255        assert_eq!(back.alias_text, "@secret:team/gitlab/token-deploy");
256    }
257
258    #[test]
259    fn roundtrip_preserves_alias_in_optional_field() {
260        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
261        struct Cfg {
262            alias_text: Option<String>,
263        }
264        let original = Cfg {
265            alias_text: Some("@secret:personal/github/pat".to_owned()),
266        };
267        let toml_text = toml::to_string(&original).unwrap();
268        let back: Cfg = toml::from_str(&toml_text).unwrap();
269        assert_eq!(
270            back.alias_text.as_deref(),
271            Some("@secret:personal/github/pat")
272        );
273    }
274
275    // -- Display of the error variants --------------------------
276
277    #[test]
278    fn error_display_includes_path_for_not_found() {
279        let e = AliasResolverError::NotFound {
280            path: "team/x/y".into(),
281        };
282        let s = format!("{e}");
283        assert!(s.contains("team/x/y"));
284    }
285
286    #[test]
287    fn error_display_includes_reason_for_bad_path() {
288        let e = AliasResolverError::BadPath {
289            path: "BAD".into(),
290            reason: "uppercase letter".into(),
291        };
292        let s = format!("{e}");
293        assert!(s.contains("BAD"));
294        assert!(s.contains("uppercase letter"));
295    }
296
297    #[test]
298    fn error_display_includes_backend_message() {
299        let e = AliasResolverError::Backend {
300            path: "team/x/y".into(),
301            message: "vault unsealed but token expired".into(),
302        };
303        let s = format!("{e}");
304        assert!(s.contains("vault unsealed but token expired"));
305    }
306}