Skip to main content

vault_core/
stack.rs

1//! Phase 38 — stack integrations as data (ADR-0144).
2//!
3//! The homelab stack is where secrets and config already meet: Prometheus scrape
4//! jobs with `basic_auth`, Grafana datasources with `secureJsonData`, Homepage
5//! widgets with one API key per service. Eleven hand-written exporters were
6//! already "eleven treadmills" (review-01 section 1); five more would be sixteen.
7//! So an integration is not code here, it is a **descriptor** in
8//! `data/stack-adapters.json`: the chunk types it adds (with their fields,
9//! defaults and which are secret), the shape of the file it produces, and the
10//! rules a generated file has to satisfy. This module interprets descriptors;
11//! `src/ts/stack.ts` interprets the same file for the app, and a golden fixture
12//! asserted from both sides keeps the two interpreters honest.
13//!
14//! If an integration cannot be expressed in the descriptor grammar, that is
15//! information about the grammar, not a reason to hand-write another exporter.
16//!
17//! # Rendering
18//!
19//! The caller supplies how a stored field value becomes text (`resolve`): it
20//! decides whether `${Provider/field}` becomes the real value (a deploy), a
21//! fingerprint (a terminal), or stays literal. The interpreter only knows which
22//! fields the descriptor marks `secret`, and passes that flag along so a
23//! redacting caller can mask a literal secret as well as a reference.
24
25use crate::config_check::Finding;
26use serde_json::Value;
27use std::collections::HashSet;
28use std::sync::OnceLock;
29
30const DESCRIPTORS: &str = include_str!("../data/stack-adapters.json");
31
32#[derive(Debug, Clone)]
33pub struct FieldSpec {
34    pub key: String,
35    /// `text` (default), `list` or `bool`.
36    pub kind: String,
37    pub secret: bool,
38    pub default: Option<String>,
39    pub choices: Vec<String>,
40    pub help: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct ChunkSpec {
45    pub type_id: String,
46    pub label: String,
47    pub singleton: bool,
48    pub fields: Vec<FieldSpec>,
49}
50
51#[derive(Debug, Clone)]
52pub struct Starter {
53    pub type_id: String,
54    pub name: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct Rule {
59    pub id: &'static str,
60    pub kind: String,
61    pub type_id: String,
62    pub field: Option<String>,
63    pub fields: Vec<String>,
64    pub when: Option<String>,
65    pub values: Vec<String>,
66    pub severity: &'static str,
67    pub message: String,
68}
69
70#[derive(Debug, Clone)]
71pub struct Adapter {
72    pub id: String,
73    pub label: String,
74    pub abbr: String,
75    pub description: String,
76    pub file: String,
77    pub chunks: Vec<ChunkSpec>,
78    pub starter: Vec<Starter>,
79    pub output: Value,
80    pub rules: Vec<Rule>,
81}
82
83fn str_of(v: &Value, k: &str) -> String {
84    v.get(k).and_then(Value::as_str).unwrap_or("").to_string()
85}
86
87fn strs(v: &Value, k: &str) -> Vec<String> {
88    v.get(k)
89        .and_then(Value::as_array)
90        .map(|a| {
91            a.iter()
92                .filter_map(Value::as_str)
93                .map(String::from)
94                .collect()
95        })
96        .unwrap_or_default()
97}
98
99/// Rule ids and severities live for the whole process: there is one descriptor
100/// file, parsed once, and `Finding` carries `&'static str`.
101fn leak(s: String) -> &'static str {
102    Box::leak(s.into_boxed_str())
103}
104
105fn parse() -> Vec<Adapter> {
106    let doc: Value = serde_json::from_str(DESCRIPTORS).expect("stack-adapters.json is valid JSON");
107    doc["adapters"]
108        .as_array()
109        .expect("adapters is an array")
110        .iter()
111        .map(|a| {
112            let id = str_of(a, "id");
113            let chunks = a["chunks"]
114                .as_array()
115                .map(|cs| {
116                    cs.iter()
117                        .map(|c| ChunkSpec {
118                            type_id: str_of(c, "type"),
119                            label: str_of(c, "label"),
120                            singleton: c["singleton"].as_bool().unwrap_or(false),
121                            fields: c["fields"]
122                                .as_array()
123                                .map(|fs| {
124                                    fs.iter()
125                                        .map(|f| FieldSpec {
126                                            key: str_of(f, "key"),
127                                            kind: if f["kind"].is_string() {
128                                                str_of(f, "kind")
129                                            } else {
130                                                "text".into()
131                                            },
132                                            secret: f["secret"].as_bool().unwrap_or(false),
133                                            default: f["default"].as_str().map(String::from),
134                                            choices: strs(f, "choices"),
135                                            help: f["help"].as_str().map(String::from),
136                                        })
137                                        .collect()
138                                })
139                                .unwrap_or_default(),
140                        })
141                        .collect()
142                })
143                .unwrap_or_default();
144            let rules = a["rules"]
145                .as_array()
146                .map(|rs| {
147                    rs.iter()
148                        .map(|r| Rule {
149                            id: leak(format!("stack-{id}-{}", str_of(r, "id"))),
150                            kind: str_of(r, "kind"),
151                            type_id: str_of(r, "type"),
152                            field: r["field"].as_str().map(String::from),
153                            fields: strs(r, "fields"),
154                            when: r["when"].as_str().map(String::from),
155                            values: strs(r, "values"),
156                            severity: if str_of(r, "severity") == "error" {
157                                "error"
158                            } else {
159                                "warning"
160                            },
161                            message: str_of(r, "message"),
162                        })
163                        .collect()
164                })
165                .unwrap_or_default();
166            Adapter {
167                label: str_of(a, "label"),
168                abbr: str_of(a, "abbr"),
169                description: str_of(a, "description"),
170                file: str_of(a, "file"),
171                starter: a["starter"]
172                    .as_array()
173                    .map(|ss| {
174                        ss.iter()
175                            .map(|s| Starter {
176                                type_id: str_of(s, "type"),
177                                name: str_of(s, "name"),
178                            })
179                            .collect()
180                    })
181                    .unwrap_or_default(),
182                output: a["output"].clone(),
183                chunks,
184                rules,
185                id,
186            }
187        })
188        .collect()
189}
190
191pub fn adapters() -> &'static [Adapter] {
192    static ALL: OnceLock<Vec<Adapter>> = OnceLock::new();
193    ALL.get_or_init(parse)
194}
195
196pub fn adapter(id: &str) -> Option<&'static Adapter> {
197    adapters().iter().find(|a| a.id == id)
198}
199
200/// Every chunk type any adapter adds, in descriptor order.
201pub fn chunk_types() -> Vec<&'static str> {
202    adapters()
203        .iter()
204        .flat_map(|a| a.chunks.iter().map(|c| c.type_id.as_str()))
205        .collect()
206}
207
208impl Adapter {
209    pub fn chunk_spec(&self, type_id: &str) -> Option<&ChunkSpec> {
210        self.chunks.iter().find(|c| c.type_id == type_id)
211    }
212}
213
214// ── The value tree and its YAML writer ───────────────────────────────────────
215
216#[derive(Debug, Clone, PartialEq)]
217enum Y {
218    Str(String),
219    Bool(bool),
220    Int(i64),
221    List(Vec<Y>),
222    Map(Vec<(String, Y)>),
223}
224
225/// YAML words that are not strings when written bare (YAML 1.1 as well as 1.2,
226/// because Prometheus and Grafana are Go programs on 1.1-flavoured parsers).
227fn reserved(s: &str) -> bool {
228    matches!(
229        s.to_ascii_lowercase().as_str(),
230        "true" | "false" | "yes" | "no" | "on" | "off" | "y" | "n" | "null" | "~" | "nan" | "inf"
231    )
232}
233
234/// A scalar that a parser will read back as the same string. Only plain words
235/// are written bare; anything else (a number-like password, a URL, a value with
236/// a colon or a leading symbol) is double-quoted, because a password of `123456`
237/// written bare becomes an integer and a config that "works" with the wrong value
238/// is the worst failure here.
239fn scalar(s: &str) -> String {
240    let bare = !s.is_empty()
241        && !reserved(s)
242        && s.chars()
243            .next()
244            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
245        && s.chars()
246            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
247    if bare {
248        s.to_string()
249    } else {
250        serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
251    }
252}
253
254fn write_map(out: &mut String, entries: &[(String, Y)], indent: usize) {
255    let pad = " ".repeat(indent);
256    for (k, v) in entries {
257        out.push_str(&pad);
258        out.push_str(&scalar(k));
259        out.push(':');
260        match v {
261            Y::Str(s) => {
262                out.push(' ');
263                out.push_str(&scalar(s));
264                out.push('\n');
265            }
266            Y::Bool(b) => out.push_str(&format!(" {b}\n")),
267            Y::Int(i) => out.push_str(&format!(" {i}\n")),
268            Y::Map(m) if m.is_empty() => out.push_str(" {}\n"),
269            Y::List(l) if l.is_empty() => out.push_str(" []\n"),
270            Y::Map(m) => {
271                out.push('\n');
272                write_map(out, m, indent + 2);
273            }
274            Y::List(l) => {
275                out.push('\n');
276                write_list(out, l, indent + 2);
277            }
278        }
279    }
280}
281
282fn write_list(out: &mut String, items: &[Y], indent: usize) {
283    let pad = " ".repeat(indent);
284    for item in items {
285        match item {
286            Y::Map(m) if !m.is_empty() => {
287                // The first key shares the dash's line; the rest align under it.
288                let mut inner = String::new();
289                write_map(&mut inner, m, indent + 2);
290                out.push_str(&pad);
291                out.push_str("- ");
292                out.push_str(&inner[indent + 2..]);
293            }
294            Y::List(l) if !l.is_empty() => {
295                out.push_str(&pad);
296                out.push_str("-\n");
297                write_list(out, l, indent + 2);
298            }
299            Y::Str(s) => out.push_str(&format!("{pad}- {}\n", scalar(s))),
300            Y::Bool(b) => out.push_str(&format!("{pad}- {b}\n")),
301            Y::Int(i) => out.push_str(&format!("{pad}- {i}\n")),
302            Y::Map(_) => out.push_str(&format!("{pad}- {{}}\n")),
303            Y::List(_) => out.push_str(&format!("{pad}- []\n")),
304        }
305    }
306}
307
308fn to_yaml(root: &Y) -> String {
309    let mut out = String::from("# Generated by UnENVerse\n");
310    match root {
311        Y::Map(m) => write_map(&mut out, m, 0),
312        Y::List(l) => write_list(&mut out, l, 0),
313        _ => {}
314    }
315    out
316}
317
318// ── The interpreter ──────────────────────────────────────────────────────────
319
320/// How a stored field value becomes text: `(raw, is_secret) -> text`.
321pub type Resolve<'a> = &'a dyn Fn(&str, bool) -> String;
322
323struct Ctx<'a> {
324    adapter: &'a Adapter,
325    chunks: &'a [&'a Value],
326    resolve: Resolve<'a>,
327    /// The chunk a field lookup reads from, and its spec.
328    cur: Option<(&'a Value, &'a ChunkSpec)>,
329}
330
331fn chunk_type(c: &Value) -> &str {
332    c.get("chunk_type").and_then(Value::as_str).unwrap_or("")
333}
334
335fn raw_field<'a>(chunk: &'a Value, key: &str) -> &'a str {
336    if key == "@name" {
337        return chunk.get("name").and_then(Value::as_str).unwrap_or("");
338    }
339    chunk
340        .get("fields")
341        .and_then(Value::as_array)
342        .and_then(|fs| {
343            fs.iter()
344                .find(|f| f.get("key").and_then(Value::as_str) == Some(key))
345        })
346        .and_then(|f| f.get("value"))
347        .and_then(Value::as_str)
348        .unwrap_or("")
349}
350
351fn is_secret(spec: &ChunkSpec, key: &str) -> bool {
352    spec.fields.iter().any(|f| f.key == key && f.secret)
353}
354
355impl Ctx<'_> {
356    fn field_text(&self, key: &str) -> String {
357        let Some((chunk, spec)) = self.cur else {
358            return String::new();
359        };
360        let raw = raw_field(chunk, key);
361        if raw.trim().is_empty() {
362            return String::new();
363        }
364        if key == "@name" {
365            return raw.to_string();
366        }
367        (self.resolve)(raw, is_secret(spec, key))
368    }
369
370    fn of_type(&self, type_id: &str) -> Vec<&Value> {
371        self.chunks
372            .iter()
373            .copied()
374            .filter(|c| chunk_type(c) == type_id)
375            .collect()
376    }
377
378    fn with<'b>(&'b self, chunk: &'b Value, spec: &'b ChunkSpec) -> Ctx<'b> {
379        Ctx {
380            adapter: self.adapter,
381            chunks: self.chunks,
382            resolve: self.resolve,
383            cur: Some((chunk, spec)),
384        }
385    }
386
387    fn each_chunk(&self, type_id: &str) -> Vec<Ctx<'_>> {
388        let Some(spec) = self.adapter.chunk_spec(type_id) else {
389            return Vec::new();
390        };
391        self.of_type(type_id)
392            .into_iter()
393            .map(|c| self.with(c, spec))
394            .collect()
395    }
396}
397
398fn split_list(s: &str) -> Vec<String> {
399    s.split(['\n', ','])
400        .map(str::trim)
401        .filter(|x| !x.is_empty())
402        .map(String::from)
403        .collect()
404}
405
406fn truthy(s: &str) -> Option<bool> {
407    match s.trim().to_ascii_lowercase().as_str() {
408        "true" | "yes" | "on" | "1" => Some(true),
409        "false" | "no" | "off" | "0" => Some(false),
410        _ => None,
411    }
412}
413
414fn keep(node: &Value) -> bool {
415    node.get("keep").and_then(Value::as_bool).unwrap_or(false)
416}
417
418fn eval(node: &Value, ctx: &Ctx) -> Option<Y> {
419    match node {
420        Value::String(s) => Some(Y::Str(s.clone())),
421        Value::Bool(b) => Some(Y::Bool(*b)),
422        Value::Number(n) => n.as_i64().map(Y::Int),
423        Value::Object(o) => {
424            if let Some(f) = o.get("f").and_then(Value::as_str) {
425                let text = ctx.field_text(f);
426                if text.is_empty() {
427                    return None;
428                }
429                return match o.get("as").and_then(Value::as_str).unwrap_or("str") {
430                    "list" => {
431                        let items = split_list(&text);
432                        (!items.is_empty())
433                            .then(|| Y::List(items.into_iter().map(Y::Str).collect()))
434                    }
435                    "bool" => truthy(&text).map(Y::Bool),
436                    "int" => Some(text.trim().parse::<i64>().map_or(Y::Str(text), Y::Int)),
437                    _ => Some(Y::Str(match o.get("fmt").and_then(Value::as_str) {
438                        Some(fmt) => fmt.replace("{}", &text),
439                        None => text,
440                    })),
441                };
442            }
443            if let Some(pairs) = o.get("map").and_then(Value::as_array) {
444                let entries: Vec<(String, Y)> = pairs
445                    .iter()
446                    .filter_map(|p| {
447                        let p = p.as_array()?;
448                        let key = p.first()?.as_str()?.to_string();
449                        Some((key, eval(p.get(1)?, ctx)?))
450                    })
451                    .collect();
452                return (!entries.is_empty() || keep(node)).then_some(Y::Map(entries));
453            }
454            if let Some(items) = o.get("list").and_then(Value::as_array) {
455                let l: Vec<Y> = items.iter().filter_map(|n| eval(n, ctx)).collect();
456                return (!l.is_empty() || keep(node)).then_some(Y::List(l));
457            }
458            if let Some(t) = o.get("each").and_then(Value::as_str) {
459                let inner = o.get("node")?;
460                let l: Vec<Y> = ctx
461                    .each_chunk(t)
462                    .iter()
463                    .filter_map(|c| eval(inner, c))
464                    .collect();
465                return (!l.is_empty() || keep(node)).then_some(Y::List(l));
466            }
467            if let Some(t) = o.get("singleton").and_then(Value::as_str) {
468                let inner = o.get("node")?;
469                let first = ctx.each_chunk(t).into_iter().next()?;
470                return eval(inner, &first);
471            }
472            if let Some(g) = o.get("group") {
473                let of = g.get("of").and_then(Value::as_str)?;
474                let by = g.get("by").and_then(Value::as_str)?;
475                let default = g.get("default").and_then(Value::as_str).unwrap_or("");
476                let item = g.get("item")?;
477                let mut groups: Vec<(String, Vec<Y>)> = Vec::new();
478                for c in ctx.each_chunk(of) {
479                    let name = match c.field_text(by) {
480                        n if n.is_empty() => default.to_string(),
481                        n => n,
482                    };
483                    let Some(y) = eval(item, &c) else {
484                        continue;
485                    };
486                    match groups.iter_mut().find(|(n, _)| *n == name) {
487                        Some((_, items)) => items.push(y),
488                        None => groups.push((name, vec![y])),
489                    }
490                }
491                let l: Vec<Y> = groups
492                    .into_iter()
493                    .map(|(n, items)| Y::Map(vec![(n, Y::List(items))]))
494                    .collect();
495                return (!l.is_empty() || keep(node)).then_some(Y::List(l));
496            }
497            if let Some(e) = o.get("entry") {
498                let key = match eval(e.get("key")?, ctx)? {
499                    Y::Str(s) => s,
500                    _ => return None,
501                };
502                let value = e
503                    .get("value")
504                    .and_then(|v| eval(v, ctx))
505                    .unwrap_or(Y::Map(Vec::new()));
506                return Some(Y::Map(vec![(key, value)]));
507            }
508            if let Some(fields) = o.get("when").and_then(Value::as_array) {
509                let any = fields
510                    .iter()
511                    .filter_map(Value::as_str)
512                    .any(|f| !ctx.field_text(f).is_empty());
513                return if any { eval(o.get("node")?, ctx) } else { None };
514            }
515            None
516        }
517        _ => None,
518    }
519}
520
521/// Renders a project of this adapter's type to the file it produces.
522pub fn render(adapter: &Adapter, project: &Value, resolve: Resolve) -> String {
523    let chunks: Vec<&Value> = project
524        .get("chunks")
525        .and_then(Value::as_array)
526        .map(|cs| {
527            cs.iter()
528                .filter(|c| !c.get("disabled").and_then(Value::as_bool).unwrap_or(false))
529                .collect()
530        })
531        .unwrap_or_default();
532    let ctx = Ctx {
533        adapter,
534        chunks: &chunks,
535        resolve,
536        cur: None,
537    };
538    match eval(&adapter.output, &ctx) {
539        Some(root) => to_yaml(&root),
540        None => "# Generated by UnENVerse\n".to_string(),
541    }
542}
543
544/// The chunks a new project of this type starts with.
545pub fn starter_chunks(adapter: &Adapter, new_id: &dyn Fn() -> String) -> Vec<Value> {
546    adapter
547        .starter
548        .iter()
549        .filter_map(|s| {
550            let spec = adapter.chunk_spec(&s.type_id)?;
551            Some(new_chunk(spec, &s.name, new_id()))
552        })
553        .collect()
554}
555
556/// A new, empty chunk of this type, with its defaults filled in.
557pub fn new_chunk(spec: &ChunkSpec, name: &str, id: String) -> Value {
558    serde_json::json!({
559        "id": id,
560        "name": name,
561        "chunk_type": spec.type_id,
562        "fields": spec.fields.iter().map(|f| serde_json::json!({
563            "key": f.key,
564            "value": f.default.clone().unwrap_or_default(),
565            "field_type": if f.secret { "secret" } else if f.kind == "list" { "list" } else { "var" },
566        })).collect::<Vec<_>>(),
567    })
568}
569
570// ── Rules ────────────────────────────────────────────────────────────────────
571
572fn finding(rule: &Rule, chunk: &Value, field: &str, related: Vec<String>) -> Finding {
573    let name = chunk.get("name").and_then(Value::as_str).unwrap_or("");
574    Finding {
575        rule: rule.id,
576        severity: rule.severity,
577        chunk_id: chunk
578            .get("id")
579            .and_then(Value::as_str)
580            .unwrap_or("")
581            .to_string(),
582        chunk_name: name.to_string(),
583        chunk_type: chunk_type(chunk).to_string(),
584        field: field.to_string(),
585        // Only the chunk's name and a field name go into a message, never a value:
586        // a finding is shown in places a secret must not reach.
587        message: rule
588            .message
589            .replace("{name}", name)
590            .replace("{field}", field),
591        related,
592    }
593}
594
595fn set(chunk: &Value, key: &str) -> bool {
596    !raw_field(chunk, key).trim().is_empty()
597}
598
599/// Checks a project of this adapter's type against the descriptor's rules, plus
600/// the one rule every adapter shares: a `${…}` that names no vault entry.
601pub fn check(adapter: &Adapter, project: &Value, vault_names: &[String]) -> Vec<Finding> {
602    let chunks: Vec<&Value> = project
603        .get("chunks")
604        .and_then(Value::as_array)
605        .map(|cs| {
606            cs.iter()
607                .filter(|c| !c.get("disabled").and_then(Value::as_bool).unwrap_or(false))
608                .collect()
609        })
610        .unwrap_or_default();
611    let mut out = Vec::new();
612    for rule in &adapter.rules {
613        let of: Vec<&Value> = chunks
614            .iter()
615            .copied()
616            .filter(|c| chunk_type(c) == rule.type_id)
617            .collect();
618        match rule.kind.as_str() {
619            "unique" => {
620                let field = rule.field.as_deref().unwrap_or("@name");
621                let mut seen: Vec<&str> = Vec::new();
622                for c in &of {
623                    let v = raw_field(c, field).trim();
624                    if v.is_empty() {
625                        continue;
626                    }
627                    if seen.contains(&v) {
628                        out.push(finding(rule, c, field, vec![]));
629                    } else {
630                        seen.push(v);
631                    }
632                }
633            }
634            "required" => {
635                let field = rule.field.as_deref().unwrap_or("");
636                for c in &of {
637                    if !set(c, field) {
638                        out.push(finding(rule, c, field, vec![]));
639                    }
640                }
641            }
642            "exclusive" => {
643                for c in &of {
644                    if rule.fields.iter().all(|f| set(c, f)) {
645                        out.push(finding(rule, c, &rule.fields.join(" and "), vec![]));
646                    }
647                }
648            }
649            "together" => {
650                for c in &of {
651                    let n = rule.fields.iter().filter(|f| set(c, f)).count();
652                    if n > 0 && n < rule.fields.len() {
653                        out.push(finding(rule, c, &rule.fields.join(" and "), vec![]));
654                    }
655                }
656            }
657            "choices" => {
658                let field = rule.field.as_deref().unwrap_or("");
659                for c in &of {
660                    let v = raw_field(c, field).trim();
661                    // A reference is checked when it is resolved, not here.
662                    if !v.is_empty() && !v.starts_with("${") && !rule.values.iter().any(|x| x == v)
663                    {
664                        out.push(finding(rule, c, field, vec![]));
665                    }
666                }
667            }
668            "needs_one_of" => {
669                let when = rule.when.as_deref().unwrap_or("");
670                for c in &of {
671                    if set(c, when) && !rule.fields.iter().any(|f| set(c, f)) {
672                        out.push(finding(rule, c, &rule.fields.join(" or "), vec![]));
673                    }
674                }
675            }
676            _ => {}
677        }
678    }
679
680    // A reference to something the vault does not hold renders as literal
681    // `${…}` text in a file a service is about to read.
682    let known: Vec<String> = vault_names.iter().map(|n| norm(n)).collect();
683    let mut reported: HashSet<String> = HashSet::new();
684    for c in &chunks {
685        if adapter.chunk_spec(chunk_type(c)).is_none() {
686            continue;
687        }
688        let Some(fields) = c.get("fields").and_then(Value::as_array) else {
689            continue;
690        };
691        for f in fields {
692            let raw = f.get("value").and_then(Value::as_str).unwrap_or("").trim();
693            let Some(name) = raw.strip_prefix("${").and_then(|r| r.strip_suffix('}')) else {
694                continue;
695            };
696            if name.starts_with("chunk:") || name.starts_with("bundle:") {
697                continue; // resolved by machinery this module does not duplicate
698            }
699            let head = norm(name.split('/').next().unwrap_or(name));
700            let resolves = known
701                .iter()
702                .any(|p| !p.is_empty() && (head == *p || head.starts_with(&format!("{p}_"))));
703            let key = f.get("key").and_then(Value::as_str).unwrap_or("");
704            if !resolves && reported.insert(format!("{}|{key}|{name}", chunk_type(c))) {
705                out.push(Finding {
706                    rule: leak(format!("stack-{}-unresolved-ref", adapter.id)),
707                    severity: "warning",
708                    chunk_id: c
709                        .get("id")
710                        .and_then(Value::as_str)
711                        .unwrap_or("")
712                        .to_string(),
713                    chunk_name: c
714                        .get("name")
715                        .and_then(Value::as_str)
716                        .unwrap_or("")
717                        .to_string(),
718                    chunk_type: chunk_type(c).to_string(),
719                    field: key.to_string(),
720                    message: format!("`{key}` reads `${{{name}}}`, which is not a vault entry"),
721                    related: vec![],
722                });
723            }
724        }
725    }
726    out
727}
728
729fn norm(s: &str) -> String {
730    s.trim()
731        .to_lowercase()
732        .chars()
733        .map(|c| if c.is_alphanumeric() { c } else { '_' })
734        .collect()
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use serde_json::json;
741
742    fn chunk(t: &str, name: &str, fields: &[(&str, &str)]) -> Value {
743        json!({
744            "id": format!("id-{name}"), "name": name, "chunk_type": t,
745            "fields": fields.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>()
746        })
747    }
748
749    fn project(ptype: &str, chunks: Vec<Value>) -> Value {
750        json!({"id": "p", "name": "p", "project_type": ptype, "chunks": chunks})
751    }
752
753    fn plain(raw: &str, _secret: bool) -> String {
754        raw.to_string()
755    }
756
757    fn render_plain(id: &str, p: &Value) -> String {
758        render(adapter(id).unwrap(), p, &plain)
759    }
760
761    #[test]
762    fn the_descriptors_are_internally_consistent() {
763        assert_eq!(
764            adapters().iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
765            vec!["prometheus", "grafana", "homepage"]
766        );
767        let mut ids = HashSet::new();
768        let mut types = HashSet::new();
769        for a in adapters() {
770            assert!(ids.insert(&a.id), "duplicate adapter {}", a.id);
771            assert!(!a.file.is_empty() && !a.label.is_empty() && !a.abbr.is_empty());
772            for c in &a.chunks {
773                assert!(
774                    types.insert(&c.type_id),
775                    "chunk type {} is in two adapters",
776                    c.type_id
777                );
778                assert!(c.type_id.starts_with(match a.id.as_str() {
779                    "prometheus" => "prom_",
780                    "grafana" => "grafana_",
781                    _ => "homepage_",
782                }));
783                let keys: HashSet<_> = c.fields.iter().map(|f| &f.key).collect();
784                assert_eq!(
785                    keys.len(),
786                    c.fields.len(),
787                    "duplicate field in {}",
788                    c.type_id
789                );
790            }
791            for s in &a.starter {
792                assert!(
793                    a.chunk_spec(&s.type_id).is_some(),
794                    "starter names unknown chunk {}",
795                    s.type_id
796                );
797            }
798            // Every field the output reads, and every rule's field, exists in its chunk type.
799            for r in &a.rules {
800                let spec = a
801                    .chunk_spec(&r.type_id)
802                    .unwrap_or_else(|| panic!("rule {} names unknown chunk {}", r.id, r.type_id));
803                let mut named: Vec<&str> = r.fields.iter().map(String::as_str).collect();
804                named.extend(r.field.as_deref());
805                named.extend(r.when.as_deref());
806                for f in named {
807                    assert!(
808                        f == "@name" || spec.fields.iter().any(|x| x.key == f),
809                        "rule {} names unknown field {f}",
810                        r.id
811                    );
812                }
813                assert!(
814                    [
815                        "unique",
816                        "required",
817                        "exclusive",
818                        "together",
819                        "choices",
820                        "needs_one_of"
821                    ]
822                    .contains(&r.kind.as_str()),
823                    "rule kind {}",
824                    r.kind
825                );
826            }
827        }
828    }
829
830    #[test]
831    fn every_field_the_output_reads_exists_in_the_chunk_it_reads_from() {
832        // A typo in a descriptor would silently drop a field from every file.
833        fn walk(n: &Value, chunk: Option<&str>, a: &Adapter, bad: &mut Vec<String>) {
834            match n {
835                Value::Object(o) => {
836                    if let Some(f) = o.get("f").and_then(Value::as_str) {
837                        let ok = chunk
838                            .and_then(|c| a.chunk_spec(c))
839                            .is_some_and(|s| f == "@name" || s.fields.iter().any(|x| x.key == f));
840                        if !ok {
841                            bad.push(format!("{}: field '{f}' not in {chunk:?}", a.id));
842                        }
843                    }
844                    if let Some(fs) = o.get("when").and_then(Value::as_array) {
845                        for f in fs.iter().filter_map(Value::as_str) {
846                            let ok = chunk
847                                .and_then(|c| a.chunk_spec(c))
848                                .is_some_and(|s| s.fields.iter().any(|x| x.key == f));
849                            if !ok {
850                                bad.push(format!("{}: when '{f}' not in {chunk:?}", a.id));
851                            }
852                        }
853                    }
854                    let inner = o
855                        .get("each")
856                        .or_else(|| o.get("singleton"))
857                        .and_then(Value::as_str)
858                        .or_else(|| {
859                            o.get("group")
860                                .and_then(|g| g.get("of"))
861                                .and_then(Value::as_str)
862                        })
863                        .or(chunk);
864                    if let Some(g) = o.get("group") {
865                        if let Some(by) = g.get("by").and_then(Value::as_str) {
866                            let of = g.get("of").and_then(Value::as_str);
867                            let ok = of
868                                .and_then(|c| a.chunk_spec(c))
869                                .is_some_and(|s| s.fields.iter().any(|x| x.key == by));
870                            if !ok {
871                                bad.push(format!("{}: group by '{by}' not in {of:?}", a.id));
872                            }
873                        }
874                    }
875                    for (k, v) in o {
876                        if k == "f" || k == "when" {
877                            continue;
878                        }
879                        walk(v, inner, a, bad);
880                    }
881                }
882                Value::Array(arr) => arr.iter().for_each(|v| walk(v, chunk, a, bad)),
883                _ => {}
884            }
885        }
886        for a in adapters() {
887            let mut bad = Vec::new();
888            walk(&a.output, None, a, &mut bad);
889            assert!(bad.is_empty(), "{bad:?}");
890        }
891    }
892
893    #[test]
894    fn a_prometheus_project_renders_a_prometheus_file() {
895        let p = project(
896            "prometheus",
897            vec![
898                chunk(
899                    "prom_global",
900                    "global",
901                    &[("scrape_interval", "30s"), ("evaluation_interval", "15s")],
902                ),
903                chunk(
904                    "prom_scrape",
905                    "node",
906                    &[("targets", "a:9100\nb:9100"), ("metrics_path", "/metrics")],
907                ),
908                chunk(
909                    "prom_scrape",
910                    "app",
911                    &[
912                        ("targets", "app:8080"),
913                        ("scheme", "https"),
914                        ("username", "scraper"),
915                        ("password", "s3cret"),
916                        ("ca_file", "/etc/ca.pem"),
917                    ],
918                ),
919                chunk(
920                    "prom_scrape",
921                    "api",
922                    &[("targets", "api:8080"), ("bearer_token", "tok")],
923                ),
924                chunk(
925                    "prom_remote_write",
926                    "cloud",
927                    &[
928                        ("url", "https://prom.example/api/v1/write"),
929                        ("username", "u"),
930                        ("password", "p"),
931                    ],
932                ),
933            ],
934        );
935        assert_eq!(
936            render_plain("prometheus", &p),
937            "# Generated by UnENVerse
938global:
939  scrape_interval: \"30s\"
940  evaluation_interval: \"15s\"
941scrape_configs:
942  - job_name: node
943    metrics_path: \"/metrics\"
944    static_configs:
945      - targets:
946          - \"a:9100\"
947          - \"b:9100\"
948  - job_name: app
949    scheme: https
950    basic_auth:
951      username: scraper
952      password: s3cret
953    tls_config:
954      ca_file: \"/etc/ca.pem\"
955    static_configs:
956      - targets:
957          - \"app:8080\"
958  - job_name: api
959    authorization:
960      type: Bearer
961      credentials: tok
962    static_configs:
963      - targets:
964          - \"api:8080\"
965remote_write:
966  - url: \"https://prom.example/api/v1/write\"
967    basic_auth:
968      username: u
969      password: p
970"
971        );
972    }
973
974    #[test]
975    fn an_empty_scrape_list_is_written_not_dropped_and_empty_sections_vanish() {
976        let p = project("prometheus", vec![]);
977        assert_eq!(
978            render_plain("prometheus", &p),
979            "# Generated by UnENVerse\nscrape_configs: []\n"
980        );
981        let only_global = project(
982            "prometheus",
983            vec![chunk("prom_global", "g", &[("scrape_interval", "1m")])],
984        );
985        assert!(render_plain("prometheus", &only_global)
986            .starts_with("# Generated by UnENVerse\nglobal:\n  scrape_interval: \"1m\"\n"));
987    }
988
989    #[test]
990    fn a_disabled_chunk_is_left_out() {
991        let mut off = chunk("prom_scrape", "paused", &[("targets", "x:1")]);
992        off["disabled"] = json!(true);
993        let p = project(
994            "prometheus",
995            vec![off, chunk("prom_scrape", "active", &[("targets", "y:2")])],
996        );
997        let out = render_plain("prometheus", &p);
998        assert!(out.contains("job_name: active") && !out.contains("paused"));
999    }
1000
1001    #[test]
1002    fn strings_that_yaml_would_read_as_something_else_are_quoted() {
1003        let p = project(
1004            "prometheus",
1005            vec![chunk(
1006                "prom_scrape",
1007                "j",
1008                &[
1009                    ("targets", "h:1"),
1010                    ("username", "true"),
1011                    ("password", "123456"),
1012                ],
1013            )],
1014        );
1015        let out = render_plain("prometheus", &p);
1016        assert!(out.contains("username: \"true\""), "{out}");
1017        assert!(out.contains("password: \"123456\""), "{out}");
1018        for (s, want) in [
1019            ("plain", "plain"),
1020            ("with-dash_and.dot", "with-dash_and.dot"),
1021            ("", "\"\""),
1022            ("null", "\"null\""),
1023            ("No", "\"No\""),
1024            ("~", "\"~\""),
1025            ("0x1F", "\"0x1F\""),
1026            ("1e3", "\"1e3\""),
1027            ("a: b", "\"a: b\""),
1028            ("- x", "\"- x\""),
1029            ("# c", "\"# c\""),
1030            ("line\nbreak", "\"line\\nbreak\""),
1031            ("quo\"te", "\"quo\\\"te\""),
1032            ("ünï", "\"ünï\""),
1033        ] {
1034            assert_eq!(scalar(s), want, "{s:?}");
1035        }
1036    }
1037
1038    #[test]
1039    fn the_resolver_decides_what_a_reference_becomes_and_is_told_which_fields_are_secret() {
1040        let p = project(
1041            "prometheus",
1042            vec![chunk(
1043                "prom_scrape",
1044                "j",
1045                &[
1046                    ("targets", "h:1"),
1047                    ("username", "u"),
1048                    ("password", "${Prom/password}"),
1049                ],
1050            )],
1051        );
1052        let seen = std::cell::RefCell::new(Vec::new());
1053        let r = |raw: &str, secret: bool| {
1054            seen.borrow_mut().push((raw.to_string(), secret));
1055            if raw.starts_with("${") {
1056                "RESOLVED".into()
1057            } else {
1058                raw.to_string()
1059            }
1060        };
1061        let out = render(adapter("prometheus").unwrap(), &p, &r);
1062        assert!(out.contains("password: RESOLVED"), "{out}");
1063        let seen = seen.borrow();
1064        assert!(seen.contains(&("${Prom/password}".to_string(), true)));
1065        assert!(
1066            seen.contains(&("u".to_string(), false)),
1067            "username is not secret"
1068        );
1069    }
1070
1071    #[test]
1072    fn grafana_datasources_carry_credentials_in_secure_json_data() {
1073        let p = project(
1074            "grafana",
1075            vec![
1076                chunk(
1077                    "grafana_datasource",
1078                    "Prometheus",
1079                    &[
1080                        ("type", "prometheus"),
1081                        ("url", "http://prometheus:9090"),
1082                        ("access", "proxy"),
1083                        ("is_default", "true"),
1084                        ("api_token", "tok"),
1085                    ],
1086                ),
1087                chunk(
1088                    "grafana_datasource",
1089                    "pg",
1090                    &[
1091                        ("type", "postgres"),
1092                        ("url", "db:5432"),
1093                        ("user", "grafana"),
1094                        ("password", "pw"),
1095                        ("database", "app"),
1096                    ],
1097                ),
1098            ],
1099        );
1100        assert_eq!(
1101            render_plain("grafana", &p),
1102            "# Generated by UnENVerse
1103apiVersion: 1
1104datasources:
1105  - name: Prometheus
1106    type: prometheus
1107    access: proxy
1108    url: \"http://prometheus:9090\"
1109    isDefault: true
1110    jsonData:
1111      httpHeaderName1: Authorization
1112    secureJsonData:
1113      httpHeaderValue1: \"Bearer tok\"
1114  - name: pg
1115    type: postgres
1116    url: \"db:5432\"
1117    user: grafana
1118    database: app
1119    secureJsonData:
1120      password: pw
1121"
1122        );
1123    }
1124
1125    #[test]
1126    fn homepage_groups_services_in_first_seen_order_and_attaches_widget_credentials() {
1127        let p = project(
1128            "homepage",
1129            vec![
1130                chunk(
1131                    "homepage_service",
1132                    "Jellyfin",
1133                    &[
1134                        ("group", "Media"),
1135                        ("href", "http://jf:8096"),
1136                        ("icon", "jellyfin.png"),
1137                        ("widget_type", "jellyfin"),
1138                        ("widget_url", "http://jf:8096"),
1139                        ("widget_key", "k1"),
1140                    ],
1141                ),
1142                chunk(
1143                    "homepage_service",
1144                    "Pi-hole",
1145                    &[("group", "Network"), ("href", "http://pi.hole/admin")],
1146                ),
1147                chunk(
1148                    "homepage_service",
1149                    "Sonarr",
1150                    &[
1151                        ("group", "Media"),
1152                        ("widget_type", "sonarr"),
1153                        ("widget_url", "http://sonarr:8989"),
1154                        ("widget_key", "k2"),
1155                    ],
1156                ),
1157                chunk("homepage_service", "Loose", &[("group", "")]),
1158            ],
1159        );
1160        assert_eq!(
1161            render_plain("homepage", &p),
1162            "# Generated by UnENVerse
1163- Media:
1164    - Jellyfin:
1165        icon: jellyfin.png
1166        href: \"http://jf:8096\"
1167        widget:
1168          type: jellyfin
1169          url: \"http://jf:8096\"
1170          key: k1
1171    - Sonarr:
1172        widget:
1173          type: sonarr
1174          url: \"http://sonarr:8989\"
1175          key: k2
1176- Network:
1177    - Pi-hole:
1178        href: \"http://pi.hole/admin\"
1179- Services:
1180    - Loose: {}
1181"
1182        );
1183    }
1184
1185    #[test]
1186    fn starter_chunks_carry_the_descriptors_defaults() {
1187        let a = adapter("prometheus").unwrap();
1188        let n = std::cell::Cell::new(0);
1189        let chunks = starter_chunks(a, &|| {
1190            n.set(n.get() + 1);
1191            format!("id{}", n.get())
1192        });
1193        assert_eq!(chunks.len(), 2);
1194        assert_eq!(chunks[0]["chunk_type"], "prom_global");
1195        let targets = chunks[1]["fields"]
1196            .as_array()
1197            .unwrap()
1198            .iter()
1199            .find(|f| f["key"] == "targets")
1200            .unwrap();
1201        assert_eq!(targets["value"], "localhost:9090");
1202        assert_eq!(targets["field_type"], "list");
1203        let pw = chunks[1]["fields"]
1204            .as_array()
1205            .unwrap()
1206            .iter()
1207            .find(|f| f["key"] == "password")
1208            .unwrap();
1209        assert_eq!(pw["field_type"], "secret");
1210    }
1211
1212    fn rules_of(a: &str, p: &Value, names: &[&str]) -> Vec<(String, String)> {
1213        let names: Vec<String> = names.iter().map(|s| s.to_string()).collect();
1214        check(adapter(a).unwrap(), p, &names)
1215            .into_iter()
1216            .map(|f| (f.rule.to_string(), f.chunk_name))
1217            .collect()
1218    }
1219
1220    #[test]
1221    fn each_prometheus_rule_fires_on_its_case_and_stays_silent_on_a_clean_file() {
1222        let clean = project(
1223            "prometheus",
1224            vec![chunk(
1225                "prom_scrape",
1226                "a",
1227                &[
1228                    ("targets", "x:1"),
1229                    ("scheme", "https"),
1230                    ("username", "u"),
1231                    ("password", "p"),
1232                ],
1233            )],
1234        );
1235        assert!(rules_of("prometheus", &clean, &[]).is_empty());
1236        let dup = project(
1237            "prometheus",
1238            vec![
1239                chunk("prom_scrape", "a", &[("targets", "x:1")]),
1240                chunk("prom_scrape", "a", &[("targets", "y:1")]),
1241            ],
1242        );
1243        assert_eq!(
1244            rules_of("prometheus", &dup, &[]),
1245            vec![("stack-prometheus-duplicate-job".into(), "a".into())]
1246        );
1247        let none = project(
1248            "prometheus",
1249            vec![chunk("prom_scrape", "a", &[("targets", " ")])],
1250        );
1251        assert_eq!(
1252            rules_of("prometheus", &none, &[])[0].0,
1253            "stack-prometheus-job-without-targets"
1254        );
1255        let both = project(
1256            "prometheus",
1257            vec![chunk(
1258                "prom_scrape",
1259                "a",
1260                &[
1261                    ("targets", "x:1"),
1262                    ("username", "u"),
1263                    ("password", "p"),
1264                    ("bearer_token", "t"),
1265                ],
1266            )],
1267        );
1268        assert_eq!(
1269            rules_of("prometheus", &both, &[])[0].0,
1270            "stack-prometheus-auth-conflict"
1271        );
1272        let half = project(
1273            "prometheus",
1274            vec![chunk(
1275                "prom_scrape",
1276                "a",
1277                &[("targets", "x:1"), ("username", "u")],
1278            )],
1279        );
1280        assert_eq!(
1281            rules_of("prometheus", &half, &[])[0].0,
1282            "stack-prometheus-basic-auth-incomplete"
1283        );
1284        let scheme = project(
1285            "prometheus",
1286            vec![chunk(
1287                "prom_scrape",
1288                "a",
1289                &[("targets", "x:1"), ("scheme", "ftp")],
1290            )],
1291        );
1292        assert_eq!(
1293            rules_of("prometheus", &scheme, &[])[0].0,
1294            "stack-prometheus-bad-scheme"
1295        );
1296        let rw = project(
1297            "prometheus",
1298            vec![chunk("prom_remote_write", "r", &[("url", "")])],
1299        );
1300        assert_eq!(
1301            rules_of("prometheus", &rw, &[])[0].0,
1302            "stack-prometheus-remote-write-without-url"
1303        );
1304    }
1305
1306    #[test]
1307    fn homepage_and_grafana_rules() {
1308        let dup = project(
1309            "homepage",
1310            vec![
1311                chunk("homepage_service", "A", &[]),
1312                chunk("homepage_service", "A", &[]),
1313            ],
1314        );
1315        assert_eq!(
1316            rules_of("homepage", &dup, &[])[0].0,
1317            "stack-homepage-duplicate-service"
1318        );
1319        let w = project(
1320            "homepage",
1321            vec![chunk(
1322                "homepage_service",
1323                "A",
1324                &[("widget_type", "sonarr"), ("widget_url", "http://x")],
1325            )],
1326        );
1327        assert_eq!(
1328            rules_of("homepage", &w, &[])[0].0,
1329            "stack-homepage-widget-without-credential"
1330        );
1331        let ok = project(
1332            "homepage",
1333            vec![chunk(
1334                "homepage_service",
1335                "A",
1336                &[
1337                    ("widget_type", "sonarr"),
1338                    ("widget_url", "http://x"),
1339                    ("widget_key", "k"),
1340                ],
1341            )],
1342        );
1343        assert!(rules_of("homepage", &ok, &[]).is_empty());
1344        let half = project(
1345            "homepage",
1346            vec![chunk(
1347                "homepage_service",
1348                "A",
1349                &[("widget_type", "sonarr"), ("widget_key", "k")],
1350            )],
1351        );
1352        assert_eq!(
1353            rules_of("homepage", &half, &[])[0].0,
1354            "stack-homepage-widget-without-url"
1355        );
1356        let g = project(
1357            "grafana",
1358            vec![chunk(
1359                "grafana_datasource",
1360                "d",
1361                &[("type", ""), ("access", "weird")],
1362            )],
1363        );
1364        let got: Vec<String> = rules_of("grafana", &g, &[])
1365            .into_iter()
1366            .map(|r| r.0)
1367            .collect();
1368        assert!(got.contains(&"stack-grafana-datasource-without-type".to_string()));
1369        assert!(got.contains(&"stack-grafana-bad-access".to_string()));
1370    }
1371
1372    #[test]
1373    fn a_reference_to_nothing_in_the_vault_is_flagged_once_and_a_real_one_is_not() {
1374        let p = project(
1375            "prometheus",
1376            vec![chunk(
1377                "prom_scrape",
1378                "a",
1379                &[
1380                    ("targets", "x:1"),
1381                    ("username", "u"),
1382                    ("password", "${Ghost/password}"),
1383                ],
1384            )],
1385        );
1386        let found = rules_of("prometheus", &p, &["Prometheus"]);
1387        assert_eq!(
1388            found,
1389            vec![("stack-prometheus-unresolved-ref".into(), "a".into())]
1390        );
1391        assert!(rules_of("prometheus", &p, &["Ghost"]).is_empty());
1392        let keyed = project(
1393            "prometheus",
1394            vec![chunk(
1395                "prom_scrape",
1396                "a",
1397                &[
1398                    ("targets", "x:1"),
1399                    ("username", "u"),
1400                    ("password", "${Ghost_Admin}"),
1401                ],
1402            )],
1403        );
1404        assert!(
1405            rules_of("prometheus", &keyed, &["Ghost"]).is_empty(),
1406            "PROVIDER_KEYID still names the provider"
1407        );
1408        let chunkref = project(
1409            "prometheus",
1410            vec![chunk(
1411                "prom_scrape",
1412                "a",
1413                &[
1414                    ("targets", "x:1"),
1415                    ("username", "u"),
1416                    ("password", "${chunk:other/pw}"),
1417                ],
1418            )],
1419        );
1420        assert!(rules_of("prometheus", &chunkref, &[]).is_empty());
1421    }
1422
1423    #[test]
1424    fn a_finding_never_carries_a_field_value() {
1425        let p = project(
1426            "prometheus",
1427            vec![chunk(
1428                "prom_scrape",
1429                "a",
1430                &[
1431                    ("targets", "x:1"),
1432                    ("username", "alice"),
1433                    ("password", "hunter2-SECRET"),
1434                    ("bearer_token", "tok-SECRET"),
1435                ],
1436            )],
1437        );
1438        for f in check(adapter("prometheus").unwrap(), &p, &[]) {
1439            let all = format!("{} {} {}", f.message, f.field, f.related.join(" "));
1440            assert!(!all.contains("SECRET") && !all.contains("alice"), "{all}");
1441        }
1442    }
1443}