1use 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#[derive(Debug, Clone, Copy)]
50pub struct RenderInput<'a> {
51 pub gateway: &'a str,
53 pub env: &'a str,
55 pub manifest: &'a Manifest,
57 pub target: &'a Target,
59 pub source: &'a SourceTree,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct RenderedResource {
66 pub module: String,
68 #[serde(rename = "type")]
70 pub ty: String,
71 pub name: Option<String>,
73 pub files: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RenderOutcome {
80 pub tree: RenderedTree,
82 pub resources: Vec<RenderedResource>,
84 pub tag_providers: Vec<String>,
86 pub uuids_minted: Vec<String>,
89 pub opaque_files: Vec<String>,
92 pub warnings: Vec<String>,
94}
95
96pub fn render(input: &RenderInput<'_>) -> Result<RenderOutcome, CoreError> {
99 let mut findings = Vec::new();
100 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 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
139struct Entry {
143 bytes: Vec<u8>,
144 prefix: String,
145}
146
147type Effective = BTreeMap<RelPath, Entry>;
149
150struct Origin {
152 file: String,
154 prefix: String,
156}
157
158type Origins = BTreeMap<RelPath, Origin>;
161
162struct Mapped {
164 files: BTreeMap<RelPath, Vec<u8>>,
165 origins: Origins,
166 tags: BTreeMap<RelPath, String>,
168}
169
170type JsonTable = BTreeMap<RelPath, (JsonClass, Node)>;
173
174fn 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
224fn 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
268fn 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
315fn enforce_policy(
322 input: &RenderInput<'_>,
323 mapped: &Mapped,
324 excluded: &[String],
325 findings: &mut Vec<DeployFinding>,
326) {
327 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 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
406fn 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
442const MARKED_TAG_TYPES: [&str; 2] = ["tag-definition", "tag-type-definition"];
445
446const TAG_MEMBERS: [&str; 2] = ["tags.json", "udts.json"];
448
449const TAG_MARKER: &str = "unary-resource.json";
451
452fn 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
464pub(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
476fn 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
484fn 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 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
623fn 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
704fn resource_id(module: &str, ty: &str, name: &str) -> String {
706 format!("{module}/{ty}/{name}")
707}
708
709enum UuidState {
711 Absent,
712 Valid,
713 Invalid(&'static str),
715}
716
717fn 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
739fn 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
759fn 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
783fn assign_uuids(
801 gateway: &str,
802 mapped: &mut Mapped,
803 json: &mut JsonTable,
804 findings: &mut Vec<DeployFinding>,
805) -> Vec<String> {
806 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, };
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 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 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
930fn 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
958fn 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}