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
475fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
477 core.keys().any(|path| !rendered.contains_key(path))
478}
479
480const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
482
483fn tag_data_dropped(
497 key: &str,
498 effective: Option<&BTreeMap<String, String>>,
499 rendered: &BTreeMap<String, String>,
500) -> Option<String> {
501 let live: Vec<&str> = effective
502 .into_iter()
503 .flat_map(BTreeMap::keys)
504 .filter(|path| {
505 let name = path.rsplit('/').next().unwrap_or_default();
506 TAG_MEMBER_FILES
507 .iter()
508 .any(|m| m.eq_ignore_ascii_case(name))
509 && !rendered.contains_key(*path)
510 })
511 .map(String::as_str)
512 .collect();
513 if live.is_empty() {
514 return None;
515 }
516 Some(format!(
517 "{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",
518 if live.len() == 1 {
519 format!("{} is", live[0])
520 } else {
521 format!("{} are", live.join(", "))
522 }
523 ))
524}
525
526fn in_scope(scope: &Scope, path: &RelPath) -> bool {
530 let parts: Vec<&str> = path.components().collect();
531 if parts.len() < 3 {
532 return false;
533 }
534 let (module, ty) = (parts[0], parts[1]);
535 if scope.types.iter().any(|(m, t)| m == module && t == ty) {
536 return true;
537 }
538 module == "ignition"
539 && TAG_TYPES.contains(&ty)
540 && scope.tag_providers.iter().any(|p| p == parts[2])
541}
542
543struct KeyLayers<'a> {
546 core: &'a BTreeMap<RelPath, ()>,
547 mode: &'a BTreeMap<RelPath, ()>,
548}
549
550fn delete_set(
557 rendered: &RenderedTree,
558 observed: &ObservedFiles,
559 ledger: Option<&Ledger>,
560 scope: &Scope,
561 excluded: &[String],
562 layers: &KeyLayers<'_>,
563 unreadable: &BTreeMap<String, UnreadableKey>,
564) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
565 let Some(ledger) = ledger else {
566 return (Vec::new(), Vec::new(), BTreeSet::new());
567 };
568 let rendered_folded: BTreeSet<String> = rendered
569 .files
570 .keys()
571 .map(|p| p.as_str().to_ascii_lowercase())
572 .collect();
573 let mut listed: Vec<&String> = ledger.files.iter().collect();
574 listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
575 listed.dedup();
576
577 let mut delete = Vec::new();
578 let mut warnings = Vec::new();
579 let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
580 let mut ledgered: BTreeSet<String> = BTreeSet::new();
581 for raw in listed {
582 let Ok(path) = RelPath::new(raw) else {
583 continue;
584 };
585 if path
586 .components()
587 .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
588 {
589 continue;
590 }
591 if let Some(provider) = exclude::excluded_provider(&path, excluded) {
594 let entry = excluded
597 .iter()
598 .find(|e| e.eq_ignore_ascii_case(provider))
599 .map_or(provider, String::as_str);
600 *excluded_files.entry(entry.to_string()).or_default() += 1;
601 continue;
602 }
603 if !in_scope(scope, &path) {
604 warnings.push(format!(
605 "ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
606 ));
607 continue;
608 }
609 if !unreadable.is_empty() {
612 let hit = [unit_key(&path, layers.mode), unit_key(&path, layers.core)]
613 .into_iter()
614 .find(|key| unreadable.contains_key(key));
615 if let Some(key) = hit {
616 ledgered.insert(key);
617 continue;
618 }
619 }
620 if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
621 continue;
622 }
623 if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
624 warnings.push(format!(
625 "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"
626 ));
627 continue;
628 }
629 delete.push(path);
630 }
631 for (provider, count) in excluded_files {
633 warnings.push(format!(
634 "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"
635 ));
636 }
637 (delete, warnings, ledgered)
638}
639
640fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
642 match (module, ty) {
643 ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
644 "tag_data_loss"
645 }
646 ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
647 "connection_removed"
648 }
649 ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
650 _ if status == PlanStatus::Removed => "resource_removed",
651 _ => "files_removed",
652 }
653}
654
655pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
669 let live = observed
670 .core
671 .keys()
672 .map(|path| ("core", path))
673 .chain(observed.mode.keys().map(|path| (mode, path)))
674 .chain(observed.unreadable.iter().map(|u| {
675 let collection = if u.collection == "core" { "core" } else { mode };
676 (collection, &u.path)
677 }));
678 let folders: BTreeSet<(&str, String)> = live
679 .filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
680 .collect();
681 folders
682 .into_iter()
683 .map(|(collection, dir)| DeployFinding {
684 file: format!("config/resources/{collection}/{dir}/"),
685 line: None,
686 column: None,
687 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(),
688 })
689 .collect()
690}
691
692pub fn plan(
698 rendered: &RenderedTree,
699 observed: &ObservedFiles,
700 ledger: Option<&Ledger>,
701 scope: &Scope,
702 excluded: &[String],
703) -> Plan {
704 let unreadable_keys = unreadable_unit_keys(observed);
705 let core_layer = unit_key_layer(observed, "core");
706 let mode_layer = unit_key_layer(observed, "mode");
707 let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
708 let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
709 let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
710 let layers = KeyLayers {
711 core: &core_layer,
712 mode: &mode_layer,
713 };
714 let (delete, ledger_warnings, ledgered) = delete_set(
715 rendered,
716 observed,
717 ledger,
718 scope,
719 excluded,
720 &layers,
721 &unreadable_keys,
722 );
723 let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
724 for path in &delete {
725 *deleted.entry(unit_key(path, &mode_layer)).or_default() += 1;
726 }
727
728 let mut unreadable = Vec::with_capacity(unreadable_keys.len());
731 for (key, info) in &unreadable_keys {
732 let declared = desired.remove(key).is_some();
733 let in_core = core.remove(key).is_some() || info.collections.contains("core");
734 let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
735 let mut live_in = Vec::new();
736 if in_core {
737 live_in.push("core".to_string());
738 }
739 if in_mode {
740 live_in.push("mode".to_string());
741 }
742 unreadable.push(UnreadableUnit {
743 key: key.clone(),
744 live_in,
745 declared,
746 in_ledger: ledgered.contains(key),
747 paths: info.paths.clone(),
748 longest: info.longest,
749 });
750 }
751
752 let keys: BTreeSet<&String> = desired
753 .keys()
754 .chain(core.keys())
755 .chain(mode.keys())
756 .collect();
757 let empty = BTreeMap::new();
758
759 let mut entries = Vec::with_capacity(keys.len());
760 let mut tag_loss_warnings: Vec<String> = Vec::new();
761 for key in keys {
762 let wanted = desired.get(key);
763 let core_copy = core.get(key);
764 let mode_copy = mode.get(key);
765 let effective = mode_copy.or(core_copy);
766
767 let mut live_in = Vec::new();
768 if core_copy.is_some() {
769 live_in.push("core".to_string());
770 }
771 if mode_copy.is_some() {
772 live_in.push("mode".to_string());
773 }
774
775 let deleted_here = deleted.get(key).copied().unwrap_or(0);
776 let status = match (wanted, effective) {
777 (Some(_), None) => PlanStatus::Added,
778 (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
779 (Some(_), Some(_)) => PlanStatus::Changed,
780 (None, _) if deleted_here > 0 => PlanStatus::Removed,
781 (None, _) => PlanStatus::Unmanaged,
782 };
783 let rendered_files = wanted.unwrap_or(&empty);
784 let core_relation = match core_copy {
785 None => CoreRelation::None,
786 Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
787 Some(c)
790 if is_tag_folder(key)
791 && (mode_copy.is_some() || wanted.is_some())
792 && hides(c, rendered_files) =>
793 {
794 CoreRelation::Hidden
795 }
796 Some(c) if c == rendered_files => CoreRelation::Identical,
797 Some(_) => CoreRelation::Differs,
798 };
799
800 let paths: BTreeSet<&String> = wanted
801 .into_iter()
802 .chain(effective)
803 .flat_map(BTreeMap::keys)
804 .collect();
805 let files = paths
806 .into_iter()
807 .map(|path| PlanFile {
808 path: path.clone(),
809 live_sha256: effective.and_then(|e| e.get(path)).cloned(),
810 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
811 })
812 .collect();
813
814 let parts: Vec<&str> = key.split('/').collect();
815 let module = parts.first().copied().unwrap_or_default();
816 let ty = parts.get(1).copied().unwrap_or_default();
817 let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
820 && is_tag_folder(key))
821 .then(|| tag_data_dropped(key, effective, rendered_files))
822 .flatten();
823 let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
824 tag_loss_warnings.extend(dropped);
825 let lockout_risk = matches!(
826 status,
827 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
828 ) && module == "ignition"
829 && matches!(ty, "identity-provider" | "security-zone");
830 entries.push(PlanEntry {
831 key: key.clone(),
832 module: module.to_string(),
833 ty: ty.to_string(),
834 name: (parts.len() >= 3).then(|| parts[2..].join("/")),
835 status,
836 live_in,
837 core: core_relation,
838 destructive,
839 destructive_reason: destructive
840 .then(|| destructive_reason(module, ty, status).to_string()),
841 lockout_risk,
842 drift: None,
843 files,
844 diff: None,
845 });
846 }
847
848 let mut summary = PlanSummary::default();
849 for entry in &entries {
850 match entry.status {
851 PlanStatus::Added => summary.added += 1,
852 PlanStatus::Changed => summary.changed += 1,
853 PlanStatus::Removed => summary.removed += 1,
854 PlanStatus::Same => summary.same += 1,
855 PlanStatus::Unmanaged => summary.unmanaged += 1,
856 }
857 }
858 summary.unreadable = unreadable.len();
859 summary.has_changes = summary.added + summary.changed + summary.removed > 0
861 || unreadable.iter().any(|u| u.declared || u.in_ledger);
862
863 let mut warnings: Vec<String> = observed
864 .local_units
865 .iter()
866 .map(|unit| {
867 format!(
868 "{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"
869 )
870 })
871 .collect();
872 warnings.extend(ledger_warnings);
873 warnings.extend(tag_loss_warnings);
874 for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
875 warnings.push(format!(
876 "{} 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",
877 unit.key, unit.longest
878 ));
879 }
880
881 let plan_sha256 = sha256_hex(&canonical_text(
882 &rendered.render_sha256(),
883 &entries,
884 &delete,
885 &unreadable,
886 ));
887 Plan {
888 entries,
889 summary,
890 delete_set: DeleteSet { files: delete },
891 plan_sha256,
892 warnings,
893 unreadable,
894 }
895}
896
897#[derive(Debug, Clone, PartialEq, Eq)]
907pub struct BaseTree {
908 pub dir: String,
910 pub render_sha256: String,
912 pub files: BTreeMap<RelPath, String>,
914}
915
916fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
918 let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
919 CoreError::InvalidInput {
920 reason: format!("--base {}: {problem}{at}", dir.display()),
921 }
922}
923
924fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
926 a.symmetric_difference(b)
927 .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
928}
929
930impl BaseTree {
931 pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
943 let meta = match std::fs::symlink_metadata(dir) {
944 Ok(meta) => meta,
945 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
946 return Err(base_refusal(dir, "does not exist", None));
947 }
948 Err(e) => {
949 return Err(base_refusal(
950 dir,
951 format!("cannot inspect it ({})", e.kind()),
952 None,
953 ));
954 }
955 };
956 if meta.file_type().is_symlink() {
957 return Err(base_refusal(
958 dir,
959 "is a symlink; symlinks are refused",
960 None,
961 ));
962 }
963 if !meta.file_type().is_dir() {
964 return Err(base_refusal(dir, "is not a directory", None));
965 }
966
967 let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
968 let mut dirs: BTreeSet<String> = BTreeSet::new();
969 walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
970 .map_err(|why| base_refusal(dir, why, None))?;
971
972 let Some(marker_path) = walked.remove(RENDER_MARKER) else {
973 return Err(base_refusal(
974 dir,
975 format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
976 Some(RENDER_MARKER),
977 ));
978 };
979 let marker = read_marker(dir, &marker_path)?;
980 if marker.schema_version != 1 {
981 return Err(base_refusal(
982 dir,
983 format!(
984 "{RENDER_MARKER} has schema_version {}, expected 1",
985 marker.schema_version
986 ),
987 None,
988 ));
989 }
990 for (what, found, expected) in [
991 ("gateway", &marker.gateway, gateway),
992 ("env", &marker.env, env),
993 ("mode", &marker.mode, mode),
994 ] {
995 if found != expected {
996 return Err(base_refusal(
997 dir,
998 format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
999 None,
1000 ));
1001 }
1002 }
1003
1004 let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
1005 for entry in &marker.files {
1006 if listed.insert(entry.path.as_str(), entry).is_some() {
1007 return Err(base_refusal(
1008 dir,
1009 format!("{RENDER_MARKER} lists a path twice"),
1010 Some(&entry.path),
1011 ));
1012 }
1013 }
1014 let found: BTreeSet<String> = walked.keys().cloned().collect();
1015 let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
1016 if let Some(path) = first_difference(&found, &named) {
1017 let problem = if path.ends_with(TMP_SUFFIX) {
1018 "holds a leftover temporary file from an interrupted render".to_string()
1019 } else if found.contains(path) {
1020 format!("holds a file not listed in {RENDER_MARKER}")
1021 } else {
1022 format!("is missing a file listed in {RENDER_MARKER}")
1023 };
1024 return Err(base_refusal(dir, problem, Some(path)));
1025 }
1026 for d in &dirs {
1027 let prefix = format!("{d}/");
1028 if !named.iter().any(|p| p.starts_with(&prefix)) {
1029 return Err(base_refusal(
1030 dir,
1031 "holds a directory with no rendered file",
1032 Some(d),
1033 ));
1034 }
1035 }
1036
1037 let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
1038 let mut total: u64 = 0;
1039 for (rel, path) in &walked {
1040 let entry = listed[rel.as_str()];
1041 let key = RelPath::new(rel).map_err(|why| {
1042 base_refusal(
1043 dir,
1044 format!("{RENDER_MARKER} lists an unusable path ({why})"),
1045 Some(rel),
1046 )
1047 })?;
1048 let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
1049 if sha256 != entry.sha256 || len != entry.bytes {
1050 return Err(base_refusal(
1051 dir,
1052 format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
1053 Some(rel),
1054 ));
1055 }
1056 files.insert(key, sha256);
1057 }
1058
1059 let render_sha256 = listing_digest(&files);
1060 if render_sha256 != marker.render_sha256 {
1061 return Err(base_refusal(
1062 dir,
1063 format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
1064 None,
1065 ));
1066 }
1067 Ok(BaseTree {
1068 dir: dir.to_string_lossy().into_owned(),
1069 render_sha256,
1070 files,
1071 })
1072 }
1073}
1074
1075fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
1078 let before = std::fs::symlink_metadata(path).map_err(|e| {
1079 base_refusal(
1080 dir,
1081 format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
1082 None,
1083 )
1084 })?;
1085 let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
1086 if held.len() > READ_MAX_FILE_BYTES {
1087 return Err(base_refusal(
1088 dir,
1089 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1090 None,
1091 ));
1092 }
1093 let mut bytes = Vec::new();
1094 file.take(READ_MAX_FILE_BYTES + 1)
1095 .read_to_end(&mut bytes)
1096 .map_err(|e| {
1097 base_refusal(
1098 dir,
1099 format!("cannot read {RENDER_MARKER} ({})", e.kind()),
1100 None,
1101 )
1102 })?;
1103 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
1104 return Err(base_refusal(
1105 dir,
1106 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1107 None,
1108 ));
1109 }
1110 serde_json::from_slice(&bytes).map_err(|_| {
1111 base_refusal(
1112 dir,
1113 format!("{RENDER_MARKER} is not a valid render marker"),
1114 None,
1115 )
1116 })
1117}
1118
1119fn open_base_file(
1120 dir: &Path,
1121 rel: &str,
1122 before: &std::fs::Metadata,
1123) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1124 open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1125 OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1126 OpenError::Io(e) => {
1127 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1128 }
1129 })
1130}
1131
1132fn hash_base_file(
1135 dir: &Path,
1136 rel: &str,
1137 path: &Path,
1138 total: &mut u64,
1139) -> Result<(String, u64), CoreError> {
1140 let before = std::fs::symlink_metadata(path).map_err(|e| {
1141 base_refusal(
1142 dir,
1143 format!("cannot inspect a file ({})", e.kind()),
1144 Some(rel),
1145 )
1146 })?;
1147 if !before.file_type().is_file() {
1148 return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1149 }
1150 let (file, held) = open_base_file(dir, rel, &before)?;
1151 if held.len() > READ_MAX_FILE_BYTES {
1152 return Err(base_refusal(
1153 dir,
1154 format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1155 Some(rel),
1156 ));
1157 }
1158 *total += held.len();
1159 if *total > READ_MAX_TOTAL_BYTES {
1160 return Err(base_refusal(
1161 dir,
1162 format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1163 Some(rel),
1164 ));
1165 }
1166 let mut hasher = Sha256::new();
1167 let mut buf = [0u8; 64 * 1024];
1168 let mut len: u64 = 0;
1169 let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1170 loop {
1171 let n = reader.read(&mut buf).map_err(|e| {
1172 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1173 })?;
1174 if n == 0 {
1175 break;
1176 }
1177 len += n as u64;
1178 if len > READ_MAX_FILE_BYTES {
1179 return Err(base_refusal(
1180 dir,
1181 format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1182 Some(rel),
1183 ));
1184 }
1185 hasher.update(&buf[..n]);
1186 }
1187 Ok((format!("{:x}", hasher.finalize()), len))
1188}
1189
1190fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1193 let mut hasher = Sha256::new();
1194 for (path, sha256) in files {
1195 hasher.update(path.as_str().as_bytes());
1196 hasher.update([0u8]);
1197 hasher.update(sha256.as_bytes());
1198 hasher.update(b"\n");
1199 }
1200 format!("{:x}", hasher.finalize())
1201}
1202
1203pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1211 let mut drifted = 0;
1212 for entry in &mut plan.entries {
1213 let drift = entry.files.iter().any(|file| {
1214 let base_sha = RelPath::new(&file.path)
1215 .ok()
1216 .and_then(|path| base.files.get(&path));
1217 file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
1218 });
1219 entry.drift = Some(drift);
1220 drifted += usize::from(drift);
1221 }
1222 plan.summary.drifted = Some(drifted);
1223}
1224
1225pub const DIFF_MAX_BYTES: usize = 65_536;
1228
1229pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1231
1232pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1264 for entry in &mut plan.entries {
1265 if !matches!(
1266 entry.status,
1267 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1268 ) {
1269 continue;
1270 }
1271 let layer = if entry.live_in.iter().any(|l| l == "mode") {
1272 &observed.mode
1273 } else {
1274 &observed.core
1275 };
1276 let mut text = String::new();
1277 for file in &entry.files {
1278 if file.live_sha256 == file.desired_sha256 {
1279 continue;
1280 }
1281 let Ok(path) = RelPath::new(&file.path) else {
1282 continue;
1283 };
1284 let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1285 let desired = rendered.files.get(&path).map(Vec::as_slice);
1286 text.push_str(&file_diff(
1287 &path,
1288 live,
1289 desired,
1290 file.live_sha256.as_deref(),
1291 file.desired_sha256.as_deref(),
1292 ));
1293 }
1294 entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1295 }
1296}
1297
1298fn file_diff(
1300 path: &RelPath,
1301 live: Option<&[u8]>,
1302 desired: Option<&[u8]>,
1303 live_sha: Option<&str>,
1304 desired_sha: Option<&str>,
1305) -> String {
1306 let shas = format!(
1307 "live {}, desired {}",
1308 live_sha.unwrap_or("-"),
1309 desired_sha.unwrap_or("-")
1310 );
1311 let hash_only = || format!("{path}: differs ({shas})\n");
1312 if !path.as_str().ends_with(".json") {
1313 return hash_only();
1314 }
1315 let mut locations = 0;
1316 for bytes in [live, desired].into_iter().flatten() {
1317 let Ok((_, node)) = classify(bytes) else {
1318 return hash_only();
1319 };
1320 locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1321 }
1322 if locations > 0 {
1323 return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1324 }
1325 let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1327 let new = desired
1328 .and_then(|b| std::str::from_utf8(b).ok())
1329 .unwrap_or("");
1330 let diff = TextDiff::from_lines(old, new);
1331 let mut unified = diff.unified_diff();
1332 unified
1333 .context_radius(3)
1334 .header(&format!("live/{path}"), &format!("desired/{path}"));
1335 let mut text = unified.to_string();
1336 if !text.ends_with('\n') {
1337 text.push('\n');
1338 }
1339 text
1340}
1341
1342fn secret_locations(path: &RelPath, node: &Node) -> usize {
1345 let origin = path.as_str();
1346 let parts: Vec<&str> = path.components().collect();
1347 let tag_file =
1348 parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1349 let scan = if tag_file {
1350 scan_embedded(origin, node)
1351 } else {
1352 let key = resource_key(path);
1353 let skip = if key.name.is_some() { 3 } else { 2 };
1354 let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1355 scan_secrets(origin, &key, &rest.join("/"), node)
1356 };
1357 scan.refused.len() + scan.warnings.len()
1358}
1359
1360const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1362 "password",
1363 "passwd",
1364 "secret",
1365 "token",
1366 "apikey",
1367 "api_key",
1368 "credential",
1369 "privatekey",
1370 "private_key",
1371];
1372
1373fn sensitive_key_hits(node: &Node) -> usize {
1384 sensitive_walk(node, false)
1385}
1386
1387fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1388 match node {
1389 Node::Obj(members) => members
1390 .iter()
1391 .map(|(key, value)| {
1392 let lowered = key.to_ascii_lowercase();
1393 let secret = under_secret_key
1394 || SENSITIVE_KEY_FRAGMENTS
1395 .iter()
1396 .any(|fragment| lowered.contains(fragment));
1397 sensitive_walk(value, secret)
1398 })
1399 .sum(),
1400 Node::Arr(items) => items
1401 .iter()
1402 .map(|item| sensitive_walk(item, under_secret_key))
1403 .sum(),
1404 Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1405 Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1406 }
1407}
1408
1409fn cap_diff(mut text: String) -> String {
1412 if text.len() > DIFF_MAX_BYTES {
1413 let mut cut = DIFF_MAX_BYTES;
1414 while !text.is_char_boundary(cut) {
1415 cut -= 1;
1416 }
1417 text.truncate(cut);
1418 if !text.ends_with('\n') {
1419 text.push('\n');
1420 }
1421 text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1422 } else {
1423 while text.ends_with('\n') {
1424 text.pop();
1425 }
1426 }
1427 text
1428}
1429
1430fn canonical_text(
1432 render_sha256: &str,
1433 entries: &[PlanEntry],
1434 delete_set: &[RelPath],
1435 unreadable: &[UnreadableUnit],
1436) -> Vec<u8> {
1437 let mut text: Vec<u8> = Vec::new();
1438 text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1439 text.push(b'\n');
1440 text.extend_from_slice(b"render_sha256\0");
1441 text.extend_from_slice(render_sha256.as_bytes());
1442 text.push(b'\n');
1443 for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1444 text.extend_from_slice(b"E\0");
1445 text.extend_from_slice(entry.key.as_bytes());
1446 text.push(0);
1447 text.extend_from_slice(entry.status.word().as_bytes());
1448 text.push(0);
1449 text.extend_from_slice(entry.core.word().as_bytes());
1450 text.push(0);
1451 text.extend_from_slice(entry.live_in.join(",").as_bytes());
1452 text.push(b'\n');
1453 for file in &entry.files {
1454 text.extend_from_slice(b"F\0");
1455 text.extend_from_slice(file.path.as_bytes());
1456 text.push(0);
1457 text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1458 text.push(0);
1459 text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1460 text.push(b'\n');
1461 }
1462 }
1463 for path in delete_set {
1464 text.extend_from_slice(b"D\0");
1465 text.extend_from_slice(path.as_str().as_bytes());
1466 text.push(b'\n');
1467 }
1468 for unit in unreadable {
1469 text.extend_from_slice(b"U\0");
1470 text.extend_from_slice(unit.key.as_bytes());
1471 text.push(0);
1472 text.extend_from_slice(unit.live_in.join(",").as_bytes());
1473 text.push(0);
1474 text.push(if unit.declared { b'1' } else { b'0' });
1475 text.push(0);
1476 text.push(if unit.in_ledger { b'1' } else { b'0' });
1477 text.push(b'\n');
1478 }
1479 text
1480}
1481
1482#[cfg(test)]
1483mod tests {
1484 use super::*;
1485
1486 fn rp(s: &str) -> RelPath {
1487 RelPath::new(s).unwrap()
1488 }
1489
1490 fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1491 paths.iter().map(|p| (rp(p), ())).collect()
1492 }
1493
1494 #[test]
1495 fn resource_files_group_under_their_resource_directory() {
1496 let l = layer(&[
1497 "ignition/database-translator/POSTGRES/config.json",
1498 "ignition/database-translator/POSTGRES/resource.json",
1499 ]);
1500 let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1501 assert_eq!(key, "ignition/database-translator/POSTGRES");
1502 }
1503
1504 #[test]
1505 fn singletons_group_per_type() {
1506 let l = layer(&[
1507 "ignition/general-alarm-settings/config.json",
1508 "ignition/general-alarm-settings/resource.json",
1509 ]);
1510 let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1511 assert_eq!(key, "ignition/general-alarm-settings");
1512 }
1513
1514 #[test]
1515 fn nearest_marker_wins_for_tag_folders() {
1516 let l = layer(&[
1517 "ignition/tag-definition/P/unary-resource.json",
1518 "ignition/tag-definition/P/A/unary-resource.json",
1519 "ignition/tag-definition/P/A/tags.json",
1520 "ignition/tag-definition/P/B/tags.json",
1521 ]);
1522 assert_eq!(
1523 unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1524 "ignition/tag-definition/P/A"
1525 );
1526 assert_eq!(
1528 unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1529 "ignition/tag-definition/P"
1530 );
1531 }
1532
1533 #[test]
1537 fn cd5_unit_keys() {
1538 let l = layer(&[
1539 "ignition/tag-definition/P/unary-resource.json",
1540 "ignition/tag-definition/P/tags.json",
1541 "ignition/tag-definition/P/udts.json",
1542 "ignition/tag-type-definition/P/unary-resource.json",
1543 "ignition/tag-type-definition/P/udts.json",
1544 "ignition/tag-type-definition/P/A/unary-resource.json",
1545 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1546 "ignition/tag-type-definition/P/A/B/udts.json",
1547 ]);
1548 for (path, key) in [
1549 (
1550 "ignition/tag-definition/P/tags.json",
1551 "ignition/tag-definition/P",
1552 ),
1553 (
1554 "ignition/tag-definition/P/udts.json",
1555 "ignition/tag-definition/P",
1556 ),
1557 (
1558 "ignition/tag-type-definition/P/udts.json",
1559 "ignition/tag-type-definition/P",
1560 ),
1561 (
1562 "ignition/tag-type-definition/P/A/unary-resource.json",
1563 "ignition/tag-type-definition/P/A",
1564 ),
1565 (
1566 "ignition/tag-type-definition/P/A/B/udts.json",
1567 "ignition/tag-type-definition/P/A/B",
1568 ),
1569 (
1570 "ignition/tag-type-definition/P/A/B/unary-resource.json",
1571 "ignition/tag-type-definition/P/A/B",
1572 ),
1573 ] {
1574 assert_eq!(unit_key(&rp(path), &l), key, "{path}");
1575 }
1576 let l = layer(&[
1578 "ignition/tag-type-definition/P/unary-resource.json",
1579 "ignition/tag-type-definition/P/A/unary-resource.json",
1580 "ignition/tag-type-definition/P/A/B/udts.json",
1581 ]);
1582 assert_eq!(
1583 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1584 "ignition/tag-type-definition/P/A"
1585 );
1586 let l = layer(&[
1587 "ignition/tag-type-definition/P/unary-resource.json",
1588 "ignition/tag-type-definition/P/A/B/udts.json",
1589 ]);
1590 assert_eq!(
1591 unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1592 "ignition/tag-type-definition/P"
1593 );
1594 }
1595
1596 #[test]
1597 fn no_marker_falls_back_to_the_parent() {
1598 let l = layer(&["ignition/database-translator/X/config.json"]);
1599 assert_eq!(
1600 unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1601 "ignition/database-translator/X"
1602 );
1603 }
1604
1605 #[test]
1606 fn empty_inputs_plan_empty() {
1607 let scope = Scope {
1608 mode: "dev".to_string(),
1609 types: Vec::new(),
1610 tag_providers: Vec::new(),
1611 };
1612 let p = plan(
1613 &RenderedTree::default(),
1614 &ObservedFiles::default(),
1615 None,
1616 &scope,
1617 &["System".to_string()],
1618 );
1619 assert!(p.entries.is_empty());
1620 assert!(!p.summary.has_changes);
1621 }
1622}