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}