dodot_lib/commands/secret.rs
1//! `dodot secret` subcommands — Phase S5 ergonomics surface.
2//!
3//! Two read-only commands for inspecting the secrets configuration
4//! and template references without running `dodot up`:
5//!
6//! - [`probe`] — runs `probe()` on every configured provider and
7//! returns one row per provider with the outcome. Useful as a
8//! "is my secrets setup healthy?" check before relying on a
9//! `dodot up` to surface the same diagnostics.
10//! - [`list`] — scans every pack's templates for `secret(...)`
11//! references and returns one row per call. Read-only — never
12//! invokes a provider.
13//!
14//! Both commands take an [`ExecutionContext`] (already built by the
15//! CLI handler), build the registry from root config (Phase S4
16//! contract: `[secret]` is root-only — see `SecretSection` docs),
17//! and return a serializable result for the standout renderer.
18
19use serde::Serialize;
20
21use crate::packs::orchestration::ExecutionContext;
22use crate::Result;
23
24/// One provider's row in `dodot secret probe` output.
25#[derive(Debug, Clone, Serialize)]
26pub struct ProbeRow {
27 pub scheme: String,
28 /// Snake-case state suitable for template branching:
29 /// `ok` / `not_installed` / `not_authenticated` / `misconfigured`
30 /// / `probe_failed`.
31 pub state: String,
32 /// Human-readable hint (the provider's own string for
33 /// non-Ok outcomes; empty for Ok).
34 pub hint: String,
35}
36
37/// Aggregate result of `dodot secret probe`.
38///
39/// Always non-fatal — even a fully-broken provider lineup isn't
40/// an error here, just information. The CLI exit code is 0 on
41/// success of the *command* (we got results); the *contents*
42/// surface via the rendered output.
43#[derive(Debug, Clone, Serialize)]
44pub struct ProbeResult {
45 pub rows: Vec<ProbeRow>,
46 pub ok_count: usize,
47 pub failing_count: usize,
48 /// True iff `[secret] enabled = false` or no provider is
49 /// enabled. The renderer surfaces a different message in that
50 /// case (no providers to probe vs. all-Ok).
51 pub disabled: bool,
52}
53
54/// Run `dodot secret probe`. Builds the registry from root config
55/// (`[secret]` is root-only per `SecretSection` docs), runs
56/// `probe()` on each enabled provider, and returns a row per
57/// provider. Never invokes `resolve()` and never reads template
58/// content.
59pub fn probe(ctx: &ExecutionContext) -> Result<ProbeResult> {
60 let root_config = ctx.config_manager.root_config()?;
61 if !root_config.secret.enabled {
62 return Ok(ProbeResult {
63 rows: Vec::new(),
64 ok_count: 0,
65 failing_count: 0,
66 disabled: true,
67 });
68 }
69 let registry = match crate::preprocessing::build_secret_registry(
70 &root_config.secret,
71 ctx.command_runner.clone(),
72 ctx.paths.dotfiles_root(),
73 ) {
74 Some(r) => r,
75 None => {
76 return Ok(ProbeResult {
77 rows: Vec::new(),
78 ok_count: 0,
79 failing_count: 0,
80 disabled: true,
81 });
82 }
83 };
84
85 use crate::secret::ProbeResult as P;
86 let outcomes = registry.probe_all();
87 let mut rows = Vec::with_capacity(outcomes.len());
88 let mut ok_count = 0usize;
89 let mut failing_count = 0usize;
90 for (scheme, outcome) in outcomes {
91 let (state, hint) = match outcome {
92 P::Ok => {
93 ok_count += 1;
94 ("ok", String::new())
95 }
96 P::NotInstalled { hint } => {
97 failing_count += 1;
98 ("not_installed", hint)
99 }
100 P::NotAuthenticated { hint } => {
101 failing_count += 1;
102 ("not_authenticated", hint)
103 }
104 P::Misconfigured { hint } => {
105 failing_count += 1;
106 ("misconfigured", hint)
107 }
108 P::ProbeFailed { details } => {
109 failing_count += 1;
110 ("probe_failed", details)
111 }
112 };
113 rows.push(ProbeRow {
114 scheme,
115 state: state.to_string(),
116 hint,
117 });
118 }
119
120 Ok(ProbeResult {
121 rows,
122 ok_count,
123 failing_count,
124 disabled: false,
125 })
126}
127
128/// One occurrence of a `secret(...)` call in a template source.
129#[derive(Debug, Clone, Serialize)]
130pub struct SecretRefRow {
131 pub pack: String,
132 /// Pack-relative path of the template source (e.g.
133 /// `config.toml.tmpl`, `nested/db.toml.tmpl`).
134 pub source_path: String,
135 /// 1-indexed line number where the `secret(...)` call begins
136 /// in the template source.
137 pub line: usize,
138 /// The full reference passed to `secret(...)`, with scheme
139 /// prefix (e.g. `pass:test/db_password`,
140 /// `op://Personal/GitHub/token`).
141 pub reference: String,
142 /// The scheme half (`pass`, `op`, `bw`, ...) — the part
143 /// before the first `:` of the reference. Empty if the
144 /// reference is malformed (we still surface the row so the
145 /// user can see the broken call site).
146 pub scheme: String,
147 /// True iff a provider for this scheme is currently enabled
148 /// in `[secret.providers.*]`. Lets the renderer flag
149 /// references that would fail at render time today, so the
150 /// user can decide whether to enable a provider or remove
151 /// the call.
152 pub provider_enabled: bool,
153}
154
155/// Aggregate result of `dodot secret list`.
156#[derive(Debug, Clone, Serialize)]
157pub struct ListResult {
158 pub rows: Vec<SecretRefRow>,
159 pub total_count: usize,
160 /// Set of distinct schemes referenced across all rows,
161 /// sorted for stable output. Useful for the
162 /// "schemes referenced but not enabled" rollup.
163 pub schemes_referenced: Vec<String>,
164 /// Subset of `schemes_referenced` that does NOT have a
165 /// provider enabled in the current config — these
166 /// references would fail at render time today.
167 pub schemes_without_provider: Vec<String>,
168}
169
170/// Run `dodot secret list`. Walks every pack's template source
171/// files, extracts `secret(...)` calls with the byte-wise
172/// scanner in [`scan_secret_calls`], and returns one row per
173/// occurrence. Read-only — never invokes a provider and never
174/// reads sidecars (sidecars only exist post-render; `list` is
175/// meant to be useful BEFORE the first `dodot up`).
176///
177/// "Template" here means files whose name matches
178/// `[preprocessor.template] extensions` per the root config —
179/// same set the template preprocessor would expand. Other
180/// preprocessors (age / gpg) are deliberately not scanned: a
181/// `secret(...)` call inside an encrypted file isn't visible
182/// without decrypting first.
183pub fn list(ctx: &ExecutionContext) -> Result<ListResult> {
184 use crate::packs::orchestration::prepare_packs;
185
186 let root_config = ctx.config_manager.root_config()?;
187 let template_extensions: Vec<String> = root_config
188 .preprocessor
189 .template
190 .extensions
191 .iter()
192 .map(|e| e.trim_start_matches('.').to_string())
193 .collect();
194
195 // Compute the set of currently-enabled provider schemes once
196 // — used to set `provider_enabled` per row without rebuilding
197 // the registry per call.
198 let enabled_schemes: std::collections::HashSet<String> = {
199 let mut s = std::collections::HashSet::new();
200 if root_config.secret.enabled {
201 let p = &root_config.secret.providers;
202 if p.pass.enabled {
203 s.insert("pass".into());
204 }
205 if p.op.enabled {
206 s.insert("op".into());
207 }
208 if p.bw.enabled {
209 s.insert("bw".into());
210 }
211 if p.sops.enabled {
212 s.insert("sops".into());
213 }
214 if p.keychain.enabled {
215 s.insert("keychain".into());
216 }
217 if p.secret_tool.enabled {
218 s.insert("secret-tool".into());
219 }
220 }
221 s
222 };
223
224 let packs = prepare_packs(None, ctx)?;
225 let mut rows: Vec<SecretRefRow> = Vec::new();
226 let scanner = crate::rules::Scanner::new(ctx.fs.as_ref());
227
228 for pack in &packs {
229 let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
230 let entries = scanner.walk_pack(&pack.path, &pack_config.pack.ignore)?;
231 for entry in entries {
232 if entry.is_dir {
233 continue;
234 }
235 // Only scan template-shaped files. Other extensions
236 // can't contain `secret(...)` calls in a way the
237 // template preprocessor would resolve.
238 let filename = entry
239 .relative_path
240 .file_name()
241 .map(|n| n.to_string_lossy().to_string())
242 .unwrap_or_default();
243 let is_template = template_extensions.iter().any(|ext| {
244 filename
245 .strip_suffix(ext.as_str())
246 .is_some_and(|prefix| prefix.ends_with('.'))
247 });
248 if !is_template {
249 continue;
250 }
251 let bytes = match ctx.fs.read_file(&entry.absolute_path) {
252 Ok(b) => b,
253 Err(_) => continue, // unreadable file → silently skip
254 };
255 let text = match std::str::from_utf8(&bytes) {
256 Ok(s) => s,
257 Err(_) => continue, // non-UTF-8 template → skip
258 };
259 for occ in scan_secret_calls(text) {
260 let scheme = match occ.reference.split_once(':') {
261 Some((s, _)) => s.to_string(),
262 None => String::new(),
263 };
264 let provider_enabled = !scheme.is_empty() && enabled_schemes.contains(&scheme);
265 rows.push(SecretRefRow {
266 pack: pack.display_name.clone(),
267 source_path: entry.relative_path.to_string_lossy().to_string(),
268 line: occ.line,
269 reference: occ.reference,
270 scheme,
271 provider_enabled,
272 });
273 }
274 }
275 }
276
277 let mut schemes_referenced: Vec<String> = rows
278 .iter()
279 .filter(|r| !r.scheme.is_empty())
280 .map(|r| r.scheme.clone())
281 .collect::<std::collections::BTreeSet<_>>()
282 .into_iter()
283 .collect();
284 schemes_referenced.sort();
285
286 let schemes_without_provider: Vec<String> = schemes_referenced
287 .iter()
288 .filter(|s| !enabled_schemes.contains(s.as_str()))
289 .cloned()
290 .collect();
291
292 let total_count = rows.len();
293 Ok(ListResult {
294 rows,
295 total_count,
296 schemes_referenced,
297 schemes_without_provider,
298 })
299}
300
301/// One match from [`scan_secret_calls`].
302#[derive(Debug, Clone)]
303struct SecretCallOccurrence {
304 line: usize,
305 reference: String,
306}
307
308/// Find every `secret(...)` call in a template source. Matches
309/// the canonical MiniJinja shapes:
310///
311/// {{ secret("op://Vault/Item/Field") }}
312/// {{ secret('pass:path/to/secret') }}
313/// {%- if secret("op://...") -%} ... {%- endif -%}
314///
315/// Whitespace between `secret`, the parens, and the string is
316/// allowed. Both single- and double-quoted strings work; the
317/// quote character must match. Escape sequences inside the
318/// string are NOT honored — references in dotfiles don't
319/// contain backslash escapes in practice, and the simpler
320/// "everything between matching quotes is the reference" rule
321/// keeps the scanner predictable.
322///
323/// The scanner is deliberately a hand-rolled byte-wise state
324/// machine rather than a real MiniJinja AST walk: this command
325/// runs BEFORE the first `dodot up`, so we can't rely on a
326/// baseline cache, and a false positive here just lists a
327/// string the user already typed in the template — they can
328/// verify by opening the file at the reported line. Actual
329/// rendering still goes through MiniJinja's parser. Skipping
330/// the regex crate keeps this off the dependency footprint and
331/// makes the matching rule grep-able from one place.
332fn scan_secret_calls(text: &str) -> Vec<SecretCallOccurrence> {
333 let mut out = Vec::new();
334 let bytes = text.as_bytes();
335 let mut i = 0usize;
336 let needle = b"secret";
337 while i + needle.len() <= bytes.len() {
338 if &bytes[i..i + needle.len()] != needle {
339 i += 1;
340 continue;
341 }
342 // Must be at a word boundary on the left — otherwise
343 // `mysecret(...)` would match.
344 let left_ok = i == 0 || {
345 let prev = bytes[i - 1];
346 !prev.is_ascii_alphanumeric() && prev != b'_'
347 };
348 if !left_ok {
349 i += 1;
350 continue;
351 }
352 // Walk past `secret`, optional whitespace, expect `(`.
353 let mut j = i + needle.len();
354 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
355 j += 1;
356 }
357 if j >= bytes.len() || bytes[j] != b'(' {
358 i += 1;
359 continue;
360 }
361 j += 1;
362 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
363 j += 1;
364 }
365 if j >= bytes.len() || (bytes[j] != b'"' && bytes[j] != b'\'') {
366 i += 1;
367 continue;
368 }
369 let quote = bytes[j];
370 j += 1;
371 let ref_start = j;
372 while j < bytes.len() && bytes[j] != quote {
373 j += 1;
374 }
375 if j >= bytes.len() {
376 // Unterminated — bail; whoever rendered this would
377 // get a MiniJinja parse error anyway.
378 break;
379 }
380 let reference = std::str::from_utf8(&bytes[ref_start..j])
381 .unwrap_or("")
382 .to_string();
383 // Compute 1-indexed line number for the reported
384 // position (the start of `secret`).
385 let line = bytes[..i].iter().filter(|&&b| b == b'\n').count() + 1;
386 out.push(SecretCallOccurrence { line, reference });
387 i = j + 1;
388 }
389 out
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::fs::Fs;
396 use crate::testing::TempEnvironment;
397
398 fn make_ctx(env: &TempEnvironment, root_config_toml: Option<&str>) -> ExecutionContext {
399 if let Some(toml) = root_config_toml {
400 let path = env.dotfiles_root.join(".dodot.toml");
401 env.fs.write_file(&path, toml.as_bytes()).unwrap();
402 }
403 ExecutionContext::production(&env.dotfiles_root, false).expect("test context build")
404 }
405
406 #[test]
407 fn probe_reports_disabled_when_master_switch_off() {
408 let env = TempEnvironment::builder().build();
409 let ctx = make_ctx(&env, Some("[secret]\nenabled = false\n"));
410 let r = probe(&ctx).unwrap();
411 assert!(r.disabled);
412 assert!(r.rows.is_empty());
413 assert_eq!(r.ok_count, 0);
414 assert_eq!(r.failing_count, 0);
415 }
416
417 #[test]
418 fn probe_reports_disabled_when_no_provider_is_enabled() {
419 // Master switch on, but every provider block is opt-in
420 // (`enabled = false` by default) — same observable shape
421 // as the master switch off, just a different reason.
422 let env = TempEnvironment::builder().build();
423 let ctx = make_ctx(&env, Some("[secret]\nenabled = true\n"));
424 let r = probe(&ctx).unwrap();
425 assert!(r.disabled);
426 assert!(r.rows.is_empty());
427 }
428
429 // Note: tests that exercise the live registry path (with a
430 // mock CommandRunner injected) belong in the e2e suite —
431 // production()'s ExecutionContext owns the runner and there's
432 // no tier-0 seam to substitute it. The error-render tests
433 // already pin the per-row mapping shape; this command is the
434 // shallow aggregator that calls into them.
435
436 // ── scan_secret_calls ───────────────────────────────────────
437
438 #[test]
439 fn scan_finds_double_quoted_call() {
440 let text = r#"value = "{{ secret("pass:test/k") }}""#;
441 let r = scan_secret_calls(text);
442 assert_eq!(r.len(), 1);
443 assert_eq!(r[0].line, 1);
444 assert_eq!(r[0].reference, "pass:test/k");
445 }
446
447 #[test]
448 fn scan_finds_single_quoted_call() {
449 let text = r#"value = "{{ secret('pass:test/k') }}""#;
450 let r = scan_secret_calls(text);
451 assert_eq!(r.len(), 1);
452 assert_eq!(r[0].reference, "pass:test/k");
453 }
454
455 #[test]
456 fn scan_tolerates_whitespace_between_secret_paren_and_string() {
457 let text = r#"{{ secret ( "op://V/I/F" ) }}"#;
458 let r = scan_secret_calls(text);
459 assert_eq!(r.len(), 1);
460 assert_eq!(r[0].reference, "op://V/I/F");
461 }
462
463 #[test]
464 fn scan_reports_correct_line_number_in_multiline_template() {
465 let text = "header\nport = 5432\nkey = {{ secret(\"pass:k\") }}\nfooter\n";
466 let r = scan_secret_calls(text);
467 assert_eq!(r.len(), 1);
468 assert_eq!(r[0].line, 3);
469 }
470
471 #[test]
472 fn scan_finds_multiple_calls_in_one_template() {
473 let text = r#"a = "{{ secret("pass:a") }}"
474b = "{{ secret('op://V/I/F') }}"
475c = "{{ secret("bw:gh-token") }}""#;
476 let r = scan_secret_calls(text);
477 assert_eq!(r.len(), 3);
478 assert_eq!(r[0].reference, "pass:a");
479 assert_eq!(r[1].reference, "op://V/I/F");
480 assert_eq!(r[2].reference, "bw:gh-token");
481 }
482
483 #[test]
484 fn scan_does_not_match_word_with_secret_prefix() {
485 // `mysecret(...)` must not match — left word boundary
486 // matters.
487 let text = r#"x = mysecret("not-this")"#;
488 let r = scan_secret_calls(text);
489 assert!(r.is_empty());
490 }
491
492 #[test]
493 fn scan_does_not_match_word_with_secret_suffix() {
494 // `secrets(...)` (plural) must not match either.
495 let text = r#"x = secrets("not-this")"#;
496 let r = scan_secret_calls(text);
497 assert!(r.is_empty());
498 }
499
500 #[test]
501 fn scan_skips_unterminated_string_and_does_not_panic() {
502 // Malformed input shouldn't crash the scanner; the user
503 // would get a MiniJinja parse error at render time.
504 let text = r#"x = {{ secret("unterminated"#;
505 let _ = scan_secret_calls(text); // doesn't panic; we don't care what comes back
506 }
507
508 #[test]
509 fn scan_handles_mismatched_quote_styles_independently() {
510 // `secret("...')` is broken — opening double, closing
511 // single. The scanner should walk to the next double
512 // quote, which doesn't exist, and bail without surfacing
513 // a misleading row.
514 let text = r#"x = {{ secret("pass:k') }}"#;
515 let r = scan_secret_calls(text);
516 // Either zero rows (preferred) or one with garbage —
517 // the contract is "don't crash, don't fabricate".
518 for row in &r {
519 assert!(!row.reference.is_empty());
520 }
521 }
522}