1use std::collections::{BTreeMap, BTreeSet};
124use std::io::Read;
125use std::path::Path;
126
127use serde::Serialize;
128use sha2::{Digest, Sha256};
129
130use similar::TextDiff;
131
132use super::exclude;
133use super::layout::{RelPath, TAG_TYPES, resource_key};
134use super::ledger::Ledger;
135use super::lint::{scan_embedded, scan_secrets};
136use super::node::{Node, classify};
137use super::rules::GATEWAY_OWNED_FILES;
138use super::transport::{
139 DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
140 READ_MAX_TOTAL_BYTES, Scope,
141};
142use super::tree::{
143 MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
144 open_regular_file, sha256_hex, walk_capped,
145};
146use crate::error::CoreError;
147
148pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "lowercase")]
154pub enum PlanStatus {
155 Added,
157 Changed,
159 Removed,
161 Same,
163 Unmanaged,
165}
166
167impl PlanStatus {
168 pub fn word(self) -> &'static str {
170 match self {
171 PlanStatus::Added => "added",
172 PlanStatus::Changed => "changed",
173 PlanStatus::Removed => "removed",
174 PlanStatus::Same => "same",
175 PlanStatus::Unmanaged => "unmanaged",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
182#[serde(rename_all = "snake_case")]
183pub enum CoreRelation {
184 None,
186 Identical,
188 Differs,
190 Hidden,
192 ShadowsRemoval,
195}
196
197impl CoreRelation {
198 pub fn word(self) -> &'static str {
200 match self {
201 CoreRelation::None => "none",
202 CoreRelation::Identical => "identical",
203 CoreRelation::Differs => "differs",
204 CoreRelation::Hidden => "hidden",
205 CoreRelation::ShadowsRemoval => "shadows_removal",
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct PlanFile {
213 pub path: String,
215 pub live_sha256: Option<String>,
217 pub desired_sha256: Option<String>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
223pub struct PlanEntry {
224 pub key: String,
226 pub module: String,
228 #[serde(rename = "type")]
230 pub ty: String,
231 pub name: Option<String>,
233 pub status: PlanStatus,
235 pub live_in: Vec<String>,
237 pub core: CoreRelation,
239 pub destructive: bool,
241 pub destructive_reason: Option<String>,
243 pub lockout_risk: bool,
246 pub drift: Option<bool>,
250 pub files: Vec<PlanFile>,
252 pub diff: Option<String>,
256}
257
258#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
260pub struct PlanSummary {
261 pub added: usize,
263 pub changed: usize,
265 pub removed: usize,
267 pub same: usize,
269 pub unmanaged: usize,
271 pub unreadable: usize,
274 pub has_changes: bool,
282 pub drifted: Option<usize>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct Plan {
289 pub entries: Vec<PlanEntry>,
291 pub summary: PlanSummary,
293 pub delete_set: DeleteSet,
295 pub plan_sha256: String,
297 pub warnings: Vec<String>,
299 pub unreadable: Vec<UnreadableUnit>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
310pub struct UnreadableUnit {
311 pub key: String,
313 pub live_in: Vec<String>,
316 pub declared: bool,
318 pub in_ledger: bool,
320 pub paths: Vec<String>,
322 pub longest: usize,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq)]
328pub struct UnreadableKey {
329 pub collections: BTreeSet<String>,
331 pub paths: Vec<String>,
333 pub longest: usize,
335}
336
337pub fn unit_key_layer(observed: &ObservedFiles, collection: &str) -> BTreeMap<RelPath, ()> {
343 let files = if collection == "core" {
344 &observed.core
345 } else {
346 &observed.mode
347 };
348 files
349 .keys()
350 .cloned()
351 .chain(
352 observed
353 .unreadable
354 .iter()
355 .filter(|u| u.collection == collection)
356 .map(|u| u.path.clone()),
357 )
358 .map(|p| (p, ()))
359 .collect()
360}
361
362pub fn unreadable_unit_keys(observed: &ObservedFiles) -> BTreeMap<String, UnreadableKey> {
366 let mut out: BTreeMap<String, UnreadableKey> = BTreeMap::new();
367 if observed.unreadable.is_empty() {
368 return out;
369 }
370 let core_layer = unit_key_layer(observed, "core");
371 let mode_layer = unit_key_layer(observed, "mode");
372 for u in &observed.unreadable {
373 let layer = match u.collection.as_str() {
374 "core" => &core_layer,
375 "mode" => &mode_layer,
376 _ => continue,
377 };
378 let info = out.entry(unit_key(&u.path, layer)).or_default();
379 info.collections.insert(u.collection.clone());
380 info.paths.push(u.path.as_str().to_string());
381 info.longest = info.longest.max(u.length);
382 }
383 for info in out.values_mut() {
384 info.paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
385 info.paths.dedup();
386 }
387 out
388}
389
390pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
395 let parts: Vec<&str> = path.components().collect();
396 if parts.len() < 2 {
397 return path.as_str().to_string();
398 }
399 let parent_len = parts.len() - 1;
400 for end in (2..=parent_len).rev() {
401 let dir = parts[..end].join("/");
402 for marker in ["resource.json", "unary-resource.json"] {
403 if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
404 && layer.contains_key(&candidate)
405 {
406 return dir;
407 }
408 }
409 }
410 parts[..parent_len].join("/")
411}
412
413type Units = BTreeMap<String, BTreeMap<String, String>>;
415
416fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
417 let mut units = Units::new();
418 for (path, value) in layer {
419 units
420 .entry(unit_key(path, layer))
421 .or_default()
422 .insert(path.as_str().to_string(), digest(value));
423 }
424 units
425}
426
427fn group_keyed<'a, V: 'a>(
429 layer: &'a BTreeMap<RelPath, V>,
430 keys: &BTreeMap<RelPath, ()>,
431 digest: impl Fn(&'a V) -> String,
432) -> Units {
433 let mut units = Units::new();
434 for (path, value) in layer {
435 units
436 .entry(unit_key(path, keys))
437 .or_default()
438 .insert(path.as_str().to_string(), digest(value));
439 }
440 units
441}
442
443fn blob_digest(blob: &FileBlob) -> String {
444 blob.sha256.clone()
445}
446
447fn is_tag_folder(key: &str) -> bool {
451 let mut parts = key.split('/');
452 parts.next() == Some("ignition")
453 && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
454 && parts.next().is_some()
455}
456
457fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
459 core.keys().any(|path| !rendered.contains_key(path))
460}
461
462fn in_scope(scope: &Scope, path: &RelPath) -> bool {
466 let parts: Vec<&str> = path.components().collect();
467 if parts.len() < 3 {
468 return false;
469 }
470 let (module, ty) = (parts[0], parts[1]);
471 if scope.types.iter().any(|(m, t)| m == module && t == ty) {
472 return true;
473 }
474 module == "ignition"
475 && TAG_TYPES.contains(&ty)
476 && scope.tag_providers.iter().any(|p| p == parts[2])
477}
478
479struct KeyLayers<'a> {
482 core: &'a BTreeMap<RelPath, ()>,
483 mode: &'a BTreeMap<RelPath, ()>,
484}
485
486fn delete_set(
493 rendered: &RenderedTree,
494 observed: &ObservedFiles,
495 ledger: Option<&Ledger>,
496 scope: &Scope,
497 excluded: &[String],
498 layers: &KeyLayers<'_>,
499 unreadable: &BTreeMap<String, UnreadableKey>,
500) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
501 let Some(ledger) = ledger else {
502 return (Vec::new(), Vec::new(), BTreeSet::new());
503 };
504 let rendered_folded: BTreeSet<String> = rendered
505 .files
506 .keys()
507 .map(|p| p.as_str().to_ascii_lowercase())
508 .collect();
509 let mut listed: Vec<&String> = ledger.files.iter().collect();
510 listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
511 listed.dedup();
512
513 let mut delete = Vec::new();
514 let mut warnings = Vec::new();
515 let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
516 let mut ledgered: BTreeSet<String> = BTreeSet::new();
517 for raw in listed {
518 let Ok(path) = RelPath::new(raw) else {
519 continue;
520 };
521 if path
522 .components()
523 .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
524 {
525 continue;
526 }
527 if let Some(provider) = exclude::excluded_provider(&path, excluded) {
530 let entry = excluded
533 .iter()
534 .find(|e| e.eq_ignore_ascii_case(provider))
535 .map_or(provider, String::as_str);
536 *excluded_files.entry(entry.to_string()).or_default() += 1;
537 continue;
538 }
539 if !in_scope(scope, &path) {
540 warnings.push(format!(
541 "ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
542 ));
543 continue;
544 }
545 if !unreadable.is_empty() {
548 let hit = [unit_key(&path, layers.mode), unit_key(&path, layers.core)]
549 .into_iter()
550 .find(|key| unreadable.contains_key(key));
551 if let Some(key) = hit {
552 ledgered.insert(key);
553 continue;
554 }
555 }
556 if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
557 continue;
558 }
559 if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
560 warnings.push(format!(
561 "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"
562 ));
563 continue;
564 }
565 delete.push(path);
566 }
567 for (provider, count) in excluded_files {
569 warnings.push(format!(
570 "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"
571 ));
572 }
573 (delete, warnings, ledgered)
574}
575
576fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
578 match (module, ty) {
579 ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
580 "tag_data_loss"
581 }
582 ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
583 "connection_removed"
584 }
585 ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
586 _ if status == PlanStatus::Removed => "resource_removed",
587 _ => "files_removed",
588 }
589}
590
591pub fn plan(
597 rendered: &RenderedTree,
598 observed: &ObservedFiles,
599 ledger: Option<&Ledger>,
600 scope: &Scope,
601 excluded: &[String],
602) -> Plan {
603 let unreadable_keys = unreadable_unit_keys(observed);
604 let core_layer = unit_key_layer(observed, "core");
605 let mode_layer = unit_key_layer(observed, "mode");
606 let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
607 let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
608 let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
609 let layers = KeyLayers {
610 core: &core_layer,
611 mode: &mode_layer,
612 };
613 let (delete, ledger_warnings, ledgered) = delete_set(
614 rendered,
615 observed,
616 ledger,
617 scope,
618 excluded,
619 &layers,
620 &unreadable_keys,
621 );
622 let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
623 for path in &delete {
624 *deleted.entry(unit_key(path, &mode_layer)).or_default() += 1;
625 }
626
627 let mut unreadable = Vec::with_capacity(unreadable_keys.len());
630 for (key, info) in &unreadable_keys {
631 let declared = desired.remove(key).is_some();
632 let in_core = core.remove(key).is_some() || info.collections.contains("core");
633 let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
634 let mut live_in = Vec::new();
635 if in_core {
636 live_in.push("core".to_string());
637 }
638 if in_mode {
639 live_in.push("mode".to_string());
640 }
641 unreadable.push(UnreadableUnit {
642 key: key.clone(),
643 live_in,
644 declared,
645 in_ledger: ledgered.contains(key),
646 paths: info.paths.clone(),
647 longest: info.longest,
648 });
649 }
650
651 let keys: BTreeSet<&String> = desired
652 .keys()
653 .chain(core.keys())
654 .chain(mode.keys())
655 .collect();
656 let empty = BTreeMap::new();
657
658 let mut entries = Vec::with_capacity(keys.len());
659 for key in keys {
660 let wanted = desired.get(key);
661 let core_copy = core.get(key);
662 let mode_copy = mode.get(key);
663 let effective = mode_copy.or(core_copy);
664
665 let mut live_in = Vec::new();
666 if core_copy.is_some() {
667 live_in.push("core".to_string());
668 }
669 if mode_copy.is_some() {
670 live_in.push("mode".to_string());
671 }
672
673 let deleted_here = deleted.get(key).copied().unwrap_or(0);
674 let status = match (wanted, effective) {
675 (Some(_), None) => PlanStatus::Added,
676 (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
677 (Some(_), Some(_)) => PlanStatus::Changed,
678 (None, _) if deleted_here > 0 => PlanStatus::Removed,
679 (None, _) => PlanStatus::Unmanaged,
680 };
681 let rendered_files = wanted.unwrap_or(&empty);
682 let core_relation = match core_copy {
683 None => CoreRelation::None,
684 Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
685 Some(c) if is_tag_folder(key) && mode_copy.is_some() && hides(c, rendered_files) => {
686 CoreRelation::Hidden
687 }
688 Some(c) if c == rendered_files => CoreRelation::Identical,
689 Some(_) => CoreRelation::Differs,
690 };
691
692 let paths: BTreeSet<&String> = wanted
693 .into_iter()
694 .chain(effective)
695 .flat_map(BTreeMap::keys)
696 .collect();
697 let files = paths
698 .into_iter()
699 .map(|path| PlanFile {
700 path: path.clone(),
701 live_sha256: effective.and_then(|e| e.get(path)).cloned(),
702 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
703 })
704 .collect();
705
706 let parts: Vec<&str> = key.split('/').collect();
707 let module = parts.first().copied().unwrap_or_default();
708 let ty = parts.get(1).copied().unwrap_or_default();
709 let destructive = status == PlanStatus::Removed || deleted_here > 0;
710 let lockout_risk = matches!(
711 status,
712 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
713 ) && module == "ignition"
714 && matches!(ty, "identity-provider" | "security-zone");
715 entries.push(PlanEntry {
716 key: key.clone(),
717 module: module.to_string(),
718 ty: ty.to_string(),
719 name: (parts.len() >= 3).then(|| parts[2..].join("/")),
720 status,
721 live_in,
722 core: core_relation,
723 destructive,
724 destructive_reason: destructive
725 .then(|| destructive_reason(module, ty, status).to_string()),
726 lockout_risk,
727 drift: None,
728 files,
729 diff: None,
730 });
731 }
732
733 let mut summary = PlanSummary::default();
734 for entry in &entries {
735 match entry.status {
736 PlanStatus::Added => summary.added += 1,
737 PlanStatus::Changed => summary.changed += 1,
738 PlanStatus::Removed => summary.removed += 1,
739 PlanStatus::Same => summary.same += 1,
740 PlanStatus::Unmanaged => summary.unmanaged += 1,
741 }
742 }
743 summary.unreadable = unreadable.len();
744 summary.has_changes = summary.added + summary.changed + summary.removed > 0
746 || unreadable.iter().any(|u| u.declared || u.in_ledger);
747
748 let mut warnings: Vec<String> = observed
749 .local_units
750 .iter()
751 .map(|unit| {
752 format!(
753 "{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"
754 )
755 })
756 .collect();
757 warnings.extend(ledger_warnings);
758 for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
759 warnings.push(format!(
760 "{} 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",
761 unit.key, unit.longest
762 ));
763 }
764
765 let plan_sha256 = sha256_hex(&canonical_text(
766 &rendered.render_sha256(),
767 &entries,
768 &delete,
769 &unreadable,
770 ));
771 Plan {
772 entries,
773 summary,
774 delete_set: DeleteSet { files: delete },
775 plan_sha256,
776 warnings,
777 unreadable,
778 }
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
791pub struct BaseTree {
792 pub dir: String,
794 pub render_sha256: String,
796 pub files: BTreeMap<RelPath, String>,
798}
799
800fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
802 let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
803 CoreError::InvalidInput {
804 reason: format!("--base {}: {problem}{at}", dir.display()),
805 }
806}
807
808fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
810 a.symmetric_difference(b)
811 .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
812}
813
814impl BaseTree {
815 pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
827 let meta = match std::fs::symlink_metadata(dir) {
828 Ok(meta) => meta,
829 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
830 return Err(base_refusal(dir, "does not exist", None));
831 }
832 Err(e) => {
833 return Err(base_refusal(
834 dir,
835 format!("cannot inspect it ({})", e.kind()),
836 None,
837 ));
838 }
839 };
840 if meta.file_type().is_symlink() {
841 return Err(base_refusal(
842 dir,
843 "is a symlink; symlinks are refused",
844 None,
845 ));
846 }
847 if !meta.file_type().is_dir() {
848 return Err(base_refusal(dir, "is not a directory", None));
849 }
850
851 let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
852 let mut dirs: BTreeSet<String> = BTreeSet::new();
853 walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
854 .map_err(|why| base_refusal(dir, why, None))?;
855
856 let Some(marker_path) = walked.remove(RENDER_MARKER) else {
857 return Err(base_refusal(
858 dir,
859 format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
860 Some(RENDER_MARKER),
861 ));
862 };
863 let marker = read_marker(dir, &marker_path)?;
864 if marker.schema_version != 1 {
865 return Err(base_refusal(
866 dir,
867 format!(
868 "{RENDER_MARKER} has schema_version {}, expected 1",
869 marker.schema_version
870 ),
871 None,
872 ));
873 }
874 for (what, found, expected) in [
875 ("gateway", &marker.gateway, gateway),
876 ("env", &marker.env, env),
877 ("mode", &marker.mode, mode),
878 ] {
879 if found != expected {
880 return Err(base_refusal(
881 dir,
882 format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
883 None,
884 ));
885 }
886 }
887
888 let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
889 for entry in &marker.files {
890 if listed.insert(entry.path.as_str(), entry).is_some() {
891 return Err(base_refusal(
892 dir,
893 format!("{RENDER_MARKER} lists a path twice"),
894 Some(&entry.path),
895 ));
896 }
897 }
898 let found: BTreeSet<String> = walked.keys().cloned().collect();
899 let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
900 if let Some(path) = first_difference(&found, &named) {
901 let problem = if path.ends_with(TMP_SUFFIX) {
902 "holds a leftover temporary file from an interrupted render".to_string()
903 } else if found.contains(path) {
904 format!("holds a file not listed in {RENDER_MARKER}")
905 } else {
906 format!("is missing a file listed in {RENDER_MARKER}")
907 };
908 return Err(base_refusal(dir, problem, Some(path)));
909 }
910 for d in &dirs {
911 let prefix = format!("{d}/");
912 if !named.iter().any(|p| p.starts_with(&prefix)) {
913 return Err(base_refusal(
914 dir,
915 "holds a directory with no rendered file",
916 Some(d),
917 ));
918 }
919 }
920
921 let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
922 let mut total: u64 = 0;
923 for (rel, path) in &walked {
924 let entry = listed[rel.as_str()];
925 let key = RelPath::new(rel).map_err(|why| {
926 base_refusal(
927 dir,
928 format!("{RENDER_MARKER} lists an unusable path ({why})"),
929 Some(rel),
930 )
931 })?;
932 let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
933 if sha256 != entry.sha256 || len != entry.bytes {
934 return Err(base_refusal(
935 dir,
936 format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
937 Some(rel),
938 ));
939 }
940 files.insert(key, sha256);
941 }
942
943 let render_sha256 = listing_digest(&files);
944 if render_sha256 != marker.render_sha256 {
945 return Err(base_refusal(
946 dir,
947 format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
948 None,
949 ));
950 }
951 Ok(BaseTree {
952 dir: dir.to_string_lossy().into_owned(),
953 render_sha256,
954 files,
955 })
956 }
957}
958
959fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
962 let before = std::fs::symlink_metadata(path).map_err(|e| {
963 base_refusal(
964 dir,
965 format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
966 None,
967 )
968 })?;
969 let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
970 if held.len() > READ_MAX_FILE_BYTES {
971 return Err(base_refusal(
972 dir,
973 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
974 None,
975 ));
976 }
977 let mut bytes = Vec::new();
978 file.take(READ_MAX_FILE_BYTES + 1)
979 .read_to_end(&mut bytes)
980 .map_err(|e| {
981 base_refusal(
982 dir,
983 format!("cannot read {RENDER_MARKER} ({})", e.kind()),
984 None,
985 )
986 })?;
987 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
988 return Err(base_refusal(
989 dir,
990 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
991 None,
992 ));
993 }
994 serde_json::from_slice(&bytes).map_err(|_| {
995 base_refusal(
996 dir,
997 format!("{RENDER_MARKER} is not a valid render marker"),
998 None,
999 )
1000 })
1001}
1002
1003fn open_base_file(
1004 dir: &Path,
1005 rel: &str,
1006 before: &std::fs::Metadata,
1007) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1008 open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1009 OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1010 OpenError::Io(e) => {
1011 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1012 }
1013 })
1014}
1015
1016fn hash_base_file(
1019 dir: &Path,
1020 rel: &str,
1021 path: &Path,
1022 total: &mut u64,
1023) -> Result<(String, u64), CoreError> {
1024 let before = std::fs::symlink_metadata(path).map_err(|e| {
1025 base_refusal(
1026 dir,
1027 format!("cannot inspect a file ({})", e.kind()),
1028 Some(rel),
1029 )
1030 })?;
1031 if !before.file_type().is_file() {
1032 return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1033 }
1034 let (file, held) = open_base_file(dir, rel, &before)?;
1035 if held.len() > READ_MAX_FILE_BYTES {
1036 return Err(base_refusal(
1037 dir,
1038 format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1039 Some(rel),
1040 ));
1041 }
1042 *total += held.len();
1043 if *total > READ_MAX_TOTAL_BYTES {
1044 return Err(base_refusal(
1045 dir,
1046 format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1047 Some(rel),
1048 ));
1049 }
1050 let mut hasher = Sha256::new();
1051 let mut buf = [0u8; 64 * 1024];
1052 let mut len: u64 = 0;
1053 let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1054 loop {
1055 let n = reader.read(&mut buf).map_err(|e| {
1056 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1057 })?;
1058 if n == 0 {
1059 break;
1060 }
1061 len += n as u64;
1062 if len > READ_MAX_FILE_BYTES {
1063 return Err(base_refusal(
1064 dir,
1065 format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1066 Some(rel),
1067 ));
1068 }
1069 hasher.update(&buf[..n]);
1070 }
1071 Ok((format!("{:x}", hasher.finalize()), len))
1072}
1073
1074fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1077 let mut hasher = Sha256::new();
1078 for (path, sha256) in files {
1079 hasher.update(path.as_str().as_bytes());
1080 hasher.update([0u8]);
1081 hasher.update(sha256.as_bytes());
1082 hasher.update(b"\n");
1083 }
1084 format!("{:x}", hasher.finalize())
1085}
1086
1087pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1095 let mut drifted = 0;
1096 for entry in &mut plan.entries {
1097 let drift = entry.files.iter().any(|file| {
1098 let base_sha = RelPath::new(&file.path)
1099 .ok()
1100 .and_then(|path| base.files.get(&path));
1101 file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
1102 });
1103 entry.drift = Some(drift);
1104 drifted += usize::from(drift);
1105 }
1106 plan.summary.drifted = Some(drifted);
1107}
1108
1109pub const DIFF_MAX_BYTES: usize = 65_536;
1112
1113pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1115
1116pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1148 for entry in &mut plan.entries {
1149 if !matches!(
1150 entry.status,
1151 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1152 ) {
1153 continue;
1154 }
1155 let layer = if entry.live_in.iter().any(|l| l == "mode") {
1156 &observed.mode
1157 } else {
1158 &observed.core
1159 };
1160 let mut text = String::new();
1161 for file in &entry.files {
1162 if file.live_sha256 == file.desired_sha256 {
1163 continue;
1164 }
1165 let Ok(path) = RelPath::new(&file.path) else {
1166 continue;
1167 };
1168 let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1169 let desired = rendered.files.get(&path).map(Vec::as_slice);
1170 text.push_str(&file_diff(
1171 &path,
1172 live,
1173 desired,
1174 file.live_sha256.as_deref(),
1175 file.desired_sha256.as_deref(),
1176 ));
1177 }
1178 entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1179 }
1180}
1181
1182fn file_diff(
1184 path: &RelPath,
1185 live: Option<&[u8]>,
1186 desired: Option<&[u8]>,
1187 live_sha: Option<&str>,
1188 desired_sha: Option<&str>,
1189) -> String {
1190 let shas = format!(
1191 "live {}, desired {}",
1192 live_sha.unwrap_or("-"),
1193 desired_sha.unwrap_or("-")
1194 );
1195 let hash_only = || format!("{path}: differs ({shas})\n");
1196 if !path.as_str().ends_with(".json") {
1197 return hash_only();
1198 }
1199 let mut locations = 0;
1200 for bytes in [live, desired].into_iter().flatten() {
1201 let Ok((_, node)) = classify(bytes) else {
1202 return hash_only();
1203 };
1204 locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1205 }
1206 if locations > 0 {
1207 return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1208 }
1209 let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1211 let new = desired
1212 .and_then(|b| std::str::from_utf8(b).ok())
1213 .unwrap_or("");
1214 let diff = TextDiff::from_lines(old, new);
1215 let mut unified = diff.unified_diff();
1216 unified
1217 .context_radius(3)
1218 .header(&format!("live/{path}"), &format!("desired/{path}"));
1219 let mut text = unified.to_string();
1220 if !text.ends_with('\n') {
1221 text.push('\n');
1222 }
1223 text
1224}
1225
1226fn secret_locations(path: &RelPath, node: &Node) -> usize {
1229 let origin = path.as_str();
1230 let parts: Vec<&str> = path.components().collect();
1231 let tag_file =
1232 parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1233 let scan = if tag_file {
1234 scan_embedded(origin, node)
1235 } else {
1236 let key = resource_key(path);
1237 let skip = if key.name.is_some() { 3 } else { 2 };
1238 let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1239 scan_secrets(origin, &key, &rest.join("/"), node)
1240 };
1241 scan.refused.len() + scan.warnings.len()
1242}
1243
1244const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1246 "password",
1247 "passwd",
1248 "secret",
1249 "token",
1250 "apikey",
1251 "api_key",
1252 "credential",
1253 "privatekey",
1254 "private_key",
1255];
1256
1257fn sensitive_key_hits(node: &Node) -> usize {
1268 sensitive_walk(node, false)
1269}
1270
1271fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1272 match node {
1273 Node::Obj(members) => members
1274 .iter()
1275 .map(|(key, value)| {
1276 let lowered = key.to_ascii_lowercase();
1277 let secret = under_secret_key
1278 || SENSITIVE_KEY_FRAGMENTS
1279 .iter()
1280 .any(|fragment| lowered.contains(fragment));
1281 sensitive_walk(value, secret)
1282 })
1283 .sum(),
1284 Node::Arr(items) => items
1285 .iter()
1286 .map(|item| sensitive_walk(item, under_secret_key))
1287 .sum(),
1288 Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1289 Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1290 }
1291}
1292
1293fn cap_diff(mut text: String) -> String {
1296 if text.len() > DIFF_MAX_BYTES {
1297 let mut cut = DIFF_MAX_BYTES;
1298 while !text.is_char_boundary(cut) {
1299 cut -= 1;
1300 }
1301 text.truncate(cut);
1302 if !text.ends_with('\n') {
1303 text.push('\n');
1304 }
1305 text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1306 } else {
1307 while text.ends_with('\n') {
1308 text.pop();
1309 }
1310 }
1311 text
1312}
1313
1314fn canonical_text(
1316 render_sha256: &str,
1317 entries: &[PlanEntry],
1318 delete_set: &[RelPath],
1319 unreadable: &[UnreadableUnit],
1320) -> Vec<u8> {
1321 let mut text: Vec<u8> = Vec::new();
1322 text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1323 text.push(b'\n');
1324 text.extend_from_slice(b"render_sha256\0");
1325 text.extend_from_slice(render_sha256.as_bytes());
1326 text.push(b'\n');
1327 for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1328 text.extend_from_slice(b"E\0");
1329 text.extend_from_slice(entry.key.as_bytes());
1330 text.push(0);
1331 text.extend_from_slice(entry.status.word().as_bytes());
1332 text.push(0);
1333 text.extend_from_slice(entry.core.word().as_bytes());
1334 text.push(0);
1335 text.extend_from_slice(entry.live_in.join(",").as_bytes());
1336 text.push(b'\n');
1337 for file in &entry.files {
1338 text.extend_from_slice(b"F\0");
1339 text.extend_from_slice(file.path.as_bytes());
1340 text.push(0);
1341 text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1342 text.push(0);
1343 text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1344 text.push(b'\n');
1345 }
1346 }
1347 for path in delete_set {
1348 text.extend_from_slice(b"D\0");
1349 text.extend_from_slice(path.as_str().as_bytes());
1350 text.push(b'\n');
1351 }
1352 for unit in unreadable {
1353 text.extend_from_slice(b"U\0");
1354 text.extend_from_slice(unit.key.as_bytes());
1355 text.push(0);
1356 text.extend_from_slice(unit.live_in.join(",").as_bytes());
1357 text.push(0);
1358 text.push(if unit.declared { b'1' } else { b'0' });
1359 text.push(0);
1360 text.push(if unit.in_ledger { b'1' } else { b'0' });
1361 text.push(b'\n');
1362 }
1363 text
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 fn rp(s: &str) -> RelPath {
1371 RelPath::new(s).unwrap()
1372 }
1373
1374 fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1375 paths.iter().map(|p| (rp(p), ())).collect()
1376 }
1377
1378 #[test]
1379 fn resource_files_group_under_their_resource_directory() {
1380 let l = layer(&[
1381 "ignition/database-translator/POSTGRES/config.json",
1382 "ignition/database-translator/POSTGRES/resource.json",
1383 ]);
1384 let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1385 assert_eq!(key, "ignition/database-translator/POSTGRES");
1386 }
1387
1388 #[test]
1389 fn singletons_group_per_type() {
1390 let l = layer(&[
1391 "ignition/general-alarm-settings/config.json",
1392 "ignition/general-alarm-settings/resource.json",
1393 ]);
1394 let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1395 assert_eq!(key, "ignition/general-alarm-settings");
1396 }
1397
1398 #[test]
1399 fn nearest_marker_wins_for_tag_folders() {
1400 let l = layer(&[
1401 "ignition/tag-definition/P/unary-resource.json",
1402 "ignition/tag-definition/P/A/unary-resource.json",
1403 "ignition/tag-definition/P/A/tags.json",
1404 "ignition/tag-definition/P/B/tags.json",
1405 ]);
1406 assert_eq!(
1407 unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1408 "ignition/tag-definition/P/A"
1409 );
1410 assert_eq!(
1412 unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1413 "ignition/tag-definition/P"
1414 );
1415 }
1416
1417 #[test]
1418 fn no_marker_falls_back_to_the_parent() {
1419 let l = layer(&["ignition/database-translator/X/config.json"]);
1420 assert_eq!(
1421 unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1422 "ignition/database-translator/X"
1423 );
1424 }
1425
1426 #[test]
1427 fn empty_inputs_plan_empty() {
1428 let scope = Scope {
1429 mode: "dev".to_string(),
1430 types: Vec::new(),
1431 tag_providers: Vec::new(),
1432 };
1433 let p = plan(
1434 &RenderedTree::default(),
1435 &ObservedFiles::default(),
1436 None,
1437 &scope,
1438 &["System".to_string()],
1439 );
1440 assert!(p.entries.is_empty());
1441 assert!(!p.summary.has_changes);
1442 }
1443}