1use std::collections::{BTreeMap, BTreeSet};
158use std::io::Read;
159use std::path::Path;
160
161use serde::Serialize;
162use sha2::{Digest, Sha256};
163
164use similar::TextDiff;
165
166use super::exclude;
167use super::layout::{
168 KeyCollection, PAIR_UNSYNCED_TAG_TYPES, RelPath, TAG_TYPES, core_key,
169 is_pair_unsynced_tag_path, resource_key, route, split_key,
170};
171use super::ledger::Ledger;
172use super::lint::{scan_embedded, scan_secrets};
173use super::node::{Node, classify};
174use super::render::member_named_tag_folder;
175use super::rules::GATEWAY_OWNED_FILES;
176use super::target::Redundancy;
177use super::transport::{
178 DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
179 READ_MAX_TOTAL_BYTES, Scope, TreeListing,
180};
181use super::tree::{
182 MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
183 open_regular_file, sha256_hex, walk_capped,
184};
185use crate::error::{CoreError, DeployFinding};
186
187pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
192#[serde(rename_all = "lowercase")]
193pub enum PlanStatus {
194 Added,
196 Changed,
198 Removed,
200 Same,
202 Unmanaged,
204}
205
206impl PlanStatus {
207 pub fn word(self) -> &'static str {
209 match self {
210 PlanStatus::Added => "added",
211 PlanStatus::Changed => "changed",
212 PlanStatus::Removed => "removed",
213 PlanStatus::Same => "same",
214 PlanStatus::Unmanaged => "unmanaged",
215 }
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum CoreRelation {
223 None,
225 Identical,
227 Differs,
229 Hidden,
232 ShadowsRemoval,
235}
236
237impl CoreRelation {
238 pub fn word(self) -> &'static str {
240 match self {
241 CoreRelation::None => "none",
242 CoreRelation::Identical => "identical",
243 CoreRelation::Differs => "differs",
244 CoreRelation::Hidden => "hidden",
245 CoreRelation::ShadowsRemoval => "shadows_removal",
246 }
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
252pub struct PlanFile {
253 pub path: String,
259 pub live_sha256: Option<String>,
263 pub desired_sha256: Option<String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269pub struct PlanEntry {
270 pub key: String,
272 pub module: String,
274 #[serde(rename = "type")]
276 pub ty: String,
277 pub name: Option<String>,
279 pub status: PlanStatus,
281 pub live_in: Vec<String>,
283 pub core: CoreRelation,
285 pub destructive: bool,
288 pub destructive_reason: Option<String>,
290 pub lockout_risk: bool,
293 pub drift: Option<bool>,
297 pub files: Vec<PlanFile>,
299 pub diff: Option<String>,
303}
304
305#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
307pub struct PlanSummary {
308 pub added: usize,
310 pub changed: usize,
312 pub removed: usize,
314 pub same: usize,
316 pub unmanaged: usize,
318 pub unreadable: usize,
321 pub has_changes: bool,
329 pub drifted: Option<usize>,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
335pub struct Plan {
336 pub entries: Vec<PlanEntry>,
338 pub summary: PlanSummary,
340 pub delete_set: DeleteSet,
342 pub plan_sha256: String,
344 pub warnings: Vec<String>,
346 pub unreadable: Vec<UnreadableUnit>,
349 pub provider_wipes: Vec<String>,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
359pub struct UnreadableUnit {
360 pub key: String,
362 pub live_in: Vec<String>,
365 pub declared: bool,
367 pub in_ledger: bool,
369 pub paths: Vec<String>,
371 pub longest: usize,
373}
374
375#[derive(Debug, Clone, Default, PartialEq, Eq)]
377pub struct UnreadableKey {
378 pub collections: BTreeSet<String>,
380 pub paths: Vec<String>,
382 pub longest: usize,
384}
385
386pub fn unit_key_layer(observed: &ObservedFiles, collection: &str) -> BTreeMap<RelPath, ()> {
392 let files = if collection == "core" {
393 &observed.core
394 } else {
395 &observed.mode
396 };
397 files
398 .keys()
399 .cloned()
400 .chain(
401 observed
402 .unreadable
403 .iter()
404 .filter(|u| u.collection == collection)
405 .map(|u| u.path.clone()),
406 )
407 .map(|p| (p, ()))
408 .collect()
409}
410
411pub fn unreadable_unit_keys(observed: &ObservedFiles) -> BTreeMap<String, UnreadableKey> {
415 let mut out: BTreeMap<String, UnreadableKey> = BTreeMap::new();
416 if observed.unreadable.is_empty() {
417 return out;
418 }
419 let core_layer = unit_key_layer(observed, "core");
420 let mode_layer = unit_key_layer(observed, "mode");
421 for u in &observed.unreadable {
422 let layer = match u.collection.as_str() {
423 "core" => &core_layer,
424 "mode" => &mode_layer,
425 _ => continue,
426 };
427 let info = out.entry(unit_key(&u.path, layer)).or_default();
428 info.collections.insert(u.collection.clone());
429 info.paths.push(u.path.as_str().to_string());
430 info.longest = info.longest.max(u.length);
431 }
432 for info in out.values_mut() {
433 info.paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
434 info.paths.dedup();
435 }
436 out
437}
438
439pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
444 let parts: Vec<&str> = path.components().collect();
445 if parts.len() < 2 {
446 return path.as_str().to_string();
447 }
448 let parent_len = parts.len() - 1;
449 for end in (2..=parent_len).rev() {
450 let dir = parts[..end].join("/");
451 for marker in ["resource.json", "unary-resource.json"] {
452 if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
453 && layer.contains_key(&candidate)
454 {
455 return dir;
456 }
457 }
458 }
459 parts[..parent_len].join("/")
460}
461
462type Units = BTreeMap<String, BTreeMap<String, String>>;
464
465fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
466 let mut units = Units::new();
467 for (path, value) in layer {
468 units
469 .entry(unit_key(path, layer))
470 .or_default()
471 .insert(path.as_str().to_string(), digest(value));
472 }
473 units
474}
475
476fn group_keyed<'a, V: 'a>(
478 layer: &'a BTreeMap<RelPath, V>,
479 keys: &BTreeMap<RelPath, ()>,
480 digest: impl Fn(&'a V) -> String,
481) -> Units {
482 let mut units = Units::new();
483 for (path, value) in layer {
484 units
485 .entry(unit_key(path, keys))
486 .or_default()
487 .insert(path.as_str().to_string(), digest(value));
488 }
489 units
490}
491
492fn blob_digest(blob: &FileBlob) -> String {
493 blob.sha256.clone()
494}
495
496fn is_tag_folder(key: &str) -> bool {
500 let mut parts = key.split('/');
501 parts.next() == Some("ignition")
502 && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
503 && parts.next().is_some()
504}
505
506pub fn pair_tag_keys(entries: &[PlanEntry], delete: &DeleteSet) -> Vec<String> {
524 let deleted: BTreeSet<&str> = delete.files.iter().map(RelPath::as_str).collect();
525 let mut keys: Vec<String> = entries
526 .iter()
527 .filter(|e| e.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str()))
528 .filter(|e| e.status != PlanStatus::Unmanaged)
529 .filter(|e| {
530 let in_mode = e.live_in.iter().any(|c| c == "mode");
531 e.files.iter().any(|f| {
532 split_key(&f.path).0 == KeyCollection::Mode
533 && (f.desired_sha256.is_some()
534 || (in_mode
535 && f.live_sha256.is_some()
536 && !deleted.contains(f.path.as_str())))
537 })
538 })
539 .map(|e| e.key.clone())
540 .collect();
541 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
542 keys.dedup();
543 keys
544}
545
546pub fn pair_mode_tag_files(entries: &[PlanEntry]) -> Vec<String> {
551 let mut paths: Vec<String> = entries
552 .iter()
553 .filter(|e| e.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str()))
554 .flat_map(|e| e.files.iter())
555 .filter(|f| split_key(&f.path).0 == KeyCollection::Mode && f.live_sha256.is_some())
556 .map(|f| f.path.clone())
557 .collect();
558 paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
559 paths.dedup();
560 paths
561}
562
563pub fn pair_mode_tag_dirs(listed: &[TreeListing], entries: &[PlanEntry]) -> Vec<String> {
583 let mut found: Vec<String> = Vec::new();
584 for listing in listed
585 .iter()
586 .filter(|l| l.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&l.ty.as_str()))
587 {
588 let folder = format!("ignition/{}", listing.ty);
589 let under = format!("{folder}/");
590 let read: BTreeSet<&str> = entries
591 .iter()
592 .filter(|e| e.module == "ignition" && e.ty == listing.ty)
593 .flat_map(|e| e.files.iter())
594 .filter(|f| split_key(&f.path).0 == KeyCollection::Mode && f.live_sha256.is_some())
595 .map(|f| f.path.as_str())
596 .filter(|path| path.starts_with(&under))
597 .collect();
598 if read.is_empty() {
599 found.push(folder);
600 continue;
601 }
602 let residue: BTreeSet<&str> = listing
603 .entries
604 .iter()
605 .filter(|entry| {
606 let path = entry.path.as_str();
607 if entry.dir {
608 let inside = format!("{path}/");
609 !read.iter().any(|r| r.starts_with(&inside))
610 } else {
611 !read.contains(path)
612 }
613 })
614 .map(|entry| entry.path.as_str())
615 .collect();
616 found.extend(
617 residue
618 .iter()
619 .filter(|path| {
620 path.rsplit_once('/')
621 .is_none_or(|(parent, _)| !residue.contains(parent))
622 })
623 .map(|path| (*path).to_string()),
624 );
625 }
626 found.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
627 found.dedup();
628 found
629}
630
631fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
633 core.keys().any(|path| !rendered.contains_key(path))
634}
635
636fn routed_rows(
641 wanted: Option<&BTreeMap<String, String>>,
642 core_copy: Option<&BTreeMap<String, String>>,
643 mode_copy: Option<&BTreeMap<String, String>>,
644) -> Vec<PlanFile> {
645 let paths: BTreeSet<&String> = wanted
646 .into_iter()
647 .chain(core_copy)
648 .flat_map(BTreeMap::keys)
649 .collect();
650 let mut files: Vec<PlanFile> = paths
651 .into_iter()
652 .map(|path| PlanFile {
653 path: core_key(path),
654 live_sha256: core_copy.and_then(|c| c.get(path)).cloned(),
655 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
656 })
657 .chain(mode_copy.into_iter().flatten().map(|(path, sha)| PlanFile {
658 path: path.clone(),
659 live_sha256: Some(sha.clone()),
660 desired_sha256: None,
661 }))
662 .collect();
663 files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
664 files
665}
666
667const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
669
670fn tag_data_dropped(
684 key: &str,
685 effective: Option<&BTreeMap<String, String>>,
686 rendered: &BTreeMap<String, String>,
687) -> Option<String> {
688 let live: Vec<&str> = effective
689 .into_iter()
690 .flat_map(BTreeMap::keys)
691 .filter(|path| {
692 let name = path.rsplit('/').next().unwrap_or_default();
693 TAG_MEMBER_FILES
694 .iter()
695 .any(|m| m.eq_ignore_ascii_case(name))
696 && !rendered.contains_key(*path)
697 })
698 .map(String::as_str)
699 .collect();
700 if live.is_empty() {
701 return None;
702 }
703 Some(format!(
704 "{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",
705 if live.len() == 1 {
706 format!("{} is", live[0])
707 } else {
708 format!("{} are", live.join(", "))
709 }
710 ))
711}
712
713fn in_scope(scope: &Scope, path: &RelPath) -> bool {
717 let parts: Vec<&str> = path.components().collect();
718 if parts.len() < 3 {
719 return false;
720 }
721 let (module, ty) = (parts[0], parts[1]);
722 if scope.types.iter().any(|(m, t)| m == module && t == ty) {
723 return true;
724 }
725 module == "ignition"
726 && TAG_TYPES.contains(&ty)
727 && scope.tag_providers.iter().any(|p| p == parts[2])
728}
729
730struct KeyLayers<'a> {
733 core: &'a BTreeMap<RelPath, ()>,
734 mode: &'a BTreeMap<RelPath, ()>,
735}
736
737#[allow(clippy::too_many_arguments)]
755fn delete_set(
756 rendered: &RenderedTree,
757 observed: &ObservedFiles,
758 ledger: Option<&Ledger>,
759 scope: &Scope,
760 excluded: &[String],
761 layers: &KeyLayers<'_>,
762 unreadable: &BTreeMap<String, UnreadableKey>,
763 redundancy: Redundancy,
764) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
765 let Some(ledger) = ledger else {
766 return (Vec::new(), Vec::new(), BTreeSet::new());
767 };
768 let mut routed: BTreeSet<String> = BTreeSet::new();
769 let mut mode_folded: BTreeSet<String> = BTreeSet::new();
770 let mut core_folded: BTreeSet<String> = BTreeSet::new();
771 for path in rendered.files.keys() {
772 let key = route(redundancy, path.as_str());
773 let folded = path.as_str().to_ascii_lowercase();
774 match split_key(&key).0 {
775 KeyCollection::Mode => mode_folded.insert(folded),
776 KeyCollection::Core => core_folded.insert(folded),
777 };
778 routed.insert(key);
779 }
780 let mut listed: Vec<&String> = ledger.files.iter().collect();
781 listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
782 listed.dedup();
783
784 let mut delete = Vec::new();
785 let mut warnings = Vec::new();
786 let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
787 let mut ledgered: BTreeSet<String> = BTreeSet::new();
788 for raw in listed {
789 let Ok(key) = RelPath::new(raw) else {
790 continue;
791 };
792 let (collection, rest) = split_key(raw);
793 let Ok(path) = RelPath::new(rest) else {
794 continue;
795 };
796 if collection == KeyCollection::Core && !is_pair_unsynced_tag_path(rest) {
799 continue;
800 }
801 if path
802 .components()
803 .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
804 {
805 continue;
806 }
807 if let Some(provider) = exclude::excluded_provider(&path, excluded) {
810 let entry = excluded
813 .iter()
814 .find(|e| e.eq_ignore_ascii_case(provider))
815 .map_or(provider, String::as_str);
816 *excluded_files.entry(entry.to_string()).or_default() += 1;
817 continue;
818 }
819 if !in_scope(scope, &path) {
820 warnings.push(format!(
821 "ledger file {key} is under a type or tag provider the manifest no longer allows; it is not deleted"
822 ));
823 continue;
824 }
825 if !unreadable.is_empty() {
828 let candidates = match collection {
829 KeyCollection::Mode => {
830 vec![unit_key(&path, layers.mode), unit_key(&path, layers.core)]
831 }
832 KeyCollection::Core => vec![unit_key(&path, layers.core)],
833 };
834 let hit = candidates
835 .into_iter()
836 .find(|key| unreadable.contains_key(key));
837 if let Some(key) = hit {
838 ledgered.insert(key);
839 continue;
840 }
841 }
842 let (live, folded) = match collection {
843 KeyCollection::Mode => (&observed.mode, &mode_folded),
844 KeyCollection::Core => (&observed.core, &core_folded),
845 };
846 if routed.contains(raw.as_str()) || !live.contains_key(&path) {
847 continue;
848 }
849 if folded.contains(&path.as_str().to_ascii_lowercase()) {
850 warnings.push(format!(
851 "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"
852 ));
853 continue;
854 }
855 delete.push(key);
856 }
857 for (provider, count) in excluded_files {
859 warnings.push(format!(
860 "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"
861 ));
862 }
863 (delete, warnings, ledgered)
864}
865
866fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
868 match (module, ty) {
869 ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
870 "tag_data_loss"
871 }
872 ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
873 "connection_removed"
874 }
875 ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
876 _ if status == PlanStatus::Removed => "resource_removed",
877 _ => "files_removed",
878 }
879}
880
881pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
895 let live = observed
896 .core
897 .keys()
898 .map(|path| ("core", path))
899 .chain(observed.mode.keys().map(|path| (mode, path)))
900 .chain(observed.unreadable.iter().map(|u| {
901 let collection = if u.collection == "core" { "core" } else { mode };
902 (collection, &u.path)
903 }));
904 let folders: BTreeSet<(&str, String)> = live
905 .filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
906 .collect();
907 folders
908 .into_iter()
909 .map(|(collection, dir)| DeployFinding {
910 file: format!("config/resources/{collection}/{dir}/"),
911 line: None,
912 column: None,
913 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(),
914 })
915 .collect()
916}
917
918pub fn plan(
927 rendered: &RenderedTree,
928 observed: &ObservedFiles,
929 ledger: Option<&Ledger>,
930 scope: &Scope,
931 excluded: &[String],
932) -> Plan {
933 plan_with(
934 rendered,
935 observed,
936 ledger,
937 scope,
938 excluded,
939 Redundancy::Standalone,
940 )
941}
942
943pub fn plan_with(
969 rendered: &RenderedTree,
970 observed: &ObservedFiles,
971 ledger: Option<&Ledger>,
972 scope: &Scope,
973 excluded: &[String],
974 redundancy: Redundancy,
975) -> Plan {
976 let unreadable_keys = unreadable_unit_keys(observed);
977 let core_layer = unit_key_layer(observed, "core");
978 let mode_layer = unit_key_layer(observed, "mode");
979 let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
980 let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
981 let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
982 let layers = KeyLayers {
983 core: &core_layer,
984 mode: &mode_layer,
985 };
986 let (delete, ledger_warnings, ledgered) = delete_set(
987 rendered,
988 observed,
989 ledger,
990 scope,
991 excluded,
992 &layers,
993 &unreadable_keys,
994 redundancy,
995 );
996 let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
998 let mut core_deleted: BTreeSet<String> = BTreeSet::new();
999 for path in &delete {
1000 let unit = match split_key(path.as_str()) {
1001 (KeyCollection::Mode, _) => unit_key(path, &mode_layer),
1002 (KeyCollection::Core, rest) => {
1003 core_deleted.insert(rest.to_string());
1004 match RelPath::new(rest) {
1005 Ok(rest) => unit_key(&rest, &core_layer),
1006 Err(_) => continue,
1007 }
1008 }
1009 };
1010 *deleted.entry(unit).or_default() += 1;
1011 }
1012
1013 let mut unreadable = Vec::with_capacity(unreadable_keys.len());
1016 for (key, info) in &unreadable_keys {
1017 let declared = desired.remove(key).is_some();
1018 let in_core = core.remove(key).is_some() || info.collections.contains("core");
1019 let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
1020 let mut live_in = Vec::new();
1021 if in_core {
1022 live_in.push("core".to_string());
1023 }
1024 if in_mode {
1025 live_in.push("mode".to_string());
1026 }
1027 unreadable.push(UnreadableUnit {
1028 key: key.clone(),
1029 live_in,
1030 declared,
1031 in_ledger: ledgered.contains(key),
1032 paths: info.paths.clone(),
1033 longest: info.longest,
1034 });
1035 }
1036
1037 let keys: BTreeSet<&String> = desired
1038 .keys()
1039 .chain(core.keys())
1040 .chain(mode.keys())
1041 .collect();
1042 let empty = BTreeMap::new();
1043
1044 let mut entries = Vec::with_capacity(keys.len());
1045 let mut tag_loss_warnings: Vec<String> = Vec::new();
1046 for key in keys {
1047 let wanted = desired.get(key);
1048 let core_copy = core.get(key);
1049 let mode_copy = mode.get(key);
1050 let effective = mode_copy.or(core_copy);
1051
1052 let mut live_in = Vec::new();
1053 if core_copy.is_some() {
1054 live_in.push("core".to_string());
1055 }
1056 if mode_copy.is_some() {
1057 live_in.push("mode".to_string());
1058 }
1059
1060 let routed = redundancy == Redundancy::Pair && is_pair_unsynced_tag_path(key);
1063 let basis = if routed { core_copy } else { effective };
1064 let deleted_here = deleted.get(key).copied().unwrap_or(0);
1065 let status = match (wanted, basis) {
1066 (Some(_), None) => PlanStatus::Added,
1067 (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
1068 (Some(_), Some(_)) => PlanStatus::Changed,
1069 (None, _) if deleted_here > 0 => PlanStatus::Removed,
1070 (None, _) => PlanStatus::Unmanaged,
1071 };
1072 let rendered_files = wanted.unwrap_or(&empty);
1073 let core_gone = core_copy.is_some_and(|c| c.keys().all(|path| core_deleted.contains(path)));
1075 let core_relation = match core_copy {
1076 None => CoreRelation::None,
1077 Some(_) if status == PlanStatus::Removed && !core_gone => CoreRelation::ShadowsRemoval,
1078 Some(c)
1082 if !routed
1083 && is_tag_folder(key)
1084 && (mode_copy.is_some() || wanted.is_some())
1085 && hides(c, rendered_files) =>
1086 {
1087 CoreRelation::Hidden
1088 }
1089 Some(c) if c == rendered_files => CoreRelation::Identical,
1090 Some(_) => CoreRelation::Differs,
1091 };
1092
1093 let files = if routed {
1094 routed_rows(wanted, core_copy, mode_copy)
1095 } else {
1096 let paths: BTreeSet<&String> = wanted
1097 .into_iter()
1098 .chain(effective)
1099 .flat_map(BTreeMap::keys)
1100 .collect();
1101 let mut files: Vec<PlanFile> = paths
1102 .into_iter()
1103 .map(|path| PlanFile {
1104 path: path.clone(),
1105 live_sha256: effective.and_then(|e| e.get(path)).cloned(),
1106 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
1107 })
1108 .collect();
1109 if !core_deleted.is_empty() {
1112 files.extend(
1113 core_copy
1114 .into_iter()
1115 .flatten()
1116 .filter(|(path, _)| core_deleted.contains(*path))
1117 .map(|(path, sha)| PlanFile {
1118 path: core_key(path),
1119 live_sha256: Some(sha.clone()),
1120 desired_sha256: None,
1121 }),
1122 );
1123 files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
1124 }
1125 files
1126 };
1127
1128 let parts: Vec<&str> = key.split('/').collect();
1129 let module = parts.first().copied().unwrap_or_default();
1130 let ty = parts.get(1).copied().unwrap_or_default();
1131 let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
1134 && is_tag_folder(key))
1135 .then(|| tag_data_dropped(key, effective, rendered_files))
1136 .flatten();
1137 let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
1138 tag_loss_warnings.extend(dropped);
1139 let lockout_risk = matches!(
1140 status,
1141 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1142 ) && module == "ignition"
1143 && matches!(ty, "identity-provider" | "security-zone");
1144 entries.push(PlanEntry {
1145 key: key.clone(),
1146 module: module.to_string(),
1147 ty: ty.to_string(),
1148 name: (parts.len() >= 3).then(|| parts[2..].join("/")),
1149 status,
1150 live_in,
1151 core: core_relation,
1152 destructive,
1153 destructive_reason: destructive
1154 .then(|| destructive_reason(module, ty, status).to_string()),
1155 lockout_risk,
1156 drift: None,
1157 files,
1158 diff: None,
1159 });
1160 }
1161
1162 let mut summary = PlanSummary::default();
1163 for entry in &entries {
1164 match entry.status {
1165 PlanStatus::Added => summary.added += 1,
1166 PlanStatus::Changed => summary.changed += 1,
1167 PlanStatus::Removed => summary.removed += 1,
1168 PlanStatus::Same => summary.same += 1,
1169 PlanStatus::Unmanaged => summary.unmanaged += 1,
1170 }
1171 }
1172 summary.unreadable = unreadable.len();
1173 summary.has_changes = summary.added + summary.changed + summary.removed > 0
1175 || unreadable.iter().any(|u| u.declared || u.in_ledger);
1176
1177 let mut warnings: Vec<String> = observed
1178 .local_units
1179 .iter()
1180 .map(|unit| {
1181 format!(
1182 "{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"
1183 )
1184 })
1185 .collect();
1186 warnings.extend(ledger_warnings);
1187 warnings.extend(tag_loss_warnings);
1188 for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
1189 warnings.push(format!(
1190 "{} 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",
1191 unit.key, unit.longest
1192 ));
1193 }
1194
1195 let provider_wipes = provider_wipes(rendered, &delete);
1196 let plan_sha256 = sha256_hex(&canonical_text(
1197 &rendered.render_sha256(),
1198 &entries,
1199 &delete,
1200 &unreadable,
1201 &provider_wipes,
1202 ));
1203 Plan {
1204 entries,
1205 summary,
1206 delete_set: DeleteSet { files: delete },
1207 plan_sha256,
1208 warnings,
1209 unreadable,
1210 provider_wipes,
1211 }
1212}
1213
1214fn tag_provider_of(path: &str) -> Option<(&str, &str)> {
1218 let mut parts = path.split('/');
1219 let (Some("ignition"), Some(ty), Some(provider), Some(_)) =
1220 (parts.next(), parts.next(), parts.next(), parts.next())
1221 else {
1222 return None;
1223 };
1224 PAIR_UNSYNCED_TAG_TYPES
1225 .contains(&ty)
1226 .then_some((provider, ty))
1227}
1228
1229pub fn provider_wipes(rendered: &RenderedTree, delete: &[RelPath]) -> Vec<String> {
1243 let kept: BTreeSet<(&str, &str)> = rendered
1244 .files
1245 .keys()
1246 .filter_map(|path| tag_provider_of(path.as_str()))
1247 .collect();
1248 let wiped: BTreeSet<&str> = delete
1249 .iter()
1250 .filter_map(|path| tag_provider_of(split_key(path.as_str()).1))
1251 .filter(|pair| !kept.contains(pair))
1252 .map(|(provider, _)| provider)
1253 .collect();
1254 wiped.into_iter().map(str::to_string).collect()
1255}
1256
1257#[derive(Debug, Clone, PartialEq, Eq)]
1267pub struct BaseTree {
1268 pub dir: String,
1270 pub render_sha256: String,
1272 pub files: BTreeMap<RelPath, String>,
1274}
1275
1276fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
1278 let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
1279 CoreError::InvalidInput {
1280 reason: format!("--base {}: {problem}{at}", dir.display()),
1281 }
1282}
1283
1284fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
1286 a.symmetric_difference(b)
1287 .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
1288}
1289
1290impl BaseTree {
1291 pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
1303 let meta = match std::fs::symlink_metadata(dir) {
1304 Ok(meta) => meta,
1305 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1306 return Err(base_refusal(dir, "does not exist", None));
1307 }
1308 Err(e) => {
1309 return Err(base_refusal(
1310 dir,
1311 format!("cannot inspect it ({})", e.kind()),
1312 None,
1313 ));
1314 }
1315 };
1316 if meta.file_type().is_symlink() {
1317 return Err(base_refusal(
1318 dir,
1319 "is a symlink; symlinks are refused",
1320 None,
1321 ));
1322 }
1323 if !meta.file_type().is_dir() {
1324 return Err(base_refusal(dir, "is not a directory", None));
1325 }
1326
1327 let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
1328 let mut dirs: BTreeSet<String> = BTreeSet::new();
1329 walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
1330 .map_err(|why| base_refusal(dir, why, None))?;
1331
1332 let Some(marker_path) = walked.remove(RENDER_MARKER) else {
1333 return Err(base_refusal(
1334 dir,
1335 format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
1336 Some(RENDER_MARKER),
1337 ));
1338 };
1339 let marker = read_marker(dir, &marker_path)?;
1340 if marker.schema_version != 1 {
1341 return Err(base_refusal(
1342 dir,
1343 format!(
1344 "{RENDER_MARKER} has schema_version {}, expected 1",
1345 marker.schema_version
1346 ),
1347 None,
1348 ));
1349 }
1350 for (what, found, expected) in [
1351 ("gateway", &marker.gateway, gateway),
1352 ("env", &marker.env, env),
1353 ("mode", &marker.mode, mode),
1354 ] {
1355 if found != expected {
1356 return Err(base_refusal(
1357 dir,
1358 format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
1359 None,
1360 ));
1361 }
1362 }
1363
1364 let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
1365 for entry in &marker.files {
1366 if listed.insert(entry.path.as_str(), entry).is_some() {
1367 return Err(base_refusal(
1368 dir,
1369 format!("{RENDER_MARKER} lists a path twice"),
1370 Some(&entry.path),
1371 ));
1372 }
1373 }
1374 let found: BTreeSet<String> = walked.keys().cloned().collect();
1375 let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
1376 if let Some(path) = first_difference(&found, &named) {
1377 let problem = if path.ends_with(TMP_SUFFIX) {
1378 "holds a leftover temporary file from an interrupted render".to_string()
1379 } else if found.contains(path) {
1380 format!("holds a file not listed in {RENDER_MARKER}")
1381 } else {
1382 format!("is missing a file listed in {RENDER_MARKER}")
1383 };
1384 return Err(base_refusal(dir, problem, Some(path)));
1385 }
1386 for d in &dirs {
1387 let prefix = format!("{d}/");
1388 if !named.iter().any(|p| p.starts_with(&prefix)) {
1389 return Err(base_refusal(
1390 dir,
1391 "holds a directory with no rendered file",
1392 Some(d),
1393 ));
1394 }
1395 }
1396
1397 let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
1398 let mut total: u64 = 0;
1399 for (rel, path) in &walked {
1400 let entry = listed[rel.as_str()];
1401 let key = RelPath::new(rel).map_err(|why| {
1402 base_refusal(
1403 dir,
1404 format!("{RENDER_MARKER} lists an unusable path ({why})"),
1405 Some(rel),
1406 )
1407 })?;
1408 let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
1409 if sha256 != entry.sha256 || len != entry.bytes {
1410 return Err(base_refusal(
1411 dir,
1412 format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
1413 Some(rel),
1414 ));
1415 }
1416 files.insert(key, sha256);
1417 }
1418
1419 let render_sha256 = listing_digest(&files);
1420 if render_sha256 != marker.render_sha256 {
1421 return Err(base_refusal(
1422 dir,
1423 format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
1424 None,
1425 ));
1426 }
1427 Ok(BaseTree {
1428 dir: dir.to_string_lossy().into_owned(),
1429 render_sha256,
1430 files,
1431 })
1432 }
1433}
1434
1435fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
1438 let before = std::fs::symlink_metadata(path).map_err(|e| {
1439 base_refusal(
1440 dir,
1441 format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
1442 None,
1443 )
1444 })?;
1445 let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
1446 if held.len() > READ_MAX_FILE_BYTES {
1447 return Err(base_refusal(
1448 dir,
1449 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1450 None,
1451 ));
1452 }
1453 let mut bytes = Vec::new();
1454 file.take(READ_MAX_FILE_BYTES + 1)
1455 .read_to_end(&mut bytes)
1456 .map_err(|e| {
1457 base_refusal(
1458 dir,
1459 format!("cannot read {RENDER_MARKER} ({})", e.kind()),
1460 None,
1461 )
1462 })?;
1463 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
1464 return Err(base_refusal(
1465 dir,
1466 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1467 None,
1468 ));
1469 }
1470 serde_json::from_slice(&bytes).map_err(|_| {
1471 base_refusal(
1472 dir,
1473 format!("{RENDER_MARKER} is not a valid render marker"),
1474 None,
1475 )
1476 })
1477}
1478
1479fn open_base_file(
1480 dir: &Path,
1481 rel: &str,
1482 before: &std::fs::Metadata,
1483) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1484 open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1485 OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1486 OpenError::Io(e) => {
1487 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1488 }
1489 })
1490}
1491
1492fn hash_base_file(
1495 dir: &Path,
1496 rel: &str,
1497 path: &Path,
1498 total: &mut u64,
1499) -> Result<(String, u64), CoreError> {
1500 let before = std::fs::symlink_metadata(path).map_err(|e| {
1501 base_refusal(
1502 dir,
1503 format!("cannot inspect a file ({})", e.kind()),
1504 Some(rel),
1505 )
1506 })?;
1507 if !before.file_type().is_file() {
1508 return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1509 }
1510 let (file, held) = open_base_file(dir, rel, &before)?;
1511 if held.len() > READ_MAX_FILE_BYTES {
1512 return Err(base_refusal(
1513 dir,
1514 format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1515 Some(rel),
1516 ));
1517 }
1518 *total += held.len();
1519 if *total > READ_MAX_TOTAL_BYTES {
1520 return Err(base_refusal(
1521 dir,
1522 format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1523 Some(rel),
1524 ));
1525 }
1526 let mut hasher = Sha256::new();
1527 let mut buf = [0u8; 64 * 1024];
1528 let mut len: u64 = 0;
1529 let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1530 loop {
1531 let n = reader.read(&mut buf).map_err(|e| {
1532 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1533 })?;
1534 if n == 0 {
1535 break;
1536 }
1537 len += n as u64;
1538 if len > READ_MAX_FILE_BYTES {
1539 return Err(base_refusal(
1540 dir,
1541 format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1542 Some(rel),
1543 ));
1544 }
1545 hasher.update(&buf[..n]);
1546 }
1547 Ok((format!("{:x}", hasher.finalize()), len))
1548}
1549
1550fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1553 let mut hasher = Sha256::new();
1554 for (path, sha256) in files {
1555 hasher.update(path.as_str().as_bytes());
1556 hasher.update([0u8]);
1557 hasher.update(sha256.as_bytes());
1558 hasher.update(b"\n");
1559 }
1560 format!("{:x}", hasher.finalize())
1561}
1562
1563pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1582 let deleted_bare: BTreeSet<&str> = plan
1583 .delete_set
1584 .files
1585 .iter()
1586 .map(RelPath::as_str)
1587 .filter(|path| split_key(path).0 == KeyCollection::Mode)
1588 .collect();
1589 let mut drifted = 0;
1590 for entry in &mut plan.entries {
1591 let core_absent = !entry.live_in.iter().any(|c| c == "core");
1592 let mode_shas: BTreeMap<&str, &String> = entry
1593 .files
1594 .iter()
1595 .filter(|file| split_key(&file.path).0 == KeyCollection::Mode)
1596 .filter_map(|file| Some((file.path.as_str(), file.live_sha256.as_ref()?)))
1597 .collect();
1598 let drift = entry.files.iter().any(|file| {
1599 let (collection, path) = split_key(&file.path);
1602 let base_sha = RelPath::new(path)
1603 .ok()
1604 .and_then(|path| base.files.get(&path));
1605 let migrated = collection == KeyCollection::Core
1606 && file.live_sha256.is_none()
1607 && core_absent
1608 && deleted_bare.contains(path);
1609 let live = if migrated {
1610 mode_shas.get(path).copied()
1611 } else {
1612 file.live_sha256.as_ref()
1613 };
1614 live != base_sha && live != file.desired_sha256.as_ref()
1615 });
1616 entry.drift = Some(drift);
1617 drifted += usize::from(drift);
1618 }
1619 plan.summary.drifted = Some(drifted);
1620}
1621
1622pub const DIFF_MAX_BYTES: usize = 65_536;
1625
1626pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1628
1629pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1661 for entry in &mut plan.entries {
1662 if !matches!(
1663 entry.status,
1664 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1665 ) {
1666 continue;
1667 }
1668 let entry_layer = if entry.live_in.iter().any(|l| l == "mode") {
1669 &observed.mode
1670 } else {
1671 &observed.core
1672 };
1673 let mut text = String::new();
1674 for file in &entry.files {
1675 if file.live_sha256 == file.desired_sha256 {
1676 continue;
1677 }
1678 let (collection, rest) = split_key(&file.path);
1681 let layer = match collection {
1682 KeyCollection::Core => &observed.core,
1683 KeyCollection::Mode => entry_layer,
1684 };
1685 let Ok(path) = RelPath::new(rest) else {
1686 continue;
1687 };
1688 let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1689 let desired = file
1694 .desired_sha256
1695 .is_some()
1696 .then(|| rendered.files.get(&path).map(Vec::as_slice))
1697 .flatten();
1698 text.push_str(&file_diff(
1699 &path,
1700 live,
1701 desired,
1702 file.live_sha256.as_deref(),
1703 file.desired_sha256.as_deref(),
1704 ));
1705 }
1706 entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1707 }
1708}
1709
1710fn file_diff(
1712 path: &RelPath,
1713 live: Option<&[u8]>,
1714 desired: Option<&[u8]>,
1715 live_sha: Option<&str>,
1716 desired_sha: Option<&str>,
1717) -> String {
1718 let shas = format!(
1719 "live {}, desired {}",
1720 live_sha.unwrap_or("-"),
1721 desired_sha.unwrap_or("-")
1722 );
1723 let hash_only = || format!("{path}: differs ({shas})\n");
1724 if !path.as_str().ends_with(".json") {
1725 return hash_only();
1726 }
1727 let mut locations = 0;
1728 for bytes in [live, desired].into_iter().flatten() {
1729 let Ok((_, node)) = classify(bytes) else {
1730 return hash_only();
1731 };
1732 locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1733 }
1734 if locations > 0 {
1735 return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1736 }
1737 let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1739 let new = desired
1740 .and_then(|b| std::str::from_utf8(b).ok())
1741 .unwrap_or("");
1742 let diff = TextDiff::from_lines(old, new);
1743 let mut unified = diff.unified_diff();
1744 unified
1745 .context_radius(3)
1746 .header(&format!("live/{path}"), &format!("desired/{path}"));
1747 let mut text = unified.to_string();
1748 if !text.ends_with('\n') {
1749 text.push('\n');
1750 }
1751 text
1752}
1753
1754fn secret_locations(path: &RelPath, node: &Node) -> usize {
1757 let origin = path.as_str();
1758 let parts: Vec<&str> = path.components().collect();
1759 let tag_file =
1760 parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1761 let scan = if tag_file {
1762 scan_embedded(origin, node)
1763 } else {
1764 let key = resource_key(path);
1765 let skip = if key.name.is_some() { 3 } else { 2 };
1766 let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1767 scan_secrets(origin, &key, &rest.join("/"), node)
1768 };
1769 scan.refused.len() + scan.warnings.len()
1770}
1771
1772const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1774 "password",
1775 "passwd",
1776 "secret",
1777 "token",
1778 "apikey",
1779 "api_key",
1780 "credential",
1781 "privatekey",
1782 "private_key",
1783];
1784
1785fn sensitive_key_hits(node: &Node) -> usize {
1796 sensitive_walk(node, false)
1797}
1798
1799fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1800 match node {
1801 Node::Obj(members) => members
1802 .iter()
1803 .map(|(key, value)| {
1804 let lowered = key.to_ascii_lowercase();
1805 let secret = under_secret_key
1806 || SENSITIVE_KEY_FRAGMENTS
1807 .iter()
1808 .any(|fragment| lowered.contains(fragment));
1809 sensitive_walk(value, secret)
1810 })
1811 .sum(),
1812 Node::Arr(items) => items
1813 .iter()
1814 .map(|item| sensitive_walk(item, under_secret_key))
1815 .sum(),
1816 Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1817 Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1818 }
1819}
1820
1821fn cap_diff(mut text: String) -> String {
1824 if text.len() > DIFF_MAX_BYTES {
1825 let mut cut = DIFF_MAX_BYTES;
1826 while !text.is_char_boundary(cut) {
1827 cut -= 1;
1828 }
1829 text.truncate(cut);
1830 if !text.ends_with('\n') {
1831 text.push('\n');
1832 }
1833 text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1834 } else {
1835 while text.ends_with('\n') {
1836 text.pop();
1837 }
1838 }
1839 text
1840}
1841
1842fn canonical_text(
1844 render_sha256: &str,
1845 entries: &[PlanEntry],
1846 delete_set: &[RelPath],
1847 unreadable: &[UnreadableUnit],
1848 provider_wipes: &[String],
1849) -> Vec<u8> {
1850 let mut text: Vec<u8> = Vec::new();
1851 text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1852 text.push(b'\n');
1853 text.extend_from_slice(b"render_sha256\0");
1854 text.extend_from_slice(render_sha256.as_bytes());
1855 text.push(b'\n');
1856 for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1857 text.extend_from_slice(b"E\0");
1858 text.extend_from_slice(entry.key.as_bytes());
1859 text.push(0);
1860 text.extend_from_slice(entry.status.word().as_bytes());
1861 text.push(0);
1862 text.extend_from_slice(entry.core.word().as_bytes());
1863 text.push(0);
1864 text.extend_from_slice(entry.live_in.join(",").as_bytes());
1865 text.push(b'\n');
1866 for file in &entry.files {
1867 text.extend_from_slice(b"F\0");
1868 text.extend_from_slice(file.path.as_bytes());
1869 text.push(0);
1870 text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1871 text.push(0);
1872 text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1873 text.push(b'\n');
1874 }
1875 }
1876 for path in delete_set {
1877 text.extend_from_slice(b"D\0");
1878 text.extend_from_slice(path.as_str().as_bytes());
1879 text.push(b'\n');
1880 }
1881 for unit in unreadable {
1882 text.extend_from_slice(b"U\0");
1883 text.extend_from_slice(unit.key.as_bytes());
1884 text.push(0);
1885 text.extend_from_slice(unit.live_in.join(",").as_bytes());
1886 text.push(0);
1887 text.push(if unit.declared { b'1' } else { b'0' });
1888 text.push(0);
1889 text.push(if unit.in_ledger { b'1' } else { b'0' });
1890 text.push(b'\n');
1891 }
1892 for provider in provider_wipes {
1893 text.extend_from_slice(b"W\0");
1894 text.extend_from_slice(provider.as_bytes());
1895 text.push(b'\n');
1896 }
1897 text
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902 use super::*;
1903
1904 fn rp(s: &str) -> RelPath {
1905 RelPath::new(s).unwrap()
1906 }
1907
1908 fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1909 paths.iter().map(|p| (rp(p), ())).collect()
1910 }
1911
1912 #[test]
1913 fn resource_files_group_under_their_resource_directory() {
1914 let l = layer(&[
1915 "ignition/database-translator/POSTGRES/config.json",
1916 "ignition/database-translator/POSTGRES/resource.json",
1917 ]);
1918 let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1919 assert_eq!(key, "ignition/database-translator/POSTGRES");
1920 }
1921
1922 #[test]
1923 fn singletons_group_per_type() {
1924 let l = layer(&[
1925 "ignition/general-alarm-settings/config.json",
1926 "ignition/general-alarm-settings/resource.json",
1927 ]);
1928 let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1929 assert_eq!(key, "ignition/general-alarm-settings");
1930 }
1931
1932 #[test]
1933 fn nearest_marker_wins_for_tag_folders() {
1934 let l = layer(&[
1935 "ignition/tag-definition/P/unary-resource.json",
1936 "ignition/tag-definition/P/A/unary-resource.json",
1937 "ignition/tag-definition/P/A/tags.json",
1938 "ignition/tag-definition/P/B/tags.json",
1939 ]);
1940 assert_eq!(
1941 unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1942 "ignition/tag-definition/P/A"
1943 );
1944 assert_eq!(
1946 unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1947 "ignition/tag-definition/P"
1948 );
1949 }
1950
1951 #[test]
1955 fn cd5_unit_keys() {
1956 let l = layer(&[
1957 "ignition/tag-definition/P/unary-resource.json",
1958 "ignition/tag-definition/P/tags.json",
1959 "ignition/tag-definition/P/udts.json",
1960 "ignition/tag-type-definition/P/unary-resource.json",
1961 "ignition/tag-type-definition/P/udts.json",
1962 "ignition/tag-type-definition/P/A/unary-resource.json",
1963 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1964 "ignition/tag-type-definition/P/A/B/udts.json",
1965 ]);
1966 for (path, key) in [
1967 (
1968 "ignition/tag-definition/P/tags.json",
1969 "ignition/tag-definition/P",
1970 ),
1971 (
1972 "ignition/tag-definition/P/udts.json",
1973 "ignition/tag-definition/P",
1974 ),
1975 (
1976 "ignition/tag-type-definition/P/udts.json",
1977 "ignition/tag-type-definition/P",
1978 ),
1979 (
1980 "ignition/tag-type-definition/P/A/unary-resource.json",
1981 "ignition/tag-type-definition/P/A",
1982 ),
1983 (
1984 "ignition/tag-type-definition/P/A/B/udts.json",
1985 "ignition/tag-type-definition/P/A/B",
1986 ),
1987 (
1988 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1989 "ignition/tag-type-definition/P/A/B",
1990 ),
1991 ] {
1992 assert_eq!(unit_key(&rp(path), &l), key, "{path}");
1993 }
1994 let l = layer(&[
1996 "ignition/tag-type-definition/P/unary-resource.json",
1997 "ignition/tag-type-definition/P/A/unary-resource.json",
1998 "ignition/tag-type-definition/P/A/B/udts.json",
1999 ]);
2000 assert_eq!(
2001 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
2002 "ignition/tag-type-definition/P/A"
2003 );
2004 let l = layer(&[
2005 "ignition/tag-type-definition/P/unary-resource.json",
2006 "ignition/tag-type-definition/P/A/B/udts.json",
2007 ]);
2008 assert_eq!(
2009 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
2010 "ignition/tag-type-definition/P"
2011 );
2012 }
2013
2014 #[test]
2015 fn no_marker_falls_back_to_the_parent() {
2016 let l = layer(&["ignition/database-translator/X/config.json"]);
2017 assert_eq!(
2018 unit_key(&rp("ignition/database-translator/X/config.json"), &l),
2019 "ignition/database-translator/X"
2020 );
2021 }
2022
2023 #[test]
2024 fn empty_inputs_plan_empty() {
2025 let scope = Scope {
2026 mode: "dev".to_string(),
2027 types: Vec::new(),
2028 tag_providers: Vec::new(),
2029 };
2030 let p = plan(
2031 &RenderedTree::default(),
2032 &ObservedFiles::default(),
2033 None,
2034 &scope,
2035 &["System".to_string()],
2036 );
2037 assert!(p.entries.is_empty());
2038 assert!(!p.summary.has_changes);
2039 }
2040}