use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::Result;
#[derive(Debug, Clone, Serialize)]
pub struct ProbeRow {
pub scheme: String,
pub state: String,
pub hint: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProbeResult {
pub rows: Vec<ProbeRow>,
pub ok_count: usize,
pub failing_count: usize,
pub disabled: bool,
}
pub fn probe(ctx: &ExecutionContext) -> Result<ProbeResult> {
let root_config = ctx.config_manager.root_config()?;
if !root_config.secret.enabled {
return Ok(ProbeResult {
rows: Vec::new(),
ok_count: 0,
failing_count: 0,
disabled: true,
});
}
let registry = match crate::preprocessing::build_secret_registry(
&root_config.secret,
ctx.command_runner.clone(),
ctx.paths.dotfiles_root(),
) {
Some(r) => r,
None => {
return Ok(ProbeResult {
rows: Vec::new(),
ok_count: 0,
failing_count: 0,
disabled: true,
});
}
};
use crate::secret::ProbeResult as P;
let outcomes = registry.probe_all();
let mut rows = Vec::with_capacity(outcomes.len());
let mut ok_count = 0usize;
let mut failing_count = 0usize;
for (scheme, outcome) in outcomes {
let (state, hint) = match outcome {
P::Ok => {
ok_count += 1;
("ok", String::new())
}
P::NotInstalled { hint } => {
failing_count += 1;
("not_installed", hint)
}
P::NotAuthenticated { hint } => {
failing_count += 1;
("not_authenticated", hint)
}
P::Misconfigured { hint } => {
failing_count += 1;
("misconfigured", hint)
}
P::ProbeFailed { details } => {
failing_count += 1;
("probe_failed", details)
}
};
rows.push(ProbeRow {
scheme,
state: state.to_string(),
hint,
});
}
Ok(ProbeResult {
rows,
ok_count,
failing_count,
disabled: false,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct SecretRefRow {
pub pack: String,
pub source_path: String,
pub line: usize,
pub reference: String,
pub scheme: String,
pub provider_enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ListResult {
pub rows: Vec<SecretRefRow>,
pub total_count: usize,
pub schemes_referenced: Vec<String>,
pub schemes_without_provider: Vec<String>,
}
pub fn list(ctx: &ExecutionContext) -> Result<ListResult> {
use crate::packs::orchestration::prepare_packs;
let root_config = ctx.config_manager.root_config()?;
let template_extensions: Vec<String> = root_config
.preprocessor
.template
.extensions
.iter()
.map(|e| e.trim_start_matches('.').to_string())
.collect();
let enabled_schemes: std::collections::HashSet<String> = {
let mut s = std::collections::HashSet::new();
if root_config.secret.enabled {
let p = &root_config.secret.providers;
if p.pass.enabled {
s.insert("pass".into());
}
if p.op.enabled {
s.insert("op".into());
}
if p.bw.enabled {
s.insert("bw".into());
}
if p.sops.enabled {
s.insert("sops".into());
}
if p.keychain.enabled {
s.insert("keychain".into());
}
if p.secret_tool.enabled {
s.insert("secret-tool".into());
}
}
s
};
let packs = prepare_packs(None, ctx)?;
let mut rows: Vec<SecretRefRow> = Vec::new();
let scanner = crate::rules::Scanner::new(ctx.fs.as_ref());
for pack in &packs {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
let entries = scanner.walk_pack(&pack.path, &pack_config.pack.ignore)?;
for entry in entries {
if entry.is_dir {
continue;
}
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let is_template = template_extensions.iter().any(|ext| {
filename
.strip_suffix(ext.as_str())
.is_some_and(|prefix| prefix.ends_with('.'))
});
if !is_template {
continue;
}
let bytes = match ctx.fs.read_file(&entry.absolute_path) {
Ok(b) => b,
Err(_) => continue, };
let text = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(_) => continue, };
for occ in scan_secret_calls(text) {
let scheme = match occ.reference.split_once(':') {
Some((s, _)) => s.to_string(),
None => String::new(),
};
let provider_enabled = !scheme.is_empty() && enabled_schemes.contains(&scheme);
rows.push(SecretRefRow {
pack: pack.display_name.clone(),
source_path: entry.relative_path.to_string_lossy().to_string(),
line: occ.line,
reference: occ.reference,
scheme,
provider_enabled,
});
}
}
}
let mut schemes_referenced: Vec<String> = rows
.iter()
.filter(|r| !r.scheme.is_empty())
.map(|r| r.scheme.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
schemes_referenced.sort();
let schemes_without_provider: Vec<String> = schemes_referenced
.iter()
.filter(|s| !enabled_schemes.contains(s.as_str()))
.cloned()
.collect();
let total_count = rows.len();
Ok(ListResult {
rows,
total_count,
schemes_referenced,
schemes_without_provider,
})
}
#[derive(Debug, Clone)]
struct SecretCallOccurrence {
line: usize,
reference: String,
}
fn scan_secret_calls(text: &str) -> Vec<SecretCallOccurrence> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0usize;
let needle = b"secret";
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] != needle {
i += 1;
continue;
}
let left_ok = i == 0 || {
let prev = bytes[i - 1];
!prev.is_ascii_alphanumeric() && prev != b'_'
};
if !left_ok {
i += 1;
continue;
}
let mut j = i + needle.len();
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= bytes.len() || bytes[j] != b'(' {
i += 1;
continue;
}
j += 1;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= bytes.len() || (bytes[j] != b'"' && bytes[j] != b'\'') {
i += 1;
continue;
}
let quote = bytes[j];
j += 1;
let ref_start = j;
while j < bytes.len() && bytes[j] != quote {
j += 1;
}
if j >= bytes.len() {
break;
}
let reference = std::str::from_utf8(&bytes[ref_start..j])
.unwrap_or("")
.to_string();
let line = bytes[..i].iter().filter(|&&b| b == b'\n').count() + 1;
out.push(SecretCallOccurrence { line, reference });
i = j + 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::Fs;
use crate::testing::TempEnvironment;
fn make_ctx(env: &TempEnvironment, root_config_toml: Option<&str>) -> ExecutionContext {
if let Some(toml) = root_config_toml {
let path = env.dotfiles_root.join(".dodot.toml");
env.fs.write_file(&path, toml.as_bytes()).unwrap();
}
ExecutionContext::production(&env.dotfiles_root, false).expect("test context build")
}
#[test]
fn probe_reports_disabled_when_master_switch_off() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env, Some("[secret]\nenabled = false\n"));
let r = probe(&ctx).unwrap();
assert!(r.disabled);
assert!(r.rows.is_empty());
assert_eq!(r.ok_count, 0);
assert_eq!(r.failing_count, 0);
}
#[test]
fn probe_reports_disabled_when_no_provider_is_enabled() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env, Some("[secret]\nenabled = true\n"));
let r = probe(&ctx).unwrap();
assert!(r.disabled);
assert!(r.rows.is_empty());
}
#[test]
fn scan_finds_double_quoted_call() {
let text = r#"value = "{{ secret("pass:test/k") }}""#;
let r = scan_secret_calls(text);
assert_eq!(r.len(), 1);
assert_eq!(r[0].line, 1);
assert_eq!(r[0].reference, "pass:test/k");
}
#[test]
fn scan_finds_single_quoted_call() {
let text = r#"value = "{{ secret('pass:test/k') }}""#;
let r = scan_secret_calls(text);
assert_eq!(r.len(), 1);
assert_eq!(r[0].reference, "pass:test/k");
}
#[test]
fn scan_tolerates_whitespace_between_secret_paren_and_string() {
let text = r#"{{ secret ( "op://V/I/F" ) }}"#;
let r = scan_secret_calls(text);
assert_eq!(r.len(), 1);
assert_eq!(r[0].reference, "op://V/I/F");
}
#[test]
fn scan_reports_correct_line_number_in_multiline_template() {
let text = "header\nport = 5432\nkey = {{ secret(\"pass:k\") }}\nfooter\n";
let r = scan_secret_calls(text);
assert_eq!(r.len(), 1);
assert_eq!(r[0].line, 3);
}
#[test]
fn scan_finds_multiple_calls_in_one_template() {
let text = r#"a = "{{ secret("pass:a") }}"
b = "{{ secret('op://V/I/F') }}"
c = "{{ secret("bw:gh-token") }}""#;
let r = scan_secret_calls(text);
assert_eq!(r.len(), 3);
assert_eq!(r[0].reference, "pass:a");
assert_eq!(r[1].reference, "op://V/I/F");
assert_eq!(r[2].reference, "bw:gh-token");
}
#[test]
fn scan_does_not_match_word_with_secret_prefix() {
let text = r#"x = mysecret("not-this")"#;
let r = scan_secret_calls(text);
assert!(r.is_empty());
}
#[test]
fn scan_does_not_match_word_with_secret_suffix() {
let text = r#"x = secrets("not-this")"#;
let r = scan_secret_calls(text);
assert!(r.is_empty());
}
#[test]
fn scan_skips_unterminated_string_and_does_not_panic() {
let text = r#"x = {{ secret("unterminated"#;
let _ = scan_secret_calls(text); }
#[test]
fn scan_handles_mismatched_quote_styles_independently() {
let text = r#"x = {{ secret("pass:k') }}"#;
let r = scan_secret_calls(text);
for row in &r {
assert!(!row.reference.is_empty());
}
}
}