Skip to main content

rto_graph/
config_keys.rs

1//! Config-file → flat config-key parsing (ADR-0009).
2//!
3//! A deployment/config repo is mostly key/value files. This module flattens
4//! TOML, JSON, `.env`, and **YAML** into dotted **leaf keys**, used two ways: the
5//! extraction pipeline turns them into `config_key` graph nodes (so config keys
6//! are queryable and visible in the graph), and `roteiro links --infer` matches
7//! them across repos. One parser, so the graph and the matcher never disagree.
8//!
9//! YAML gets special handling because a Kubernetes spoke repo is mostly YAML: a
10//! **k8s manifest** (a document with `apiVersion` + `kind`) is *not* flattened
11//! wholesale — that would bury real config under `apiVersion`/`metadata` noise —
12//! but mined for the settings a deployment actually overrides: ConfigMap/Secret
13//! `data`, and each container's `image` and literal `env` vars (Secret values
14//! and secret-looking keys redacted). Any other YAML — a Helm `values.yaml`, a
15//! kustomization, a plain config — is flattened like TOML/JSON.
16//!
17//! Deterministic — TOML/JSON/YAML object iteration is sorted (the caller sorts
18//! emitted nodes); `.env` preserves file order. Parsers (`toml`, `serde_json`,
19//! `yaml-rust2`) are all permissive and `cargo deny`-clean.
20
21/// The `NodeKind::Other` token for a config-key node (`cfgkey:<file>#<dotted>`).
22/// Shared by the extractor that emits them and the store reader that finds them.
23pub(crate) const KIND: &str = "config_key";
24
25/// A single leaf config setting: its dotted key, source file, and value.
26#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
27pub struct ConfigKey {
28    /// Repo-relative file the key was read from.
29    pub file: String,
30    /// Dotted key path (e.g. `serve.addr`), verbatim from the source.
31    pub key: String,
32    /// The scalar (or compact list/object) value, as a string. String scalars are
33    /// **unquoted** so the same setting compares across TOML / JSON / `.env`.
34    pub value: String,
35}
36
37/// The lowercased file extension, if any (`config.TOML` → `toml`).
38fn 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/// Whether a repo-relative path is a config file this module understands:
46/// `*.toml`, `*.json`, `*.yaml`, `*.yml`, `*.env`, or a dotenv name (`.env`,
47/// `.env.<x>`).
48///
49/// `.github/` is excluded: CI workflows and repo metadata are YAML but not *app*
50/// config, so mining them would bury a spoke's real overrides under `jobs`/`steps`
51/// noise (and add nothing to a hub repo's graph).
52#[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/// Flatten a config file's bytes into leaf keys, dispatched by extension. An
66/// unparseable file yields nothing (a config we can't read is not an error here).
67#[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        // `.env`, `.env.<x>`, `*.env`, or anything else we treat as line-format.
86        _ => 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
109/// A TOML leaf value as a plain string — strings unquoted, so they compare with
110/// env/JSON; other scalars and arrays keep their canonical rendering.
111fn toml_scalar(v: &toml::Value) -> String {
112    match v {
113        toml::Value::String(s) => s.clone(),
114        other => other.to_string(),
115    }
116}
117
118/// A JSON leaf value as a plain string — strings unquoted, matching [`toml_scalar`].
119fn 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
126/// Recurse into TOML tables; every non-table (scalar, array, inline) is a leaf
127/// value keyed by its dotted path — so `serve.models = ["a"]` is one key.
128fn 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
139/// Recurse into JSON objects; arrays and scalars are leaves.
140fn 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
151/// Parse `KEY=VALUE` lines (skipping blanks / `#` comments), stripping surrounding
152/// single/double quote characters from the value.
153fn 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
169/// A YAML scalar as a plain string (strings unquoted, like [`toml_scalar`]);
170/// `None` for containers/aliases/bad values (handled by recursion, not as leaves).
171fn yaml_scalar(v: &Yaml) -> Option<String> {
172    match v {
173        // `Real` already holds its source text, so it renders like a string scalar.
174        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
182/// The string value at `key` in a YAML mapping, if present and scalar-stringy.
183fn 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
187/// The array at `key` in a YAML mapping, if present.
188fn 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
192/// Parse every YAML document in `text` (multi-document `---` streams included),
193/// dispatching each to k8s-aware mining or a plain flatten. An unparseable stream
194/// yields nothing.
195fn 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
207/// Flatten an arbitrary YAML document like TOML/JSON: recurse mappings, treat
208/// scalars and arrays as leaves (an array renders as one compact leaf).
209fn 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            // An all-scalar array renders as one compact leaf; if any element is a
220            // map/array, emit a sentinel rather than silently dropping it to `[]`
221            // (which would mislead the matcher/diff into a false equality).
222            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
239/// The `kind` of a document that is a Kubernetes resource (a mapping carrying
240/// both `apiVersion` and `kind`), else `None`.
241fn 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
249/// Mine a k8s resource for the settings a deployment actually overrides, rather
250/// than flattening its structural noise.
251fn flatten_k8s(doc: &Yaml, kind: &str, file: &str, out: &mut Vec<ConfigKey>) {
252    match kind {
253        // ConfigMap `data` is literally the app's config; keys stand alone.
254        "ConfigMap" => k8s_data(doc, "data", file, out, false),
255        // A Secret's `data`/`stringData` are secret by definition — always redacted.
256        "Secret" => {
257            k8s_data(doc, "data", file, out, true);
258            k8s_data(doc, "stringData", file, out, true);
259        }
260        // Workload kinds carry a pod template — mine its containers.
261        _ => {
262            if let Some(pod) = k8s_pod_spec(doc, kind) {
263                k8s_containers(pod, file, out);
264            }
265        }
266    }
267}
268
269/// Emit each entry of a k8s `data`/`stringData` mapping as a config key. When
270/// `redact`, the value is replaced with `<redacted>` (a Secret's data is secret
271/// even when the key name isn't); otherwise the caller's secret-key redaction
272/// still applies to secret-looking names.
273fn 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            // A Secret's value is secret whatever its shape — always redact.
285            push(out, file, k, "<redacted>".to_owned());
286        } else if let Some(value) = yaml_scalar(v) {
287            push(out, file, k, value);
288        }
289        // A non-scalar ConfigMap value is skipped rather than emitted as `""`,
290        // which would mislead the matcher/diff.
291    }
292}
293
294/// Navigate to the pod spec (the mapping that holds `containers`) for a workload
295/// `kind`, or `None` for kinds that carry no pod template.
296fn 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
312/// Mine each container (and init container) in a pod spec for its `image` (keyed
313/// `container.<name>.image`) and each literal `env` var (keyed by the env name,
314/// so it matches a hub `.env`/config setting). Env vars sourced from `valueFrom`
315/// carry no literal value here and are skipped.
316fn 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/// Whether a config key's *name* looks like it holds a secret (token, password,
345/// credential, …). Extraction **redacts the value** of such keys so secrets from
346/// `.env`/config files are never persisted into the graph store (which is
347/// queryable and exportable). Matched against the key with separators removed, so
348/// `API_KEY`, `apiKey`, and `api-key` all count.
349#[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/// Normalise a dotted key for matching: lowercase, split on any non-alphanumeric
372/// run, join with `.`. So `SERVE_ADDR`, `serve.addr`, and `serve-addr` all become
373/// `serve.addr` and match across TOML / env / JSON conventions.
374#[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")); // YAML is in scope (k8s spokes)
420        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        // CI workflows are YAML but not app config — excluded.
428        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        // An array is one compact leaf (as for TOML/JSON).
449        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        // An array whose elements are maps must not silently render as `[]` (which
455        // would false-match another empty list) — a sentinel signals the structure.
456        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        // A ConfigMap whose value is itself a map must be skipped, not emitted as "".
470        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        // A Deployment: env + image are mined; apiVersion/metadata are not.
482        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        // valueFrom env has no literal value → skipped; structural noise absent.
495        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        // A Secret's data is always redacted — even a non-secret-looking key name.
513        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        // One file, two docs (`---`): a ConfigMap and a Deployment.
525        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}