1use 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#[derive(Debug, Clone, Copy)]
46pub struct RenderInput<'a> {
47 pub gateway: &'a str,
49 pub env: &'a str,
51 pub manifest: &'a Manifest,
53 pub target: &'a Target,
55 pub source: &'a SourceTree,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct RenderedResource {
62 pub module: String,
64 #[serde(rename = "type")]
66 pub ty: String,
67 pub name: Option<String>,
69 pub files: Vec<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct RenderOutcome {
76 pub tree: RenderedTree,
78 pub resources: Vec<RenderedResource>,
80 pub tag_providers: Vec<String>,
82 pub uuids_minted: Vec<String>,
85 pub opaque_files: Vec<String>,
88 pub warnings: Vec<String>,
90}
91
92pub fn render(input: &RenderInput<'_>) -> Result<RenderOutcome, CoreError> {
95 let mut findings = Vec::new();
96 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 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
134struct Entry {
138 bytes: Vec<u8>,
139 prefix: String,
140}
141
142type Effective = BTreeMap<RelPath, Entry>;
144
145struct Origin {
147 file: String,
149 prefix: String,
151}
152
153type Origins = BTreeMap<RelPath, Origin>;
156
157struct Mapped {
159 files: BTreeMap<RelPath, Vec<u8>>,
160 origins: Origins,
161 tags: BTreeMap<RelPath, String>,
163}
164
165type JsonTable = BTreeMap<RelPath, (JsonClass, Node)>;
168
169fn 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
219fn 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
263fn 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
310fn enforce_policy(
317 input: &RenderInput<'_>,
318 mapped: &Mapped,
319 excluded: &[String],
320 findings: &mut Vec<DeployFinding>,
321) {
322 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
389fn 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
425fn 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
506fn resource_id(module: &str, ty: &str, name: &str) -> String {
508 format!("{module}/{ty}/{name}")
509}
510
511enum UuidState {
513 Absent,
514 Valid,
515 Invalid(&'static str),
517}
518
519fn 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
541fn 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
561fn 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
585fn assign_uuids(
603 gateway: &str,
604 mapped: &mut Mapped,
605 json: &mut JsonTable,
606 findings: &mut Vec<DeployFinding>,
607) -> Vec<String> {
608 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, };
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 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 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
732fn 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
760fn 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}