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