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