1use 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#[derive(Debug, Clone, Copy)]
45pub struct RenderInput<'a> {
46 pub gateway: &'a str,
48 pub env: &'a str,
50 pub manifest: &'a Manifest,
52 pub target: &'a Target,
54 pub source: &'a SourceTree,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60pub struct RenderedResource {
61 pub module: String,
63 #[serde(rename = "type")]
65 pub ty: String,
66 pub name: Option<String>,
68 pub files: Vec<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct RenderOutcome {
75 pub tree: RenderedTree,
77 pub resources: Vec<RenderedResource>,
79 pub tag_providers: Vec<String>,
81 pub uuids_minted: Vec<String>,
84 pub opaque_files: Vec<String>,
87 pub warnings: Vec<String>,
89}
90
91pub 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 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
125struct Entry {
129 bytes: Vec<u8>,
130 prefix: String,
131}
132
133type Effective = BTreeMap<RelPath, Entry>;
135
136struct Origin {
138 file: String,
140 prefix: String,
142}
143
144type Origins = BTreeMap<RelPath, Origin>;
147
148struct Mapped {
150 files: BTreeMap<RelPath, Vec<u8>>,
151 origins: Origins,
152 tags: BTreeMap<RelPath, String>,
154}
155
156type JsonTable = BTreeMap<RelPath, (JsonClass, Node)>;
159
160fn 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
210fn 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
254fn 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
301fn enforce_policy(input: &RenderInput<'_>, mapped: &Mapped, findings: &mut Vec<DeployFinding>) {
307 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
369fn 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
405fn 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
486fn resource_id(module: &str, ty: &str, name: &str) -> String {
488 format!("{module}/{ty}/{name}")
489}
490
491enum UuidState {
493 Absent,
494 Valid,
495 Invalid(&'static str),
497}
498
499fn 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
521fn 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
541fn 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
565fn assign_uuids(
583 gateway: &str,
584 mapped: &mut Mapped,
585 json: &mut JsonTable,
586 findings: &mut Vec<DeployFinding>,
587) -> Vec<String> {
588 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, };
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 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 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
712fn 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
740fn 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}