Skip to main content

ignition_core/deploy/
render.rs

1//! The render pipeline: a declared gateway tree to the bytes the gateway
2//! holds on disk.
3//!
4//! Stages, in order (later plans insert at the named stage; each stage is a
5//! private function below):
6//!
7//! 1. **collect** the source tree ([`super::tree::SourceTree`]) - done by the
8//!    caller and handed in as [`RenderInput::source`]
9//! 2. **overlay** `modes/<env>/` onto the base: a file replaces the base file
10//!    at the same path whole, or is added (SF-7: there is no removal). Only
11//!    the selected environment is applied.
12//! 3. **map** repo paths to collection paths: `resources/...` drops its
13//!    prefix, `tags/<P>/<type>/<rest>` becomes `ignition/<type>/<P>/<rest>`
14//!    (CD5), root-level tag files (CD5a) and nested type folders (CD5b)
15//!    included; two sources on one collection path are refused
16//! 4. **policy**: the manifest type allowlist and its D-02 refusal, the deny
17//!    list, gateway-owned files, dot-names, undeclared tag providers and tag
18//!    types under `resources/` (21-06), and tag folders named like a tag
19//!    member file (CD5d, 261030-8m8)
20//! 5. **JSON classification**, the Gson gate (21-05): `node::classify` on every
21//!    `.json` member; findings name the source path; parsed nodes are kept.
22//!    Then the tag marker check (CD5c, 261030-8m8): each tag folder's
23//!    `unary-resource.json` `files` must list exactly its member files
24//! 6. **uuid** (21-07): the rule-table checks of stage 4 (`enforce_rules`),
25//!    then per Named resource keep, inject or synthesize `attributes.uuid`
26//! 7. lints (21-08): secrets (Embedded, plaintext at schema paths, plaintext
27//!    by key name) over the parsed nodes, then the tree lints; one final raise
28//! 8. **finish**: build the [`RenderedTree`], resources and the digest
29//!
30//! Pure: no filesystem, no network, no clock, no randomness. Ordered
31//! collections only, so equal inputs give equal outputs on every OS.
32
33use std::collections::{BTreeMap, BTreeSet};
34
35use serde::Serialize;
36
37use super::exclude;
38use super::layout::{RelPath, ResourceKey, TAG_TYPES, collection_path, resource_key, tag_provider};
39use super::lint::{lint_files, scan_embedded, scan_secrets};
40use super::manifest::Manifest;
41use super::node::{self, JsonClass, Node};
42use super::rules::{GATEWAY_OWNED_FILES, TypeRule, UuidRule, is_denied, type_rule};
43use super::target::Target;
44use super::tree::{RenderedTree, SourceTree};
45use super::uuid::{is_canonical_uuid, name_based_uuid};
46use crate::error::{CoreError, DeployFinding};
47
48/// Everything render needs.
49#[derive(Debug, Clone, Copy)]
50pub struct RenderInput<'a> {
51    /// Gateway directory name under `gateways/`.
52    pub gateway: &'a str,
53    /// The environment name (`--env`).
54    pub env: &'a str,
55    /// The parsed `manifest.yaml`.
56    pub manifest: &'a Manifest,
57    /// The parsed `targets/<env>.yaml`.
58    pub target: &'a Target,
59    /// The collected gateway tree.
60    pub source: &'a SourceTree,
61}
62
63/// One rendered resource.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct RenderedResource {
66    /// Module id.
67    pub module: String,
68    /// Resource type.
69    #[serde(rename = "type")]
70    pub ty: String,
71    /// Resource name; null for a singleton.
72    pub name: Option<String>,
73    /// Files relative to the resource directory, byte-sorted.
74    pub files: Vec<String>,
75}
76
77/// What render produced.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RenderOutcome {
80    /// The rendered files.
81    pub tree: RenderedTree,
82    /// Resources ordered by (module, type, name) bytes, singletons first.
83    pub resources: Vec<RenderedResource>,
84    /// Tag providers that rendered at least one file, byte-ordered.
85    pub tag_providers: Vec<String>,
86    /// Ids (`module/type/name`) of resources that received a minted uuid,
87    /// byte-ordered.
88    pub uuids_minted: Vec<String>,
89    /// Collection paths of `.json` files that are not in the gateway's writer
90    /// format and so pass through untouched, byte-ordered.
91    pub opaque_files: Vec<String>,
92    /// Warnings in pipeline order.
93    pub warnings: Vec<String>,
94}
95
96/// Render `input`. Every policy defect is collected and raised once as a
97/// `deploy_tree_invalid`.
98pub fn render(input: &RenderInput<'_>) -> Result<RenderOutcome, CoreError> {
99    let mut findings = Vec::new();
100    // Excluded tag providers (gateway-owned) cannot be declared: in the
101    // manifest's `tag_providers` or as a `tags/<P>/` tree (enforce_policy).
102    let excluded = exclude::effective(input.target, Some(input.manifest));
103    findings.extend(exclude::contradictions(
104        &excluded,
105        input.manifest,
106        &format!("gateways/{}/manifest.yaml", input.gateway),
107    ));
108    let effective = overlay(input, &mut findings);
109    let effective = screen_names(effective, &mut findings);
110    let mut mapped = map_to_collection(effective, &mut findings);
111    enforce_policy(input, &mapped, &excluded, &mut findings);
112    enforce_rules(input, &mapped, &mut findings);
113    let mut json = classify_json(&mapped.files, &mapped.origins, &mut findings);
114    check_tag_markers(&mapped, &json, &mut findings);
115    let minted = assign_uuids(input.gateway, &mut mapped, &mut json, &mut findings);
116    let mut warnings = Vec::new();
117    let secret_paths = scan_all_secrets(&mapped, &json, &mut warnings);
118    findings.extend(lint_files(
119        &mapped.files,
120        input.target,
121        input.env,
122        &|path| {
123            mapped
124                .origins
125                .get(path)
126                .map_or_else(|| path.to_string(), |o| o.file.clone())
127        },
128    ));
129    // one raise: secret locations win, every other finding rides along
130    if !secret_paths.is_empty() {
131        return Err(CoreError::embedded_secret(secret_paths, findings));
132    }
133    if !findings.is_empty() {
134        return Err(CoreError::deploy_findings(findings));
135    }
136    Ok(finish(mapped, &json, minted, warnings))
137}
138
139/// One gateway-relative file after the overlay: its bytes and the repo path
140/// prefix (`gateways/<g>/` or `gateways/<g>/modes/<env>/`) it came from, so a
141/// finding can name the file the operator should open.
142struct Entry {
143    bytes: Vec<u8>,
144    prefix: String,
145}
146
147/// Gateway-relative path (`resources/...` or `tags/...`) to its entry.
148type Effective = BTreeMap<RelPath, Entry>;
149
150/// Where a collection path came from.
151struct Origin {
152    /// Full repo-relative source path.
153    file: String,
154    /// The prefix of `file` before the gateway-relative path.
155    prefix: String,
156}
157
158/// Collection path to its origin, so every later finding names a file the
159/// operator can open.
160type Origins = BTreeMap<RelPath, Origin>;
161
162/// The mapped tree (stage 3 output).
163struct Mapped {
164    files: BTreeMap<RelPath, Vec<u8>>,
165    origins: Origins,
166    /// Collection paths that came from `tags/`, with their provider.
167    tags: BTreeMap<RelPath, String>,
168}
169
170/// Parsed JSON members keyed by collection path, with their class. Stage 6
171/// (uuid, 21-07) updates it for files it writes; stage 7 (lints, 21-08) reads it.
172type JsonTable = BTreeMap<RelPath, (JsonClass, Node)>;
173
174/// Stage 2: apply `modes/<env>/` over the base.
175///
176/// An overlay file replaces the base file at the same path or is added; a
177/// base file with no overlay counterpart is unchanged. Keys under
178/// `modes/<other>/` are not this environment's and are ignored (the reader
179/// never opens them either).
180fn overlay(input: &RenderInput<'_>, findings: &mut Vec<DeployFinding>) -> Effective {
181    let base_prefix = format!("gateways/{}/", input.gateway);
182    let env_dir = format!("modes/{}", input.env);
183    let overlay_prefix = format!("{base_prefix}{env_dir}/");
184    let mut effective = Effective::new();
185    let mut overlaid = Vec::new();
186    for (path, bytes) in &input.source.files {
187        if path.components().next() == Some("modes") {
188            if let Some(rest) = path.strip_prefix(&env_dir) {
189                overlaid.push((rest, bytes));
190            }
191        } else {
192            effective.insert(
193                path.clone(),
194                Entry {
195                    bytes: bytes.clone(),
196                    prefix: base_prefix.clone(),
197                },
198            );
199        }
200    }
201    for (rest, bytes) in overlaid {
202        let top = rest.components().next();
203        if !matches!(top, Some("resources" | "tags")) && rest.as_str() != ".gitkeep" {
204            findings.push(DeployFinding {
205                file: format!("{overlay_prefix}{rest}"),
206                line: None,
207                column: None,
208                message: "unexpected entry under modes/<env>/ (expected: resources/, tags/)"
209                    .to_string(),
210            });
211            continue;
212        }
213        effective.insert(
214            rest,
215            Entry {
216                bytes: bytes.clone(),
217                prefix: overlay_prefix.clone(),
218            },
219        );
220    }
221    effective
222}
223
224/// Stage 4 (21-06 share, name screening): a path component that starts with
225/// `.` is refused, except a file named `.gitkeep`, which is dropped; a file
226/// whose name is gateway-owned is refused. Offending entries are dropped
227/// after being reported, so later stages do not repeat the finding.
228fn screen_names(effective: Effective, findings: &mut Vec<DeployFinding>) -> Effective {
229    let mut kept = Effective::new();
230    let mut reported: BTreeSet<String> = BTreeSet::new();
231    for (path, entry) in effective {
232        let parts: Vec<&str> = path.components().collect();
233        let last = parts.len() - 1;
234        let dot = parts.iter().position(|c| c.starts_with('.'));
235        match dot {
236            Some(i) if i == last && parts[i] == ".gitkeep" => continue,
237            Some(i) => {
238                let at = format!("{}{}", entry.prefix, parts[..=i].join("/"));
239                if reported.insert(at.clone()) {
240                    findings.push(DeployFinding {
241                        file: at,
242                        line: None,
243                        column: None,
244                        message: "name starts with `.`; dot-named entries are refused (only a `.gitkeep` file is skipped)".to_string(),
245                    });
246                }
247                continue;
248            }
249            None => {}
250        }
251        if GATEWAY_OWNED_FILES.contains(&parts[last]) {
252            findings.push(DeployFinding {
253                file: format!("{}{path}", entry.prefix),
254                line: None,
255                column: None,
256                message: format!(
257                    "`{}` is written by the gateway itself and is not deployed; remove it from the tree",
258                    parts[last]
259                ),
260            });
261            continue;
262        }
263        kept.insert(path, entry);
264    }
265    kept
266}
267
268/// Stage 3: gateway-relative paths to collection paths. The mapping must be
269/// injective: a second source on a collection path is a finding naming both.
270fn map_to_collection(effective: Effective, findings: &mut Vec<DeployFinding>) -> Mapped {
271    let mut mapped = Mapped {
272        files: BTreeMap::new(),
273        origins: Origins::new(),
274        tags: BTreeMap::new(),
275    };
276    for (path, entry) in effective {
277        let file = format!("{}{path}", entry.prefix);
278        match collection_path(&path) {
279            Ok(collection) => {
280                if let Some(first) = mapped.origins.get(&collection) {
281                    findings.push(DeployFinding {
282                        file,
283                        line: None,
284                        column: None,
285                        message: format!(
286                            "maps to collection path `{collection}`, which `{}` already produces",
287                            first.file
288                        ),
289                    });
290                    continue;
291                }
292                if let Some(provider) = tag_provider(&path) {
293                    mapped.tags.insert(collection.clone(), provider.to_string());
294                }
295                mapped.origins.insert(
296                    collection.clone(),
297                    Origin {
298                        file,
299                        prefix: entry.prefix,
300                    },
301                );
302                mapped.files.insert(collection, entry.bytes);
303            }
304            Err(why) => findings.push(DeployFinding {
305                file,
306                line: None,
307                column: None,
308                message: why,
309            }),
310        }
311    }
312    mapped
313}
314
315/// Stage 4: the manifest allowlist (D-02), the deny list, tag types under
316/// `resources/`, and undeclared or excluded tag providers (`excluded` is the
317/// effective exclusion set, [`exclude::effective`]).
318///
319/// Findings are one per type directory or provider, naming the first source
320/// in byte order (an overlay path when the overlay supplied it).
321fn enforce_policy(
322    input: &RenderInput<'_>,
323    mapped: &Mapped,
324    excluded: &[String],
325    findings: &mut Vec<DeployFinding>,
326) {
327    // the first origin prefix of each resource-side type directory
328    let mut types: BTreeMap<(String, String), &str> = BTreeMap::new();
329    let mut providers: BTreeMap<&str, &str> = BTreeMap::new();
330    for path in mapped.files.keys() {
331        let origin = &mapped.origins[path];
332        if let Some(provider) = mapped.tags.get(path) {
333            providers.entry(provider).or_insert(&origin.prefix);
334        } else {
335            let key = resource_key(path);
336            types.entry((key.module, key.ty)).or_insert(&origin.prefix);
337        }
338    }
339    let dir = |prefix: &str, module: &str, ty: &str| format!("{prefix}resources/{module}/{ty}/");
340    let finding = |file: String, message: String| DeployFinding {
341        file,
342        line: None,
343        column: None,
344        message,
345    };
346
347    for ((module, ty), prefix) in &types {
348        if !input.manifest.allows_type(module, ty) {
349            findings.push(finding(
350                dir(prefix, module, ty),
351                "type not in manifest.yaml types (D-02: un-allowlisted type directories are refused, not skipped)".to_string(),
352            ));
353        }
354        if module == "ignition" && TAG_TYPES.contains(&ty.as_str()) {
355            findings.push(finding(
356                dir(prefix, module, ty),
357                "tag types are declared under tags/<provider>/, not resources/".to_string(),
358            ));
359        }
360        if is_denied(module, ty) {
361            findings.push(finding(
362                dir(prefix, module, ty),
363                "denied type: rendering it can lock an administrator out of the gateway, so it stays gateway-owned even when allowlisted".to_string(),
364            ));
365        }
366    }
367    for entry in &input.manifest.types {
368        if let Some((module, ty)) = entry.split_once('/')
369            && is_denied(module, ty)
370        {
371            findings.push(finding(
372                format!("gateways/{}/manifest.yaml", input.gateway),
373                format!(
374                    "types entry `{entry}` is a denied type and is refused even though it is allowlisted (rendering it can lock an administrator out of the gateway)"
375                ),
376            ));
377        }
378    }
379    // CD5d (261030-8m8-LIVE-CAPTURES.md): the gateway accepts a tag folder
380    // named `tags.json` despite its own refusal text, writes it as a
381    // directory, then loses the provider's root `tags.json` to an I/O error.
382    // Matched without case: NTFS and the gateway (CD6) fold case.
383    for path in mapped.tags.keys() {
384        if member_named_tag_folder(path).is_some() {
385            findings.push(finding(
386                mapped.origins[path].file.clone(),
387                "a tag folder is named like a tag file (tags.json, udts.json or unary-resource.json); the gateway would store it beside that file and lose the provider's root tag data (CD5d)".to_string(),
388            ));
389        }
390    }
391    for (provider, prefix) in &providers {
392        if exclude::is_excluded(provider, excluded) {
393            findings.push(finding(
394                format!("{prefix}tags/{provider}/"),
395                "tag provider is excluded (by exclude_tag_providers or the built-in System default); an excluded provider is gateway-owned and cannot be declared".to_string(),
396            ));
397        } else if !input.manifest.tag_providers.iter().any(|p| p == provider) {
398            findings.push(finding(
399                format!("{prefix}tags/{provider}/"),
400                "tag provider not in manifest.yaml tag_providers (D-02: undeclared providers are refused)".to_string(),
401            ));
402        }
403    }
404}
405
406/// Stage 5 (21-05): classify every `.json` member through the Gson gate.
407///
408/// A member that cannot be inspected (too large, not UTF-8, a BOM, a syntax
409/// error, too deep, a repeated key) is a finding naming its source path;
410/// the secret lint (21-08) needs the parsed tree, so JSON render cannot
411/// parse is refused rather than copied blind. Bytes are never changed here.
412/// Non-`.json` members are not parsed. Findings carry the parser's message,
413/// which names positions and keys, never values.
414fn classify_json(
415    mapped: &BTreeMap<RelPath, Vec<u8>>,
416    origins: &Origins,
417    findings: &mut Vec<DeployFinding>,
418) -> JsonTable {
419    let mut table = JsonTable::new();
420    for (path, bytes) in mapped {
421        if !path.as_str().to_ascii_lowercase().ends_with(".json") {
422            continue;
423        }
424        match node::classify(bytes) {
425            Ok(classified) => {
426                table.insert(path.clone(), classified);
427            }
428            Err(message) => findings.push(DeployFinding {
429                file: origins
430                    .get(path)
431                    .map(|o| o.file.clone())
432                    .unwrap_or_else(|| path.to_string()),
433                line: None,
434                column: None,
435                message,
436            }),
437        }
438    }
439    table
440}
441
442/// The tag types whose folders hold a `unary-resource.json` marker and the
443/// `tags.json` / `udts.json` member files it lists (CD5, CD5a, CD5b).
444const MARKED_TAG_TYPES: [&str; 2] = ["tag-definition", "tag-type-definition"];
445
446/// The member files a tag marker can list.
447const TAG_MEMBERS: [&str; 2] = ["tags.json", "udts.json"];
448
449/// A tag folder's marker file.
450const TAG_MARKER: &str = "unary-resource.json";
451
452/// The folder components and the file name of a collection path
453/// `ignition/<marked tag type>/<P>/<folders...>/<file>`; `None` for any
454/// other path.
455fn marked_tag_parts(path: &RelPath) -> Option<(Vec<&str>, &str)> {
456    let parts: Vec<&str> = path.components().collect();
457    if parts.len() < 4 || parts[0] != "ignition" || !MARKED_TAG_TYPES.contains(&parts[1]) {
458        return None;
459    }
460    let (file, folders) = parts[3..].split_last()?;
461    Some((folders.to_vec(), file))
462}
463
464/// The collection-relative directory of the first tag folder in `path` that
465/// is named like a tag member or marker file, ASCII case folded (CD5d), for
466/// example `ignition/tag-definition/P/tags.json`; `None` when there is none.
467/// Render refuses such a source path; plan refuses such a live path
468/// (261030-8m8 review WR-04).
469pub(crate) fn member_named_tag_folder(path: &RelPath) -> Option<String> {
470    let (folders, _) = marked_tag_parts(path)?;
471    let at = folders.iter().position(|f| is_tag_member_name(f))?;
472    let parts: Vec<&str> = path.components().collect();
473    Some(parts[..3 + at + 1].join("/"))
474}
475
476/// Whether `name` is a tag member or marker file name, ASCII case folded.
477fn is_tag_member_name(name: &str) -> bool {
478    TAG_MEMBERS
479        .iter()
480        .chain(std::iter::once(&TAG_MARKER))
481        .any(|m| m.eq_ignore_ascii_case(name))
482}
483
484/// Stage 5 tail (CD5c, 261030-8m8-LIVE-CAPTURES.md): the gateway loads only
485/// the member files a tag folder's `unary-resource.json` lists in `files`.
486/// An unlisted `tags.json` or `udts.json` is silently skipped, and a listed
487/// one that is missing logs an ERROR. So every rendered marker under the two
488/// marked tag types, at every level (the provider root, folders and nested
489/// type folders alike), must list exactly the member files beside it.
490///
491/// Fail-closed (261030-8m8 review):
492///
493/// - any file whose name is not exactly `tags.json`, `udts.json` or
494///   `unary-resource.json` is a finding on that file (WR-03): every CD5
495///   capture, on both hosts, shows the gateway writing only those three in a
496///   tag folder, and it loads nothing else. A letter-case variant is named as
497///   such: a Linux gateway never matches it, and an NTFS host folds it onto
498///   the real name, so either way it is not what the marker lists (CD6);
499/// - a `tags.json` or `udts.json` whose own folder renders no marker is a
500///   finding on that file (WR-01): the gateway would never load it, and the
501///   plan would group it under an ancestor unit;
502/// - a marker that is not a JSON object, or has no `files` member, is a
503///   finding on the marker (WR-02): every gateway-written marker is an object
504///   with `files` (`files: []` when the folder has no member file);
505/// - otherwise one finding per marker whose `files` disagrees, naming its
506///   source path, the member names missing from `files` and those listed but
507///   absent. Any other `files` entry is only counted, never echoed.
508///
509/// A marker that does not parse already has a stage-5 finding.
510fn check_tag_markers(mapped: &Mapped, json: &JsonTable, findings: &mut Vec<DeployFinding>) {
511    let mut finding = |file: String, message: String| {
512        findings.push(DeployFinding {
513            file,
514            line: None,
515            column: None,
516            message,
517        });
518    };
519    for path in mapped.files.keys() {
520        if !mapped.tags.contains_key(path) {
521            continue;
522        }
523        let Some((_, file)) = marked_tag_parts(path) else {
524            continue;
525        };
526        let origin = mapped.origins[path].file.clone();
527        let dir = path
528            .as_str()
529            .strip_suffix(file)
530            .expect("the path ends in its file name");
531        if !TAG_MEMBERS.contains(&file) && file != TAG_MARKER {
532            let message = if is_tag_member_name(file) {
533                "a tag file is named like tags.json, udts.json or unary-resource.json in another letter case; the gateway matches these names exactly, and an NTFS host folds it onto the real file, so the marker never lists it (CD5c, CD6)"
534            } else {
535                "a tag folder holds only tags.json, udts.json and its unary-resource.json marker; the gateway writes no other file there and never loads one (CD5 captures, CD5c)"
536            };
537            finding(origin, message.to_string());
538            continue;
539        }
540        if TAG_MEMBERS.contains(&file) {
541            let marked = RelPath::new(&format!("{dir}{TAG_MARKER}"))
542                .is_ok_and(|m| mapped.files.contains_key(&m));
543            if !marked {
544                finding(
545                    origin,
546                    "a tag member file needs a unary-resource.json in its own folder listing it; the gateway loads only the member files a marker lists, so without one this file is never loaded (CD5c)".to_string(),
547                );
548            }
549            continue;
550        }
551        if file != TAG_MARKER {
552            continue;
553        }
554        // unparseable: stage 5 reported it
555        let Some((_, node)) = json.get(path) else {
556            continue;
557        };
558        if !matches!(node, Node::Obj(_)) {
559            finding(
560                origin,
561                "unary-resource.json is not a JSON object; the gateway writes every tag marker as an object whose `files` lists its member files (CD5c)".to_string(),
562            );
563            continue;
564        }
565        let Some(listed) = node.get("files") else {
566            finding(
567                origin,
568                "`files` is missing; the gateway loads only the member files a tag marker lists in `files`, and writes `files: []` when there are none (CD5c)".to_string(),
569            );
570            continue;
571        };
572        let present: BTreeSet<&str> = TAG_MEMBERS
573            .iter()
574            .copied()
575            .filter(|m| {
576                RelPath::new(&format!("{dir}{m}")).is_ok_and(|p| mapped.files.contains_key(&p))
577            })
578            .collect();
579        let Node::Arr(items) = listed else {
580            finding(
581                origin,
582                "`files` is not an array of file names (CD5c)".to_string(),
583            );
584            continue;
585        };
586        let mut names: BTreeSet<&str> = BTreeSet::new();
587        let mut other = 0usize;
588        for item in items {
589            match item.as_str() {
590                Some(name) if TAG_MEMBERS.contains(&name) => {
591                    names.insert(name);
592                }
593                _ => other += 1,
594            }
595        }
596        let mut parts = Vec::new();
597        let unlisted: Vec<&str> = present.difference(&names).copied().collect();
598        if !unlisted.is_empty() {
599            parts.push(format!("not listed: {}", unlisted.join(", ")));
600        }
601        let absent: Vec<&str> = names.difference(&present).copied().collect();
602        if !absent.is_empty() {
603            parts.push(format!("listed but absent: {}", absent.join(", ")));
604        }
605        if other > 0 {
606            parts.push(format!(
607                "{other} other {}",
608                if other == 1 { "entry" } else { "entries" }
609            ));
610        }
611        if !parts.is_empty() {
612            finding(
613                origin,
614                format!(
615                    "`files` must list exactly the tags.json and udts.json beside it; the gateway skips an unlisted file and logs an error for a listed one that is missing (CD5c): {}",
616                    parts.join("; ")
617                ),
618            );
619        }
620    }
621}
622
623/// Stage 4 (21-07 share): the per-type rule table, fail-closed.
624///
625/// - a manifest `types` entry, or a rendered resource type, with no
626///   [`type_rule`] row is a finding (only evidence-backed types render);
627/// - a Singleton type with a name directory, or a Named / NamedNoUuid type
628///   with a file directly under the type directory, is a finding naming the
629///   path.
630///
631/// Denied types, tag types and types that are not allowlisted already have
632/// their own findings from [`enforce_policy`] and are skipped here, so one
633/// defect yields one finding. Tag-origin files follow the `tags/` layout
634/// (CD5) and are not subject to the table.
635fn enforce_rules(input: &RenderInput<'_>, mapped: &Mapped, findings: &mut Vec<DeployFinding>) {
636    let is_tag_type = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
637    let mut finding = |file: String, message: String| {
638        findings.push(DeployFinding {
639            file,
640            line: None,
641            column: None,
642            message,
643        });
644    };
645    for entry in &input.manifest.types {
646        if let Some((module, ty)) = entry.split_once('/')
647            && !is_denied(module, ty)
648            && !is_tag_type(module, ty)
649            && type_rule(module, ty).is_none()
650        {
651            finding(
652                format!("gateways/{}/manifest.yaml", input.gateway),
653                format!(
654                    "no render rule for {module}/{ty}; only evidence-backed types render (types entry `{entry}`)"
655                ),
656            );
657        }
658    }
659    let mut no_rule: BTreeSet<(String, String)> = BTreeSet::new();
660    let mut singleton_dirs: BTreeSet<(String, String, String)> = BTreeSet::new();
661    for (path, origin) in &mapped.origins {
662        if mapped.tags.contains_key(path) {
663            continue;
664        }
665        let key = resource_key(path);
666        let (module, ty) = (key.module.as_str(), key.ty.as_str());
667        if is_denied(module, ty)
668            || is_tag_type(module, ty)
669            || !input.manifest.allows_type(module, ty)
670        {
671            continue;
672        }
673        let Some(rule) = type_rule(module, ty) else {
674            if no_rule.insert((key.module.clone(), key.ty.clone())) {
675                finding(
676                    format!("{}resources/{module}/{ty}/", origin.prefix),
677                    format!("no render rule for {module}/{ty}; only evidence-backed types render"),
678                );
679            }
680            continue;
681        };
682        match (rule.uuid, &key.name) {
683            (UuidRule::Singleton, Some(name)) => {
684                if singleton_dirs.insert((key.module.clone(), key.ty.clone(), name.clone())) {
685                    finding(
686                        format!("{}resources/{module}/{ty}/{name}/", origin.prefix),
687                        format!(
688                            "{module}/{ty} is a singleton type: its files sit directly under the type directory, not in a name directory"
689                        ),
690                    );
691                }
692            }
693            (UuidRule::Named | UuidRule::NamedNoUuid, None) => finding(
694                origin.file.clone(),
695                format!(
696                    "{module}/{ty} is a named type: a resource sits in a name directory, not directly under the type directory"
697                ),
698            ),
699            _ => {}
700        }
701    }
702}
703
704/// `module/type/name`: the id `uuids_minted` carries.
705fn resource_id(module: &str, ty: &str, name: &str) -> String {
706    format!("{module}/{ty}/{name}")
707}
708
709/// What `resource.json`'s `attributes.uuid` holds.
710enum UuidState {
711    Absent,
712    Valid,
713    /// Unusable; the message names the defect, never the value.
714    Invalid(&'static str),
715}
716
717/// Inspect `attributes.uuid` of a parsed `resource.json`.
718fn uuid_state(node: &Node) -> UuidState {
719    if !matches!(node, Node::Obj(_)) {
720        return UuidState::Invalid("resource.json is not a JSON object");
721    }
722    let attributes = match node.get("attributes") {
723        None => return UuidState::Absent,
724        Some(a @ Node::Obj(_)) => a,
725        Some(_) => return UuidState::Invalid("attributes is not a JSON object"),
726    };
727    match attributes.get("uuid") {
728        None => UuidState::Absent,
729        Some(Node::Str(s)) if s.is_empty() => UuidState::Invalid("attributes.uuid is empty"),
730        Some(Node::Str(s)) if is_canonical_uuid(s) => UuidState::Valid,
731        Some(Node::Str(_)) => {
732            UuidState::Invalid("attributes.uuid is not an 8-4-4-4-12 hexadecimal uuid")
733        }
734        Some(Node::Lit(l)) if l == "null" => UuidState::Invalid("attributes.uuid is null"),
735        Some(_) => UuidState::Invalid("attributes.uuid is not a string"),
736    }
737}
738
739/// Insert `"uuid": <uuid>` into `node`'s `attributes`, immediately after
740/// `lastModification` and `legacyId` when present (otherwise first); append an
741/// `attributes` holding only the uuid when there is none. The caller checked
742/// [`uuid_state`] is `Absent`.
743fn inject_uuid(node: &mut Node, uuid: &str) {
744    let Node::Obj(members) = node else { return };
745    let uuid = ("uuid".to_string(), Node::Str(uuid.to_string()));
746    match members.iter_mut().find(|(k, _)| k == "attributes") {
747        Some((_, Node::Obj(attrs))) => {
748            let at = attrs
749                .iter()
750                .rposition(|(k, _)| k == "lastModification" || k == "legacyId")
751                .map_or(0, |i| i + 1);
752            attrs.insert(at, uuid);
753        }
754        Some(_) => {}
755        None => members.push(("attributes".to_string(), Node::Obj(vec![uuid]))),
756    }
757}
758
759/// The `resource.json` render writes for a new Named resource: the shape the
760/// gateway writes for a resource created through its API (CD3), without
761/// `lastModification` or `lastModificationSignature`, which an 8.3.6 gateway
762/// loads without (CD2).
763fn synthesized_resource_json(rule: &TypeRule, members: &[String], uuid: &str) -> Node {
764    Node::Obj(vec![
765        ("scope".to_string(), Node::Str(rule.scope.to_string())),
766        ("version".to_string(), Node::Lit("1".to_string())),
767        ("restricted".to_string(), Node::Lit("false".to_string())),
768        ("overridable".to_string(), Node::Lit("true".to_string())),
769        (
770            "files".to_string(),
771            Node::Arr(members.iter().map(|m| Node::Str(m.clone())).collect()),
772        ),
773        (
774            "attributes".to_string(),
775            Node::Obj(vec![
776                ("uuid".to_string(), Node::Str(uuid.to_string())),
777                ("enabled".to_string(), Node::Lit("true".to_string())),
778            ]),
779        ),
780    ])
781}
782
783/// Stage 6 (21-07, DRND-06): resource identity.
784///
785/// Per resource of a type with a rule (tag-origin files follow CD5 and are
786/// skipped; a resource whose `resource.json` failed stage 5 was already
787/// reported):
788///
789/// - **Named**: an existing valid `attributes.uuid` is kept byte-for-byte; an
790///   invalid one (empty, null, not a string, not 8-4-4-4-12 hex) is a finding
791///   and is never replaced; a gateway-format `resource.json` without one gets
792///   the derived uuid inserted (an opaque one is refused, never rewritten); no
793///   `resource.json` at all gets a synthesized one, written by the Gson writer.
794/// - **NamedNoUuid**: never synthesized or injected; a missing `resource.json`
795///   is a finding, an existing uuid is kept (a gateway writes one for
796///   `store-and-forward-engine`, CD3), an invalid one is a finding.
797/// - **Singleton**: `resource.json` must exist and must carry no uuid.
798///
799/// Returns the ids (`module/type/name`) that received a uuid, byte-sorted.
800fn assign_uuids(
801    gateway: &str,
802    mapped: &mut Mapped,
803    json: &mut JsonTable,
804    findings: &mut Vec<DeployFinding>,
805) -> Vec<String> {
806    // (module, type, name) -> member paths; a singleton's name is empty
807    let mut groups: BTreeMap<(String, String, String), Vec<RelPath>> = BTreeMap::new();
808    for path in mapped.files.keys() {
809        if mapped.tags.contains_key(path) {
810            continue;
811        }
812        let key = resource_key(path);
813        let Some(rule) = type_rule(&key.module, &key.ty) else {
814            continue;
815        };
816        let name = match (rule.uuid, key.name) {
817            (UuidRule::Singleton, None) => String::new(),
818            (UuidRule::Named | UuidRule::NamedNoUuid, Some(name)) => name,
819            _ => continue, // wrong structure, reported by enforce_rules
820        };
821        groups
822            .entry((key.module, key.ty, name))
823            .or_default()
824            .push(path.clone());
825    }
826
827    let mut minted = Vec::new();
828    let mut synthesized: Vec<(RelPath, Vec<u8>, Node, Origin)> = Vec::new();
829    for ((module, ty, name), members) in &groups {
830        let rule = type_rule(module, ty).expect("grouped by a rule");
831        let dir = if name.is_empty() {
832            format!("{module}/{ty}")
833        } else {
834            format!("{module}/{ty}/{name}")
835        };
836        let rj_path = RelPath::new(&format!("{dir}/resource.json")).expect("valid path");
837        let first = &mapped.origins[&members[0]];
838        let rj_label = format!("{}resources/{rj_path}", first.prefix);
839        let mut finding = |file: String, message: String| {
840            findings.push(DeployFinding {
841                file,
842                line: None,
843                column: None,
844                message,
845            });
846        };
847
848        if mapped.files.contains_key(&rj_path) {
849            // unparseable: stage 5 reported it
850            let Some((class, node)) = json.get_mut(&rj_path) else {
851                continue;
852            };
853            let file = mapped.origins[&rj_path].file.clone();
854            match rule.uuid {
855                UuidRule::Singleton => {
856                    let has_key = node
857                        .get("attributes")
858                        .is_some_and(|a| a.get("uuid").is_some());
859                    if has_key {
860                        finding(
861                            file,
862                            format!(
863                                "{module}/{ty} is a singleton type; its resource.json must not carry attributes.uuid"
864                            ),
865                        );
866                    } else if let UuidState::Invalid(why) = uuid_state(node) {
867                        finding(file, why.to_string());
868                    }
869                }
870                UuidRule::NamedNoUuid => {
871                    if let UuidState::Invalid(why) = uuid_state(node) {
872                        finding(file, why.to_string());
873                    }
874                }
875                UuidRule::Named => match uuid_state(node) {
876                    UuidState::Valid => {}
877                    UuidState::Invalid(why) => finding(file, why.to_string()),
878                    UuidState::Absent if *class == JsonClass::Opaque => finding(
879                        file,
880                        "resource.json is not in gateway format; render will not rewrite it; add attributes.uuid or re-pull it".to_string(),
881                    ),
882                    UuidState::Absent => {
883                        inject_uuid(node, &name_based_uuid(gateway, module, ty, name));
884                        mapped
885                            .files
886                            .insert(rj_path.clone(), node::write(node).into_bytes());
887                        minted.push(resource_id(module, ty, name));
888                    }
889                },
890            }
891            continue;
892        }
893
894        match rule.uuid {
895            UuidRule::Singleton | UuidRule::NamedNoUuid => finding(
896                rj_label,
897                format!(
898                    "{module}/{ty} has no resource.json; render only synthesizes one for types whose resources carry a uuid"
899                ),
900            ),
901            UuidRule::Named => {
902                // CD2: a hand-written resource.json without lastModification
903                // or lastModificationSignature loads and is not rewritten.
904                let dir_prefix = format!("{dir}/");
905                let names: Vec<String> = members
906                    .iter()
907                    .map(|p| p.as_str()[dir_prefix.len()..].to_string())
908                    .collect();
909                let uuid = name_based_uuid(gateway, module, ty, name);
910                let node = synthesized_resource_json(rule, &names, &uuid);
911                let bytes = node::write(&node).into_bytes();
912                let origin = Origin {
913                    file: rj_label,
914                    prefix: first.prefix.clone(),
915                };
916                synthesized.push((rj_path, bytes, node, origin));
917                minted.push(resource_id(module, ty, name));
918            }
919        }
920    }
921    for (path, bytes, node, origin) in synthesized {
922        mapped.files.insert(path.clone(), bytes);
923        mapped.origins.insert(path.clone(), origin);
924        json.insert(path, (JsonClass::GatewayFormat, node));
925    }
926    minted.sort();
927    minted
928}
929
930/// Stage 7 (21-08, DRND-07), secret half: scan every parsed `.json` member.
931///
932/// Resource files get the full rule set for their type; tag-origin files
933/// (CD5) have no resource type and get the generic Embedded rule only.
934/// Returns the refused locations in (collection path, document) order; the
935/// scan output carries paths only, never a value. Warnings are appended to
936/// `warnings`.
937fn scan_all_secrets(mapped: &Mapped, json: &JsonTable, warnings: &mut Vec<String>) -> Vec<String> {
938    let mut refused = Vec::new();
939    for (path, (_, node)) in json {
940        let origin = mapped
941            .origins
942            .get(path)
943            .map_or_else(|| path.to_string(), |o| o.file.clone());
944        let scan = if mapped.tags.contains_key(path) {
945            scan_embedded(&origin, node)
946        } else {
947            let key = resource_key(path);
948            let skip = if key.name.is_some() { 3 } else { 2 };
949            let rest: Vec<&str> = path.components().skip(skip).collect();
950            scan_secrets(&origin, &key, &rest.join("/"), node)
951        };
952        refused.extend(scan.refused);
953        warnings.extend(scan.warnings);
954    }
955    refused
956}
957
958/// Stage 8: the rendered tree and its resource listing.
959fn finish(
960    mapped: Mapped,
961    json: &JsonTable,
962    uuids_minted: Vec<String>,
963    mut warnings: Vec<String>,
964) -> RenderOutcome {
965    let mut groups: BTreeMap<ResourceKey, Vec<String>> = BTreeMap::new();
966    for path in mapped.files.keys() {
967        let key = resource_key(path);
968        let skip = if key.name.is_some() { 3 } else { 2 };
969        let rest: Vec<&str> = path.components().skip(skip).collect();
970        groups.entry(key).or_default().push(rest.join("/"));
971    }
972    let resources = groups
973        .into_iter()
974        .map(|(key, mut files)| {
975            files.sort();
976            RenderedResource {
977                module: key.module,
978                ty: key.ty,
979                name: key.name,
980                files,
981            }
982        })
983        .collect();
984    if mapped.files.is_empty() {
985        warnings.push("render produced no files".to_string());
986    }
987    RenderOutcome {
988        tag_providers: mapped
989            .tags
990            .values()
991            .cloned()
992            .collect::<BTreeSet<_>>()
993            .into_iter()
994            .collect(),
995        tree: RenderedTree {
996            files: mapped.files,
997        },
998        resources,
999        uuids_minted,
1000        opaque_files: json
1001            .iter()
1002            .filter(|(_, (class, _))| *class == JsonClass::Opaque)
1003            .map(|(path, _)| path.to_string())
1004            .collect(),
1005        warnings,
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    const TARGET: &[u8] =
1014        b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n";
1015
1016    fn run(types: &str, files: &[(&str, &str)]) -> Result<RenderOutcome, CoreError> {
1017        let manifest = Manifest::parse(
1018            "m.yaml",
1019            format!("schema_version: 1\ntypes:\n{types}").as_bytes(),
1020        )
1021        .unwrap();
1022        let target = Target::parse("t.yaml", TARGET).unwrap();
1023        let source = SourceTree::from_files(files.iter().copied()).unwrap();
1024        render(&RenderInput {
1025            gateway: "g",
1026            env: "dev",
1027            manifest: &manifest,
1028            target: &target,
1029            source: &source,
1030        })
1031    }
1032
1033    #[test]
1034    fn unlisted_types_are_refused_together() {
1035        let err = run(
1036            "  - m/a\n",
1037            &[
1038                ("resources/m/a/x/config.json", "{}"),
1039                ("resources/m/b/x/config.json", "{}"),
1040                ("resources/m/b/y/config.json", "{}"),
1041                ("resources/n/c/config.json", "{}"),
1042            ],
1043        )
1044        .unwrap_err();
1045        let text = err.to_string();
1046        assert!(text.contains("gateways/g/resources/m/b/: type not in manifest.yaml types"));
1047        assert!(text.contains("gateways/g/resources/n/c/: type not in"));
1048        assert_eq!(text.matches("resources/m/b/").count(), 1, "{text}");
1049    }
1050
1051    #[test]
1052    fn shallow_files_are_findings() {
1053        let err = run("  - m/a\n", &[("resources/m/x.json", "{}")]).unwrap_err();
1054        assert!(err.to_string().contains("gateways/g/resources/m/x.json"));
1055    }
1056
1057    #[test]
1058    fn empty_tree_warns() {
1059        let out = run("  - ignition/schedule\n", &[]).unwrap();
1060        assert_eq!(out.warnings, ["render produced no files"]);
1061        assert!(out.resources.is_empty());
1062    }
1063
1064    const NAMED_RJ: &str = "{\n  \"scope\": \"A\",\n  \"version\": 1,\n  \"restricted\": false,\n  \"overridable\": true,\n  \"files\": [\n    \"config.json\"\n  ],\n  \"attributes\": {\n    \"uuid\": \"4a14d749-bb78-47c7-847a-cafa17da4da8\",\n    \"enabled\": true\n  }\n}";
1065    const SINGLETON_RJ: &str = "{\n  \"scope\": \"A\",\n  \"version\": 1,\n  \"restricted\": false,\n  \"overridable\": true,\n  \"files\": [\n    \"config.json\"\n  ],\n  \"attributes\": {\n    \"enabled\": true\n  }\n}";
1066
1067    #[test]
1068    fn named_resources_sort_by_name_bytes_and_singletons_follow_their_type() {
1069        let out = run(
1070            "  - ignition/schedule\n  - ignition/system-properties\n",
1071            &[
1072                ("resources/ignition/schedule/Z/config.json", "{}"),
1073                ("resources/ignition/schedule/Z/resource.json", NAMED_RJ),
1074                ("resources/ignition/schedule/B/sub/x.txt", "{}"),
1075                ("resources/ignition/schedule/B/resource.json", NAMED_RJ),
1076                ("resources/ignition/system-properties/config.json", "{}"),
1077                (
1078                    "resources/ignition/system-properties/resource.json",
1079                    SINGLETON_RJ,
1080                ),
1081            ],
1082        )
1083        .unwrap();
1084        let names: Vec<(&str, Option<&str>)> = out
1085            .resources
1086            .iter()
1087            .map(|r| (r.ty.as_str(), r.name.as_deref()))
1088            .collect();
1089        assert_eq!(
1090            names,
1091            [
1092                ("schedule", Some("B")),
1093                ("schedule", Some("Z")),
1094                ("system-properties", None)
1095            ]
1096        );
1097        assert_eq!(out.resources[0].files, ["resource.json", "sub/x.txt"]);
1098        assert_eq!(out.resources[2].files, ["config.json", "resource.json"]);
1099    }
1100}