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/// The placeholder a redacted config value is replaced with, before anything is
26/// persisted. Single-sourced across the two places that *write* it — this module
27/// (a k8s `Secret`'s data, secret whatever the key is called) and
28/// [`crate::extract`] (any secret-*named* key) — and the one that *reads* it back,
29/// [`crate::config_secrets`]. That lens's whole report is "was this redacted",
30/// so it cannot be allowed to drift from the redactor by a spelling.
31pub(crate) const REDACTED: &str = "<redacted>";
32
33/// A single leaf config setting: its dotted key, source file, and value.
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
35pub struct ConfigKey {
36 /// Repo-relative file the key was read from.
37 pub file: String,
38 /// Dotted key path (e.g. `serve.addr`), verbatim from the source.
39 pub key: String,
40 /// The scalar (or compact list/object) value, as a string. String scalars are
41 /// **unquoted** so the same setting compares across TOML / JSON / `.env`. When
42 /// [`value_known`](Self::value_known) is `false` this is a placeholder (empty)
43 /// and carries no meaning — see that field.
44 pub value: String,
45 /// Whether [`value`](Self::value) is a **real** setting read from a source, as
46 /// opposed to *absent*. File-derived keys always carry a value (even a genuine
47 /// empty string), so this is `true`; a **struct-derived** key
48 /// (`meta.source = "struct"`) has no literal value in code, so it is `false` and
49 /// `value` is an empty placeholder. Value-agreement matching
50 /// (`roteiro links --infer`) must gate on this so an *unknown* value never
51 /// false-matches a spoke's genuine empty string. Not serialized — an internal
52 /// matching detail, not part of the reported config-key shape.
53 #[serde(skip)]
54 pub value_known: bool,
55}
56
57/// The lowercased file extension, if any (`config.TOML` → `toml`).
58fn 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/// Whether a repo-relative path is a config file this module understands:
66/// `*.toml`, `*.json`, `*.yaml`, `*.yml`, `*.env`, or a dotenv name (`.env`,
67/// `.env.<x>`).
68///
69/// `.github/` is excluded: CI workflows and repo metadata are YAML but not *app*
70/// config, so mining them would bury a spoke's real overrides under `jobs`/`steps`
71/// noise (and add nothing to a hub repo's graph).
72#[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/// Whether a repo-relative config path is **build / tooling / CI** config rather
86/// than an application's own config — a `Cargo.toml`, a `rustfmt.toml`, a CI
87/// workflow, and so on. Used only by *opt-in* filters (`--app-config-only`, the
88/// explorer's "hide tooling config" toggle): the default everywhere is to show
89/// every config key, so this classifier never changes what is extracted or stored.
90///
91/// **Conservative by design** — it returns `true` only for a curated allow-list of
92/// well-known tooling names and directories, so real app config is never hidden by
93/// mistake. A file it doesn't recognise (e.g. `config/app.toml`, `values.yaml`,
94/// `prod.env`) is treated as app config. The list is meant to grow; add new
95/// well-known tooling files to the `match` (basename) or the directory checks.
96///
97/// Matches on the **file-path component** of a `cfgkey:<file>#<dotted>` node, so
98/// callers extract that path (see the CLI's `--app-config-only`) before calling.
99///
100/// Covered today:
101/// - Rust build/tooling: `Cargo.toml`, `Cargo.lock`, `rust-toolchain[.toml]`,
102/// `rustfmt.toml` / `.rustfmt.toml`, `clippy.toml`, `deny.toml`, `release-plz.toml`.
103/// - Cargo's own config: `.cargo/config` / `.cargo/config.toml`.
104/// - Anything under `.config/` (e.g. `.config/nextest.toml`).
105/// - Anything under `.github/` (CI workflows, `dependabot.yml`).
106/// - `.gitlab-ci.yml`.
107#[must_use]
108pub fn is_tooling_config_path(path: &str) -> bool {
109 // Path components, ignoring any leading `./` or empty segments. Repo-relative
110 // paths use `/`, matching the `cfgkey:<file>` ids these are checked against.
111 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 // Directory-scoped: a whole directory that is tooling/CI, not app config.
119 // `.github/` — CI workflows + repo metadata. `.config/` — nextest & friends.
120 if segments.iter().any(|s| *s == ".github" || *s == ".config") {
121 return true;
122 }
123
124 // `.cargo/config` or `.cargo/config.toml` — cargo's own build config. Scoped
125 // to that exact file inside `.cargo/`, so an unrelated `.cargo/app.toml` isn't
126 // swept up.
127 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 // Well-known tooling files by basename, anywhere in the tree (a vendored crate
135 // carries its own `Cargo.toml`, and it's tooling there too).
136 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/// Flatten a config file's bytes into leaf keys, dispatched by extension. An
152/// unparseable file yields nothing (a config we can't read is not an error here).
153#[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 // `.env`, `.env.<x>`, `*.env`, or anything else we treat as line-format.
172 _ => 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 // A flattened file key always has a real value (an empty string is a
184 // genuine empty setting, not an unknown one).
185 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
198/// A TOML leaf value as a plain string — strings unquoted, so they compare with
199/// env/JSON; other scalars and arrays keep their canonical rendering.
200fn toml_scalar(v: &toml::Value) -> String {
201 match v {
202 toml::Value::String(s) => s.clone(),
203 other => other.to_string(),
204 }
205}
206
207/// A JSON leaf value as a plain string — strings unquoted, matching [`toml_scalar`].
208fn 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
215/// Recurse into TOML tables; every non-table (scalar, array, inline) is a leaf
216/// value keyed by its dotted path — so `serve.models = ["a"]` is one key.
217fn 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
228/// Recurse into JSON objects; arrays and scalars are leaves.
229fn 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
240/// Parse `KEY=VALUE` lines (skipping blanks / `#` comments), stripping surrounding
241/// single/double quote characters from the value.
242fn 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
258/// A YAML scalar as a plain string (strings unquoted, like [`toml_scalar`]);
259/// `None` for containers/aliases/bad values (handled by recursion, not as leaves).
260fn yaml_scalar(v: &Yaml) -> Option<String> {
261 match v {
262 // `Real` already holds its source text, so it renders like a string scalar.
263 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
271/// The string value at `key` in a YAML mapping, if present and scalar-stringy.
272fn 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
276/// The array at `key` in a YAML mapping, if present.
277fn 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
281/// Parse every YAML document in `text` (multi-document `---` streams included),
282/// dispatching each to k8s-aware mining or a plain flatten. An unparseable stream
283/// yields nothing.
284fn 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
296/// Flatten an arbitrary YAML document like TOML/JSON: recurse mappings, treat
297/// scalars and arrays as leaves (an array renders as one compact leaf).
298fn 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 // An all-scalar array renders as one compact leaf; if any element is a
309 // map/array, emit a sentinel rather than silently dropping it to `[]`
310 // (which would mislead the matcher/diff into a false equality).
311 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
328/// The `kind` of a document that is a Kubernetes resource (a mapping carrying
329/// both `apiVersion` and `kind`), else `None`.
330fn 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
338/// Mine a k8s resource for the settings a deployment actually overrides, rather
339/// than flattening its structural noise.
340fn flatten_k8s(doc: &Yaml, kind: &str, file: &str, out: &mut Vec<ConfigKey>) {
341 match kind {
342 // ConfigMap `data` is literally the app's config; keys stand alone.
343 "ConfigMap" => k8s_data(doc, "data", file, out, false),
344 // A Secret's `data`/`stringData` are secret by definition — always redacted.
345 "Secret" => {
346 k8s_data(doc, "data", file, out, true);
347 k8s_data(doc, "stringData", file, out, true);
348 }
349 // Workload kinds carry a pod template — mine its containers.
350 _ => {
351 if let Some(pod) = k8s_pod_spec(doc, kind) {
352 k8s_containers(pod, file, out);
353 }
354 }
355 }
356}
357
358/// Emit each entry of a k8s `data`/`stringData` mapping as a config key. When
359/// `redact`, the value is replaced with `<redacted>` (a Secret's data is secret
360/// even when the key name isn't); otherwise the caller's secret-key redaction
361/// still applies to secret-looking names.
362fn 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 // A Secret's value is secret whatever its shape — always redact.
374 push(out, file, k, REDACTED.to_owned());
375 } else if let Some(value) = yaml_scalar(v) {
376 push(out, file, k, value);
377 }
378 // A non-scalar ConfigMap value is skipped rather than emitted as `""`,
379 // which would mislead the matcher/diff.
380 }
381}
382
383/// Navigate to the pod spec (the mapping that holds `containers`) for a workload
384/// `kind`, or `None` for kinds that carry no pod template.
385fn 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
401/// Mine each container (and init container) in a pod spec for its `image` (keyed
402/// `container.<name>.image`) and each literal `env` var (keyed by the env name,
403/// so it matches a hub `.env`/config setting). Env vars sourced from `valueFrom`
404/// carry no literal value here and are skipped.
405fn 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/// Whether a config key's *name* looks like it holds a secret (token, password,
434/// credential, …). Extraction **redacts the value** of such keys so secrets from
435/// `.env`/config files are never persisted into the graph store (which is
436/// queryable and exportable). Matched against the key with separators removed, so
437/// `API_KEY`, `apiKey`, and `api-key` all count.
438#[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/// Normalise a dotted key for matching: lowercase, split on any non-alphanumeric
461/// run, join with `.`. So `SERVE_ADDR`, `serve.addr`, and `serve-addr` all become
462/// `serve.addr` and match across TOML / env / JSON conventions.
463#[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/// Canonicalise a dotted key for cross-**naming-convention** matching: keep the
473/// dotted structure, but collapse each `.`-delimited segment to its lowercased
474/// ASCII-alphanumerics only — dropping `_`, `-`, and any other punctuation within
475/// the segment. So within a segment `serverEndpoint`, `server_endpoint`, and
476/// `server-endpoint` all become `serverendpoint`, letting a Kubernetes YAML
477/// `zerobus.serverEndpoint` (`camelCase`) match an app TOML `zerobus.server_endpoint`
478/// (`snake_case`) that [`normalize`] keeps apart — `normalize` splits on *any* run
479/// of non-ASCII-alphanumeric chars, so `_` becomes a boundary
480/// (`zerobus.server.endpoint`) and a compound leaf never lines up with its
481/// `camelCase` spelling. The dotted structure is preserved here (segments are split
482/// on `.` only) so `a.b` and `ab` stay distinct.
483#[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")); // YAML is in scope (k8s spokes)
534 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 // CI workflows are YAML but not app config — excluded.
542 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 // Well-known build/tooling/CI files → tooling (hidden by the opt-in filter).
549 for p in [
550 "Cargo.toml",
551 "Cargo.lock",
552 "crates/rto-graph/Cargo.toml", // a workspace member's manifest, too
553 "vendor/some-crate/Cargo.toml", // a vendored crate's manifest
554 "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 // Ordinary application config → NOT tooling (always shown; never misclassified).
572 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", // a plain `config.toml`, not under `.cargo/`
582 ".cargo/app.toml", // an unrelated file that merely lives under `.cargo/`
583 ] {
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 // An array is one compact leaf (as for TOML/JSON).
605 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 // An array whose elements are maps must not silently render as `[]` (which
611 // would false-match another empty list) — a sentinel signals the structure.
612 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 // A ConfigMap whose value is itself a map must be skipped, not emitted as "".
626 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 // A Deployment: env + image are mined; apiVersion/metadata are not.
638 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 // valueFrom env has no literal value → skipped; structural noise absent.
651 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 // A Secret's data is always redacted — even a non-secret-looking key name.
669 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 // One file, two docs (`---`): a ConfigMap and a Deployment.
681 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 // The three spellings of a compound leaf collapse to one canonical form,
699 // which `normalize` (separator-as-boundary) keeps apart.
700 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 // Dotted structure is preserved: `a.b` must not collapse into `ab`.
718 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}