1pub(crate) const KIND: &str = "config_key";
24
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
27pub struct ConfigKey {
28 pub file: String,
30 pub key: String,
32 pub value: String,
37 #[serde(skip)]
46 pub value_known: bool,
47}
48
49fn ext_lower(path: &str) -> Option<String> {
51 std::path::Path::new(path)
52 .extension()
53 .and_then(|e| e.to_str())
54 .map(str::to_ascii_lowercase)
55}
56
57#[must_use]
65pub fn is_config_path(path: &str) -> bool {
66 if path == ".github" || path.starts_with(".github/") {
67 return false;
68 }
69 let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
70 matches!(
71 ext_lower(path).as_deref(),
72 Some("toml" | "json" | "yaml" | "yml" | "env")
73 ) || base == ".env"
74 || base.starts_with(".env.")
75}
76
77#[must_use]
100pub fn is_tooling_config_path(path: &str) -> bool {
101 let segments: Vec<&str> = path
104 .split('/')
105 .filter(|s| !s.is_empty() && *s != ".")
106 .collect();
107 let base = segments.last().copied().unwrap_or(path);
108 let base_lower = base.to_ascii_lowercase();
109
110 if segments.iter().any(|s| *s == ".github" || *s == ".config") {
113 return true;
114 }
115
116 if segments.len() >= 2
120 && segments[segments.len() - 2] == ".cargo"
121 && matches!(base_lower.as_str(), "config" | "config.toml")
122 {
123 return true;
124 }
125
126 matches!(
129 base_lower.as_str(),
130 "cargo.toml"
131 | "cargo.lock"
132 | "rust-toolchain"
133 | "rust-toolchain.toml"
134 | "rustfmt.toml"
135 | ".rustfmt.toml"
136 | "clippy.toml"
137 | "deny.toml"
138 | "release-plz.toml"
139 | ".gitlab-ci.yml"
140 )
141}
142
143#[must_use]
146pub fn flatten(path: &str, bytes: &[u8]) -> Vec<ConfigKey> {
147 let Ok(text) = std::str::from_utf8(bytes) else {
148 return Vec::new();
149 };
150 let mut out = Vec::new();
151 match ext_lower(path).as_deref() {
152 Some("toml") => {
153 if let Ok(v) = toml::from_str::<toml::Value>(text) {
154 flatten_toml(&v, "", path, &mut out);
155 }
156 }
157 Some("json") => {
158 if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
159 flatten_json(&v, "", path, &mut out);
160 }
161 }
162 Some("yaml" | "yml") => flatten_yaml(text, path, &mut out),
163 _ => flatten_env(text, path, &mut out),
165 }
166 out
167}
168
169fn push(out: &mut Vec<ConfigKey>, file: &str, key: &str, value: String) {
170 if !key.is_empty() {
171 out.push(ConfigKey {
172 file: file.to_owned(),
173 key: key.to_owned(),
174 value,
175 value_known: true,
178 });
179 }
180}
181
182fn join(prefix: &str, seg: &str) -> String {
183 if prefix.is_empty() {
184 seg.to_owned()
185 } else {
186 format!("{prefix}.{seg}")
187 }
188}
189
190fn toml_scalar(v: &toml::Value) -> String {
193 match v {
194 toml::Value::String(s) => s.clone(),
195 other => other.to_string(),
196 }
197}
198
199fn json_scalar(v: &serde_json::Value) -> String {
201 match v {
202 serde_json::Value::String(s) => s.clone(),
203 other => other.to_string(),
204 }
205}
206
207fn flatten_toml(v: &toml::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
210 match v {
211 toml::Value::Table(t) => {
212 for (k, val) in t {
213 flatten_toml(val, &join(prefix, k), file, out);
214 }
215 }
216 other => push(out, file, prefix, toml_scalar(other)),
217 }
218}
219
220fn flatten_json(v: &serde_json::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
222 match v {
223 serde_json::Value::Object(m) => {
224 for (k, val) in m {
225 flatten_json(val, &join(prefix, k), file, out);
226 }
227 }
228 other => push(out, file, prefix, json_scalar(other)),
229 }
230}
231
232fn flatten_env(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
235 for line in text.lines() {
236 let line = line.trim();
237 if line.is_empty() || line.starts_with('#') {
238 continue;
239 }
240 if let Some((k, val)) = line.strip_prefix("export ").unwrap_or(line).split_once('=') {
241 let key = k.trim();
242 let val = val.trim().trim_matches('"').trim_matches('\'').to_owned();
243 push(out, file, key, val);
244 }
245 }
246}
247
248use yaml_rust2::Yaml;
249
250fn yaml_scalar(v: &Yaml) -> Option<String> {
253 match v {
254 Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
256 Yaml::Integer(i) => Some(i.to_string()),
257 Yaml::Boolean(b) => Some(b.to_string()),
258 Yaml::Null => Some("null".to_owned()),
259 _ => None,
260 }
261}
262
263fn yaml_get_str<'a>(doc: &'a Yaml, key: &str) -> Option<&'a str> {
265 doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_str()
266}
267
268fn yaml_get_vec<'a>(doc: &'a Yaml, key: &str) -> Option<&'a Vec<Yaml>> {
270 doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_vec()
271}
272
273fn flatten_yaml(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
277 let Ok(docs) = yaml_rust2::YamlLoader::load_from_str(text) else {
278 return;
279 };
280 for doc in &docs {
281 match k8s_kind(doc) {
282 Some(kind) => flatten_k8s(doc, &kind, file, out),
283 None => flatten_yaml_node(doc, "", file, out),
284 }
285 }
286}
287
288fn flatten_yaml_node(v: &Yaml, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
291 match v {
292 Yaml::Hash(h) => {
293 for (k, val) in h {
294 if let Some(k) = k.as_str() {
295 flatten_yaml_node(val, &join(prefix, k), file, out);
296 }
297 }
298 }
299 Yaml::Array(items) => {
300 let parts: Vec<Option<String>> = items.iter().map(yaml_scalar).collect();
304 let value = if parts.iter().all(Option::is_some) {
305 let scalars: Vec<String> = parts.into_iter().flatten().collect();
306 format!("[{}]", scalars.join(", "))
307 } else {
308 format!("[<{} items>]", items.len())
309 };
310 push(out, file, prefix, value);
311 }
312 other => {
313 if let Some(s) = yaml_scalar(other) {
314 push(out, file, prefix, s);
315 }
316 }
317 }
318}
319
320fn k8s_kind(doc: &Yaml) -> Option<String> {
323 let h = doc.as_hash()?;
324 let has = |k: &str| h.contains_key(&Yaml::String(k.to_owned()));
325 (has("apiVersion") && has("kind"))
326 .then(|| yaml_get_str(doc, "kind").map(str::to_owned))
327 .flatten()
328}
329
330fn flatten_k8s(doc: &Yaml, kind: &str, file: &str, out: &mut Vec<ConfigKey>) {
333 match kind {
334 "ConfigMap" => k8s_data(doc, "data", file, out, false),
336 "Secret" => {
338 k8s_data(doc, "data", file, out, true);
339 k8s_data(doc, "stringData", file, out, true);
340 }
341 _ => {
343 if let Some(pod) = k8s_pod_spec(doc, kind) {
344 k8s_containers(pod, file, out);
345 }
346 }
347 }
348}
349
350fn k8s_data(doc: &Yaml, field: &str, file: &str, out: &mut Vec<ConfigKey>, redact: bool) {
355 let Some(map) = doc
356 .as_hash()
357 .and_then(|h| h.get(&Yaml::String(field.to_owned())))
358 .and_then(Yaml::as_hash)
359 else {
360 return;
361 };
362 for (k, v) in map {
363 let Some(k) = k.as_str() else { continue };
364 if redact {
365 push(out, file, k, "<redacted>".to_owned());
367 } else if let Some(value) = yaml_scalar(v) {
368 push(out, file, k, value);
369 }
370 }
373}
374
375fn k8s_pod_spec<'a>(doc: &'a Yaml, kind: &str) -> Option<&'a Yaml> {
378 let path: &[&str] = match kind {
379 "Pod" => &["spec"],
380 "Deployment" | "StatefulSet" | "DaemonSet" | "ReplicaSet" | "Job" => {
381 &["spec", "template", "spec"]
382 }
383 "CronJob" => &["spec", "jobTemplate", "spec", "template", "spec"],
384 _ => return None,
385 };
386 let mut cur = doc;
387 for seg in path {
388 cur = cur.as_hash()?.get(&Yaml::String((*seg).to_owned()))?;
389 }
390 Some(cur)
391}
392
393fn k8s_containers(pod: &Yaml, file: &str, out: &mut Vec<ConfigKey>) {
398 for field in ["containers", "initContainers"] {
399 let Some(list) = yaml_get_vec(pod, field) else {
400 continue;
401 };
402 for c in list {
403 let cname = yaml_get_str(c, "name").unwrap_or("container");
404 if let Some(image) = yaml_get_str(c, "image") {
405 push(
406 out,
407 file,
408 &format!("container.{cname}.image"),
409 image.to_owned(),
410 );
411 }
412 if let Some(env) = yaml_get_vec(c, "env") {
413 for e in env {
414 if let (Some(name), Some(value)) =
415 (yaml_get_str(e, "name"), yaml_get_str(e, "value"))
416 {
417 push(out, file, name, value.to_owned());
418 }
419 }
420 }
421 }
422 }
423}
424
425#[must_use]
431pub fn is_secret_key(key: &str) -> bool {
432 const NEEDLES: &[&str] = &[
433 "secret",
434 "password",
435 "passwd",
436 "passphrase",
437 "token",
438 "apikey",
439 "credential",
440 "privatekey",
441 "accesskey",
442 "pwd",
443 ];
444 let flat: String = key
445 .chars()
446 .filter(char::is_ascii_alphanumeric)
447 .map(|c| c.to_ascii_lowercase())
448 .collect();
449 NEEDLES.iter().any(|n| flat.contains(n))
450}
451
452#[must_use]
456pub fn normalize(key: &str) -> String {
457 key.split(|c: char| !c.is_ascii_alphanumeric())
458 .filter(|s| !s.is_empty())
459 .map(str::to_ascii_lowercase)
460 .collect::<Vec<_>>()
461 .join(".")
462}
463
464#[must_use]
476pub fn canonicalize(key: &str) -> String {
477 key.split('.')
478 .map(|seg| {
479 seg.chars()
480 .filter(char::is_ascii_alphanumeric)
481 .map(|c| c.to_ascii_lowercase())
482 .collect::<String>()
483 })
484 .filter(|s| !s.is_empty())
485 .collect::<Vec<_>>()
486 .join(".")
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn flatten_unquotes_strings_and_treats_arrays_as_one_leaf() {
495 let toml = flatten(
496 "a.toml",
497 b"[serve]\naddr = \"0.0.0.0:8443\"\nmodels = [\"q8\"]\n",
498 );
499 assert!(
500 toml.iter()
501 .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
502 );
503 assert!(toml.iter().any(|k| k.key == "serve.models"));
504 let json = flatten(
505 "a.json",
506 br#"{"serve":{"addr":"0.0.0.0:8443","tools":false}}"#,
507 );
508 assert!(
509 json.iter()
510 .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
511 );
512 assert!(
513 json.iter()
514 .any(|k| k.key == "serve.tools" && k.value == "false")
515 );
516 let env = flatten(".env", b"# c\nexport SERVE_ADDR=127.0.0.1:8017\n");
517 assert!(
518 env.iter()
519 .any(|k| k.key == "SERVE_ADDR" && k.value == "127.0.0.1:8017")
520 );
521 }
522
523 #[test]
524 fn is_config_path_matches_toml_json_yaml_env() {
525 assert!(is_config_path("values.prod.yaml")); assert!(is_config_path("deploy.yml"));
527 assert!(is_config_path("config.toml"));
528 assert!(is_config_path("a/b.json"));
529 assert!(is_config_path(".env"));
530 assert!(is_config_path(".env.local"));
531 assert!(is_config_path("prod.env"));
532 assert!(!is_config_path("src/main.rs"));
533 assert!(!is_config_path(".github/workflows/ci.yml"));
535 assert!(!is_config_path(".github/dependabot.yml"));
536 }
537
538 #[test]
539 fn tooling_config_paths_are_flagged_conservatively() {
540 for p in [
542 "Cargo.toml",
543 "Cargo.lock",
544 "crates/rto-graph/Cargo.toml", "vendor/some-crate/Cargo.toml", "rust-toolchain",
547 "rust-toolchain.toml",
548 "rustfmt.toml",
549 ".rustfmt.toml",
550 "clippy.toml",
551 "deny.toml",
552 "release-plz.toml",
553 ".config/nextest.toml",
554 ".cargo/config",
555 ".cargo/config.toml",
556 ".github/workflows/ci.yml",
557 ".github/dependabot.yml",
558 ".gitlab-ci.yml",
559 ] {
560 assert!(is_tooling_config_path(p), "{p} should be tooling config");
561 }
562
563 for p in [
565 "config/app.toml",
566 "values.yaml",
567 "values.prod.yaml",
568 "prod.env",
569 ".env",
570 ".env.local",
571 "zerobus-example.toml",
572 "deploy/service.json",
573 "settings/config.toml", ".cargo/app.toml", ] {
576 assert!(!is_tooling_config_path(p), "{p} should be app config");
577 }
578 }
579
580 #[test]
581 fn yaml_helm_values_flatten_like_toml() {
582 let ks = flatten(
583 "values.yaml",
584 b"service:\n addr: 0.0.0.0:8443\n tools: false\nreplicas: 3\nmodels:\n - a\n - b\n",
585 );
586 assert!(
587 ks.iter()
588 .any(|k| k.key == "service.addr" && k.value == "0.0.0.0:8443"),
589 "{ks:?}"
590 );
591 assert!(
592 ks.iter()
593 .any(|k| k.key == "service.tools" && k.value == "false")
594 );
595 assert!(ks.iter().any(|k| k.key == "replicas" && k.value == "3"));
596 assert!(ks.iter().any(|k| k.key == "models" && k.value == "[a, b]"));
598 }
599
600 #[test]
601 fn yaml_array_of_objects_emits_a_sentinel_not_empty() {
602 let ks = flatten(
605 "values.yaml",
606 b"ingress:\n hosts:\n - host: a.example\n paths: [/]\n - host: b.example\n",
607 );
608 let hosts = ks
609 .iter()
610 .find(|k| k.key == "ingress.hosts")
611 .expect("hosts leaf");
612 assert_eq!(hosts.value, "[<2 items>]", "non-scalar array is a sentinel");
613 }
614
615 #[test]
616 fn k8s_configmap_skips_non_scalar_values() {
617 let cm = b"apiVersion: v1\nkind: ConfigMap\ndata:\n flat: ok\n nested:\n a: 1\n";
619 let ks = flatten("cm.yaml", cm);
620 assert!(ks.iter().any(|k| k.key == "flat" && k.value == "ok"));
621 assert!(
622 !ks.iter().any(|k| k.key == "nested"),
623 "non-scalar ConfigMap value skipped, not emitted empty: {ks:?}"
624 );
625 }
626
627 #[test]
628 fn k8s_manifest_mines_config_not_structural_noise() {
629 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";
631 let ks = flatten("deploy.yaml", dep);
632 assert!(
633 ks.iter()
634 .any(|k| k.key == "SERVE_ADDR" && k.value == "0.0.0.0:8443"),
635 "env var mined as a bare key so it matches a hub .env: {ks:?}"
636 );
637 assert!(
638 ks.iter()
639 .any(|k| k.key == "container.api.image" && k.value == "registry/app:1.2"),
640 "{ks:?}"
641 );
642 assert!(!ks.iter().any(|k| k.key == "DB_HOST"));
644 assert!(
645 !ks.iter()
646 .any(|k| k.key.starts_with("apiVersion") || k.key.contains("metadata"))
647 );
648 }
649
650 #[test]
651 fn k8s_configmap_and_secret_data_with_redaction() {
652 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";
653 let ks = flatten("cm.yaml", cm);
654 assert!(
655 ks.iter()
656 .any(|k| k.key == "serve.addr" && k.value == "127.0.0.1:8017")
657 );
658 assert!(ks.iter().any(|k| k.key == "log_level" && k.value == "info"));
659
660 let sec = b"apiVersion: v1\nkind: Secret\ndata:\n database-url: aHR0cA==\n";
662 let ks = flatten("secret.yaml", sec);
663 assert!(
664 ks.iter()
665 .any(|k| k.key == "database-url" && k.value == "<redacted>"),
666 "secret data must be redacted regardless of key name: {ks:?}"
667 );
668 }
669
670 #[test]
671 fn yaml_multi_document_stream_mines_each_doc() {
672 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";
674 let ks = flatten("bundle.yaml", stream);
675 assert!(ks.iter().any(|k| k.key == "port" && k.value == "8443"));
676 assert!(
677 ks.iter()
678 .any(|k| k.key == "container.web.image" && k.value == "app:2.0")
679 );
680 }
681
682 #[test]
683 fn normalize_bridges_conventions() {
684 assert_eq!(normalize("SERVE_ADDR"), "serve.addr");
685 assert_eq!(normalize("serve-addr"), "serve.addr");
686 }
687
688 #[test]
689 fn canonicalize_bridges_camel_snake_kebab_within_a_segment() {
690 assert_eq!(
693 canonicalize("zerobus.serverEndpoint"),
694 "zerobus.serverendpoint"
695 );
696 assert_eq!(
697 canonicalize("zerobus.server_endpoint"),
698 "zerobus.serverendpoint"
699 );
700 assert_eq!(
701 canonicalize("zerobus.server-endpoint"),
702 "zerobus.serverendpoint"
703 );
704 assert_ne!(
705 normalize("zerobus.server_endpoint"),
706 normalize("zerobus.serverEndpoint"),
707 "normalize splits snake_case on `_`, so it cannot bridge camelCase"
708 );
709 assert_ne!(canonicalize("a.b"), canonicalize("ab"));
711 }
712
713 #[test]
714 fn secret_keys_are_flagged_across_conventions() {
715 for k in [
716 "API_TOKEN",
717 "apiKey",
718 "db.password",
719 "AWS_SECRET_ACCESS_KEY",
720 "PWD",
721 ] {
722 assert!(is_secret_key(k), "{k} should be secret");
723 }
724 for k in ["serve.addr", "models.generative", "port", "workspace.roots"] {
725 assert!(!is_secret_key(k), "{k} should not be secret");
726 }
727 }
728}