1pub(crate) const KIND: &str = "config_key";
24
25pub(crate) const REDACTED: &str = "<redacted>";
32
33#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
35pub struct ConfigKey {
36 pub file: String,
38 pub key: String,
40 pub value: String,
45 #[serde(skip)]
54 pub value_known: bool,
55}
56
57fn ext_lower(path: &str) -> Option<String> {
59 std::path::Path::new(path)
60 .extension()
61 .and_then(|e| e.to_str())
62 .map(str::to_ascii_lowercase)
63}
64
65#[must_use]
73pub fn is_config_path(path: &str) -> bool {
74 if path == ".github" || path.starts_with(".github/") {
75 return false;
76 }
77 let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
78 matches!(
79 ext_lower(path).as_deref(),
80 Some("toml" | "json" | "yaml" | "yml" | "env")
81 ) || base == ".env"
82 || base.starts_with(".env.")
83}
84
85#[must_use]
108pub fn is_tooling_config_path(path: &str) -> bool {
109 let segments: Vec<&str> = path
112 .split('/')
113 .filter(|s| !s.is_empty() && *s != ".")
114 .collect();
115 let base = segments.last().copied().unwrap_or(path);
116 let base_lower = base.to_ascii_lowercase();
117
118 if segments.iter().any(|s| *s == ".github" || *s == ".config") {
121 return true;
122 }
123
124 if segments.len() >= 2
128 && segments[segments.len() - 2] == ".cargo"
129 && matches!(base_lower.as_str(), "config" | "config.toml")
130 {
131 return true;
132 }
133
134 matches!(
137 base_lower.as_str(),
138 "cargo.toml"
139 | "cargo.lock"
140 | "rust-toolchain"
141 | "rust-toolchain.toml"
142 | "rustfmt.toml"
143 | ".rustfmt.toml"
144 | "clippy.toml"
145 | "deny.toml"
146 | "release-plz.toml"
147 | ".gitlab-ci.yml"
148 )
149}
150
151#[must_use]
154pub fn flatten(path: &str, bytes: &[u8]) -> Vec<ConfigKey> {
155 let Ok(text) = std::str::from_utf8(bytes) else {
156 return Vec::new();
157 };
158 let mut out = Vec::new();
159 match ext_lower(path).as_deref() {
160 Some("toml") => {
161 if let Ok(v) = toml::from_str::<toml::Value>(text) {
162 flatten_toml(&v, "", path, &mut out);
163 }
164 }
165 Some("json") => {
166 if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
167 flatten_json(&v, "", path, &mut out);
168 }
169 }
170 Some("yaml" | "yml") => flatten_yaml(text, path, &mut out),
171 _ => flatten_env(text, path, &mut out),
173 }
174 out
175}
176
177fn push(out: &mut Vec<ConfigKey>, file: &str, key: &str, value: String) {
178 if !key.is_empty() {
179 out.push(ConfigKey {
180 file: file.to_owned(),
181 key: key.to_owned(),
182 value,
183 value_known: true,
186 });
187 }
188}
189
190fn join(prefix: &str, seg: &str) -> String {
191 if prefix.is_empty() {
192 seg.to_owned()
193 } else {
194 format!("{prefix}.{seg}")
195 }
196}
197
198fn toml_scalar(v: &toml::Value) -> String {
201 match v {
202 toml::Value::String(s) => s.clone(),
203 other => other.to_string(),
204 }
205}
206
207fn json_scalar(v: &serde_json::Value) -> String {
209 match v {
210 serde_json::Value::String(s) => s.clone(),
211 other => other.to_string(),
212 }
213}
214
215fn flatten_toml(v: &toml::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
218 match v {
219 toml::Value::Table(t) => {
220 for (k, val) in t {
221 flatten_toml(val, &join(prefix, k), file, out);
222 }
223 }
224 other => push(out, file, prefix, toml_scalar(other)),
225 }
226}
227
228fn flatten_json(v: &serde_json::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
230 match v {
231 serde_json::Value::Object(m) => {
232 for (k, val) in m {
233 flatten_json(val, &join(prefix, k), file, out);
234 }
235 }
236 other => push(out, file, prefix, json_scalar(other)),
237 }
238}
239
240fn flatten_env(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
243 for line in text.lines() {
244 let line = line.trim();
245 if line.is_empty() || line.starts_with('#') {
246 continue;
247 }
248 if let Some((k, val)) = line.strip_prefix("export ").unwrap_or(line).split_once('=') {
249 let key = k.trim();
250 let val = val.trim().trim_matches('"').trim_matches('\'').to_owned();
251 push(out, file, key, val);
252 }
253 }
254}
255
256use yaml_rust2::Yaml;
257
258fn yaml_scalar(v: &Yaml) -> Option<String> {
261 match v {
262 Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
264 Yaml::Integer(i) => Some(i.to_string()),
265 Yaml::Boolean(b) => Some(b.to_string()),
266 Yaml::Null => Some("null".to_owned()),
267 _ => None,
268 }
269}
270
271fn yaml_get_str<'a>(doc: &'a Yaml, key: &str) -> Option<&'a str> {
273 doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_str()
274}
275
276fn yaml_get_vec<'a>(doc: &'a Yaml, key: &str) -> Option<&'a Vec<Yaml>> {
278 doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_vec()
279}
280
281fn flatten_yaml(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
285 let Ok(docs) = yaml_rust2::YamlLoader::load_from_str(text) else {
286 return;
287 };
288 for doc in &docs {
289 match k8s_kind(doc) {
290 Some(kind) => flatten_k8s(doc, &kind, file, out),
291 None => flatten_yaml_node(doc, "", file, out),
292 }
293 }
294}
295
296fn flatten_yaml_node(v: &Yaml, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
299 match v {
300 Yaml::Hash(h) => {
301 for (k, val) in h {
302 if let Some(k) = k.as_str() {
303 flatten_yaml_node(val, &join(prefix, k), file, out);
304 }
305 }
306 }
307 Yaml::Array(items) => {
308 let parts: Vec<Option<String>> = items.iter().map(yaml_scalar).collect();
312 let value = if parts.iter().all(Option::is_some) {
313 let scalars: Vec<String> = parts.into_iter().flatten().collect();
314 format!("[{}]", scalars.join(", "))
315 } else {
316 format!("[<{} items>]", items.len())
317 };
318 push(out, file, prefix, value);
319 }
320 other => {
321 if let Some(s) = yaml_scalar(other) {
322 push(out, file, prefix, s);
323 }
324 }
325 }
326}
327
328fn k8s_kind(doc: &Yaml) -> Option<String> {
331 let h = doc.as_hash()?;
332 let has = |k: &str| h.contains_key(&Yaml::String(k.to_owned()));
333 (has("apiVersion") && has("kind"))
334 .then(|| yaml_get_str(doc, "kind").map(str::to_owned))
335 .flatten()
336}
337
338fn flatten_k8s(doc: &Yaml, kind: &str, file: &str, out: &mut Vec<ConfigKey>) {
341 match kind {
342 "ConfigMap" => k8s_data(doc, "data", file, out, false),
344 "Secret" => {
346 k8s_data(doc, "data", file, out, true);
347 k8s_data(doc, "stringData", file, out, true);
348 }
349 _ => {
351 if let Some(pod) = k8s_pod_spec(doc, kind) {
352 k8s_containers(pod, file, out);
353 }
354 }
355 }
356}
357
358fn k8s_data(doc: &Yaml, field: &str, file: &str, out: &mut Vec<ConfigKey>, redact: bool) {
363 let Some(map) = doc
364 .as_hash()
365 .and_then(|h| h.get(&Yaml::String(field.to_owned())))
366 .and_then(Yaml::as_hash)
367 else {
368 return;
369 };
370 for (k, v) in map {
371 let Some(k) = k.as_str() else { continue };
372 if redact {
373 push(out, file, k, REDACTED.to_owned());
375 } else if let Some(value) = yaml_scalar(v) {
376 push(out, file, k, value);
377 }
378 }
381}
382
383fn k8s_pod_spec<'a>(doc: &'a Yaml, kind: &str) -> Option<&'a Yaml> {
386 let path: &[&str] = match kind {
387 "Pod" => &["spec"],
388 "Deployment" | "StatefulSet" | "DaemonSet" | "ReplicaSet" | "Job" => {
389 &["spec", "template", "spec"]
390 }
391 "CronJob" => &["spec", "jobTemplate", "spec", "template", "spec"],
392 _ => return None,
393 };
394 let mut cur = doc;
395 for seg in path {
396 cur = cur.as_hash()?.get(&Yaml::String((*seg).to_owned()))?;
397 }
398 Some(cur)
399}
400
401fn k8s_containers(pod: &Yaml, file: &str, out: &mut Vec<ConfigKey>) {
406 for field in ["containers", "initContainers"] {
407 let Some(list) = yaml_get_vec(pod, field) else {
408 continue;
409 };
410 for c in list {
411 let cname = yaml_get_str(c, "name").unwrap_or("container");
412 if let Some(image) = yaml_get_str(c, "image") {
413 push(
414 out,
415 file,
416 &format!("container.{cname}.image"),
417 image.to_owned(),
418 );
419 }
420 if let Some(env) = yaml_get_vec(c, "env") {
421 for e in env {
422 if let (Some(name), Some(value)) =
423 (yaml_get_str(e, "name"), yaml_get_str(e, "value"))
424 {
425 push(out, file, name, value.to_owned());
426 }
427 }
428 }
429 }
430 }
431}
432
433#[must_use]
439pub fn is_secret_key(key: &str) -> bool {
440 const NEEDLES: &[&str] = &[
441 "secret",
442 "password",
443 "passwd",
444 "passphrase",
445 "token",
446 "apikey",
447 "credential",
448 "privatekey",
449 "accesskey",
450 "pwd",
451 ];
452 let flat: String = key
453 .chars()
454 .filter(char::is_ascii_alphanumeric)
455 .map(|c| c.to_ascii_lowercase())
456 .collect();
457 NEEDLES.iter().any(|n| flat.contains(n))
458}
459
460#[must_use]
464pub fn normalize(key: &str) -> String {
465 key.split(|c: char| !c.is_ascii_alphanumeric())
466 .filter(|s| !s.is_empty())
467 .map(str::to_ascii_lowercase)
468 .collect::<Vec<_>>()
469 .join(".")
470}
471
472#[must_use]
484pub fn canonicalize(key: &str) -> String {
485 key.split('.')
486 .map(|seg| {
487 seg.chars()
488 .filter(char::is_ascii_alphanumeric)
489 .map(|c| c.to_ascii_lowercase())
490 .collect::<String>()
491 })
492 .filter(|s| !s.is_empty())
493 .collect::<Vec<_>>()
494 .join(".")
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn flatten_unquotes_strings_and_treats_arrays_as_one_leaf() {
503 let toml = flatten(
504 "a.toml",
505 b"[serve]\naddr = \"0.0.0.0:8443\"\nmodels = [\"q8\"]\n",
506 );
507 assert!(
508 toml.iter()
509 .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
510 );
511 assert!(toml.iter().any(|k| k.key == "serve.models"));
512 let json = flatten(
513 "a.json",
514 br#"{"serve":{"addr":"0.0.0.0:8443","tools":false}}"#,
515 );
516 assert!(
517 json.iter()
518 .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
519 );
520 assert!(
521 json.iter()
522 .any(|k| k.key == "serve.tools" && k.value == "false")
523 );
524 let env = flatten(".env", b"# c\nexport SERVE_ADDR=127.0.0.1:8017\n");
525 assert!(
526 env.iter()
527 .any(|k| k.key == "SERVE_ADDR" && k.value == "127.0.0.1:8017")
528 );
529 }
530
531 #[test]
532 fn is_config_path_matches_toml_json_yaml_env() {
533 assert!(is_config_path("values.prod.yaml")); assert!(is_config_path("deploy.yml"));
535 assert!(is_config_path("config.toml"));
536 assert!(is_config_path("a/b.json"));
537 assert!(is_config_path(".env"));
538 assert!(is_config_path(".env.local"));
539 assert!(is_config_path("prod.env"));
540 assert!(!is_config_path("src/main.rs"));
541 assert!(!is_config_path(".github/workflows/ci.yml"));
543 assert!(!is_config_path(".github/dependabot.yml"));
544 }
545
546 #[test]
547 fn tooling_config_paths_are_flagged_conservatively() {
548 for p in [
550 "Cargo.toml",
551 "Cargo.lock",
552 "crates/rto-graph/Cargo.toml", "vendor/some-crate/Cargo.toml", "rust-toolchain",
555 "rust-toolchain.toml",
556 "rustfmt.toml",
557 ".rustfmt.toml",
558 "clippy.toml",
559 "deny.toml",
560 "release-plz.toml",
561 ".config/nextest.toml",
562 ".cargo/config",
563 ".cargo/config.toml",
564 ".github/workflows/ci.yml",
565 ".github/dependabot.yml",
566 ".gitlab-ci.yml",
567 ] {
568 assert!(is_tooling_config_path(p), "{p} should be tooling config");
569 }
570
571 for p in [
573 "config/app.toml",
574 "values.yaml",
575 "values.prod.yaml",
576 "prod.env",
577 ".env",
578 ".env.local",
579 "zerobus-example.toml",
580 "deploy/service.json",
581 "settings/config.toml", ".cargo/app.toml", ] {
584 assert!(!is_tooling_config_path(p), "{p} should be app config");
585 }
586 }
587
588 #[test]
589 fn yaml_helm_values_flatten_like_toml() {
590 let ks = flatten(
591 "values.yaml",
592 b"service:\n addr: 0.0.0.0:8443\n tools: false\nreplicas: 3\nmodels:\n - a\n - b\n",
593 );
594 assert!(
595 ks.iter()
596 .any(|k| k.key == "service.addr" && k.value == "0.0.0.0:8443"),
597 "{ks:?}"
598 );
599 assert!(
600 ks.iter()
601 .any(|k| k.key == "service.tools" && k.value == "false")
602 );
603 assert!(ks.iter().any(|k| k.key == "replicas" && k.value == "3"));
604 assert!(ks.iter().any(|k| k.key == "models" && k.value == "[a, b]"));
606 }
607
608 #[test]
609 fn yaml_array_of_objects_emits_a_sentinel_not_empty() {
610 let ks = flatten(
613 "values.yaml",
614 b"ingress:\n hosts:\n - host: a.example\n paths: [/]\n - host: b.example\n",
615 );
616 let hosts = ks
617 .iter()
618 .find(|k| k.key == "ingress.hosts")
619 .expect("hosts leaf");
620 assert_eq!(hosts.value, "[<2 items>]", "non-scalar array is a sentinel");
621 }
622
623 #[test]
624 fn k8s_configmap_skips_non_scalar_values() {
625 let cm = b"apiVersion: v1\nkind: ConfigMap\ndata:\n flat: ok\n nested:\n a: 1\n";
627 let ks = flatten("cm.yaml", cm);
628 assert!(ks.iter().any(|k| k.key == "flat" && k.value == "ok"));
629 assert!(
630 !ks.iter().any(|k| k.key == "nested"),
631 "non-scalar ConfigMap value skipped, not emitted empty: {ks:?}"
632 );
633 }
634
635 #[test]
636 fn k8s_manifest_mines_config_not_structural_noise() {
637 let dep = b"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: api\nspec:\n template:\n spec:\n containers:\n - name: api\n image: registry/app:1.2\n env:\n - name: SERVE_ADDR\n value: 0.0.0.0:8443\n - name: DB_HOST\n valueFrom:\n secretKeyRef:\n name: db\n";
639 let ks = flatten("deploy.yaml", dep);
640 assert!(
641 ks.iter()
642 .any(|k| k.key == "SERVE_ADDR" && k.value == "0.0.0.0:8443"),
643 "env var mined as a bare key so it matches a hub .env: {ks:?}"
644 );
645 assert!(
646 ks.iter()
647 .any(|k| k.key == "container.api.image" && k.value == "registry/app:1.2"),
648 "{ks:?}"
649 );
650 assert!(!ks.iter().any(|k| k.key == "DB_HOST"));
652 assert!(
653 !ks.iter()
654 .any(|k| k.key.starts_with("apiVersion") || k.key.contains("metadata"))
655 );
656 }
657
658 #[test]
659 fn k8s_configmap_and_secret_data_with_redaction() {
660 let cm = b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\ndata:\n serve.addr: 127.0.0.1:8017\n log_level: info\n";
661 let ks = flatten("cm.yaml", cm);
662 assert!(
663 ks.iter()
664 .any(|k| k.key == "serve.addr" && k.value == "127.0.0.1:8017")
665 );
666 assert!(ks.iter().any(|k| k.key == "log_level" && k.value == "info"));
667
668 let sec = b"apiVersion: v1\nkind: Secret\ndata:\n database-url: aHR0cA==\n";
670 let ks = flatten("secret.yaml", sec);
671 assert!(
672 ks.iter()
673 .any(|k| k.key == "database-url" && k.value == "<redacted>"),
674 "secret data must be redacted regardless of key name: {ks:?}"
675 );
676 }
677
678 #[test]
679 fn yaml_multi_document_stream_mines_each_doc() {
680 let stream = b"apiVersion: v1\nkind: ConfigMap\ndata:\n port: \"8443\"\n---\napiVersion: apps/v1\nkind: Deployment\nspec:\n template:\n spec:\n containers:\n - name: web\n image: app:2.0\n";
682 let ks = flatten("bundle.yaml", stream);
683 assert!(ks.iter().any(|k| k.key == "port" && k.value == "8443"));
684 assert!(
685 ks.iter()
686 .any(|k| k.key == "container.web.image" && k.value == "app:2.0")
687 );
688 }
689
690 #[test]
691 fn normalize_bridges_conventions() {
692 assert_eq!(normalize("SERVE_ADDR"), "serve.addr");
693 assert_eq!(normalize("serve-addr"), "serve.addr");
694 }
695
696 #[test]
697 fn canonicalize_bridges_camel_snake_kebab_within_a_segment() {
698 assert_eq!(
701 canonicalize("zerobus.serverEndpoint"),
702 "zerobus.serverendpoint"
703 );
704 assert_eq!(
705 canonicalize("zerobus.server_endpoint"),
706 "zerobus.serverendpoint"
707 );
708 assert_eq!(
709 canonicalize("zerobus.server-endpoint"),
710 "zerobus.serverendpoint"
711 );
712 assert_ne!(
713 normalize("zerobus.server_endpoint"),
714 normalize("zerobus.serverEndpoint"),
715 "normalize splits snake_case on `_`, so it cannot bridge camelCase"
716 );
717 assert_ne!(canonicalize("a.b"), canonicalize("ab"));
719 }
720
721 #[test]
722 fn secret_keys_are_flagged_across_conventions() {
723 for k in [
724 "API_TOKEN",
725 "apiKey",
726 "db.password",
727 "AWS_SECRET_ACCESS_KEY",
728 "PWD",
729 ] {
730 assert!(is_secret_key(k), "{k} should be secret");
731 }
732 for k in ["serve.addr", "models.generative", "port", "workspace.roots"] {
733 assert!(!is_secret_key(k), "{k} should not be secret");
734 }
735 }
736}