1use std::collections::{BTreeMap, BTreeSet};
139use std::io::Read;
140use std::path::Path;
141
142use serde::Serialize;
143use sha2::{Digest, Sha256};
144
145use similar::TextDiff;
146
147use super::exclude;
148use super::layout::{RelPath, TAG_TYPES, resource_key};
149use super::ledger::Ledger;
150use super::lint::{scan_embedded, scan_secrets};
151use super::node::{Node, classify};
152use super::render::member_named_tag_folder;
153use super::rules::GATEWAY_OWNED_FILES;
154use super::transport::{
155 DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
156 READ_MAX_TOTAL_BYTES, Scope,
157};
158use super::tree::{
159 MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
160 open_regular_file, sha256_hex, walk_capped,
161};
162use crate::error::{CoreError, DeployFinding};
163
164pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
169#[serde(rename_all = "lowercase")]
170pub enum PlanStatus {
171 Added,
173 Changed,
175 Removed,
177 Same,
179 Unmanaged,
181}
182
183impl PlanStatus {
184 pub fn word(self) -> &'static str {
186 match self {
187 PlanStatus::Added => "added",
188 PlanStatus::Changed => "changed",
189 PlanStatus::Removed => "removed",
190 PlanStatus::Same => "same",
191 PlanStatus::Unmanaged => "unmanaged",
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
198#[serde(rename_all = "snake_case")]
199pub enum CoreRelation {
200 None,
202 Identical,
204 Differs,
206 Hidden,
209 ShadowsRemoval,
212}
213
214impl CoreRelation {
215 pub fn word(self) -> &'static str {
217 match self {
218 CoreRelation::None => "none",
219 CoreRelation::Identical => "identical",
220 CoreRelation::Differs => "differs",
221 CoreRelation::Hidden => "hidden",
222 CoreRelation::ShadowsRemoval => "shadows_removal",
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
229pub struct PlanFile {
230 pub path: String,
232 pub live_sha256: Option<String>,
234 pub desired_sha256: Option<String>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
240pub struct PlanEntry {
241 pub key: String,
243 pub module: String,
245 #[serde(rename = "type")]
247 pub ty: String,
248 pub name: Option<String>,
250 pub status: PlanStatus,
252 pub live_in: Vec<String>,
254 pub core: CoreRelation,
256 pub destructive: bool,
259 pub destructive_reason: Option<String>,
261 pub lockout_risk: bool,
264 pub drift: Option<bool>,
268 pub files: Vec<PlanFile>,
270 pub diff: Option<String>,
274}
275
276#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
278pub struct PlanSummary {
279 pub added: usize,
281 pub changed: usize,
283 pub removed: usize,
285 pub same: usize,
287 pub unmanaged: usize,
289 pub unreadable: usize,
292 pub has_changes: bool,
300 pub drifted: Option<usize>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct Plan {
307 pub entries: Vec<PlanEntry>,
309 pub summary: PlanSummary,
311 pub delete_set: DeleteSet,
313 pub plan_sha256: String,
315 pub warnings: Vec<String>,
317 pub unreadable: Vec<UnreadableUnit>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
328pub struct UnreadableUnit {
329 pub key: String,
331 pub live_in: Vec<String>,
334 pub declared: bool,
336 pub in_ledger: bool,
338 pub paths: Vec<String>,
340 pub longest: usize,
342}
343
344#[derive(Debug, Clone, Default, PartialEq, Eq)]
346pub struct UnreadableKey {
347 pub collections: BTreeSet<String>,
349 pub paths: Vec<String>,
351 pub longest: usize,
353}
354
355pub fn unit_key_layer(observed: &ObservedFiles, collection: &str) -> BTreeMap<RelPath, ()> {
361 let files = if collection == "core" {
362 &observed.core
363 } else {
364 &observed.mode
365 };
366 files
367 .keys()
368 .cloned()
369 .chain(
370 observed
371 .unreadable
372 .iter()
373 .filter(|u| u.collection == collection)
374 .map(|u| u.path.clone()),
375 )
376 .map(|p| (p, ()))
377 .collect()
378}
379
380pub fn unreadable_unit_keys(observed: &ObservedFiles) -> BTreeMap<String, UnreadableKey> {
384 let mut out: BTreeMap<String, UnreadableKey> = BTreeMap::new();
385 if observed.unreadable.is_empty() {
386 return out;
387 }
388 let core_layer = unit_key_layer(observed, "core");
389 let mode_layer = unit_key_layer(observed, "mode");
390 for u in &observed.unreadable {
391 let layer = match u.collection.as_str() {
392 "core" => &core_layer,
393 "mode" => &mode_layer,
394 _ => continue,
395 };
396 let info = out.entry(unit_key(&u.path, layer)).or_default();
397 info.collections.insert(u.collection.clone());
398 info.paths.push(u.path.as_str().to_string());
399 info.longest = info.longest.max(u.length);
400 }
401 for info in out.values_mut() {
402 info.paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
403 info.paths.dedup();
404 }
405 out
406}
407
408pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
413 let parts: Vec<&str> = path.components().collect();
414 if parts.len() < 2 {
415 return path.as_str().to_string();
416 }
417 let parent_len = parts.len() - 1;
418 for end in (2..=parent_len).rev() {
419 let dir = parts[..end].join("/");
420 for marker in ["resource.json", "unary-resource.json"] {
421 if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
422 && layer.contains_key(&candidate)
423 {
424 return dir;
425 }
426 }
427 }
428 parts[..parent_len].join("/")
429}
430
431type Units = BTreeMap<String, BTreeMap<String, String>>;
433
434fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
435 let mut units = Units::new();
436 for (path, value) in layer {
437 units
438 .entry(unit_key(path, layer))
439 .or_default()
440 .insert(path.as_str().to_string(), digest(value));
441 }
442 units
443}
444
445fn group_keyed<'a, V: 'a>(
447 layer: &'a BTreeMap<RelPath, V>,
448 keys: &BTreeMap<RelPath, ()>,
449 digest: impl Fn(&'a V) -> String,
450) -> Units {
451 let mut units = Units::new();
452 for (path, value) in layer {
453 units
454 .entry(unit_key(path, keys))
455 .or_default()
456 .insert(path.as_str().to_string(), digest(value));
457 }
458 units
459}
460
461fn blob_digest(blob: &FileBlob) -> String {
462 blob.sha256.clone()
463}
464
465fn is_tag_folder(key: &str) -> bool {
469 let mut parts = key.split('/');
470 parts.next() == Some("ignition")
471 && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
472 && parts.next().is_some()
473}
474
475pub fn pair_tag_keys(entries: &[PlanEntry], delete: &DeleteSet) -> Vec<String> {
490 let deleted: BTreeSet<&str> = delete.files.iter().map(RelPath::as_str).collect();
491 let mut keys: Vec<String> = entries
492 .iter()
493 .filter(|e| {
494 e.module == "ignition"
495 && super::layout::PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str())
496 })
497 .filter(|e| match e.status {
498 PlanStatus::Added | PlanStatus::Changed => true,
499 PlanStatus::Same => e.live_in.iter().any(|c| c == "mode"),
500 PlanStatus::Removed => {
501 e.live_in.iter().any(|c| c == "mode")
502 && e.files
503 .iter()
504 .any(|f| f.live_sha256.is_some() && !deleted.contains(f.path.as_str()))
505 }
506 PlanStatus::Unmanaged => false,
507 })
508 .map(|e| e.key.clone())
509 .collect();
510 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
511 keys.dedup();
512 keys
513}
514
515fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
517 core.keys().any(|path| !rendered.contains_key(path))
518}
519
520const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
522
523fn tag_data_dropped(
537 key: &str,
538 effective: Option<&BTreeMap<String, String>>,
539 rendered: &BTreeMap<String, String>,
540) -> Option<String> {
541 let live: Vec<&str> = effective
542 .into_iter()
543 .flat_map(BTreeMap::keys)
544 .filter(|path| {
545 let name = path.rsplit('/').next().unwrap_or_default();
546 TAG_MEMBER_FILES
547 .iter()
548 .any(|m| m.eq_ignore_ascii_case(name))
549 && !rendered.contains_key(*path)
550 })
551 .map(String::as_str)
552 .collect();
553 if live.is_empty() {
554 return None;
555 }
556 Some(format!(
557 "{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",
558 if live.len() == 1 {
559 format!("{} is", live[0])
560 } else {
561 format!("{} are", live.join(", "))
562 }
563 ))
564}
565
566fn in_scope(scope: &Scope, path: &RelPath) -> bool {
570 let parts: Vec<&str> = path.components().collect();
571 if parts.len() < 3 {
572 return false;
573 }
574 let (module, ty) = (parts[0], parts[1]);
575 if scope.types.iter().any(|(m, t)| m == module && t == ty) {
576 return true;
577 }
578 module == "ignition"
579 && TAG_TYPES.contains(&ty)
580 && scope.tag_providers.iter().any(|p| p == parts[2])
581}
582
583struct KeyLayers<'a> {
586 core: &'a BTreeMap<RelPath, ()>,
587 mode: &'a BTreeMap<RelPath, ()>,
588}
589
590fn delete_set(
597 rendered: &RenderedTree,
598 observed: &ObservedFiles,
599 ledger: Option<&Ledger>,
600 scope: &Scope,
601 excluded: &[String],
602 layers: &KeyLayers<'_>,
603 unreadable: &BTreeMap<String, UnreadableKey>,
604) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
605 let Some(ledger) = ledger else {
606 return (Vec::new(), Vec::new(), BTreeSet::new());
607 };
608 let rendered_folded: BTreeSet<String> = rendered
609 .files
610 .keys()
611 .map(|p| p.as_str().to_ascii_lowercase())
612 .collect();
613 let mut listed: Vec<&String> = ledger.files.iter().collect();
614 listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
615 listed.dedup();
616
617 let mut delete = Vec::new();
618 let mut warnings = Vec::new();
619 let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
620 let mut ledgered: BTreeSet<String> = BTreeSet::new();
621 for raw in listed {
622 let Ok(path) = RelPath::new(raw) else {
623 continue;
624 };
625 if path
626 .components()
627 .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
628 {
629 continue;
630 }
631 if let Some(provider) = exclude::excluded_provider(&path, excluded) {
634 let entry = excluded
637 .iter()
638 .find(|e| e.eq_ignore_ascii_case(provider))
639 .map_or(provider, String::as_str);
640 *excluded_files.entry(entry.to_string()).or_default() += 1;
641 continue;
642 }
643 if !in_scope(scope, &path) {
644 warnings.push(format!(
645 "ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
646 ));
647 continue;
648 }
649 if !unreadable.is_empty() {
652 let hit = [unit_key(&path, layers.mode), unit_key(&path, layers.core)]
653 .into_iter()
654 .find(|key| unreadable.contains_key(key));
655 if let Some(key) = hit {
656 ledgered.insert(key);
657 continue;
658 }
659 }
660 if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
661 continue;
662 }
663 if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
664 warnings.push(format!(
665 "ledger file {path} differs from a rendered file only by case; it is not deleted because a case-insensitive host would delete the rendered file"
666 ));
667 continue;
668 }
669 delete.push(path);
670 }
671 for (provider, count) in excluded_files {
673 warnings.push(format!(
674 "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"
675 ));
676 }
677 (delete, warnings, ledgered)
678}
679
680fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
682 match (module, ty) {
683 ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
684 "tag_data_loss"
685 }
686 ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
687 "connection_removed"
688 }
689 ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
690 _ if status == PlanStatus::Removed => "resource_removed",
691 _ => "files_removed",
692 }
693}
694
695pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
709 let live = observed
710 .core
711 .keys()
712 .map(|path| ("core", path))
713 .chain(observed.mode.keys().map(|path| (mode, path)))
714 .chain(observed.unreadable.iter().map(|u| {
715 let collection = if u.collection == "core" { "core" } else { mode };
716 (collection, &u.path)
717 }));
718 let folders: BTreeSet<(&str, String)> = live
719 .filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
720 .collect();
721 folders
722 .into_iter()
723 .map(|(collection, dir)| DeployFinding {
724 file: format!("config/resources/{collection}/{dir}/"),
725 line: None,
726 column: None,
727 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(),
728 })
729 .collect()
730}
731
732pub fn plan(
738 rendered: &RenderedTree,
739 observed: &ObservedFiles,
740 ledger: Option<&Ledger>,
741 scope: &Scope,
742 excluded: &[String],
743) -> Plan {
744 let unreadable_keys = unreadable_unit_keys(observed);
745 let core_layer = unit_key_layer(observed, "core");
746 let mode_layer = unit_key_layer(observed, "mode");
747 let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
748 let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
749 let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
750 let layers = KeyLayers {
751 core: &core_layer,
752 mode: &mode_layer,
753 };
754 let (delete, ledger_warnings, ledgered) = delete_set(
755 rendered,
756 observed,
757 ledger,
758 scope,
759 excluded,
760 &layers,
761 &unreadable_keys,
762 );
763 let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
764 for path in &delete {
765 *deleted.entry(unit_key(path, &mode_layer)).or_default() += 1;
766 }
767
768 let mut unreadable = Vec::with_capacity(unreadable_keys.len());
771 for (key, info) in &unreadable_keys {
772 let declared = desired.remove(key).is_some();
773 let in_core = core.remove(key).is_some() || info.collections.contains("core");
774 let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
775 let mut live_in = Vec::new();
776 if in_core {
777 live_in.push("core".to_string());
778 }
779 if in_mode {
780 live_in.push("mode".to_string());
781 }
782 unreadable.push(UnreadableUnit {
783 key: key.clone(),
784 live_in,
785 declared,
786 in_ledger: ledgered.contains(key),
787 paths: info.paths.clone(),
788 longest: info.longest,
789 });
790 }
791
792 let keys: BTreeSet<&String> = desired
793 .keys()
794 .chain(core.keys())
795 .chain(mode.keys())
796 .collect();
797 let empty = BTreeMap::new();
798
799 let mut entries = Vec::with_capacity(keys.len());
800 let mut tag_loss_warnings: Vec<String> = Vec::new();
801 for key in keys {
802 let wanted = desired.get(key);
803 let core_copy = core.get(key);
804 let mode_copy = mode.get(key);
805 let effective = mode_copy.or(core_copy);
806
807 let mut live_in = Vec::new();
808 if core_copy.is_some() {
809 live_in.push("core".to_string());
810 }
811 if mode_copy.is_some() {
812 live_in.push("mode".to_string());
813 }
814
815 let deleted_here = deleted.get(key).copied().unwrap_or(0);
816 let status = match (wanted, effective) {
817 (Some(_), None) => PlanStatus::Added,
818 (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
819 (Some(_), Some(_)) => PlanStatus::Changed,
820 (None, _) if deleted_here > 0 => PlanStatus::Removed,
821 (None, _) => PlanStatus::Unmanaged,
822 };
823 let rendered_files = wanted.unwrap_or(&empty);
824 let core_relation = match core_copy {
825 None => CoreRelation::None,
826 Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
827 Some(c)
830 if is_tag_folder(key)
831 && (mode_copy.is_some() || wanted.is_some())
832 && hides(c, rendered_files) =>
833 {
834 CoreRelation::Hidden
835 }
836 Some(c) if c == rendered_files => CoreRelation::Identical,
837 Some(_) => CoreRelation::Differs,
838 };
839
840 let paths: BTreeSet<&String> = wanted
841 .into_iter()
842 .chain(effective)
843 .flat_map(BTreeMap::keys)
844 .collect();
845 let files = paths
846 .into_iter()
847 .map(|path| PlanFile {
848 path: path.clone(),
849 live_sha256: effective.and_then(|e| e.get(path)).cloned(),
850 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
851 })
852 .collect();
853
854 let parts: Vec<&str> = key.split('/').collect();
855 let module = parts.first().copied().unwrap_or_default();
856 let ty = parts.get(1).copied().unwrap_or_default();
857 let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
860 && is_tag_folder(key))
861 .then(|| tag_data_dropped(key, effective, rendered_files))
862 .flatten();
863 let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
864 tag_loss_warnings.extend(dropped);
865 let lockout_risk = matches!(
866 status,
867 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
868 ) && module == "ignition"
869 && matches!(ty, "identity-provider" | "security-zone");
870 entries.push(PlanEntry {
871 key: key.clone(),
872 module: module.to_string(),
873 ty: ty.to_string(),
874 name: (parts.len() >= 3).then(|| parts[2..].join("/")),
875 status,
876 live_in,
877 core: core_relation,
878 destructive,
879 destructive_reason: destructive
880 .then(|| destructive_reason(module, ty, status).to_string()),
881 lockout_risk,
882 drift: None,
883 files,
884 diff: None,
885 });
886 }
887
888 let mut summary = PlanSummary::default();
889 for entry in &entries {
890 match entry.status {
891 PlanStatus::Added => summary.added += 1,
892 PlanStatus::Changed => summary.changed += 1,
893 PlanStatus::Removed => summary.removed += 1,
894 PlanStatus::Same => summary.same += 1,
895 PlanStatus::Unmanaged => summary.unmanaged += 1,
896 }
897 }
898 summary.unreadable = unreadable.len();
899 summary.has_changes = summary.added + summary.changed + summary.removed > 0
901 || unreadable.iter().any(|u| u.declared || u.in_ledger);
902
903 let mut warnings: Vec<String> = observed
904 .local_units
905 .iter()
906 .map(|unit| {
907 format!(
908 "{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"
909 )
910 })
911 .collect();
912 warnings.extend(ledger_warnings);
913 warnings.extend(tag_loss_warnings);
914 for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
915 warnings.push(format!(
916 "{} 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",
917 unit.key, unit.longest
918 ));
919 }
920
921 let plan_sha256 = sha256_hex(&canonical_text(
922 &rendered.render_sha256(),
923 &entries,
924 &delete,
925 &unreadable,
926 ));
927 Plan {
928 entries,
929 summary,
930 delete_set: DeleteSet { files: delete },
931 plan_sha256,
932 warnings,
933 unreadable,
934 }
935}
936
937#[derive(Debug, Clone, PartialEq, Eq)]
947pub struct BaseTree {
948 pub dir: String,
950 pub render_sha256: String,
952 pub files: BTreeMap<RelPath, String>,
954}
955
956fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
958 let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
959 CoreError::InvalidInput {
960 reason: format!("--base {}: {problem}{at}", dir.display()),
961 }
962}
963
964fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
966 a.symmetric_difference(b)
967 .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
968}
969
970impl BaseTree {
971 pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
983 let meta = match std::fs::symlink_metadata(dir) {
984 Ok(meta) => meta,
985 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
986 return Err(base_refusal(dir, "does not exist", None));
987 }
988 Err(e) => {
989 return Err(base_refusal(
990 dir,
991 format!("cannot inspect it ({})", e.kind()),
992 None,
993 ));
994 }
995 };
996 if meta.file_type().is_symlink() {
997 return Err(base_refusal(
998 dir,
999 "is a symlink; symlinks are refused",
1000 None,
1001 ));
1002 }
1003 if !meta.file_type().is_dir() {
1004 return Err(base_refusal(dir, "is not a directory", None));
1005 }
1006
1007 let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
1008 let mut dirs: BTreeSet<String> = BTreeSet::new();
1009 walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
1010 .map_err(|why| base_refusal(dir, why, None))?;
1011
1012 let Some(marker_path) = walked.remove(RENDER_MARKER) else {
1013 return Err(base_refusal(
1014 dir,
1015 format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
1016 Some(RENDER_MARKER),
1017 ));
1018 };
1019 let marker = read_marker(dir, &marker_path)?;
1020 if marker.schema_version != 1 {
1021 return Err(base_refusal(
1022 dir,
1023 format!(
1024 "{RENDER_MARKER} has schema_version {}, expected 1",
1025 marker.schema_version
1026 ),
1027 None,
1028 ));
1029 }
1030 for (what, found, expected) in [
1031 ("gateway", &marker.gateway, gateway),
1032 ("env", &marker.env, env),
1033 ("mode", &marker.mode, mode),
1034 ] {
1035 if found != expected {
1036 return Err(base_refusal(
1037 dir,
1038 format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
1039 None,
1040 ));
1041 }
1042 }
1043
1044 let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
1045 for entry in &marker.files {
1046 if listed.insert(entry.path.as_str(), entry).is_some() {
1047 return Err(base_refusal(
1048 dir,
1049 format!("{RENDER_MARKER} lists a path twice"),
1050 Some(&entry.path),
1051 ));
1052 }
1053 }
1054 let found: BTreeSet<String> = walked.keys().cloned().collect();
1055 let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
1056 if let Some(path) = first_difference(&found, &named) {
1057 let problem = if path.ends_with(TMP_SUFFIX) {
1058 "holds a leftover temporary file from an interrupted render".to_string()
1059 } else if found.contains(path) {
1060 format!("holds a file not listed in {RENDER_MARKER}")
1061 } else {
1062 format!("is missing a file listed in {RENDER_MARKER}")
1063 };
1064 return Err(base_refusal(dir, problem, Some(path)));
1065 }
1066 for d in &dirs {
1067 let prefix = format!("{d}/");
1068 if !named.iter().any(|p| p.starts_with(&prefix)) {
1069 return Err(base_refusal(
1070 dir,
1071 "holds a directory with no rendered file",
1072 Some(d),
1073 ));
1074 }
1075 }
1076
1077 let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
1078 let mut total: u64 = 0;
1079 for (rel, path) in &walked {
1080 let entry = listed[rel.as_str()];
1081 let key = RelPath::new(rel).map_err(|why| {
1082 base_refusal(
1083 dir,
1084 format!("{RENDER_MARKER} lists an unusable path ({why})"),
1085 Some(rel),
1086 )
1087 })?;
1088 let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
1089 if sha256 != entry.sha256 || len != entry.bytes {
1090 return Err(base_refusal(
1091 dir,
1092 format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
1093 Some(rel),
1094 ));
1095 }
1096 files.insert(key, sha256);
1097 }
1098
1099 let render_sha256 = listing_digest(&files);
1100 if render_sha256 != marker.render_sha256 {
1101 return Err(base_refusal(
1102 dir,
1103 format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
1104 None,
1105 ));
1106 }
1107 Ok(BaseTree {
1108 dir: dir.to_string_lossy().into_owned(),
1109 render_sha256,
1110 files,
1111 })
1112 }
1113}
1114
1115fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
1118 let before = std::fs::symlink_metadata(path).map_err(|e| {
1119 base_refusal(
1120 dir,
1121 format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
1122 None,
1123 )
1124 })?;
1125 let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
1126 if held.len() > READ_MAX_FILE_BYTES {
1127 return Err(base_refusal(
1128 dir,
1129 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1130 None,
1131 ));
1132 }
1133 let mut bytes = Vec::new();
1134 file.take(READ_MAX_FILE_BYTES + 1)
1135 .read_to_end(&mut bytes)
1136 .map_err(|e| {
1137 base_refusal(
1138 dir,
1139 format!("cannot read {RENDER_MARKER} ({})", e.kind()),
1140 None,
1141 )
1142 })?;
1143 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
1144 return Err(base_refusal(
1145 dir,
1146 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1147 None,
1148 ));
1149 }
1150 serde_json::from_slice(&bytes).map_err(|_| {
1151 base_refusal(
1152 dir,
1153 format!("{RENDER_MARKER} is not a valid render marker"),
1154 None,
1155 )
1156 })
1157}
1158
1159fn open_base_file(
1160 dir: &Path,
1161 rel: &str,
1162 before: &std::fs::Metadata,
1163) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1164 open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1165 OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1166 OpenError::Io(e) => {
1167 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1168 }
1169 })
1170}
1171
1172fn hash_base_file(
1175 dir: &Path,
1176 rel: &str,
1177 path: &Path,
1178 total: &mut u64,
1179) -> Result<(String, u64), CoreError> {
1180 let before = std::fs::symlink_metadata(path).map_err(|e| {
1181 base_refusal(
1182 dir,
1183 format!("cannot inspect a file ({})", e.kind()),
1184 Some(rel),
1185 )
1186 })?;
1187 if !before.file_type().is_file() {
1188 return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1189 }
1190 let (file, held) = open_base_file(dir, rel, &before)?;
1191 if held.len() > READ_MAX_FILE_BYTES {
1192 return Err(base_refusal(
1193 dir,
1194 format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1195 Some(rel),
1196 ));
1197 }
1198 *total += held.len();
1199 if *total > READ_MAX_TOTAL_BYTES {
1200 return Err(base_refusal(
1201 dir,
1202 format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1203 Some(rel),
1204 ));
1205 }
1206 let mut hasher = Sha256::new();
1207 let mut buf = [0u8; 64 * 1024];
1208 let mut len: u64 = 0;
1209 let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1210 loop {
1211 let n = reader.read(&mut buf).map_err(|e| {
1212 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1213 })?;
1214 if n == 0 {
1215 break;
1216 }
1217 len += n as u64;
1218 if len > READ_MAX_FILE_BYTES {
1219 return Err(base_refusal(
1220 dir,
1221 format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1222 Some(rel),
1223 ));
1224 }
1225 hasher.update(&buf[..n]);
1226 }
1227 Ok((format!("{:x}", hasher.finalize()), len))
1228}
1229
1230fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1233 let mut hasher = Sha256::new();
1234 for (path, sha256) in files {
1235 hasher.update(path.as_str().as_bytes());
1236 hasher.update([0u8]);
1237 hasher.update(sha256.as_bytes());
1238 hasher.update(b"\n");
1239 }
1240 format!("{:x}", hasher.finalize())
1241}
1242
1243pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1251 let mut drifted = 0;
1252 for entry in &mut plan.entries {
1253 let drift = entry.files.iter().any(|file| {
1254 let base_sha = RelPath::new(&file.path)
1255 .ok()
1256 .and_then(|path| base.files.get(&path));
1257 file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
1258 });
1259 entry.drift = Some(drift);
1260 drifted += usize::from(drift);
1261 }
1262 plan.summary.drifted = Some(drifted);
1263}
1264
1265pub const DIFF_MAX_BYTES: usize = 65_536;
1268
1269pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1271
1272pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1304 for entry in &mut plan.entries {
1305 if !matches!(
1306 entry.status,
1307 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1308 ) {
1309 continue;
1310 }
1311 let layer = if entry.live_in.iter().any(|l| l == "mode") {
1312 &observed.mode
1313 } else {
1314 &observed.core
1315 };
1316 let mut text = String::new();
1317 for file in &entry.files {
1318 if file.live_sha256 == file.desired_sha256 {
1319 continue;
1320 }
1321 let Ok(path) = RelPath::new(&file.path) else {
1322 continue;
1323 };
1324 let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1325 let desired = rendered.files.get(&path).map(Vec::as_slice);
1326 text.push_str(&file_diff(
1327 &path,
1328 live,
1329 desired,
1330 file.live_sha256.as_deref(),
1331 file.desired_sha256.as_deref(),
1332 ));
1333 }
1334 entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1335 }
1336}
1337
1338fn file_diff(
1340 path: &RelPath,
1341 live: Option<&[u8]>,
1342 desired: Option<&[u8]>,
1343 live_sha: Option<&str>,
1344 desired_sha: Option<&str>,
1345) -> String {
1346 let shas = format!(
1347 "live {}, desired {}",
1348 live_sha.unwrap_or("-"),
1349 desired_sha.unwrap_or("-")
1350 );
1351 let hash_only = || format!("{path}: differs ({shas})\n");
1352 if !path.as_str().ends_with(".json") {
1353 return hash_only();
1354 }
1355 let mut locations = 0;
1356 for bytes in [live, desired].into_iter().flatten() {
1357 let Ok((_, node)) = classify(bytes) else {
1358 return hash_only();
1359 };
1360 locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1361 }
1362 if locations > 0 {
1363 return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1364 }
1365 let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1367 let new = desired
1368 .and_then(|b| std::str::from_utf8(b).ok())
1369 .unwrap_or("");
1370 let diff = TextDiff::from_lines(old, new);
1371 let mut unified = diff.unified_diff();
1372 unified
1373 .context_radius(3)
1374 .header(&format!("live/{path}"), &format!("desired/{path}"));
1375 let mut text = unified.to_string();
1376 if !text.ends_with('\n') {
1377 text.push('\n');
1378 }
1379 text
1380}
1381
1382fn secret_locations(path: &RelPath, node: &Node) -> usize {
1385 let origin = path.as_str();
1386 let parts: Vec<&str> = path.components().collect();
1387 let tag_file =
1388 parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1389 let scan = if tag_file {
1390 scan_embedded(origin, node)
1391 } else {
1392 let key = resource_key(path);
1393 let skip = if key.name.is_some() { 3 } else { 2 };
1394 let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1395 scan_secrets(origin, &key, &rest.join("/"), node)
1396 };
1397 scan.refused.len() + scan.warnings.len()
1398}
1399
1400const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1402 "password",
1403 "passwd",
1404 "secret",
1405 "token",
1406 "apikey",
1407 "api_key",
1408 "credential",
1409 "privatekey",
1410 "private_key",
1411];
1412
1413fn sensitive_key_hits(node: &Node) -> usize {
1424 sensitive_walk(node, false)
1425}
1426
1427fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1428 match node {
1429 Node::Obj(members) => members
1430 .iter()
1431 .map(|(key, value)| {
1432 let lowered = key.to_ascii_lowercase();
1433 let secret = under_secret_key
1434 || SENSITIVE_KEY_FRAGMENTS
1435 .iter()
1436 .any(|fragment| lowered.contains(fragment));
1437 sensitive_walk(value, secret)
1438 })
1439 .sum(),
1440 Node::Arr(items) => items
1441 .iter()
1442 .map(|item| sensitive_walk(item, under_secret_key))
1443 .sum(),
1444 Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1445 Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1446 }
1447}
1448
1449fn cap_diff(mut text: String) -> String {
1452 if text.len() > DIFF_MAX_BYTES {
1453 let mut cut = DIFF_MAX_BYTES;
1454 while !text.is_char_boundary(cut) {
1455 cut -= 1;
1456 }
1457 text.truncate(cut);
1458 if !text.ends_with('\n') {
1459 text.push('\n');
1460 }
1461 text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1462 } else {
1463 while text.ends_with('\n') {
1464 text.pop();
1465 }
1466 }
1467 text
1468}
1469
1470fn canonical_text(
1472 render_sha256: &str,
1473 entries: &[PlanEntry],
1474 delete_set: &[RelPath],
1475 unreadable: &[UnreadableUnit],
1476) -> Vec<u8> {
1477 let mut text: Vec<u8> = Vec::new();
1478 text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1479 text.push(b'\n');
1480 text.extend_from_slice(b"render_sha256\0");
1481 text.extend_from_slice(render_sha256.as_bytes());
1482 text.push(b'\n');
1483 for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1484 text.extend_from_slice(b"E\0");
1485 text.extend_from_slice(entry.key.as_bytes());
1486 text.push(0);
1487 text.extend_from_slice(entry.status.word().as_bytes());
1488 text.push(0);
1489 text.extend_from_slice(entry.core.word().as_bytes());
1490 text.push(0);
1491 text.extend_from_slice(entry.live_in.join(",").as_bytes());
1492 text.push(b'\n');
1493 for file in &entry.files {
1494 text.extend_from_slice(b"F\0");
1495 text.extend_from_slice(file.path.as_bytes());
1496 text.push(0);
1497 text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1498 text.push(0);
1499 text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1500 text.push(b'\n');
1501 }
1502 }
1503 for path in delete_set {
1504 text.extend_from_slice(b"D\0");
1505 text.extend_from_slice(path.as_str().as_bytes());
1506 text.push(b'\n');
1507 }
1508 for unit in unreadable {
1509 text.extend_from_slice(b"U\0");
1510 text.extend_from_slice(unit.key.as_bytes());
1511 text.push(0);
1512 text.extend_from_slice(unit.live_in.join(",").as_bytes());
1513 text.push(0);
1514 text.push(if unit.declared { b'1' } else { b'0' });
1515 text.push(0);
1516 text.push(if unit.in_ledger { b'1' } else { b'0' });
1517 text.push(b'\n');
1518 }
1519 text
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524 use super::*;
1525
1526 fn rp(s: &str) -> RelPath {
1527 RelPath::new(s).unwrap()
1528 }
1529
1530 fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1531 paths.iter().map(|p| (rp(p), ())).collect()
1532 }
1533
1534 #[test]
1535 fn resource_files_group_under_their_resource_directory() {
1536 let l = layer(&[
1537 "ignition/database-translator/POSTGRES/config.json",
1538 "ignition/database-translator/POSTGRES/resource.json",
1539 ]);
1540 let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1541 assert_eq!(key, "ignition/database-translator/POSTGRES");
1542 }
1543
1544 #[test]
1545 fn singletons_group_per_type() {
1546 let l = layer(&[
1547 "ignition/general-alarm-settings/config.json",
1548 "ignition/general-alarm-settings/resource.json",
1549 ]);
1550 let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1551 assert_eq!(key, "ignition/general-alarm-settings");
1552 }
1553
1554 #[test]
1555 fn nearest_marker_wins_for_tag_folders() {
1556 let l = layer(&[
1557 "ignition/tag-definition/P/unary-resource.json",
1558 "ignition/tag-definition/P/A/unary-resource.json",
1559 "ignition/tag-definition/P/A/tags.json",
1560 "ignition/tag-definition/P/B/tags.json",
1561 ]);
1562 assert_eq!(
1563 unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1564 "ignition/tag-definition/P/A"
1565 );
1566 assert_eq!(
1568 unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1569 "ignition/tag-definition/P"
1570 );
1571 }
1572
1573 #[test]
1577 fn cd5_unit_keys() {
1578 let l = layer(&[
1579 "ignition/tag-definition/P/unary-resource.json",
1580 "ignition/tag-definition/P/tags.json",
1581 "ignition/tag-definition/P/udts.json",
1582 "ignition/tag-type-definition/P/unary-resource.json",
1583 "ignition/tag-type-definition/P/udts.json",
1584 "ignition/tag-type-definition/P/A/unary-resource.json",
1585 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1586 "ignition/tag-type-definition/P/A/B/udts.json",
1587 ]);
1588 for (path, key) in [
1589 (
1590 "ignition/tag-definition/P/tags.json",
1591 "ignition/tag-definition/P",
1592 ),
1593 (
1594 "ignition/tag-definition/P/udts.json",
1595 "ignition/tag-definition/P",
1596 ),
1597 (
1598 "ignition/tag-type-definition/P/udts.json",
1599 "ignition/tag-type-definition/P",
1600 ),
1601 (
1602 "ignition/tag-type-definition/P/A/unary-resource.json",
1603 "ignition/tag-type-definition/P/A",
1604 ),
1605 (
1606 "ignition/tag-type-definition/P/A/B/udts.json",
1607 "ignition/tag-type-definition/P/A/B",
1608 ),
1609 (
1610 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1611 "ignition/tag-type-definition/P/A/B",
1612 ),
1613 ] {
1614 assert_eq!(unit_key(&rp(path), &l), key, "{path}");
1615 }
1616 let l = layer(&[
1618 "ignition/tag-type-definition/P/unary-resource.json",
1619 "ignition/tag-type-definition/P/A/unary-resource.json",
1620 "ignition/tag-type-definition/P/A/B/udts.json",
1621 ]);
1622 assert_eq!(
1623 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1624 "ignition/tag-type-definition/P/A"
1625 );
1626 let l = layer(&[
1627 "ignition/tag-type-definition/P/unary-resource.json",
1628 "ignition/tag-type-definition/P/A/B/udts.json",
1629 ]);
1630 assert_eq!(
1631 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1632 "ignition/tag-type-definition/P"
1633 );
1634 }
1635
1636 #[test]
1637 fn no_marker_falls_back_to_the_parent() {
1638 let l = layer(&["ignition/database-translator/X/config.json"]);
1639 assert_eq!(
1640 unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1641 "ignition/database-translator/X"
1642 );
1643 }
1644
1645 #[test]
1646 fn empty_inputs_plan_empty() {
1647 let scope = Scope {
1648 mode: "dev".to_string(),
1649 types: Vec::new(),
1650 tag_providers: Vec::new(),
1651 };
1652 let p = plan(
1653 &RenderedTree::default(),
1654 &ObservedFiles::default(),
1655 None,
1656 &scope,
1657 &["System".to_string()],
1658 );
1659 assert!(p.entries.is_empty());
1660 assert!(!p.summary.has_changes);
1661 }
1662}