1use std::collections::{BTreeMap, BTreeSet};
156use std::io::Read;
157use std::path::Path;
158
159use serde::Serialize;
160use sha2::{Digest, Sha256};
161
162use similar::TextDiff;
163
164use super::exclude;
165use super::layout::{
166 KeyCollection, PAIR_UNSYNCED_TAG_TYPES, RelPath, TAG_TYPES, core_key,
167 is_pair_unsynced_tag_path, resource_key, route, split_key,
168};
169use super::ledger::Ledger;
170use super::lint::{scan_embedded, scan_secrets};
171use super::node::{Node, classify};
172use super::render::member_named_tag_folder;
173use super::rules::GATEWAY_OWNED_FILES;
174use super::target::Redundancy;
175use super::transport::{
176 DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
177 READ_MAX_TOTAL_BYTES, Scope, TreeListing,
178};
179use super::tree::{
180 MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
181 open_regular_file, sha256_hex, walk_capped,
182};
183use crate::error::{CoreError, DeployFinding};
184
185pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
190#[serde(rename_all = "lowercase")]
191pub enum PlanStatus {
192 Added,
194 Changed,
196 Removed,
198 Same,
200 Unmanaged,
202}
203
204impl PlanStatus {
205 pub fn word(self) -> &'static str {
207 match self {
208 PlanStatus::Added => "added",
209 PlanStatus::Changed => "changed",
210 PlanStatus::Removed => "removed",
211 PlanStatus::Same => "same",
212 PlanStatus::Unmanaged => "unmanaged",
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
219#[serde(rename_all = "snake_case")]
220pub enum CoreRelation {
221 None,
223 Identical,
225 Differs,
227 Hidden,
230 ShadowsRemoval,
233}
234
235impl CoreRelation {
236 pub fn word(self) -> &'static str {
238 match self {
239 CoreRelation::None => "none",
240 CoreRelation::Identical => "identical",
241 CoreRelation::Differs => "differs",
242 CoreRelation::Hidden => "hidden",
243 CoreRelation::ShadowsRemoval => "shadows_removal",
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
250pub struct PlanFile {
251 pub path: String,
257 pub live_sha256: Option<String>,
261 pub desired_sha256: Option<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct PlanEntry {
268 pub key: String,
270 pub module: String,
272 #[serde(rename = "type")]
274 pub ty: String,
275 pub name: Option<String>,
277 pub status: PlanStatus,
279 pub live_in: Vec<String>,
281 pub core: CoreRelation,
283 pub destructive: bool,
286 pub destructive_reason: Option<String>,
288 pub lockout_risk: bool,
291 pub drift: Option<bool>,
295 pub files: Vec<PlanFile>,
297 pub diff: Option<String>,
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
305pub struct PlanSummary {
306 pub added: usize,
308 pub changed: usize,
310 pub removed: usize,
312 pub same: usize,
314 pub unmanaged: usize,
316 pub unreadable: usize,
319 pub has_changes: bool,
327 pub drifted: Option<usize>,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct Plan {
334 pub entries: Vec<PlanEntry>,
336 pub summary: PlanSummary,
338 pub delete_set: DeleteSet,
340 pub plan_sha256: String,
342 pub warnings: Vec<String>,
344 pub unreadable: Vec<UnreadableUnit>,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct UnreadableUnit {
356 pub key: String,
358 pub live_in: Vec<String>,
361 pub declared: bool,
363 pub in_ledger: bool,
365 pub paths: Vec<String>,
367 pub longest: usize,
369}
370
371#[derive(Debug, Clone, Default, PartialEq, Eq)]
373pub struct UnreadableKey {
374 pub collections: BTreeSet<String>,
376 pub paths: Vec<String>,
378 pub longest: usize,
380}
381
382pub fn unit_key_layer(observed: &ObservedFiles, collection: &str) -> BTreeMap<RelPath, ()> {
388 let files = if collection == "core" {
389 &observed.core
390 } else {
391 &observed.mode
392 };
393 files
394 .keys()
395 .cloned()
396 .chain(
397 observed
398 .unreadable
399 .iter()
400 .filter(|u| u.collection == collection)
401 .map(|u| u.path.clone()),
402 )
403 .map(|p| (p, ()))
404 .collect()
405}
406
407pub fn unreadable_unit_keys(observed: &ObservedFiles) -> BTreeMap<String, UnreadableKey> {
411 let mut out: BTreeMap<String, UnreadableKey> = BTreeMap::new();
412 if observed.unreadable.is_empty() {
413 return out;
414 }
415 let core_layer = unit_key_layer(observed, "core");
416 let mode_layer = unit_key_layer(observed, "mode");
417 for u in &observed.unreadable {
418 let layer = match u.collection.as_str() {
419 "core" => &core_layer,
420 "mode" => &mode_layer,
421 _ => continue,
422 };
423 let info = out.entry(unit_key(&u.path, layer)).or_default();
424 info.collections.insert(u.collection.clone());
425 info.paths.push(u.path.as_str().to_string());
426 info.longest = info.longest.max(u.length);
427 }
428 for info in out.values_mut() {
429 info.paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
430 info.paths.dedup();
431 }
432 out
433}
434
435pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
440 let parts: Vec<&str> = path.components().collect();
441 if parts.len() < 2 {
442 return path.as_str().to_string();
443 }
444 let parent_len = parts.len() - 1;
445 for end in (2..=parent_len).rev() {
446 let dir = parts[..end].join("/");
447 for marker in ["resource.json", "unary-resource.json"] {
448 if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
449 && layer.contains_key(&candidate)
450 {
451 return dir;
452 }
453 }
454 }
455 parts[..parent_len].join("/")
456}
457
458type Units = BTreeMap<String, BTreeMap<String, String>>;
460
461fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
462 let mut units = Units::new();
463 for (path, value) in layer {
464 units
465 .entry(unit_key(path, layer))
466 .or_default()
467 .insert(path.as_str().to_string(), digest(value));
468 }
469 units
470}
471
472fn group_keyed<'a, V: 'a>(
474 layer: &'a BTreeMap<RelPath, V>,
475 keys: &BTreeMap<RelPath, ()>,
476 digest: impl Fn(&'a V) -> String,
477) -> Units {
478 let mut units = Units::new();
479 for (path, value) in layer {
480 units
481 .entry(unit_key(path, keys))
482 .or_default()
483 .insert(path.as_str().to_string(), digest(value));
484 }
485 units
486}
487
488fn blob_digest(blob: &FileBlob) -> String {
489 blob.sha256.clone()
490}
491
492fn is_tag_folder(key: &str) -> bool {
496 let mut parts = key.split('/');
497 parts.next() == Some("ignition")
498 && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
499 && parts.next().is_some()
500}
501
502pub fn pair_tag_keys(entries: &[PlanEntry], delete: &DeleteSet) -> Vec<String> {
520 let deleted: BTreeSet<&str> = delete.files.iter().map(RelPath::as_str).collect();
521 let mut keys: Vec<String> = entries
522 .iter()
523 .filter(|e| e.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str()))
524 .filter(|e| e.status != PlanStatus::Unmanaged)
525 .filter(|e| {
526 let in_mode = e.live_in.iter().any(|c| c == "mode");
527 e.files.iter().any(|f| {
528 split_key(&f.path).0 == KeyCollection::Mode
529 && (f.desired_sha256.is_some()
530 || (in_mode
531 && f.live_sha256.is_some()
532 && !deleted.contains(f.path.as_str())))
533 })
534 })
535 .map(|e| e.key.clone())
536 .collect();
537 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
538 keys.dedup();
539 keys
540}
541
542pub fn pair_mode_tag_files(entries: &[PlanEntry]) -> Vec<String> {
547 let mut paths: Vec<String> = entries
548 .iter()
549 .filter(|e| e.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str()))
550 .flat_map(|e| e.files.iter())
551 .filter(|f| split_key(&f.path).0 == KeyCollection::Mode && f.live_sha256.is_some())
552 .map(|f| f.path.clone())
553 .collect();
554 paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
555 paths.dedup();
556 paths
557}
558
559pub fn pair_mode_tag_dirs(listed: &[TreeListing], entries: &[PlanEntry]) -> Vec<String> {
579 let mut found: Vec<String> = Vec::new();
580 for listing in listed
581 .iter()
582 .filter(|l| l.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&l.ty.as_str()))
583 {
584 let folder = format!("ignition/{}", listing.ty);
585 let under = format!("{folder}/");
586 let read: BTreeSet<&str> = entries
587 .iter()
588 .filter(|e| e.module == "ignition" && e.ty == listing.ty)
589 .flat_map(|e| e.files.iter())
590 .filter(|f| split_key(&f.path).0 == KeyCollection::Mode && f.live_sha256.is_some())
591 .map(|f| f.path.as_str())
592 .filter(|path| path.starts_with(&under))
593 .collect();
594 if read.is_empty() {
595 found.push(folder);
596 continue;
597 }
598 let residue: BTreeSet<&str> = listing
599 .entries
600 .iter()
601 .filter(|entry| {
602 let path = entry.path.as_str();
603 if entry.dir {
604 let inside = format!("{path}/");
605 !read.iter().any(|r| r.starts_with(&inside))
606 } else {
607 !read.contains(path)
608 }
609 })
610 .map(|entry| entry.path.as_str())
611 .collect();
612 found.extend(
613 residue
614 .iter()
615 .filter(|path| {
616 path.rsplit_once('/')
617 .is_none_or(|(parent, _)| !residue.contains(parent))
618 })
619 .map(|path| (*path).to_string()),
620 );
621 }
622 found.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
623 found.dedup();
624 found
625}
626
627fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
629 core.keys().any(|path| !rendered.contains_key(path))
630}
631
632fn routed_rows(
637 wanted: Option<&BTreeMap<String, String>>,
638 core_copy: Option<&BTreeMap<String, String>>,
639 mode_copy: Option<&BTreeMap<String, String>>,
640) -> Vec<PlanFile> {
641 let paths: BTreeSet<&String> = wanted
642 .into_iter()
643 .chain(core_copy)
644 .flat_map(BTreeMap::keys)
645 .collect();
646 let mut files: Vec<PlanFile> = paths
647 .into_iter()
648 .map(|path| PlanFile {
649 path: core_key(path),
650 live_sha256: core_copy.and_then(|c| c.get(path)).cloned(),
651 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
652 })
653 .chain(mode_copy.into_iter().flatten().map(|(path, sha)| PlanFile {
654 path: path.clone(),
655 live_sha256: Some(sha.clone()),
656 desired_sha256: None,
657 }))
658 .collect();
659 files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
660 files
661}
662
663const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
665
666fn tag_data_dropped(
680 key: &str,
681 effective: Option<&BTreeMap<String, String>>,
682 rendered: &BTreeMap<String, String>,
683) -> Option<String> {
684 let live: Vec<&str> = effective
685 .into_iter()
686 .flat_map(BTreeMap::keys)
687 .filter(|path| {
688 let name = path.rsplit('/').next().unwrap_or_default();
689 TAG_MEMBER_FILES
690 .iter()
691 .any(|m| m.eq_ignore_ascii_case(name))
692 && !rendered.contains_key(*path)
693 })
694 .map(String::as_str)
695 .collect();
696 if live.is_empty() {
697 return None;
698 }
699 Some(format!(
700 "{key} drops tag data the gateway runs now: the live {} not rendered, and the gateway loads only the member files a tag folder's unary-resource.json lists (CD5c) and overrides the core copy by folder (CD5a); it is destructive (tag_data_loss), so apply needs --allow-destructive",
701 if live.len() == 1 {
702 format!("{} is", live[0])
703 } else {
704 format!("{} are", live.join(", "))
705 }
706 ))
707}
708
709fn in_scope(scope: &Scope, path: &RelPath) -> bool {
713 let parts: Vec<&str> = path.components().collect();
714 if parts.len() < 3 {
715 return false;
716 }
717 let (module, ty) = (parts[0], parts[1]);
718 if scope.types.iter().any(|(m, t)| m == module && t == ty) {
719 return true;
720 }
721 module == "ignition"
722 && TAG_TYPES.contains(&ty)
723 && scope.tag_providers.iter().any(|p| p == parts[2])
724}
725
726struct KeyLayers<'a> {
729 core: &'a BTreeMap<RelPath, ()>,
730 mode: &'a BTreeMap<RelPath, ()>,
731}
732
733#[allow(clippy::too_many_arguments)]
751fn delete_set(
752 rendered: &RenderedTree,
753 observed: &ObservedFiles,
754 ledger: Option<&Ledger>,
755 scope: &Scope,
756 excluded: &[String],
757 layers: &KeyLayers<'_>,
758 unreadable: &BTreeMap<String, UnreadableKey>,
759 redundancy: Redundancy,
760) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
761 let Some(ledger) = ledger else {
762 return (Vec::new(), Vec::new(), BTreeSet::new());
763 };
764 let mut routed: BTreeSet<String> = BTreeSet::new();
765 let mut mode_folded: BTreeSet<String> = BTreeSet::new();
766 let mut core_folded: BTreeSet<String> = BTreeSet::new();
767 for path in rendered.files.keys() {
768 let key = route(redundancy, path.as_str());
769 let folded = path.as_str().to_ascii_lowercase();
770 match split_key(&key).0 {
771 KeyCollection::Mode => mode_folded.insert(folded),
772 KeyCollection::Core => core_folded.insert(folded),
773 };
774 routed.insert(key);
775 }
776 let mut listed: Vec<&String> = ledger.files.iter().collect();
777 listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
778 listed.dedup();
779
780 let mut delete = Vec::new();
781 let mut warnings = Vec::new();
782 let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
783 let mut ledgered: BTreeSet<String> = BTreeSet::new();
784 for raw in listed {
785 let Ok(key) = RelPath::new(raw) else {
786 continue;
787 };
788 let (collection, rest) = split_key(raw);
789 let Ok(path) = RelPath::new(rest) else {
790 continue;
791 };
792 if collection == KeyCollection::Core && !is_pair_unsynced_tag_path(rest) {
795 continue;
796 }
797 if path
798 .components()
799 .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
800 {
801 continue;
802 }
803 if let Some(provider) = exclude::excluded_provider(&path, excluded) {
806 let entry = excluded
809 .iter()
810 .find(|e| e.eq_ignore_ascii_case(provider))
811 .map_or(provider, String::as_str);
812 *excluded_files.entry(entry.to_string()).or_default() += 1;
813 continue;
814 }
815 if !in_scope(scope, &path) {
816 warnings.push(format!(
817 "ledger file {key} is under a type or tag provider the manifest no longer allows; it is not deleted"
818 ));
819 continue;
820 }
821 if !unreadable.is_empty() {
824 let candidates = match collection {
825 KeyCollection::Mode => {
826 vec![unit_key(&path, layers.mode), unit_key(&path, layers.core)]
827 }
828 KeyCollection::Core => vec![unit_key(&path, layers.core)],
829 };
830 let hit = candidates
831 .into_iter()
832 .find(|key| unreadable.contains_key(key));
833 if let Some(key) = hit {
834 ledgered.insert(key);
835 continue;
836 }
837 }
838 let (live, folded) = match collection {
839 KeyCollection::Mode => (&observed.mode, &mode_folded),
840 KeyCollection::Core => (&observed.core, &core_folded),
841 };
842 if routed.contains(raw.as_str()) || !live.contains_key(&path) {
843 continue;
844 }
845 if folded.contains(&path.as_str().to_ascii_lowercase()) {
846 warnings.push(format!(
847 "ledger file {key} differs from a rendered file only by case; it is not deleted because a case-insensitive host would delete the rendered file"
848 ));
849 continue;
850 }
851 delete.push(key);
852 }
853 for (provider, count) in excluded_files {
855 warnings.push(format!(
856 "ledger lists {count} file(s) under tag provider {provider}, which is excluded (exclude_tag_providers or the built-in System default); they are not deleted, because an excluded provider is never written or deleted"
857 ));
858 }
859 (delete, warnings, ledgered)
860}
861
862fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
864 match (module, ty) {
865 ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
866 "tag_data_loss"
867 }
868 ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
869 "connection_removed"
870 }
871 ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
872 _ if status == PlanStatus::Removed => "resource_removed",
873 _ => "files_removed",
874 }
875}
876
877pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
891 let live = observed
892 .core
893 .keys()
894 .map(|path| ("core", path))
895 .chain(observed.mode.keys().map(|path| (mode, path)))
896 .chain(observed.unreadable.iter().map(|u| {
897 let collection = if u.collection == "core" { "core" } else { mode };
898 (collection, &u.path)
899 }));
900 let folders: BTreeSet<(&str, String)> = live
901 .filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
902 .collect();
903 folders
904 .into_iter()
905 .map(|(collection, dir)| DeployFinding {
906 file: format!("config/resources/{collection}/{dir}/"),
907 line: None,
908 column: None,
909 message: "the gateway holds a tag folder named like a tag file (tags.json, udts.json or unary-resource.json), a directory where the provider's root tag file belongs, and loses that file (CD5d); rename or remove the folder on the gateway, then plan again".to_string(),
910 })
911 .collect()
912}
913
914pub fn plan(
923 rendered: &RenderedTree,
924 observed: &ObservedFiles,
925 ledger: Option<&Ledger>,
926 scope: &Scope,
927 excluded: &[String],
928) -> Plan {
929 plan_with(
930 rendered,
931 observed,
932 ledger,
933 scope,
934 excluded,
935 Redundancy::Standalone,
936 )
937}
938
939pub fn plan_with(
965 rendered: &RenderedTree,
966 observed: &ObservedFiles,
967 ledger: Option<&Ledger>,
968 scope: &Scope,
969 excluded: &[String],
970 redundancy: Redundancy,
971) -> Plan {
972 let unreadable_keys = unreadable_unit_keys(observed);
973 let core_layer = unit_key_layer(observed, "core");
974 let mode_layer = unit_key_layer(observed, "mode");
975 let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
976 let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
977 let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
978 let layers = KeyLayers {
979 core: &core_layer,
980 mode: &mode_layer,
981 };
982 let (delete, ledger_warnings, ledgered) = delete_set(
983 rendered,
984 observed,
985 ledger,
986 scope,
987 excluded,
988 &layers,
989 &unreadable_keys,
990 redundancy,
991 );
992 let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
994 let mut core_deleted: BTreeSet<String> = BTreeSet::new();
995 for path in &delete {
996 let unit = match split_key(path.as_str()) {
997 (KeyCollection::Mode, _) => unit_key(path, &mode_layer),
998 (KeyCollection::Core, rest) => {
999 core_deleted.insert(rest.to_string());
1000 match RelPath::new(rest) {
1001 Ok(rest) => unit_key(&rest, &core_layer),
1002 Err(_) => continue,
1003 }
1004 }
1005 };
1006 *deleted.entry(unit).or_default() += 1;
1007 }
1008
1009 let mut unreadable = Vec::with_capacity(unreadable_keys.len());
1012 for (key, info) in &unreadable_keys {
1013 let declared = desired.remove(key).is_some();
1014 let in_core = core.remove(key).is_some() || info.collections.contains("core");
1015 let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
1016 let mut live_in = Vec::new();
1017 if in_core {
1018 live_in.push("core".to_string());
1019 }
1020 if in_mode {
1021 live_in.push("mode".to_string());
1022 }
1023 unreadable.push(UnreadableUnit {
1024 key: key.clone(),
1025 live_in,
1026 declared,
1027 in_ledger: ledgered.contains(key),
1028 paths: info.paths.clone(),
1029 longest: info.longest,
1030 });
1031 }
1032
1033 let keys: BTreeSet<&String> = desired
1034 .keys()
1035 .chain(core.keys())
1036 .chain(mode.keys())
1037 .collect();
1038 let empty = BTreeMap::new();
1039
1040 let mut entries = Vec::with_capacity(keys.len());
1041 let mut tag_loss_warnings: Vec<String> = Vec::new();
1042 for key in keys {
1043 let wanted = desired.get(key);
1044 let core_copy = core.get(key);
1045 let mode_copy = mode.get(key);
1046 let effective = mode_copy.or(core_copy);
1047
1048 let mut live_in = Vec::new();
1049 if core_copy.is_some() {
1050 live_in.push("core".to_string());
1051 }
1052 if mode_copy.is_some() {
1053 live_in.push("mode".to_string());
1054 }
1055
1056 let routed = redundancy == Redundancy::Pair && is_pair_unsynced_tag_path(key);
1059 let basis = if routed { core_copy } else { effective };
1060 let deleted_here = deleted.get(key).copied().unwrap_or(0);
1061 let status = match (wanted, basis) {
1062 (Some(_), None) => PlanStatus::Added,
1063 (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
1064 (Some(_), Some(_)) => PlanStatus::Changed,
1065 (None, _) if deleted_here > 0 => PlanStatus::Removed,
1066 (None, _) => PlanStatus::Unmanaged,
1067 };
1068 let rendered_files = wanted.unwrap_or(&empty);
1069 let core_gone = core_copy.is_some_and(|c| c.keys().all(|path| core_deleted.contains(path)));
1071 let core_relation = match core_copy {
1072 None => CoreRelation::None,
1073 Some(_) if status == PlanStatus::Removed && !core_gone => CoreRelation::ShadowsRemoval,
1074 Some(c)
1078 if !routed
1079 && is_tag_folder(key)
1080 && (mode_copy.is_some() || wanted.is_some())
1081 && hides(c, rendered_files) =>
1082 {
1083 CoreRelation::Hidden
1084 }
1085 Some(c) if c == rendered_files => CoreRelation::Identical,
1086 Some(_) => CoreRelation::Differs,
1087 };
1088
1089 let files = if routed {
1090 routed_rows(wanted, core_copy, mode_copy)
1091 } else {
1092 let paths: BTreeSet<&String> = wanted
1093 .into_iter()
1094 .chain(effective)
1095 .flat_map(BTreeMap::keys)
1096 .collect();
1097 let mut files: Vec<PlanFile> = paths
1098 .into_iter()
1099 .map(|path| PlanFile {
1100 path: path.clone(),
1101 live_sha256: effective.and_then(|e| e.get(path)).cloned(),
1102 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
1103 })
1104 .collect();
1105 if !core_deleted.is_empty() {
1108 files.extend(
1109 core_copy
1110 .into_iter()
1111 .flatten()
1112 .filter(|(path, _)| core_deleted.contains(*path))
1113 .map(|(path, sha)| PlanFile {
1114 path: core_key(path),
1115 live_sha256: Some(sha.clone()),
1116 desired_sha256: None,
1117 }),
1118 );
1119 files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
1120 }
1121 files
1122 };
1123
1124 let parts: Vec<&str> = key.split('/').collect();
1125 let module = parts.first().copied().unwrap_or_default();
1126 let ty = parts.get(1).copied().unwrap_or_default();
1127 let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
1130 && is_tag_folder(key))
1131 .then(|| tag_data_dropped(key, effective, rendered_files))
1132 .flatten();
1133 let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
1134 tag_loss_warnings.extend(dropped);
1135 let lockout_risk = matches!(
1136 status,
1137 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1138 ) && module == "ignition"
1139 && matches!(ty, "identity-provider" | "security-zone");
1140 entries.push(PlanEntry {
1141 key: key.clone(),
1142 module: module.to_string(),
1143 ty: ty.to_string(),
1144 name: (parts.len() >= 3).then(|| parts[2..].join("/")),
1145 status,
1146 live_in,
1147 core: core_relation,
1148 destructive,
1149 destructive_reason: destructive
1150 .then(|| destructive_reason(module, ty, status).to_string()),
1151 lockout_risk,
1152 drift: None,
1153 files,
1154 diff: None,
1155 });
1156 }
1157
1158 let mut summary = PlanSummary::default();
1159 for entry in &entries {
1160 match entry.status {
1161 PlanStatus::Added => summary.added += 1,
1162 PlanStatus::Changed => summary.changed += 1,
1163 PlanStatus::Removed => summary.removed += 1,
1164 PlanStatus::Same => summary.same += 1,
1165 PlanStatus::Unmanaged => summary.unmanaged += 1,
1166 }
1167 }
1168 summary.unreadable = unreadable.len();
1169 summary.has_changes = summary.added + summary.changed + summary.removed > 0
1171 || unreadable.iter().any(|u| u.declared || u.in_ledger);
1172
1173 let mut warnings: Vec<String> = observed
1174 .local_units
1175 .iter()
1176 .map(|unit| {
1177 format!(
1178 "{unit} also exists in the `local` collection, which the gateway loads over the mode collection; the plan does not describe what the gateway runs for it"
1179 )
1180 })
1181 .collect();
1182 warnings.extend(ledger_warnings);
1183 warnings.extend(tag_loss_warnings);
1184 for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
1185 warnings.push(format!(
1186 "{} has a live path of {} characters, which the host's sftp server cannot read; it is not declared, so it is reported as unreadable and left alone",
1187 unit.key, unit.longest
1188 ));
1189 }
1190
1191 let plan_sha256 = sha256_hex(&canonical_text(
1192 &rendered.render_sha256(),
1193 &entries,
1194 &delete,
1195 &unreadable,
1196 ));
1197 Plan {
1198 entries,
1199 summary,
1200 delete_set: DeleteSet { files: delete },
1201 plan_sha256,
1202 warnings,
1203 unreadable,
1204 }
1205}
1206
1207#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct BaseTree {
1218 pub dir: String,
1220 pub render_sha256: String,
1222 pub files: BTreeMap<RelPath, String>,
1224}
1225
1226fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
1228 let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
1229 CoreError::InvalidInput {
1230 reason: format!("--base {}: {problem}{at}", dir.display()),
1231 }
1232}
1233
1234fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
1236 a.symmetric_difference(b)
1237 .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
1238}
1239
1240impl BaseTree {
1241 pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
1253 let meta = match std::fs::symlink_metadata(dir) {
1254 Ok(meta) => meta,
1255 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1256 return Err(base_refusal(dir, "does not exist", None));
1257 }
1258 Err(e) => {
1259 return Err(base_refusal(
1260 dir,
1261 format!("cannot inspect it ({})", e.kind()),
1262 None,
1263 ));
1264 }
1265 };
1266 if meta.file_type().is_symlink() {
1267 return Err(base_refusal(
1268 dir,
1269 "is a symlink; symlinks are refused",
1270 None,
1271 ));
1272 }
1273 if !meta.file_type().is_dir() {
1274 return Err(base_refusal(dir, "is not a directory", None));
1275 }
1276
1277 let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
1278 let mut dirs: BTreeSet<String> = BTreeSet::new();
1279 walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
1280 .map_err(|why| base_refusal(dir, why, None))?;
1281
1282 let Some(marker_path) = walked.remove(RENDER_MARKER) else {
1283 return Err(base_refusal(
1284 dir,
1285 format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
1286 Some(RENDER_MARKER),
1287 ));
1288 };
1289 let marker = read_marker(dir, &marker_path)?;
1290 if marker.schema_version != 1 {
1291 return Err(base_refusal(
1292 dir,
1293 format!(
1294 "{RENDER_MARKER} has schema_version {}, expected 1",
1295 marker.schema_version
1296 ),
1297 None,
1298 ));
1299 }
1300 for (what, found, expected) in [
1301 ("gateway", &marker.gateway, gateway),
1302 ("env", &marker.env, env),
1303 ("mode", &marker.mode, mode),
1304 ] {
1305 if found != expected {
1306 return Err(base_refusal(
1307 dir,
1308 format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
1309 None,
1310 ));
1311 }
1312 }
1313
1314 let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
1315 for entry in &marker.files {
1316 if listed.insert(entry.path.as_str(), entry).is_some() {
1317 return Err(base_refusal(
1318 dir,
1319 format!("{RENDER_MARKER} lists a path twice"),
1320 Some(&entry.path),
1321 ));
1322 }
1323 }
1324 let found: BTreeSet<String> = walked.keys().cloned().collect();
1325 let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
1326 if let Some(path) = first_difference(&found, &named) {
1327 let problem = if path.ends_with(TMP_SUFFIX) {
1328 "holds a leftover temporary file from an interrupted render".to_string()
1329 } else if found.contains(path) {
1330 format!("holds a file not listed in {RENDER_MARKER}")
1331 } else {
1332 format!("is missing a file listed in {RENDER_MARKER}")
1333 };
1334 return Err(base_refusal(dir, problem, Some(path)));
1335 }
1336 for d in &dirs {
1337 let prefix = format!("{d}/");
1338 if !named.iter().any(|p| p.starts_with(&prefix)) {
1339 return Err(base_refusal(
1340 dir,
1341 "holds a directory with no rendered file",
1342 Some(d),
1343 ));
1344 }
1345 }
1346
1347 let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
1348 let mut total: u64 = 0;
1349 for (rel, path) in &walked {
1350 let entry = listed[rel.as_str()];
1351 let key = RelPath::new(rel).map_err(|why| {
1352 base_refusal(
1353 dir,
1354 format!("{RENDER_MARKER} lists an unusable path ({why})"),
1355 Some(rel),
1356 )
1357 })?;
1358 let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
1359 if sha256 != entry.sha256 || len != entry.bytes {
1360 return Err(base_refusal(
1361 dir,
1362 format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
1363 Some(rel),
1364 ));
1365 }
1366 files.insert(key, sha256);
1367 }
1368
1369 let render_sha256 = listing_digest(&files);
1370 if render_sha256 != marker.render_sha256 {
1371 return Err(base_refusal(
1372 dir,
1373 format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
1374 None,
1375 ));
1376 }
1377 Ok(BaseTree {
1378 dir: dir.to_string_lossy().into_owned(),
1379 render_sha256,
1380 files,
1381 })
1382 }
1383}
1384
1385fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
1388 let before = std::fs::symlink_metadata(path).map_err(|e| {
1389 base_refusal(
1390 dir,
1391 format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
1392 None,
1393 )
1394 })?;
1395 let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
1396 if held.len() > READ_MAX_FILE_BYTES {
1397 return Err(base_refusal(
1398 dir,
1399 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1400 None,
1401 ));
1402 }
1403 let mut bytes = Vec::new();
1404 file.take(READ_MAX_FILE_BYTES + 1)
1405 .read_to_end(&mut bytes)
1406 .map_err(|e| {
1407 base_refusal(
1408 dir,
1409 format!("cannot read {RENDER_MARKER} ({})", e.kind()),
1410 None,
1411 )
1412 })?;
1413 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
1414 return Err(base_refusal(
1415 dir,
1416 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1417 None,
1418 ));
1419 }
1420 serde_json::from_slice(&bytes).map_err(|_| {
1421 base_refusal(
1422 dir,
1423 format!("{RENDER_MARKER} is not a valid render marker"),
1424 None,
1425 )
1426 })
1427}
1428
1429fn open_base_file(
1430 dir: &Path,
1431 rel: &str,
1432 before: &std::fs::Metadata,
1433) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1434 open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1435 OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1436 OpenError::Io(e) => {
1437 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1438 }
1439 })
1440}
1441
1442fn hash_base_file(
1445 dir: &Path,
1446 rel: &str,
1447 path: &Path,
1448 total: &mut u64,
1449) -> Result<(String, u64), CoreError> {
1450 let before = std::fs::symlink_metadata(path).map_err(|e| {
1451 base_refusal(
1452 dir,
1453 format!("cannot inspect a file ({})", e.kind()),
1454 Some(rel),
1455 )
1456 })?;
1457 if !before.file_type().is_file() {
1458 return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1459 }
1460 let (file, held) = open_base_file(dir, rel, &before)?;
1461 if held.len() > READ_MAX_FILE_BYTES {
1462 return Err(base_refusal(
1463 dir,
1464 format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1465 Some(rel),
1466 ));
1467 }
1468 *total += held.len();
1469 if *total > READ_MAX_TOTAL_BYTES {
1470 return Err(base_refusal(
1471 dir,
1472 format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1473 Some(rel),
1474 ));
1475 }
1476 let mut hasher = Sha256::new();
1477 let mut buf = [0u8; 64 * 1024];
1478 let mut len: u64 = 0;
1479 let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1480 loop {
1481 let n = reader.read(&mut buf).map_err(|e| {
1482 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1483 })?;
1484 if n == 0 {
1485 break;
1486 }
1487 len += n as u64;
1488 if len > READ_MAX_FILE_BYTES {
1489 return Err(base_refusal(
1490 dir,
1491 format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1492 Some(rel),
1493 ));
1494 }
1495 hasher.update(&buf[..n]);
1496 }
1497 Ok((format!("{:x}", hasher.finalize()), len))
1498}
1499
1500fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1503 let mut hasher = Sha256::new();
1504 for (path, sha256) in files {
1505 hasher.update(path.as_str().as_bytes());
1506 hasher.update([0u8]);
1507 hasher.update(sha256.as_bytes());
1508 hasher.update(b"\n");
1509 }
1510 format!("{:x}", hasher.finalize())
1511}
1512
1513pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1532 let deleted_bare: BTreeSet<&str> = plan
1533 .delete_set
1534 .files
1535 .iter()
1536 .map(RelPath::as_str)
1537 .filter(|path| split_key(path).0 == KeyCollection::Mode)
1538 .collect();
1539 let mut drifted = 0;
1540 for entry in &mut plan.entries {
1541 let core_absent = !entry.live_in.iter().any(|c| c == "core");
1542 let mode_shas: BTreeMap<&str, &String> = entry
1543 .files
1544 .iter()
1545 .filter(|file| split_key(&file.path).0 == KeyCollection::Mode)
1546 .filter_map(|file| Some((file.path.as_str(), file.live_sha256.as_ref()?)))
1547 .collect();
1548 let drift = entry.files.iter().any(|file| {
1549 let (collection, path) = split_key(&file.path);
1552 let base_sha = RelPath::new(path)
1553 .ok()
1554 .and_then(|path| base.files.get(&path));
1555 let migrated = collection == KeyCollection::Core
1556 && file.live_sha256.is_none()
1557 && core_absent
1558 && deleted_bare.contains(path);
1559 let live = if migrated {
1560 mode_shas.get(path).copied()
1561 } else {
1562 file.live_sha256.as_ref()
1563 };
1564 live != base_sha && live != file.desired_sha256.as_ref()
1565 });
1566 entry.drift = Some(drift);
1567 drifted += usize::from(drift);
1568 }
1569 plan.summary.drifted = Some(drifted);
1570}
1571
1572pub const DIFF_MAX_BYTES: usize = 65_536;
1575
1576pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1578
1579pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1611 for entry in &mut plan.entries {
1612 if !matches!(
1613 entry.status,
1614 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1615 ) {
1616 continue;
1617 }
1618 let entry_layer = if entry.live_in.iter().any(|l| l == "mode") {
1619 &observed.mode
1620 } else {
1621 &observed.core
1622 };
1623 let mut text = String::new();
1624 for file in &entry.files {
1625 if file.live_sha256 == file.desired_sha256 {
1626 continue;
1627 }
1628 let (collection, rest) = split_key(&file.path);
1631 let layer = match collection {
1632 KeyCollection::Core => &observed.core,
1633 KeyCollection::Mode => entry_layer,
1634 };
1635 let Ok(path) = RelPath::new(rest) else {
1636 continue;
1637 };
1638 let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1639 let desired = file
1644 .desired_sha256
1645 .is_some()
1646 .then(|| rendered.files.get(&path).map(Vec::as_slice))
1647 .flatten();
1648 text.push_str(&file_diff(
1649 &path,
1650 live,
1651 desired,
1652 file.live_sha256.as_deref(),
1653 file.desired_sha256.as_deref(),
1654 ));
1655 }
1656 entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1657 }
1658}
1659
1660fn file_diff(
1662 path: &RelPath,
1663 live: Option<&[u8]>,
1664 desired: Option<&[u8]>,
1665 live_sha: Option<&str>,
1666 desired_sha: Option<&str>,
1667) -> String {
1668 let shas = format!(
1669 "live {}, desired {}",
1670 live_sha.unwrap_or("-"),
1671 desired_sha.unwrap_or("-")
1672 );
1673 let hash_only = || format!("{path}: differs ({shas})\n");
1674 if !path.as_str().ends_with(".json") {
1675 return hash_only();
1676 }
1677 let mut locations = 0;
1678 for bytes in [live, desired].into_iter().flatten() {
1679 let Ok((_, node)) = classify(bytes) else {
1680 return hash_only();
1681 };
1682 locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1683 }
1684 if locations > 0 {
1685 return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1686 }
1687 let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1689 let new = desired
1690 .and_then(|b| std::str::from_utf8(b).ok())
1691 .unwrap_or("");
1692 let diff = TextDiff::from_lines(old, new);
1693 let mut unified = diff.unified_diff();
1694 unified
1695 .context_radius(3)
1696 .header(&format!("live/{path}"), &format!("desired/{path}"));
1697 let mut text = unified.to_string();
1698 if !text.ends_with('\n') {
1699 text.push('\n');
1700 }
1701 text
1702}
1703
1704fn secret_locations(path: &RelPath, node: &Node) -> usize {
1707 let origin = path.as_str();
1708 let parts: Vec<&str> = path.components().collect();
1709 let tag_file =
1710 parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1711 let scan = if tag_file {
1712 scan_embedded(origin, node)
1713 } else {
1714 let key = resource_key(path);
1715 let skip = if key.name.is_some() { 3 } else { 2 };
1716 let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1717 scan_secrets(origin, &key, &rest.join("/"), node)
1718 };
1719 scan.refused.len() + scan.warnings.len()
1720}
1721
1722const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1724 "password",
1725 "passwd",
1726 "secret",
1727 "token",
1728 "apikey",
1729 "api_key",
1730 "credential",
1731 "privatekey",
1732 "private_key",
1733];
1734
1735fn sensitive_key_hits(node: &Node) -> usize {
1746 sensitive_walk(node, false)
1747}
1748
1749fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1750 match node {
1751 Node::Obj(members) => members
1752 .iter()
1753 .map(|(key, value)| {
1754 let lowered = key.to_ascii_lowercase();
1755 let secret = under_secret_key
1756 || SENSITIVE_KEY_FRAGMENTS
1757 .iter()
1758 .any(|fragment| lowered.contains(fragment));
1759 sensitive_walk(value, secret)
1760 })
1761 .sum(),
1762 Node::Arr(items) => items
1763 .iter()
1764 .map(|item| sensitive_walk(item, under_secret_key))
1765 .sum(),
1766 Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1767 Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1768 }
1769}
1770
1771fn cap_diff(mut text: String) -> String {
1774 if text.len() > DIFF_MAX_BYTES {
1775 let mut cut = DIFF_MAX_BYTES;
1776 while !text.is_char_boundary(cut) {
1777 cut -= 1;
1778 }
1779 text.truncate(cut);
1780 if !text.ends_with('\n') {
1781 text.push('\n');
1782 }
1783 text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1784 } else {
1785 while text.ends_with('\n') {
1786 text.pop();
1787 }
1788 }
1789 text
1790}
1791
1792fn canonical_text(
1794 render_sha256: &str,
1795 entries: &[PlanEntry],
1796 delete_set: &[RelPath],
1797 unreadable: &[UnreadableUnit],
1798) -> Vec<u8> {
1799 let mut text: Vec<u8> = Vec::new();
1800 text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1801 text.push(b'\n');
1802 text.extend_from_slice(b"render_sha256\0");
1803 text.extend_from_slice(render_sha256.as_bytes());
1804 text.push(b'\n');
1805 for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1806 text.extend_from_slice(b"E\0");
1807 text.extend_from_slice(entry.key.as_bytes());
1808 text.push(0);
1809 text.extend_from_slice(entry.status.word().as_bytes());
1810 text.push(0);
1811 text.extend_from_slice(entry.core.word().as_bytes());
1812 text.push(0);
1813 text.extend_from_slice(entry.live_in.join(",").as_bytes());
1814 text.push(b'\n');
1815 for file in &entry.files {
1816 text.extend_from_slice(b"F\0");
1817 text.extend_from_slice(file.path.as_bytes());
1818 text.push(0);
1819 text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1820 text.push(0);
1821 text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1822 text.push(b'\n');
1823 }
1824 }
1825 for path in delete_set {
1826 text.extend_from_slice(b"D\0");
1827 text.extend_from_slice(path.as_str().as_bytes());
1828 text.push(b'\n');
1829 }
1830 for unit in unreadable {
1831 text.extend_from_slice(b"U\0");
1832 text.extend_from_slice(unit.key.as_bytes());
1833 text.push(0);
1834 text.extend_from_slice(unit.live_in.join(",").as_bytes());
1835 text.push(0);
1836 text.push(if unit.declared { b'1' } else { b'0' });
1837 text.push(0);
1838 text.push(if unit.in_ledger { b'1' } else { b'0' });
1839 text.push(b'\n');
1840 }
1841 text
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846 use super::*;
1847
1848 fn rp(s: &str) -> RelPath {
1849 RelPath::new(s).unwrap()
1850 }
1851
1852 fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1853 paths.iter().map(|p| (rp(p), ())).collect()
1854 }
1855
1856 #[test]
1857 fn resource_files_group_under_their_resource_directory() {
1858 let l = layer(&[
1859 "ignition/database-translator/POSTGRES/config.json",
1860 "ignition/database-translator/POSTGRES/resource.json",
1861 ]);
1862 let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1863 assert_eq!(key, "ignition/database-translator/POSTGRES");
1864 }
1865
1866 #[test]
1867 fn singletons_group_per_type() {
1868 let l = layer(&[
1869 "ignition/general-alarm-settings/config.json",
1870 "ignition/general-alarm-settings/resource.json",
1871 ]);
1872 let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1873 assert_eq!(key, "ignition/general-alarm-settings");
1874 }
1875
1876 #[test]
1877 fn nearest_marker_wins_for_tag_folders() {
1878 let l = layer(&[
1879 "ignition/tag-definition/P/unary-resource.json",
1880 "ignition/tag-definition/P/A/unary-resource.json",
1881 "ignition/tag-definition/P/A/tags.json",
1882 "ignition/tag-definition/P/B/tags.json",
1883 ]);
1884 assert_eq!(
1885 unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1886 "ignition/tag-definition/P/A"
1887 );
1888 assert_eq!(
1890 unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1891 "ignition/tag-definition/P"
1892 );
1893 }
1894
1895 #[test]
1899 fn cd5_unit_keys() {
1900 let l = layer(&[
1901 "ignition/tag-definition/P/unary-resource.json",
1902 "ignition/tag-definition/P/tags.json",
1903 "ignition/tag-definition/P/udts.json",
1904 "ignition/tag-type-definition/P/unary-resource.json",
1905 "ignition/tag-type-definition/P/udts.json",
1906 "ignition/tag-type-definition/P/A/unary-resource.json",
1907 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1908 "ignition/tag-type-definition/P/A/B/udts.json",
1909 ]);
1910 for (path, key) in [
1911 (
1912 "ignition/tag-definition/P/tags.json",
1913 "ignition/tag-definition/P",
1914 ),
1915 (
1916 "ignition/tag-definition/P/udts.json",
1917 "ignition/tag-definition/P",
1918 ),
1919 (
1920 "ignition/tag-type-definition/P/udts.json",
1921 "ignition/tag-type-definition/P",
1922 ),
1923 (
1924 "ignition/tag-type-definition/P/A/unary-resource.json",
1925 "ignition/tag-type-definition/P/A",
1926 ),
1927 (
1928 "ignition/tag-type-definition/P/A/B/udts.json",
1929 "ignition/tag-type-definition/P/A/B",
1930 ),
1931 (
1932 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1933 "ignition/tag-type-definition/P/A/B",
1934 ),
1935 ] {
1936 assert_eq!(unit_key(&rp(path), &l), key, "{path}");
1937 }
1938 let l = layer(&[
1940 "ignition/tag-type-definition/P/unary-resource.json",
1941 "ignition/tag-type-definition/P/A/unary-resource.json",
1942 "ignition/tag-type-definition/P/A/B/udts.json",
1943 ]);
1944 assert_eq!(
1945 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1946 "ignition/tag-type-definition/P/A"
1947 );
1948 let l = layer(&[
1949 "ignition/tag-type-definition/P/unary-resource.json",
1950 "ignition/tag-type-definition/P/A/B/udts.json",
1951 ]);
1952 assert_eq!(
1953 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1954 "ignition/tag-type-definition/P"
1955 );
1956 }
1957
1958 #[test]
1959 fn no_marker_falls_back_to_the_parent() {
1960 let l = layer(&["ignition/database-translator/X/config.json"]);
1961 assert_eq!(
1962 unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1963 "ignition/database-translator/X"
1964 );
1965 }
1966
1967 #[test]
1968 fn empty_inputs_plan_empty() {
1969 let scope = Scope {
1970 mode: "dev".to_string(),
1971 types: Vec::new(),
1972 tag_providers: Vec::new(),
1973 };
1974 let p = plan(
1975 &RenderedTree::default(),
1976 &ObservedFiles::default(),
1977 None,
1978 &scope,
1979 &["System".to_string()],
1980 );
1981 assert!(p.entries.is_empty());
1982 assert!(!p.summary.has_changes);
1983 }
1984}