Skip to main content

devboy_storage/
pattern_resolution.rs

1//! Pattern-id inheritance per [ADR-020] §3 + [ADR-023] §3.6.
2//!
3//! When an [`IndexEntry`] sets `pattern_id`, the linked entry in the
4//! shared [`Catalogue`] supplies sensible defaults for fields the
5//! entry left blank: the format regex, the retrieval URL, the rotation
6//! cadence, the rotation method. This module is the wiring that turns
7//! "entry references a pattern" into "entry has fully-resolved
8//! metadata".
9//!
10//! # Inheritance is one-way
11//!
12//! - Explicit fields on the entry **always** win.
13//! - Pattern defaults fill in **only** when the corresponding entry
14//!   field is `None`.
15//! - When `pattern_id` references an id that does not exist in the
16//!   catalogue, the entry is returned unchanged and an
17//!   [`InheritanceWarningKind::UnknownPatternId`] is recorded.
18//!   Surfacing the missing id is `doctor`'s job (epic phase P7.3);
19//!   the resolver itself does not error.
20//!
21//! # Field mapping
22//!
23//! | `IndexEntry` field       | Pattern source                                         |
24//! |--------------------------|---------------------------------------------------------|
25//! | `format_regex`           | `SecretPattern::format_regex().as_str()`               |
26//! | `retrieval_url`          | `SecretPattern::metadata()?.retrieval_url_template`    |
27//! | `rotate_every_days`      | `SecretPattern::metadata()?.default_expiry_days`       |
28//! | `rotation_method`        | `SecretPattern::rotation()?.method` (mapped)           |
29//!
30//! Other entry fields (`description`, `default_gate`, `expires_at`,
31//! `last_rotated_at`, `required_scopes`, `env_var`,
32//! `cache_ttl_seconds_max`) are not pattern-driven and pass through
33//! unchanged.
34//!
35//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
36//! [ADR-023]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-023-secret-store-ux-layer.md
37
38use devboy_secret_patterns::{Catalogue, RotationMethodSpec, SecretPattern};
39
40use crate::index::{IndexEntry, RotationMethod};
41
42/// Non-fatal advisory about pattern-id inheritance, surfaced through
43/// `doctor` (epic phase P7.3) so the user can fix manifest hygiene
44/// issues without the resolver itself failing.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct InheritanceWarning {
47    /// What the warning is about.
48    pub kind: InheritanceWarningKind,
49    /// The `pattern_id` value that triggered the warning.
50    pub pattern_id: String,
51}
52
53/// Categories of advisory warnings produced by [`apply_pattern_inheritance`].
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum InheritanceWarningKind {
56    /// `pattern_id` does not match any built-in or user-supplied
57    /// pattern. The entry is returned unchanged; the user should
58    /// either spell-check the id or register the pattern under
59    /// `~/.devboy/secrets/patterns.d/`.
60    UnknownPatternId,
61}
62
63/// Apply pattern-id inheritance to `entry` and return the resolved
64/// entry plus any advisory warning.
65///
66/// The function clones `entry` so the caller's view is unchanged.
67/// Cloning is cheap (the entry is a handful of `Option<String>` /
68/// small primitive fields) and the alternative — mutating in place —
69/// would force every caller to express the lifetime of the catalogue
70/// borrow, which is more friction than it's worth here.
71pub fn apply_pattern_inheritance(
72    entry: &IndexEntry,
73    catalogue: &Catalogue,
74) -> (IndexEntry, Option<InheritanceWarning>) {
75    let resolved = entry.clone();
76    let Some(id) = entry.pattern_id.as_deref() else {
77        return (resolved, None);
78    };
79    let Some(pattern) = catalogue.find(id) else {
80        return (
81            resolved,
82            Some(InheritanceWarning {
83                kind: InheritanceWarningKind::UnknownPatternId,
84                pattern_id: id.to_owned(),
85            }),
86        );
87    };
88    let resolved = inherit_from_pattern(resolved, pattern);
89    (resolved, None)
90}
91
92fn inherit_from_pattern(mut entry: IndexEntry, pattern: &dyn SecretPattern) -> IndexEntry {
93    if entry.format_regex.is_none() {
94        entry.format_regex = Some(pattern.format_regex().as_str().to_owned());
95    }
96    if let Some(meta) = pattern.metadata() {
97        if entry.retrieval_url.is_none() {
98            entry.retrieval_url = Some(meta.retrieval_url_template.to_string());
99        }
100        if entry.rotate_every_days.is_none() {
101            if let Some(d) = meta.default_expiry_days {
102                entry.rotate_every_days = Some(d);
103            }
104        }
105    }
106    if let Some(rotation) = pattern.rotation() {
107        if entry.rotation_method.is_none() {
108            entry.rotation_method = Some(map_rotation_method(&rotation.method));
109        }
110    }
111    entry
112}
113
114/// Translate the catalogue's [`RotationMethodSpec`] into the storage
115/// crate's [`RotationMethod`]. They are kept as separate enums on
116/// purpose: the catalogue variant carries an extra URL template that
117/// the storage TOML representation does not need.
118fn map_rotation_method(m: &RotationMethodSpec) -> RotationMethod {
119    match m {
120        RotationMethodSpec::Manual => RotationMethod::Manual,
121        RotationMethodSpec::ProviderUi { .. } => RotationMethod::ProviderUi,
122        RotationMethodSpec::ProviderApi => RotationMethod::ProviderApi,
123    }
124}
125
126// =============================================================================
127// Tests
128// =============================================================================
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::index::{Gate, IndexEntry, RotationMethod};
134
135    fn entry_with_pattern(id: &str) -> IndexEntry {
136        IndexEntry {
137            pattern_id: Some(id.to_owned()),
138            ..IndexEntry::default()
139        }
140    }
141
142    #[test]
143    fn entry_without_pattern_id_passes_through_unchanged() {
144        let cat = Catalogue::builtins_only();
145        let entry = IndexEntry {
146            description: Some("explicit".to_owned()),
147            ..IndexEntry::default()
148        };
149        let (resolved, warning) = apply_pattern_inheritance(&entry, &cat);
150        assert_eq!(resolved, entry);
151        assert!(warning.is_none());
152    }
153
154    #[test]
155    fn unknown_pattern_id_returns_entry_and_warning() {
156        let cat = Catalogue::builtins_only();
157        let entry = entry_with_pattern("no-such-pattern");
158        let (resolved, warning) = apply_pattern_inheritance(&entry, &cat);
159        assert_eq!(resolved, entry, "entry must be returned unchanged");
160        let w = warning.expect("must produce a warning");
161        assert_eq!(w.kind, InheritanceWarningKind::UnknownPatternId);
162        assert_eq!(w.pattern_id, "no-such-pattern");
163    }
164
165    #[test]
166    fn known_pattern_inherits_format_regex() {
167        // `github-pat` is built-in, no metadata override needed.
168        let cat = Catalogue::builtins_only();
169        let entry = entry_with_pattern("github-pat");
170        let (resolved, warning) = apply_pattern_inheritance(&entry, &cat);
171        assert!(warning.is_none());
172        let regex = resolved.format_regex.expect("regex inherited");
173        assert!(regex.starts_with('^'));
174        assert!(regex.contains("gh"));
175    }
176
177    #[test]
178    fn explicit_format_regex_is_not_overridden() {
179        let cat = Catalogue::builtins_only();
180        let entry = IndexEntry {
181            pattern_id: Some("github-pat".to_owned()),
182            format_regex: Some("^my-explicit-regex$".to_owned()),
183            ..IndexEntry::default()
184        };
185        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
186        assert_eq!(
187            resolved.format_regex.as_deref(),
188            Some("^my-explicit-regex$")
189        );
190    }
191
192    #[test]
193    fn known_pattern_inherits_retrieval_url() {
194        let cat = Catalogue::builtins_only();
195        let entry = entry_with_pattern("github-pat");
196        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
197        assert_eq!(
198            resolved.retrieval_url.as_deref(),
199            Some("https://github.com/settings/tokens")
200        );
201    }
202
203    #[test]
204    fn explicit_retrieval_url_is_not_overridden() {
205        let cat = Catalogue::builtins_only();
206        let entry = IndexEntry {
207            pattern_id: Some("github-pat".to_owned()),
208            retrieval_url: Some("https://internal.example/tokens".to_owned()),
209            ..IndexEntry::default()
210        };
211        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
212        assert_eq!(
213            resolved.retrieval_url.as_deref(),
214            Some("https://internal.example/tokens")
215        );
216    }
217
218    #[test]
219    fn known_pattern_inherits_rotate_every_days() {
220        // Built-in `github-pat` carries `default_expiry_days = 90`.
221        let cat = Catalogue::builtins_only();
222        let entry = entry_with_pattern("github-pat");
223        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
224        assert_eq!(resolved.rotate_every_days, Some(90));
225    }
226
227    #[test]
228    fn explicit_rotate_every_days_is_not_overridden() {
229        let cat = Catalogue::builtins_only();
230        let entry = IndexEntry {
231            pattern_id: Some("github-pat".to_owned()),
232            rotate_every_days: Some(30),
233            ..IndexEntry::default()
234        };
235        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
236        assert_eq!(resolved.rotate_every_days, Some(30));
237    }
238
239    #[test]
240    fn pattern_without_metadata_only_inherits_regex() {
241        // `jwt` is a built-in with no metadata layer — only the
242        // regex should be inherited.
243        let cat = Catalogue::builtins_only();
244        let entry = entry_with_pattern("jwt");
245        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
246        assert!(resolved.format_regex.is_some(), "regex must inherit");
247        assert!(
248            resolved.retrieval_url.is_none(),
249            "no metadata → no retrieval url"
250        );
251        assert!(
252            resolved.rotate_every_days.is_none(),
253            "no metadata → no expiry default"
254        );
255    }
256
257    #[test]
258    fn unrelated_fields_pass_through_unchanged() {
259        let cat = Catalogue::builtins_only();
260        let entry = IndexEntry {
261            pattern_id: Some("github-pat".to_owned()),
262            description: Some("My deploy token".to_owned()),
263            default_gate: Some(Gate::Touchid),
264            expires_at: Some("2026-08-01".to_owned()),
265            last_rotated_at: Some("2026-05-02".to_owned()),
266            required_scopes: vec!["repo".to_owned()],
267            env_var: Some("GH_TOKEN".to_owned()),
268            cache_ttl_seconds_max: Some(60),
269            ..IndexEntry::default()
270        };
271        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
272        // Pattern-driven fields filled in.
273        assert!(resolved.format_regex.is_some());
274        assert!(resolved.retrieval_url.is_some());
275        assert_eq!(resolved.rotate_every_days, Some(90));
276        // Non-pattern fields untouched.
277        assert_eq!(resolved.description.as_deref(), Some("My deploy token"));
278        assert_eq!(resolved.default_gate, Some(Gate::Touchid));
279        assert_eq!(resolved.expires_at.as_deref(), Some("2026-08-01"));
280        assert_eq!(resolved.last_rotated_at.as_deref(), Some("2026-05-02"));
281        assert_eq!(resolved.required_scopes, vec!["repo"]);
282        assert_eq!(resolved.env_var.as_deref(), Some("GH_TOKEN"));
283        assert_eq!(resolved.cache_ttl_seconds_max, Some(60));
284    }
285
286    #[test]
287    fn rotation_method_remains_none_for_v1_builtins() {
288        // No built-in carries a `rotation` spec in v1 (P2.2 leaves
289        // it unset; provider-driven rotation is deferred per
290        // ADR-023 §3.5). Verify the inheritance helper does not
291        // synthesise a value out of thin air.
292        let cat = Catalogue::builtins_only();
293        let entry = entry_with_pattern("github-pat");
294        let (resolved, _) = apply_pattern_inheritance(&entry, &cat);
295        assert_eq!(
296            resolved.rotation_method, None,
297            "no built-in supplies a rotation spec yet"
298        );
299    }
300
301    #[test]
302    fn map_rotation_method_covers_each_variant() {
303        // Compile-time exhaustiveness check via `match` plus
304        // explicit asserts — the storage enum and the patterns
305        // enum can drift; this catches that.
306        assert_eq!(
307            map_rotation_method(&RotationMethodSpec::Manual),
308            RotationMethod::Manual
309        );
310        assert_eq!(
311            map_rotation_method(&RotationMethodSpec::ProviderUi {
312                url_template: "https://example/r"
313            }),
314            RotationMethod::ProviderUi
315        );
316        assert_eq!(
317            map_rotation_method(&RotationMethodSpec::ProviderApi),
318            RotationMethod::ProviderApi
319        );
320    }
321}