Skip to main content

vault_core/
config_check.rs

1//! Cross-chunk, cross-format checks for a project (Phase 29, "the config compiler"; ADR-0137).
2//!
3//! Phase 18's validation matrix runs each generated file past its own tool
4//! (`nginx -t`, `wg-quick strip`), which can only ever see one format. The
5//! mistakes that bite sit between chunks: a `proxy_pass` naming a service the
6//! project does not define, two WireGuard peers claiming one address.
7//!
8//! **Eight rule ids over the six designed checks, hand-written, and no rule language**
9//! (WireGuard and Kubernetes each split into an error case and a softer one). A false positive costs far
10//! more than a missing check: a validator that cries wolf gets switched off and
11//! then protects nothing. So every rule fires only on positive evidence that the
12//! project means to define the thing (a rule about Docker services stays silent in
13//! a project with no `docker_service` chunk), and anything that could be resolved
14//! somewhere this function cannot see — a `name@provider` Traefik reference, a
15//! `${bundle:…}` reference, a hostname with a dot — is skipped, not guessed at.
16//!
17//! The functions are pure over the project JSON, so the CLI (`unv check`) and the
18//! desktop app (over IPC) cannot disagree about what a project means. Messages
19//! carry names and never values: a finding must be safe to print, and a hostname
20//! or chunk name is not a secret where a field value might be.
21
22use serde_json::{json, Value};
23use std::collections::{HashMap, HashSet};
24use std::net::IpAddr;
25
26/// One finding. `severity` is `"error"` (the generated config is wrong) or
27/// `"warning"` (probably wrong; could be satisfied somewhere this cannot see).
28pub struct Finding {
29    pub rule: &'static str,
30    pub severity: &'static str,
31    pub chunk_id: String,
32    pub chunk_name: String,
33    pub chunk_type: String,
34    pub field: String,
35    pub message: String,
36    /// Other chunks involved, by name.
37    pub related: Vec<String>,
38}
39
40impl Finding {
41    pub fn to_json(&self) -> Value {
42        json!({
43            "rule": self.rule,
44            "severity": self.severity,
45            "chunk_id": self.chunk_id,
46            "chunk": self.chunk_name,
47            "chunk_type": self.chunk_type,
48            "field": self.field,
49            "message": self.message,
50            "related": self.related,
51        })
52    }
53}
54
55fn s<'a>(v: &'a Value, key: &str) -> &'a str {
56    v.get(key).and_then(Value::as_str).unwrap_or("")
57}
58
59fn fields(chunk: &Value) -> Vec<&Value> {
60    chunk
61        .get("fields")
62        .and_then(Value::as_array)
63        .map(|a| a.iter().collect())
64        .unwrap_or_default()
65}
66
67/// First non-empty value of `key`, trimmed.
68fn field<'a>(chunk: &'a Value, key: &str) -> &'a str {
69    fields(chunk)
70        .into_iter()
71        .find(|f| s(f, "key") == key && !s(f, "value").trim().is_empty())
72        .map(|f| s(f, "value").trim())
73        .unwrap_or("")
74}
75
76/// Every non-empty value of `key` (a key may repeat, e.g. `listen`).
77fn field_all<'a>(chunk: &'a Value, key: &str) -> Vec<&'a str> {
78    fields(chunk)
79        .into_iter()
80        .filter(|f| s(f, "key") == key)
81        .map(|f| s(f, "value").trim())
82        .filter(|v| !v.is_empty())
83        .collect()
84}
85
86fn split_list(raw: &str) -> Vec<String> {
87    raw.split(|c: char| c == ',' || c.is_whitespace())
88        .map(str::trim)
89        .filter(|x| !x.is_empty())
90        .map(String::from)
91        .collect()
92}
93
94/// A disabled chunk is excluded from exports, so it is excluded here too.
95fn active_chunks(project: &Value) -> Vec<&Value> {
96    project
97        .get("chunks")
98        .and_then(Value::as_array)
99        .map(|a| {
100            a.iter()
101                .filter(|c| !c.get("disabled").and_then(Value::as_bool).unwrap_or(false))
102                .collect()
103        })
104        .unwrap_or_default()
105}
106
107fn of_type<'a>(chunks: &[&'a Value], t: &str) -> Vec<&'a Value> {
108    chunks
109        .iter()
110        .copied()
111        .filter(|c| s(c, "chunk_type") == t)
112        .collect()
113}
114
115fn finding(
116    rule: &'static str,
117    severity: &'static str,
118    chunk: &Value,
119    field: &str,
120    message: String,
121    related: Vec<String>,
122) -> Finding {
123    Finding {
124        rule,
125        severity,
126        chunk_id: s(chunk, "id").to_string(),
127        chunk_name: s(chunk, "name").to_string(),
128        chunk_type: s(chunk, "chunk_type").to_string(),
129        field: field.to_string(),
130        message,
131        related,
132    }
133}
134
135/// Compose names a service the way the exporter does: whitespace to `_`, lowercase.
136fn service_name(chunk: &Value) -> String {
137    s(chunk, "name")
138        .split_whitespace()
139        .collect::<Vec<_>>()
140        .join("_")
141        .to_lowercase()
142}
143
144/// Every name a service answers to on a Docker network.
145fn service_aliases(chunk: &Value) -> Vec<String> {
146    let mut v = vec![service_name(chunk)];
147    let cn = field(chunk, "container_name").to_lowercase();
148    if !cn.is_empty() {
149        v.push(cn);
150    }
151    v
152}
153
154fn norm(name: &str) -> String {
155    name.chars()
156        .map(|c| {
157            if c.is_ascii_alphanumeric() {
158                c.to_ascii_uppercase()
159            } else {
160                '_'
161            }
162        })
163        .collect()
164}
165
166fn is_ref(v: &str) -> bool {
167    v.contains("${")
168}
169
170// ── Rule 1: nginx proxy_pass → a Docker service the project does not define ──
171
172fn rule_nginx_proxy_pass(chunks: &[&Value], elsewhere: &[String], out: &mut Vec<Finding>) {
173    let services = of_type(chunks, "docker_service");
174    if services.is_empty() && elsewhere.is_empty() {
175        return; // no evidence this project (or, with --all-projects, any) defines services
176    }
177    let mut known: HashSet<String> = services.iter().flat_map(|c| service_aliases(c)).collect();
178    known.extend(elsewhere.iter().cloned());
179    for u in of_type(chunks, "nginx_upstream") {
180        known.insert(s(u, "name").to_lowercase());
181    }
182    for t in ["nginx_location", "nginx_server"] {
183        for c in of_type(chunks, t) {
184            for target in field_all(c, "proxy_pass") {
185                let Some(host) = proxy_host(target) else {
186                    continue;
187                };
188                if !known.contains(&host) {
189                    out.push(finding(
190                        "nginx-proxy-pass-unknown-service",
191                        "warning",
192                        c,
193                        "proxy_pass",
194                        format!(
195                            "proxy_pass names `{host}`, which is neither a Docker service nor an upstream defined in this project"
196                        ),
197                        services.iter().map(|x| service_name(x)).collect(),
198                    ));
199                }
200            }
201        }
202    }
203}
204
205/// The bare host of a `proxy_pass` target, or `None` when it is not a service-style
206/// name (an IP, a dotted hostname, `localhost`, a variable, a unix socket, a ref).
207fn proxy_host(target: &str) -> Option<String> {
208    if is_ref(target) || target.contains('$') || target.starts_with("unix:") {
209        return None;
210    }
211    let rest = target.split_once("://").map(|(_, r)| r).unwrap_or(target);
212    if rest.starts_with("unix:") {
213        return None;
214    }
215    let host = rest.split(['/', ':']).next().unwrap_or("").to_lowercase();
216    if host.is_empty()
217        || host == "localhost"
218        || host.contains('.')
219        || host.contains('[')
220        || host.parse::<IpAddr>().is_ok()
221    {
222        return None;
223    }
224    Some(host)
225}
226
227// ── Rule 2: two WireGuard peers with the same AllowedIPs network ─────────────
228
229/// `10.0.0.5/24` → the masked network `10.0.0.0/24`; `None` if unparseable.
230fn network(cidr: &str) -> Option<(u8, u128, u8)> {
231    let (addr, len) = match cidr.split_once('/') {
232        Some((a, l)) => (a, Some(l.parse::<u8>().ok()?)),
233        None => (cidr, None),
234    };
235    match addr.parse::<IpAddr>().ok()? {
236        IpAddr::V4(a) => {
237            let len = len.unwrap_or(32);
238            if len > 32 {
239                return None;
240            }
241            let bits = u32::from(a) as u128;
242            let mask = if len == 0 {
243                0
244            } else {
245                (!0u32 << (32 - len)) as u128
246            };
247            Some((4, bits & mask, len))
248        }
249        IpAddr::V6(a) => {
250            let len = len.unwrap_or(128);
251            if len > 128 {
252                return None;
253            }
254            let bits = u128::from(a);
255            let mask = if len == 0 { 0 } else { !0u128 << (128 - len) };
256            Some((6, bits & mask, len))
257        }
258    }
259}
260
261/// `a` strictly contains `b` (same family, shorter prefix, same leading bits).
262fn contains(a: (u8, u128, u8), b: (u8, u128, u8)) -> bool {
263    if a.0 != b.0 || a.2 >= b.2 {
264        return false;
265    }
266    let width: u32 = if a.0 == 4 { 32 } else { 128 };
267    let shift = width - a.2 as u32;
268    // For IPv4 the address sits in the low 32 bits of the u128.
269    if a.2 == 0 {
270        return true;
271    }
272    (a.1 >> shift) == (b.1 >> shift)
273}
274
275/// Two peers claiming the *same* network is always wrong: the kernel silently
276/// moves it to whichever peer was added last (an error). One peer's network
277/// *containing* another's is how WireGuard expresses a split route, since it routes
278/// by longest prefix, so it is only a warning, and a default route (`/0`) beside
279/// host routes, the standard full-tunnel pattern, is not reported at all.
280fn rule_wireguard_allowed_ips(chunks: &[&Value], out: &mut Vec<Finding>) {
281    let peers = of_type(chunks, "wg_peer");
282    let mut seen: HashMap<(u8, u128, u8), &Value> = HashMap::new();
283    let mut nets: Vec<((u8, u128, u8), String, &Value)> = Vec::new();
284    for p in peers {
285        let mut mine: HashSet<(u8, u128, u8)> = HashSet::new();
286        for raw in field_all(p, "AllowedIPs") {
287            if is_ref(raw) {
288                continue;
289            }
290            for item in split_list(raw) {
291                let Some(net) = network(&item) else { continue };
292                if !mine.insert(net) {
293                    continue;
294                }
295                if let Some(other) = seen.get(&net) {
296                    out.push(finding(
297                        "wireguard-allowed-ips-duplicate",
298                        "error",
299                        p,
300                        "AllowedIPs",
301                        format!(
302                            "AllowedIPs `{item}` is also claimed by peer `{}`; WireGuard gives the address to only one of them",
303                            s(other, "name")
304                        ),
305                        vec![s(other, "name").to_string()],
306                    ));
307                } else {
308                    seen.insert(net, p);
309                    nets.push((net, item.clone(), p));
310                }
311            }
312        }
313    }
314    // Strict containment between different peers, excluding default routes.
315    for (outer, outer_text, op) in &nets {
316        if outer.2 == 0 {
317            continue;
318        }
319        for (inner, inner_text, ip) in &nets {
320            if std::ptr::eq(*op, *ip) || !contains(*outer, *inner) {
321                continue;
322            }
323            out.push(finding(
324                "wireguard-allowed-ips-overlap",
325                "warning",
326                ip,
327                "AllowedIPs",
328                format!(
329                    "AllowedIPs `{inner_text}` sits inside `{outer_text}` claimed by peer `{}`; WireGuard sends it to this peer (longest prefix), so the wider peer never sees it",
330                    s(op, "name")
331                ),
332                vec![s(op, "name").to_string()],
333            ));
334        }
335    }
336}
337
338// ── Rule 3: a Traefik router naming a middleware that does not exist ────────
339
340fn rule_traefik_middleware(chunks: &[&Value], out: &mut Vec<Finding>) {
341    let defined: HashSet<String> = of_type(chunks, "traefik_middleware")
342        .iter()
343        .map(|m| s(m, "name").to_lowercase())
344        .collect();
345    for router in of_type(chunks, "traefik_router") {
346        for raw in field_all(router, "middlewares") {
347            if is_ref(raw) {
348                continue;
349            }
350            for name in split_list(raw) {
351                // `name@provider` lives in another provider; not ours to judge.
352                if name.contains('@') || defined.contains(&name.to_lowercase()) {
353                    continue;
354                }
355                out.push(finding(
356                    "traefik-middleware-missing",
357                    "warning",
358                    router,
359                    "middlewares",
360                    format!(
361                        "router uses middleware `{name}`, which no traefik_middleware chunk defines (write `{name}@file` if it lives in another file)"
362                    ),
363                    vec![],
364                ));
365            }
366        }
367    }
368}
369
370// ── Rule 4: a Deployment consuming a Secret no chunk creates ─────────────────
371//
372// A Deployment names Secrets in `secretEnv` (list) and `secretMounts`
373// (`secret:/path` list); the exporters turn them into `envFrom` and a volume.
374// Silent when the project has no `k8s_secret` chunk at all: the Secret may be
375// created by another manifest set, and a rule that fires there is a rule that
376// gets switched off. The same family also checks an Ingress's Service.
377
378fn split_list_k8s(raw: &str) -> Vec<String> {
379    split_list(raw)
380}
381
382fn rule_k8s_secrets(chunks: &[&Value], out: &mut Vec<Finding>) {
383    let secrets = of_type(chunks, "k8s_secret");
384    if secrets.is_empty() {
385        return;
386    }
387    let defined: HashSet<(String, String)> =
388        secrets.iter().map(|c| (k8s_name(c), k8s_ns(c))).collect();
389    for dep in of_type(chunks, "k8s_deployment") {
390        let ns = k8s_ns(dep);
391        let mut wanted: Vec<(&str, String)> = Vec::new();
392        for raw in field_all(dep, "secretEnv") {
393            for n in split_list_k8s(raw) {
394                wanted.push(("secretEnv", n));
395            }
396        }
397        for raw in field_all(dep, "secretMounts") {
398            for m in split_list_k8s(raw) {
399                if let Some(i) = m.find(':').filter(|i| *i > 0 && *i < m.len() - 1) {
400                    wanted.push(("secretMounts", m[..i].to_string()));
401                }
402            }
403        }
404        let mut reported: HashSet<String> = HashSet::new();
405        for (key, n) in wanted {
406            if is_ref(&n)
407                || defined.contains(&(n.clone(), ns.clone()))
408                || !reported.insert(n.clone())
409            {
410                continue;
411            }
412            out.push(finding(
413                "k8s-deployment-secret-missing",
414                "error",
415                dep,
416                key,
417                format!("Deployment uses Secret `{n}` in namespace `{ns}`, which no k8s_secret chunk creates; the Pod will not start"),
418                secrets.iter().map(|c| k8s_name(c)).collect(),
419            ));
420        }
421    }
422}
423
424// ── Rule 4b: a Kubernetes Ingress backed by a Service no chunk defines ───────
425
426fn k8s_name(chunk: &Value) -> String {
427    let n = field(chunk, "name");
428    if n.is_empty() { s(chunk, "name") } else { n }.to_string()
429}
430
431fn k8s_ns(chunk: &Value) -> String {
432    let n = field(chunk, "namespace");
433    if n.is_empty() { "default" } else { n }.to_string()
434}
435
436fn rule_k8s_ingress(chunks: &[&Value], out: &mut Vec<Finding>) {
437    let services = of_type(chunks, "k8s_service");
438    if services.is_empty() {
439        return;
440    }
441    let defined: HashSet<(String, String)> =
442        services.iter().map(|c| (k8s_name(c), k8s_ns(c))).collect();
443    for ing in of_type(chunks, "k8s_ingress") {
444        let svc = {
445            let v = field(ing, "serviceName");
446            if v.is_empty() {
447                k8s_name(ing)
448            } else {
449                v.to_string()
450            }
451        };
452        if is_ref(&svc) {
453            continue;
454        }
455        let ns = k8s_ns(ing);
456        if !defined.contains(&(svc.clone(), ns.clone())) {
457            out.push(finding(
458                "k8s-ingress-service-missing",
459                "warning",
460                ing,
461                "serviceName",
462                format!("Ingress routes to Service `{svc}` in namespace `{ns}`, which no k8s_service chunk defines"),
463                services.iter().map(|c| k8s_name(c)).collect(),
464            ));
465        }
466    }
467}
468
469// ── Rule 5: a Compose service reading a variable nothing provides ───────────
470//
471// Compose substitutes `${NAME}` anywhere in a service from the `.env` beside the
472// file and the shell. In this model that `.env` is built from the vault references
473// the exporter derives (one per environment field holding a reference) and from
474// env_file chunks. So a `${NAME}` in any service value (image tag, port, command,
475// an environment value) must be a key some env_file chunk sets, or a vault entry.
476// `${NAME:-default}` and the other operator forms carry their own fallback and
477// are not reported; `$${NAME}` is an escaped literal.
478
479/// `${NAME}` tokens in `value` whose body is a plain name or `Provider/field`,
480/// with no operator, skipping `$${…}` escapes.
481fn compose_tokens(value: &str) -> Vec<String> {
482    let b = value.as_bytes();
483    let mut out = Vec::new();
484    let mut i = 0;
485    while i + 1 < b.len() {
486        if b[i] == b'$' && b[i + 1] == b'$' {
487            i += 2;
488            continue;
489        }
490        if b[i] == b'$' && b[i + 1] == b'{' {
491            if let Some(end) = value[i + 2..].find('}') {
492                let body = &value[i + 2..i + 2 + end];
493                if !body.is_empty()
494                    && !body.contains(":-")
495                    && !body.contains(":?")
496                    && !body.contains('-')
497                    && !body.contains('?')
498                    && !body.contains('+')
499                {
500                    out.push(body.to_string());
501                }
502                i += 2 + end + 1;
503                continue;
504            }
505        }
506        i += 1;
507    }
508    out
509}
510
511/// `known` is the vault's entry names (provider, and `provider_keyid`).
512fn rule_compose_env(chunks: &[&Value], known: &[String], out: &mut Vec<Finding>) {
513    let env_keys: HashSet<String> = of_type(chunks, "env_file")
514        .iter()
515        .flat_map(|c| fields(c))
516        .map(|f| norm(s(f, "key")))
517        .collect();
518    let providers: Vec<String> = known.iter().map(|k| norm(k)).collect();
519    let resolves = |name: &str| -> bool {
520        if name.starts_with("chunk:") || name.starts_with("bundle:") {
521            return true; // resolved by machinery this module does not duplicate
522        }
523        let head = name.split('/').next().unwrap_or(name);
524        let n = norm(head);
525        env_keys.contains(&n)
526            || providers
527                .iter()
528                .any(|p| !p.is_empty() && (n == *p || n.starts_with(&format!("{p}_"))))
529    };
530    for svc in of_type(chunks, "docker_service") {
531        for f in fields(svc) {
532            let raw = s(f, "value");
533            let mut names: Vec<String> = Vec::new();
534            if !s(f, "ref_name").is_empty() {
535                names.push(s(f, "ref_name").to_string());
536            }
537            names.extend(compose_tokens(raw));
538            let mut reported: HashSet<String> = HashSet::new();
539            for name in names {
540                if resolves(&name) || !reported.insert(name.clone()) {
541                    continue;
542                }
543                out.push(finding(
544                    "compose-env-ref-unresolved",
545                    "warning",
546                    svc,
547                    s(f, "key"),
548                    format!(
549                        "`{}` reads `${{{name}}}`, which is neither a vault entry nor a key in an env_file chunk",
550                        s(f, "key")
551                    ),
552                    vec![],
553                ));
554            }
555        }
556    }
557}
558
559// ── Rule 6: a pg_connection host on a network its caller is not attached to ─
560
561fn networks_of(svc: &Value) -> Option<HashSet<String>> {
562    // `network_mode` replaces networking altogether; nothing to compare.
563    if !field(svc, "network_mode").is_empty() {
564        return None;
565    }
566    let listed: HashSet<String> = field_all(svc, "networks")
567        .iter()
568        .flat_map(|v| split_list(v))
569        .collect();
570    Some(if listed.is_empty() {
571        HashSet::from(["default".to_string()])
572    } else {
573        listed
574    })
575}
576
577fn mentions(svc: &Value, name: &str) -> bool {
578    if field_all(svc, "depends_on")
579        .iter()
580        .flat_map(|v| split_list(v))
581        .any(|d| d.to_lowercase() == name)
582    {
583        return true;
584    }
585    fields(svc)
586        .into_iter()
587        .filter(|f| s(f, "description") == "env" || s(f, "field_type") == "env_var")
588        .any(|f| {
589            s(f, "value")
590                .to_lowercase()
591                .split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))
592                .any(|tok| tok == name)
593        })
594}
595
596/// A service that reads the connection through `${chunk:<pg chunk>/…}` consumes it
597/// even though no env value spells the host.
598fn reads_chunk(svc: &Value, pg_name: &str) -> bool {
599    let needle = format!("chunk:{}", pg_name.to_lowercase());
600    fields(svc).into_iter().any(|f| {
601        let v = s(f, "value").to_lowercase();
602        let r = s(f, "ref_name").to_lowercase();
603        v.contains(&needle) || r.contains(&needle)
604    })
605}
606
607fn rule_pg_network(chunks: &[&Value], out: &mut Vec<Finding>) {
608    let services = of_type(chunks, "docker_service");
609    for pg in of_type(chunks, "pg_connection") {
610        let host = field(pg, "host").to_lowercase();
611        if host.is_empty() || is_ref(&host) {
612            continue;
613        }
614        let Some(db) = services.iter().find(|c| service_aliases(c).contains(&host)) else {
615            continue;
616        };
617        let Some(db_nets) = networks_of(db) else {
618            continue;
619        };
620        for c in &services {
621            if std::ptr::eq(*c, *db) {
622                continue;
623            }
624            let Some(c_nets) = networks_of(c) else {
625                continue;
626            };
627            let reaches =
628                service_aliases(db).iter().any(|a| mentions(c, a)) || reads_chunk(c, s(pg, "name"));
629            if reaches && c_nets.is_disjoint(&db_nets) {
630                out.push(finding(
631                    "pg-host-network-unreachable",
632                    "warning",
633                    pg,
634                    "host",
635                    format!(
636                        "host `{host}` is Docker service `{}`, but service `{}` uses it and shares no network with it",
637                        service_name(db),
638                        service_name(c)
639                    ),
640                    vec![service_name(db), service_name(c)],
641                ));
642            }
643        }
644    }
645}
646
647// ── Phase 29.1 rules ──────────────────────────────────────────────────────────
648//
649// Each fires only on positive evidence the project defines the thing it checks
650// against, and stays silent on anything that could live somewhere unseen.
651
652/// A Traefik router naming a `service` no `traefik_service` chunk defines.
653/// Silent when the project defines no service chunk at all (Traefik can build one
654/// from labels or another file), and for `name@provider` references.
655fn rule_traefik_service(chunks: &[&Value], out: &mut Vec<Finding>) {
656    let defined: HashSet<String> = of_type(chunks, "traefik_service")
657        .iter()
658        .map(|c| s(c, "name").to_lowercase())
659        .collect();
660    if defined.is_empty() {
661        return;
662    }
663    for router in of_type(chunks, "traefik_router") {
664        for raw in field_all(router, "service") {
665            if is_ref(raw) || raw.contains('@') || defined.contains(&raw.to_lowercase()) {
666                continue;
667            }
668            out.push(finding(
669                "traefik-service-missing",
670                "warning",
671                router,
672                "service",
673                format!(
674                    "router names service `{raw}`, which no traefik_service chunk defines (write `{raw}@file` if it lives in another file)"
675                ),
676                vec![],
677            ));
678        }
679    }
680}
681
682/// An `nginx_upstream` no `proxy_pass` in this project names. Silent when the
683/// project has no server or location chunk (the upstream may be used by a
684/// config this project does not hold).
685fn rule_nginx_upstream_unused(chunks: &[&Value], out: &mut Vec<Finding>) {
686    let users: Vec<&Value> = ["nginx_location", "nginx_server"]
687        .iter()
688        .flat_map(|t| of_type(chunks, t))
689        .collect();
690    if users.is_empty() {
691        return;
692    }
693    let mut used = HashSet::new();
694    for c in &users {
695        for target in field_all(c, "proxy_pass") {
696            if is_ref(target) {
697                return; // a reference could name any upstream: cannot judge
698            }
699            if let Some(h) = proxy_host(target) {
700                used.insert(h);
701            }
702        }
703    }
704    for u in of_type(chunks, "nginx_upstream") {
705        let name = s(u, "name").to_lowercase();
706        if !name.is_empty() && !used.contains(&name) {
707            out.push(finding(
708                "nginx-upstream-unused",
709                "warning",
710                u,
711                "name",
712                format!("upstream `{name}` is not named by any proxy_pass in this project"),
713                vec![],
714            ));
715        }
716    }
717}
718
719/// `depends_on` naming a service the project does not define. Silent with no
720/// `docker_service` chunk, and for references.
721fn rule_compose_depends_on(chunks: &[&Value], out: &mut Vec<Finding>) {
722    let services = of_type(chunks, "docker_service");
723    if services.is_empty() {
724        return;
725    }
726    let known: HashSet<String> = services.iter().flat_map(|c| service_aliases(c)).collect();
727    for svc in &services {
728        for raw in field_all(svc, "depends_on") {
729            if is_ref(raw) {
730                continue;
731            }
732            for dep in split_list(raw) {
733                if !known.contains(&dep.to_lowercase()) {
734                    out.push(finding(
735                        "compose-depends-on-unknown-service",
736                        "error",
737                        svc,
738                        "depends_on",
739                        format!("depends_on names `{dep}`, which is not a service in this project"),
740                        vec![],
741                    ));
742                }
743            }
744        }
745    }
746}
747
748/// `(ip, port, proto)` of a published port mapping, or `None` for a container-only
749/// port, a range, a reference or anything unreadable.
750fn published(spec: &str) -> Option<(String, String, String)> {
751    if is_ref(spec) {
752        return None;
753    }
754    let (body, proto) = spec.split_once('/').unwrap_or((spec, "tcp"));
755    let parts: Vec<&str> = body.split(':').collect();
756    let (ip, host) = match parts.as_slice() {
757        [host, _container] => ("", *host),
758        [ip, host, _container] => (*ip, *host),
759        _ => return None,
760    };
761    if host.is_empty() || !host.chars().all(|c| c.is_ascii_digit()) {
762        return None;
763    }
764    let ip = if ip == "0.0.0.0" { "" } else { ip };
765    Some((ip.to_string(), host.to_string(), proto.to_lowercase()))
766}
767
768/// Two services publishing the same host port. Compose refuses to start the second.
769fn rule_compose_port_clash(chunks: &[&Value], out: &mut Vec<Finding>) {
770    let services = of_type(chunks, "docker_service");
771    let mut seen: Vec<((String, String, String), &Value)> = Vec::new();
772    for svc in &services {
773        for raw in field_all(svc, "ports") {
774            for spec in split_list(raw) {
775                let Some(p) = published(&spec) else {
776                    continue;
777                };
778                let clash = seen.iter().find(|(q, other)| {
779                    !std::ptr::eq(*other, *svc)
780                        && q.1 == p.1
781                        && q.2 == p.2
782                        && (q.0 == p.0 || q.0.is_empty() || p.0.is_empty())
783                });
784                if let Some((_, other)) = clash {
785                    out.push(finding(
786                        "compose-port-clash",
787                        "error",
788                        svc,
789                        "ports",
790                        format!(
791                            "host port {}/{} is also published by service `{}`",
792                            p.1,
793                            p.2,
794                            service_name(other)
795                        ),
796                        vec![service_name(other)],
797                    ));
798                }
799                seen.push((p, svc));
800            }
801        }
802    }
803}
804
805// ── Rule: a service on a network no docker_network chunk declares ───────────
806//
807// Silent unless the project declares at least one network (positive evidence the
808// author manages them here); `default` always exists in Compose.
809
810fn rule_compose_network(chunks: &[&Value], out: &mut Vec<Finding>) {
811    let nets = of_type(chunks, "docker_network");
812    if nets.is_empty() {
813        return;
814    }
815    let mut declared: HashSet<String> = HashSet::from(["default".to_string()]);
816    for n in &nets {
817        let keys: Vec<String> = fields(n)
818            .into_iter()
819            .map(|f| s(f, "key").to_lowercase())
820            .filter(|k| !k.is_empty())
821            .collect();
822        if keys.is_empty() {
823            declared.insert(s(n, "name").to_lowercase());
824        }
825        declared.extend(keys);
826    }
827    for svc in of_type(chunks, "docker_service") {
828        for raw in field_all(svc, "networks") {
829            if is_ref(raw) {
830                continue;
831            }
832            for net in split_list(raw) {
833                if !declared.contains(&net.to_lowercase()) {
834                    out.push(finding(
835                        "compose-network-undeclared",
836                        "error",
837                        svc,
838                        "networks",
839                        format!(
840                            "Service joins network `{net}`, which no docker_network chunk declares"
841                        ),
842                        vec![],
843                    ));
844                }
845            }
846        }
847    }
848}
849
850// ── Rule: a k8s Service whose selector matches no Deployment ────────────────
851//
852// The starters generate `selector: app: <name>` and a Deployment's pod label
853// `app: <name>`, so the selector matches exactly the Deployment of the same name
854// and namespace. Silent when the project has no Deployment at all.
855
856fn rule_k8s_service_selector(chunks: &[&Value], out: &mut Vec<Finding>) {
857    let deps = of_type(chunks, "k8s_deployment");
858    if deps.is_empty() {
859        return;
860    }
861    let defined: HashSet<(String, String)> =
862        deps.iter().map(|c| (k8s_name(c), k8s_ns(c))).collect();
863    for svc in of_type(chunks, "k8s_service") {
864        let (n, ns) = (k8s_name(svc), k8s_ns(svc));
865        if is_ref(&n) || n.is_empty() || defined.contains(&(n.clone(), ns.clone())) {
866            continue;
867        }
868        out.push(finding(
869            "k8s-service-selector-unmatched",
870            "warning",
871            svc,
872            "name",
873            format!("Service `{n}` selects `app: {n}` in namespace `{ns}`, which no k8s_deployment chunk labels; it will have no endpoints"),
874            deps.iter().map(|c| k8s_name(c)).collect(),
875        ));
876    }
877}
878
879/// Run every rule over one project. `vault_names` are the vault's entry names
880/// (provider, and `provider_keyid`) so a `${…}` reference to a real entry is not
881/// reported; pass an empty slice to skip nothing and report every non-env_file ref.
882pub fn check_project(project: &Value, vault_names: &[String]) -> Vec<Finding> {
883    check_project_scoped(project, vault_names, &[])
884}
885
886/// Every Compose service name (and `container_name`) any of `projects` defines,
887/// lowercased. Passed to [`check_project_scoped`] as `elsewhere` so a `proxy_pass`
888/// is resolved against the whole stack, which is how people split one.
889pub fn services_of(projects: &[Value]) -> Vec<String> {
890    let mut v: Vec<String> = projects
891        .iter()
892        .flat_map(|p| {
893            let chunks = active_chunks(p);
894            of_type(&chunks, "docker_service")
895                .into_iter()
896                .flat_map(service_aliases)
897                .collect::<Vec<_>>()
898        })
899        .collect();
900    v.sort();
901    v.dedup();
902    v
903}
904
905/// As [`check_project`], with `elsewhere` (see [`services_of`]) widening what an
906/// nginx `proxy_pass` host may resolve to. Off by default because it widens the
907/// evidence a rule may use: a name another project defines is not wired to this one.
908pub fn check_project_scoped(
909    project: &Value,
910    vault_names: &[String],
911    elsewhere: &[String],
912) -> Vec<Finding> {
913    let chunks = active_chunks(project);
914    let mut out = Vec::new();
915    rule_nginx_proxy_pass(&chunks, elsewhere, &mut out);
916    rule_wireguard_allowed_ips(&chunks, &mut out);
917    rule_traefik_middleware(&chunks, &mut out);
918    rule_k8s_secrets(&chunks, &mut out);
919    rule_k8s_ingress(&chunks, &mut out);
920    rule_compose_env(&chunks, vault_names, &mut out);
921    rule_pg_network(&chunks, &mut out);
922    rule_traefik_service(&chunks, &mut out);
923    rule_nginx_upstream_unused(&chunks, &mut out);
924    rule_compose_depends_on(&chunks, &mut out);
925    rule_compose_port_clash(&chunks, &mut out);
926    rule_compose_network(&chunks, &mut out);
927    rule_k8s_service_selector(&chunks, &mut out);
928    // A stack integration (Phase 38) brings its own rules in its descriptor.
929    if let Some(a) = crate::stack::adapter(s(project, "project_type")) {
930        out.extend(crate::stack::check(a, project, vault_names));
931    }
932    out
933}
934
935/// The push gate: the error-severity findings that must stop a node from writing
936/// this project's config to a live host. Empty means go. A warning never gates.
937pub fn gate(project: &Value, vault_names: &[String]) -> Vec<Finding> {
938    check_project(project, vault_names)
939        .into_iter()
940        .filter(|f| f.severity == "error")
941        .collect()
942}
943
944/// The rule ids, in the order they run — `unv describe` and the panel list them.
945pub const RULES: [&str; 14] = [
946    "nginx-proxy-pass-unknown-service",
947    "wireguard-allowed-ips-duplicate",
948    "wireguard-allowed-ips-overlap",
949    "traefik-middleware-missing",
950    "k8s-deployment-secret-missing",
951    "k8s-ingress-service-missing",
952    "compose-env-ref-unresolved",
953    "pg-host-network-unreachable",
954    "traefik-service-missing",
955    "nginx-upstream-unused",
956    "compose-depends-on-unknown-service",
957    "compose-port-clash",
958    "compose-network-undeclared",
959    "k8s-service-selector-unmatched",
960];
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    fn f(key: &str, value: &str) -> Value {
967        json!({"key": key, "value": value, "field_type": "var"})
968    }
969    fn chunk(name: &str, t: &str, fields: Vec<Value>) -> Value {
970        json!({"id": format!("id-{name}"), "name": name, "chunk_type": t, "fields": fields})
971    }
972    fn project(chunks: Vec<Value>) -> Value {
973        json!({"id": "p", "name": "p", "chunks": chunks})
974    }
975    fn rules(p: &Value, names: &[&str]) -> Vec<&'static str> {
976        let names: Vec<String> = names.iter().map(|s| s.to_string()).collect();
977        check_project(p, &names).iter().map(|x| x.rule).collect()
978    }
979
980    #[test]
981    fn proxy_pass_to_an_undefined_service_is_flagged_but_only_when_services_exist() {
982        let loc = |t: &str| {
983            chunk(
984                "loc",
985                "nginx_location",
986                vec![f("path", "/"), f("proxy_pass", t)],
987            )
988        };
989        let svc = chunk("web app", "docker_service", vec![f("image", "x")]);
990        assert_eq!(
991            rules(&project(vec![loc("http://api:8080"), svc.clone()]), &[]),
992            ["nginx-proxy-pass-unknown-service"]
993        );
994        // The service the project does define, under the name Compose gives it.
995        assert!(rules(
996            &project(vec![loc("http://web_app:8080/x"), svc.clone()]),
997            &[]
998        )
999        .is_empty());
1000        // No docker_service chunk at all: no evidence, no finding.
1001        assert!(rules(&project(vec![loc("http://api:8080")]), &[]).is_empty());
1002        // Not service-style names: dotted, IP, localhost, variable.
1003        for t in [
1004            "http://api.example.com",
1005            "http://10.0.0.5:80",
1006            "http://localhost:3000",
1007            "http://$backend",
1008            "http://unix:/run/x.sock",
1009        ] {
1010            assert!(
1011                rules(&project(vec![loc(t), svc.clone()]), &[]).is_empty(),
1012                "{t}"
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn an_upstream_defined_in_the_project_satisfies_proxy_pass() {
1019        let up = chunk(
1020            "backend",
1021            "nginx_upstream",
1022            vec![f("server", "10.0.0.1:80")],
1023        );
1024        let loc = chunk(
1025            "loc",
1026            "nginx_location",
1027            vec![f("proxy_pass", "http://backend")],
1028        );
1029        let svc = chunk("web", "docker_service", vec![]);
1030        assert!(rules(&project(vec![up, loc, svc]), &[]).is_empty());
1031    }
1032
1033    #[test]
1034    fn two_peers_with_the_same_network_are_an_error_but_nesting_is_fine() {
1035        let peer = |n: &str, ips: &str| chunk(n, "wg_peer", vec![f("AllowedIPs", ips)]);
1036        assert_eq!(
1037            rules(
1038                &project(vec![peer("a", "10.0.0.2/32"), peer("b", "10.0.0.2")]),
1039                &[]
1040            ),
1041            ["wireguard-allowed-ips-duplicate"]
1042        );
1043        // Host bits are ignored: 10.0.0.5/24 and 10.0.0.9/24 are one network.
1044        assert_eq!(
1045            rules(
1046                &project(vec![peer("a", "10.0.0.5/24"), peer("b", "10.0.0.9/24")]),
1047                &[]
1048            )
1049            .len(),
1050            1
1051        );
1052        // Longest-prefix routing makes a catch-all beside a host route valid.
1053        assert!(rules(
1054            &project(vec![peer("a", "0.0.0.0/0, ::/0"), peer("b", "10.0.0.2/32")]),
1055            &[]
1056        )
1057        .is_empty());
1058        // One peer repeating itself is its own business; references are skipped.
1059        assert!(rules(&project(vec![peer("a", "10.0.0.2/32, 10.0.0.2/32")]), &[]).is_empty());
1060        assert!(rules(&project(vec![peer("a", "${X}"), peer("b", "${X}")]), &[]).is_empty());
1061    }
1062
1063    #[test]
1064    fn a_disabled_peer_is_not_in_the_export_and_is_not_checked() {
1065        let mut b = chunk("b", "wg_peer", vec![f("AllowedIPs", "10.0.0.2/32")]);
1066        b["disabled"] = json!(true);
1067        let a = chunk("a", "wg_peer", vec![f("AllowedIPs", "10.0.0.2/32")]);
1068        assert!(rules(&project(vec![a, b]), &[]).is_empty());
1069    }
1070
1071    #[test]
1072    fn traefik_middleware_must_exist_unless_it_names_another_provider() {
1073        let router = |m: &str| chunk("r", "traefik_router", vec![f("middlewares", m)]);
1074        let mw = chunk("auth", "traefik_middleware", vec![f("type", "basicAuth")]);
1075        assert_eq!(
1076            rules(&project(vec![router("auth, gone"), mw.clone()]), &[]),
1077            ["traefik-middleware-missing"]
1078        );
1079        assert!(rules(&project(vec![router("AUTH"), mw.clone()]), &[]).is_empty());
1080        assert!(rules(&project(vec![router("sso@docker, api@internal")]), &[]).is_empty());
1081    }
1082
1083    #[test]
1084    fn an_ingress_needs_a_service_in_the_same_namespace_but_only_when_services_exist() {
1085        let svc = chunk(
1086            "s",
1087            "k8s_service",
1088            vec![f("name", "my-app"), f("namespace", "prod")],
1089        );
1090        let ing = |n: &str, ns: &str| {
1091            chunk(
1092                "i",
1093                "k8s_ingress",
1094                vec![f("serviceName", n), f("namespace", ns)],
1095            )
1096        };
1097        assert!(rules(&project(vec![svc.clone(), ing("my-app", "prod")]), &[]).is_empty());
1098        assert_eq!(
1099            rules(&project(vec![svc.clone(), ing("my-app", "default")]), &[]),
1100            ["k8s-ingress-service-missing"]
1101        );
1102        assert_eq!(
1103            rules(&project(vec![svc, ing("other", "prod")]), &[]),
1104            ["k8s-ingress-service-missing"]
1105        );
1106        assert!(rules(&project(vec![ing("anything", "prod")]), &[]).is_empty());
1107    }
1108
1109    #[test]
1110    fn a_compose_env_ref_must_resolve_to_a_vault_entry_or_an_env_file_key() {
1111        let env = |v: &str| json!({"key": "DB_PASS", "value": v, "field_type": "env_var", "description": "env"});
1112        let svc = |v: &str| chunk("web", "docker_service", vec![env(v)]);
1113        assert_eq!(
1114            rules(&project(vec![svc("${NOPE}")]), &["GitHub"]),
1115            ["compose-env-ref-unresolved"]
1116        );
1117        assert!(rules(&project(vec![svc("${GitHub/key}")]), &["GitHub"]).is_empty());
1118        assert!(rules(&project(vec![svc("${GITHUB_PROD}")]), &["GitHub"]).is_empty());
1119        let envf = chunk("e", "env_file", vec![f("NOPE", "1")]);
1120        assert!(rules(&project(vec![svc("${NOPE}"), envf]), &[]).is_empty());
1121        assert!(rules(&project(vec![svc("${chunk:x/y}")]), &[]).is_empty());
1122        assert!(rules(&project(vec![svc("plain")]), &[]).is_empty());
1123    }
1124
1125    #[test]
1126    fn a_pg_host_service_must_share_a_network_with_the_service_that_uses_it() {
1127        let pg = chunk("p", "pg_connection", vec![f("host", "db")]);
1128        let db = |nets: &str| chunk("db", "docker_service", vec![f("networks", nets)]);
1129        let app = |nets: &str| {
1130            chunk(
1131                "app",
1132                "docker_service",
1133                vec![f("networks", nets), f("depends_on", "db")],
1134            )
1135        };
1136        assert_eq!(
1137            rules(&project(vec![pg.clone(), db("back"), app("front")]), &[]),
1138            ["pg-host-network-unreachable"]
1139        );
1140        assert!(rules(
1141            &project(vec![pg.clone(), db("back"), app("front, back")]),
1142            &[]
1143        )
1144        .is_empty());
1145        // Both on the implicit default network.
1146        assert!(rules(&project(vec![pg.clone(), db(""), app("")]), &[]).is_empty());
1147        // Nothing uses the db: nothing to say.
1148        let idle = chunk("idle", "docker_service", vec![f("networks", "front")]);
1149        assert!(rules(&project(vec![pg.clone(), db("back"), idle]), &[]).is_empty());
1150        // network_mode opts out of comparison.
1151        let host_mode = chunk(
1152            "app",
1153            "docker_service",
1154            vec![f("network_mode", "host"), f("depends_on", "db")],
1155        );
1156        assert!(rules(&project(vec![pg, db("back"), host_mode]), &[]).is_empty());
1157    }
1158
1159    #[test]
1160    fn findings_carry_names_and_never_field_values_that_could_be_secret() {
1161        let pg = chunk(
1162            "p",
1163            "pg_connection",
1164            vec![
1165                f("host", "db"),
1166                json!({"key":"password","value":"hunter2-SECRET","field_type":"secret"}),
1167            ],
1168        );
1169        let db = chunk("db", "docker_service", vec![f("networks", "back")]);
1170        let app = chunk(
1171            "app",
1172            "docker_service",
1173            vec![f("networks", "front"), f("depends_on", "db")],
1174        );
1175        let out = check_project(&project(vec![pg, db, app]), &[]);
1176        let text =
1177            serde_json::to_string(&out.iter().map(Finding::to_json).collect::<Vec<_>>()).unwrap();
1178        assert!(!text.contains("hunter2"));
1179    }
1180
1181    #[test]
1182    fn nested_allowed_ips_are_a_warning_and_a_default_route_is_not_reported() {
1183        let peer = |n: &str, ips: &str| chunk(n, "wg_peer", vec![f("AllowedIPs", ips)]);
1184        assert_eq!(
1185            rules(
1186                &project(vec![peer("a", "10.0.0.0/24"), peer("b", "10.0.0.7/32")]),
1187                &[]
1188            ),
1189            ["wireguard-allowed-ips-overlap"]
1190        );
1191        // Disjoint networks, and a catch-all beside host routes, are fine.
1192        assert!(rules(
1193            &project(vec![peer("a", "10.0.0.0/24"), peer("b", "10.0.1.7/32")]),
1194            &[]
1195        )
1196        .is_empty());
1197        assert!(rules(
1198            &project(vec![peer("a", "0.0.0.0/0"), peer("b", "10.0.0.7/32")]),
1199            &[]
1200        )
1201        .is_empty());
1202        // Different families never nest.
1203        assert!(rules(
1204            &project(vec![peer("a", "10.0.0.0/8"), peer("b", "fd00::1/128")]),
1205            &[]
1206        )
1207        .is_empty());
1208        // IPv6 nesting.
1209        assert_eq!(
1210            rules(
1211                &project(vec![peer("a", "fd00::/64"), peer("b", "fd00::5/128")]),
1212                &[]
1213            ),
1214            ["wireguard-allowed-ips-overlap"]
1215        );
1216    }
1217
1218    #[test]
1219    fn a_deployment_must_use_secrets_some_chunk_creates() {
1220        let secret = chunk(
1221            "app-secrets",
1222            "k8s_secret",
1223            vec![f("name", "app-secrets"), f("namespace", "prod")],
1224        );
1225        let dep = |env: &str, mounts: &str, ns: &str| {
1226            chunk(
1227                "web",
1228                "k8s_deployment",
1229                vec![
1230                    f("secretEnv", env),
1231                    f("secretMounts", mounts),
1232                    f("namespace", ns),
1233                ],
1234            )
1235        };
1236        assert!(rules(
1237            &project(vec![
1238                secret.clone(),
1239                dep("app-secrets", "app-secrets:/etc/a", "prod")
1240            ]),
1241            &[]
1242        )
1243        .is_empty());
1244        assert_eq!(
1245            rules(&project(vec![secret.clone(), dep("gone", "", "prod")]), &[]),
1246            ["k8s-deployment-secret-missing"]
1247        );
1248        assert_eq!(
1249            rules(
1250                &project(vec![secret.clone(), dep("", "gone:/x", "prod")]),
1251                &[]
1252            ),
1253            ["k8s-deployment-secret-missing"]
1254        );
1255        // Wrong namespace is a missing Secret.
1256        assert_eq!(
1257            rules(
1258                &project(vec![secret.clone(), dep("app-secrets", "", "default")]),
1259                &[]
1260            ),
1261            ["k8s-deployment-secret-missing"]
1262        );
1263        // Named twice, reported once. A malformed mount (no path) names nothing.
1264        assert_eq!(
1265            rules(
1266                &project(vec![
1267                    secret.clone(),
1268                    dep("gone gone", "gone:/x, nopath:", "prod")
1269                ]),
1270                &[]
1271            )
1272            .len(),
1273            1
1274        );
1275        // No k8s_secret chunk at all: another manifest set may create it.
1276        assert!(rules(&project(vec![dep("gone", "gone:/x", "prod")]), &[]).is_empty());
1277    }
1278
1279    #[test]
1280    fn compose_substitution_tokens_anywhere_in_a_service_must_resolve() {
1281        let svc = |fields: Vec<Value>| chunk("web", "docker_service", fields);
1282        // An image tag reading ${TAG} that nothing sets.
1283        assert_eq!(
1284            rules(&project(vec![svc(vec![f("image", "app:${TAG}")])]), &[]),
1285            ["compose-env-ref-unresolved"]
1286        );
1287        // Set by an env_file chunk, case-insensitively.
1288        let envf = chunk("e", "env_file", vec![f("tag", "1")]);
1289        assert!(rules(
1290            &project(vec![svc(vec![f("image", "app:${TAG}")]), envf]),
1291            &[]
1292        )
1293        .is_empty());
1294        // Defaults, escapes and operators are the author's own fallback.
1295        for v in [
1296            "app:${TAG:-latest}",
1297            "app:${TAG-latest}",
1298            "$${TAG}",
1299            "app:${TAG:?need it}",
1300            "a:${TAG+x}",
1301        ] {
1302            assert!(
1303                rules(&project(vec![svc(vec![f("image", v)])]), &[]).is_empty(),
1304                "{v}"
1305            );
1306        }
1307        // Two tokens in one value, one reported per unknown name.
1308        assert_eq!(
1309            rules(
1310                &project(vec![svc(vec![f("command", "run ${A} ${B} ${A}")])]),
1311                &[]
1312            )
1313            .len(),
1314            2
1315        );
1316    }
1317
1318    #[test]
1319    fn a_service_that_reads_the_pg_chunk_by_reference_is_a_consumer() {
1320        let pg = chunk("primary", "pg_connection", vec![f("host", "db")]);
1321        let db = chunk("db", "docker_service", vec![f("networks", "back")]);
1322        let env = json!({"key":"DB","value":"${chunk:primary/host}","field_type":"env_var","description":"env"});
1323        let app = chunk("app", "docker_service", vec![f("networks", "front"), env]);
1324        let got = rules(&project(vec![pg, db, app]), &[]);
1325        assert!(got.contains(&"pg-host-network-unreachable"), "{got:?}");
1326    }
1327    // ── Phase 29.1 ────────────────────────────────────────────────────────────
1328
1329    #[test]
1330    fn traefik_service_missing_fires_and_stays_silent() {
1331        let router = |svc: &str| {
1332            chunk(
1333                "r",
1334                "traefik_router",
1335                vec![f("rule", "Host(`a`)"), f("service", svc)],
1336            )
1337        };
1338        let svc = chunk("api", "traefik_service", vec![f("url", "http://x")]);
1339        assert_eq!(
1340            rules(&project(vec![router("web"), svc.clone()]), &[]),
1341            ["traefik-service-missing"]
1342        );
1343        assert!(rules(&project(vec![router("API"), svc.clone()]), &[]).is_empty());
1344        assert!(rules(&project(vec![router("web@docker"), svc.clone()]), &[]).is_empty());
1345        assert!(rules(&project(vec![router("${svc}"), svc]), &[]).is_empty());
1346        // No service chunk at all: no evidence.
1347        assert!(rules(&project(vec![router("web")]), &[]).is_empty());
1348    }
1349
1350    #[test]
1351    fn an_unused_upstream_fires_only_when_proxy_passes_can_be_read() {
1352        let up = chunk("backend", "nginx_upstream", vec![f("server", "x:1")]);
1353        let loc = |t: &str| {
1354            chunk(
1355                "l",
1356                "nginx_location",
1357                vec![f("path", "/"), f("proxy_pass", t)],
1358            )
1359        };
1360        assert_eq!(
1361            rules(&project(vec![up.clone(), loc("http://other")]), &[]),
1362            ["nginx-upstream-unused"]
1363        );
1364        assert!(rules(&project(vec![up.clone(), loc("http://backend/")]), &[]).is_empty());
1365        // A reference could name anything.
1366        assert!(rules(&project(vec![up.clone(), loc("${x}")]), &[]).is_empty());
1367        // No server/location chunk: the upstream may be used elsewhere.
1368        assert!(rules(&project(vec![up]), &[]).is_empty());
1369    }
1370
1371    #[test]
1372    fn depends_on_an_unknown_service_is_an_error_but_not_without_services_or_for_refs() {
1373        let svc = |n: &str, dep: &str| {
1374            chunk(
1375                n,
1376                "docker_service",
1377                vec![f("image", "x"), f("depends_on", dep)],
1378            )
1379        };
1380        let db = chunk("db", "docker_service", vec![f("image", "pg")]);
1381        assert_eq!(
1382            rules(&project(vec![svc("web", "db, cache"), db.clone()]), &[]),
1383            ["compose-depends-on-unknown-service"]
1384        );
1385        assert!(rules(&project(vec![svc("web", "DB"), db.clone()]), &[]).is_empty());
1386        assert!(rules(&project(vec![svc("web", "${deps}"), db]), &["deps"]).is_empty());
1387    }
1388
1389    #[test]
1390    fn a_published_port_two_services_bind_is_an_error() {
1391        let svc =
1392            |n: &str, p: &str| chunk(n, "docker_service", vec![f("image", "x"), f("ports", p)]);
1393        assert_eq!(
1394            rules(
1395                &project(vec![svc("a", "8080:80"), svc("b", "8080:3000")]),
1396                &[]
1397            ),
1398            ["compose-port-clash"]
1399        );
1400        // Different protocol, different host ip, container-only, range, one service twice.
1401        assert!(rules(
1402            &project(vec![svc("a", "8080:80"), svc("b", "8080:80/udp")]),
1403            &[]
1404        )
1405        .is_empty());
1406        assert!(rules(
1407            &project(vec![
1408                svc("a", "127.0.0.1:8080:80"),
1409                svc("b", "127.0.0.2:8080:80")
1410            ]),
1411            &[]
1412        )
1413        .is_empty());
1414        assert_eq!(
1415            rules(
1416                &project(vec![
1417                    svc("a", "127.0.0.1:8080:80"),
1418                    svc("b", "0.0.0.0:8080:80")
1419                ]),
1420                &[]
1421            ),
1422            ["compose-port-clash"]
1423        );
1424        assert!(rules(&project(vec![svc("a", "80"), svc("b", "80")]), &[]).is_empty());
1425        assert!(rules(
1426            &project(vec![svc("a", "8000-8010:80"), svc("b", "8000-8010:80")]),
1427            &[]
1428        )
1429        .is_empty());
1430        assert!(rules(&project(vec![svc("a", "8080:80, 9090:80")]), &[]).is_empty());
1431    }
1432
1433    #[test]
1434    fn a_service_network_must_be_declared_once_networks_are_managed_here() {
1435        let svc = |n: &str| chunk("web", "docker_service", vec![f("networks", n)]);
1436        let net = chunk("n", "docker_network", vec![f("backend", "bridge")]);
1437        assert!(rules(&project(vec![svc("backend"), net.clone()]), &[]).is_empty());
1438        assert!(rules(&project(vec![svc("default, backend"), net.clone()]), &[]).is_empty());
1439        assert_eq!(
1440            rules(&project(vec![svc("backend, other"), net]), &[]),
1441            ["compose-network-undeclared"]
1442        );
1443        assert!(rules(&project(vec![svc("anything")]), &[]).is_empty());
1444    }
1445
1446    #[test]
1447    fn a_k8s_service_needs_the_deployment_its_selector_names() {
1448        let dep = chunk(
1449            "d",
1450            "k8s_deployment",
1451            vec![f("name", "app"), f("namespace", "prod")],
1452        );
1453        let svc =
1454            |n: &str, ns: &str| chunk("s", "k8s_service", vec![f("name", n), f("namespace", ns)]);
1455        assert!(rules(&project(vec![dep.clone(), svc("app", "prod")]), &[]).is_empty());
1456        assert_eq!(
1457            rules(&project(vec![dep.clone(), svc("app", "dev")]), &[]),
1458            ["k8s-service-selector-unmatched"]
1459        );
1460        assert_eq!(
1461            rules(&project(vec![dep, svc("web", "prod")]), &[]),
1462            ["k8s-service-selector-unmatched"]
1463        );
1464        assert!(rules(&project(vec![svc("web", "prod")]), &[]).is_empty());
1465    }
1466
1467    #[test]
1468    fn a_proxy_pass_host_may_be_a_service_of_another_project_only_in_wide_scope() {
1469        let web = project(vec![chunk(
1470            "n",
1471            "nginx_location",
1472            vec![f("proxy_pass", "http://api:8080")],
1473        )]);
1474        let own = chunk("web", "docker_service", vec![]);
1475        let other = project(vec![chunk("api", "docker_service", vec![])]);
1476        // Alone, the only evidence is this project's own service: `api` is unknown.
1477        let alone = project(vec![
1478            chunk(
1479                "n",
1480                "nginx_location",
1481                vec![f("proxy_pass", "http://api:8080")],
1482            ),
1483            own,
1484        ]);
1485        assert_eq!(
1486            check_project(&alone, &[])
1487                .iter()
1488                .map(|x| x.rule)
1489                .collect::<Vec<_>>(),
1490            ["nginx-proxy-pass-unknown-service"]
1491        );
1492        let els = services_of(&[other]);
1493        assert_eq!(els, ["api"]);
1494        assert!(check_project_scoped(&alone, &[], &els).is_empty());
1495        // With no service in this project, the other projects are the evidence.
1496        assert!(check_project_scoped(&web, &[], &els).is_empty());
1497        assert!(
1498            check_project(&web, &[]).is_empty(),
1499            "no evidence, no finding"
1500        );
1501        // A name nobody defines is still reported in wide scope.
1502        let missing = project(vec![chunk(
1503            "n",
1504            "nginx_location",
1505            vec![f("proxy_pass", "http://ghost:1")],
1506        )]);
1507        assert_eq!(check_project_scoped(&missing, &[], &els).len(), 1);
1508    }
1509
1510    #[test]
1511    fn the_gate_lets_warnings_through_and_stops_errors() {
1512        let net = chunk("web", "docker_service", vec![f("networks", "nope")]);
1513        let decl = chunk("n", "docker_network", vec![f("backend", "bridge")]);
1514        assert_eq!(gate(&project(vec![net, decl]), &[]).len(), 1);
1515        let warn = project(vec![
1516            chunk(
1517                "n",
1518                "nginx_location",
1519                vec![f("proxy_pass", "http://ghost:1")],
1520            ),
1521            chunk("web", "docker_service", vec![]),
1522        ]);
1523        assert_eq!(check_project(&warn, &[]).len(), 1);
1524        assert!(gate(&warn, &[]).is_empty());
1525    }
1526}