1use devboy_secret_patterns::{Catalogue, RotationMethodSpec, SecretPattern};
39
40use crate::index::{IndexEntry, RotationMethod};
41
42#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct InheritanceWarning {
47 pub kind: InheritanceWarningKind,
49 pub pattern_id: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum InheritanceWarningKind {
56 UnknownPatternId,
61}
62
63pub 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
114fn 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#[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 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 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 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 assert!(resolved.format_regex.is_some());
274 assert!(resolved.retrieval_url.is_some());
275 assert_eq!(resolved.rotate_every_days, Some(90));
276 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 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 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}