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,
35}
36
37fn ext_lower(path: &str) -> Option<String> {
39 std::path::Path::new(path)
40 .extension()
41 .and_then(|e| e.to_str())
42 .map(str::to_ascii_lowercase)
43}
44
45#[must_use]
53pub fn is_config_path(path: &str) -> bool {
54 if path == ".github" || path.starts_with(".github/") {
55 return false;
56 }
57 let base = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
58 matches!(
59 ext_lower(path).as_deref(),
60 Some("toml" | "json" | "yaml" | "yml" | "env")
61 ) || base == ".env"
62 || base.starts_with(".env.")
63}
64
65#[must_use]
68pub fn flatten(path: &str, bytes: &[u8]) -> Vec<ConfigKey> {
69 let Ok(text) = std::str::from_utf8(bytes) else {
70 return Vec::new();
71 };
72 let mut out = Vec::new();
73 match ext_lower(path).as_deref() {
74 Some("toml") => {
75 if let Ok(v) = toml::from_str::<toml::Value>(text) {
76 flatten_toml(&v, "", path, &mut out);
77 }
78 }
79 Some("json") => {
80 if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
81 flatten_json(&v, "", path, &mut out);
82 }
83 }
84 Some("yaml" | "yml") => flatten_yaml(text, path, &mut out),
85 _ => flatten_env(text, path, &mut out),
87 }
88 out
89}
90
91fn push(out: &mut Vec<ConfigKey>, file: &str, key: &str, value: String) {
92 if !key.is_empty() {
93 out.push(ConfigKey {
94 file: file.to_owned(),
95 key: key.to_owned(),
96 value,
97 });
98 }
99}
100
101fn join(prefix: &str, seg: &str) -> String {
102 if prefix.is_empty() {
103 seg.to_owned()
104 } else {
105 format!("{prefix}.{seg}")
106 }
107}
108
109fn toml_scalar(v: &toml::Value) -> String {
112 match v {
113 toml::Value::String(s) => s.clone(),
114 other => other.to_string(),
115 }
116}
117
118fn json_scalar(v: &serde_json::Value) -> String {
120 match v {
121 serde_json::Value::String(s) => s.clone(),
122 other => other.to_string(),
123 }
124}
125
126fn flatten_toml(v: &toml::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
129 match v {
130 toml::Value::Table(t) => {
131 for (k, val) in t {
132 flatten_toml(val, &join(prefix, k), file, out);
133 }
134 }
135 other => push(out, file, prefix, toml_scalar(other)),
136 }
137}
138
139fn flatten_json(v: &serde_json::Value, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
141 match v {
142 serde_json::Value::Object(m) => {
143 for (k, val) in m {
144 flatten_json(val, &join(prefix, k), file, out);
145 }
146 }
147 other => push(out, file, prefix, json_scalar(other)),
148 }
149}
150
151fn flatten_env(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
154 for line in text.lines() {
155 let line = line.trim();
156 if line.is_empty() || line.starts_with('#') {
157 continue;
158 }
159 if let Some((k, val)) = line.strip_prefix("export ").unwrap_or(line).split_once('=') {
160 let key = k.trim();
161 let val = val.trim().trim_matches('"').trim_matches('\'').to_owned();
162 push(out, file, key, val);
163 }
164 }
165}
166
167use yaml_rust2::Yaml;
168
169fn yaml_scalar(v: &Yaml) -> Option<String> {
172 match v {
173 Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
175 Yaml::Integer(i) => Some(i.to_string()),
176 Yaml::Boolean(b) => Some(b.to_string()),
177 Yaml::Null => Some("null".to_owned()),
178 _ => None,
179 }
180}
181
182fn yaml_get_str<'a>(doc: &'a Yaml, key: &str) -> Option<&'a str> {
184 doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_str()
185}
186
187fn yaml_get_vec<'a>(doc: &'a Yaml, key: &str) -> Option<&'a Vec<Yaml>> {
189 doc.as_hash()?.get(&Yaml::String(key.to_owned()))?.as_vec()
190}
191
192fn flatten_yaml(text: &str, file: &str, out: &mut Vec<ConfigKey>) {
196 let Ok(docs) = yaml_rust2::YamlLoader::load_from_str(text) else {
197 return;
198 };
199 for doc in &docs {
200 match k8s_kind(doc) {
201 Some(kind) => flatten_k8s(doc, &kind, file, out),
202 None => flatten_yaml_node(doc, "", file, out),
203 }
204 }
205}
206
207fn flatten_yaml_node(v: &Yaml, prefix: &str, file: &str, out: &mut Vec<ConfigKey>) {
210 match v {
211 Yaml::Hash(h) => {
212 for (k, val) in h {
213 if let Some(k) = k.as_str() {
214 flatten_yaml_node(val, &join(prefix, k), file, out);
215 }
216 }
217 }
218 Yaml::Array(items) => {
219 let parts: Vec<Option<String>> = items.iter().map(yaml_scalar).collect();
223 let value = if parts.iter().all(Option::is_some) {
224 let scalars: Vec<String> = parts.into_iter().flatten().collect();
225 format!("[{}]", scalars.join(", "))
226 } else {
227 format!("[<{} items>]", items.len())
228 };
229 push(out, file, prefix, value);
230 }
231 other => {
232 if let Some(s) = yaml_scalar(other) {
233 push(out, file, prefix, s);
234 }
235 }
236 }
237}
238
239fn k8s_kind(doc: &Yaml) -> Option<String> {
242 let h = doc.as_hash()?;
243 let has = |k: &str| h.contains_key(&Yaml::String(k.to_owned()));
244 (has("apiVersion") && has("kind"))
245 .then(|| yaml_get_str(doc, "kind").map(str::to_owned))
246 .flatten()
247}
248
249fn flatten_k8s(doc: &Yaml, kind: &str, file: &str, out: &mut Vec<ConfigKey>) {
252 match kind {
253 "ConfigMap" => k8s_data(doc, "data", file, out, false),
255 "Secret" => {
257 k8s_data(doc, "data", file, out, true);
258 k8s_data(doc, "stringData", file, out, true);
259 }
260 _ => {
262 if let Some(pod) = k8s_pod_spec(doc, kind) {
263 k8s_containers(pod, file, out);
264 }
265 }
266 }
267}
268
269fn k8s_data(doc: &Yaml, field: &str, file: &str, out: &mut Vec<ConfigKey>, redact: bool) {
274 let Some(map) = doc
275 .as_hash()
276 .and_then(|h| h.get(&Yaml::String(field.to_owned())))
277 .and_then(Yaml::as_hash)
278 else {
279 return;
280 };
281 for (k, v) in map {
282 let Some(k) = k.as_str() else { continue };
283 if redact {
284 push(out, file, k, "<redacted>".to_owned());
286 } else if let Some(value) = yaml_scalar(v) {
287 push(out, file, k, value);
288 }
289 }
292}
293
294fn k8s_pod_spec<'a>(doc: &'a Yaml, kind: &str) -> Option<&'a Yaml> {
297 let path: &[&str] = match kind {
298 "Pod" => &["spec"],
299 "Deployment" | "StatefulSet" | "DaemonSet" | "ReplicaSet" | "Job" => {
300 &["spec", "template", "spec"]
301 }
302 "CronJob" => &["spec", "jobTemplate", "spec", "template", "spec"],
303 _ => return None,
304 };
305 let mut cur = doc;
306 for seg in path {
307 cur = cur.as_hash()?.get(&Yaml::String((*seg).to_owned()))?;
308 }
309 Some(cur)
310}
311
312fn k8s_containers(pod: &Yaml, file: &str, out: &mut Vec<ConfigKey>) {
317 for field in ["containers", "initContainers"] {
318 let Some(list) = yaml_get_vec(pod, field) else {
319 continue;
320 };
321 for c in list {
322 let cname = yaml_get_str(c, "name").unwrap_or("container");
323 if let Some(image) = yaml_get_str(c, "image") {
324 push(
325 out,
326 file,
327 &format!("container.{cname}.image"),
328 image.to_owned(),
329 );
330 }
331 if let Some(env) = yaml_get_vec(c, "env") {
332 for e in env {
333 if let (Some(name), Some(value)) =
334 (yaml_get_str(e, "name"), yaml_get_str(e, "value"))
335 {
336 push(out, file, name, value.to_owned());
337 }
338 }
339 }
340 }
341 }
342}
343
344#[must_use]
350pub fn is_secret_key(key: &str) -> bool {
351 const NEEDLES: &[&str] = &[
352 "secret",
353 "password",
354 "passwd",
355 "passphrase",
356 "token",
357 "apikey",
358 "credential",
359 "privatekey",
360 "accesskey",
361 "pwd",
362 ];
363 let flat: String = key
364 .chars()
365 .filter(char::is_ascii_alphanumeric)
366 .map(|c| c.to_ascii_lowercase())
367 .collect();
368 NEEDLES.iter().any(|n| flat.contains(n))
369}
370
371#[must_use]
375pub fn normalize(key: &str) -> String {
376 key.split(|c: char| !c.is_ascii_alphanumeric())
377 .filter(|s| !s.is_empty())
378 .map(str::to_ascii_lowercase)
379 .collect::<Vec<_>>()
380 .join(".")
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn flatten_unquotes_strings_and_treats_arrays_as_one_leaf() {
389 let toml = flatten(
390 "a.toml",
391 b"[serve]\naddr = \"0.0.0.0:8443\"\nmodels = [\"q8\"]\n",
392 );
393 assert!(
394 toml.iter()
395 .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
396 );
397 assert!(toml.iter().any(|k| k.key == "serve.models"));
398 let json = flatten(
399 "a.json",
400 br#"{"serve":{"addr":"0.0.0.0:8443","tools":false}}"#,
401 );
402 assert!(
403 json.iter()
404 .any(|k| k.key == "serve.addr" && k.value == "0.0.0.0:8443")
405 );
406 assert!(
407 json.iter()
408 .any(|k| k.key == "serve.tools" && k.value == "false")
409 );
410 let env = flatten(".env", b"# c\nexport SERVE_ADDR=127.0.0.1:8017\n");
411 assert!(
412 env.iter()
413 .any(|k| k.key == "SERVE_ADDR" && k.value == "127.0.0.1:8017")
414 );
415 }
416
417 #[test]
418 fn is_config_path_matches_toml_json_yaml_env() {
419 assert!(is_config_path("values.prod.yaml")); assert!(is_config_path("deploy.yml"));
421 assert!(is_config_path("config.toml"));
422 assert!(is_config_path("a/b.json"));
423 assert!(is_config_path(".env"));
424 assert!(is_config_path(".env.local"));
425 assert!(is_config_path("prod.env"));
426 assert!(!is_config_path("src/main.rs"));
427 assert!(!is_config_path(".github/workflows/ci.yml"));
429 assert!(!is_config_path(".github/dependabot.yml"));
430 }
431
432 #[test]
433 fn yaml_helm_values_flatten_like_toml() {
434 let ks = flatten(
435 "values.yaml",
436 b"service:\n addr: 0.0.0.0:8443\n tools: false\nreplicas: 3\nmodels:\n - a\n - b\n",
437 );
438 assert!(
439 ks.iter()
440 .any(|k| k.key == "service.addr" && k.value == "0.0.0.0:8443"),
441 "{ks:?}"
442 );
443 assert!(
444 ks.iter()
445 .any(|k| k.key == "service.tools" && k.value == "false")
446 );
447 assert!(ks.iter().any(|k| k.key == "replicas" && k.value == "3"));
448 assert!(ks.iter().any(|k| k.key == "models" && k.value == "[a, b]"));
450 }
451
452 #[test]
453 fn yaml_array_of_objects_emits_a_sentinel_not_empty() {
454 let ks = flatten(
457 "values.yaml",
458 b"ingress:\n hosts:\n - host: a.example\n paths: [/]\n - host: b.example\n",
459 );
460 let hosts = ks
461 .iter()
462 .find(|k| k.key == "ingress.hosts")
463 .expect("hosts leaf");
464 assert_eq!(hosts.value, "[<2 items>]", "non-scalar array is a sentinel");
465 }
466
467 #[test]
468 fn k8s_configmap_skips_non_scalar_values() {
469 let cm = b"apiVersion: v1\nkind: ConfigMap\ndata:\n flat: ok\n nested:\n a: 1\n";
471 let ks = flatten("cm.yaml", cm);
472 assert!(ks.iter().any(|k| k.key == "flat" && k.value == "ok"));
473 assert!(
474 !ks.iter().any(|k| k.key == "nested"),
475 "non-scalar ConfigMap value skipped, not emitted empty: {ks:?}"
476 );
477 }
478
479 #[test]
480 fn k8s_manifest_mines_config_not_structural_noise() {
481 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";
483 let ks = flatten("deploy.yaml", dep);
484 assert!(
485 ks.iter()
486 .any(|k| k.key == "SERVE_ADDR" && k.value == "0.0.0.0:8443"),
487 "env var mined as a bare key so it matches a hub .env: {ks:?}"
488 );
489 assert!(
490 ks.iter()
491 .any(|k| k.key == "container.api.image" && k.value == "registry/app:1.2"),
492 "{ks:?}"
493 );
494 assert!(!ks.iter().any(|k| k.key == "DB_HOST"));
496 assert!(
497 !ks.iter()
498 .any(|k| k.key.starts_with("apiVersion") || k.key.contains("metadata"))
499 );
500 }
501
502 #[test]
503 fn k8s_configmap_and_secret_data_with_redaction() {
504 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";
505 let ks = flatten("cm.yaml", cm);
506 assert!(
507 ks.iter()
508 .any(|k| k.key == "serve.addr" && k.value == "127.0.0.1:8017")
509 );
510 assert!(ks.iter().any(|k| k.key == "log_level" && k.value == "info"));
511
512 let sec = b"apiVersion: v1\nkind: Secret\ndata:\n database-url: aHR0cA==\n";
514 let ks = flatten("secret.yaml", sec);
515 assert!(
516 ks.iter()
517 .any(|k| k.key == "database-url" && k.value == "<redacted>"),
518 "secret data must be redacted regardless of key name: {ks:?}"
519 );
520 }
521
522 #[test]
523 fn yaml_multi_document_stream_mines_each_doc() {
524 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";
526 let ks = flatten("bundle.yaml", stream);
527 assert!(ks.iter().any(|k| k.key == "port" && k.value == "8443"));
528 assert!(
529 ks.iter()
530 .any(|k| k.key == "container.web.image" && k.value == "app:2.0")
531 );
532 }
533
534 #[test]
535 fn normalize_bridges_conventions() {
536 assert_eq!(normalize("SERVE_ADDR"), "serve.addr");
537 assert_eq!(normalize("serve-addr"), "serve.addr");
538 }
539
540 #[test]
541 fn secret_keys_are_flagged_across_conventions() {
542 for k in [
543 "API_TOKEN",
544 "apiKey",
545 "db.password",
546 "AWS_SECRET_ACCESS_KEY",
547 "PWD",
548 ] {
549 assert!(is_secret_key(k), "{k} should be secret");
550 }
551 for k in ["serve.addr", "models.generative", "port", "workspace.roots"] {
552 assert!(!is_secret_key(k), "{k} should not be secret");
553 }
554 }
555}