Skip to main content

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}