1use std::collections::{BTreeMap, BTreeSet};
105use std::io::Read;
106use std::path::Path;
107
108use serde::Serialize;
109use sha2::{Digest, Sha256};
110
111use similar::TextDiff;
112
113use super::layout::{RelPath, TAG_TYPES, resource_key};
114use super::ledger::Ledger;
115use super::lint::{scan_embedded, scan_secrets};
116use super::node::{Node, classify};
117use super::rules::GATEWAY_OWNED_FILES;
118use super::transport::{
119 DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
120 READ_MAX_TOTAL_BYTES, Scope,
121};
122use super::tree::{
123 MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
124 open_regular_file, sha256_hex, walk_capped,
125};
126use crate::error::CoreError;
127
128pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum PlanStatus {
135 Added,
137 Changed,
139 Removed,
141 Same,
143 Unmanaged,
145}
146
147impl PlanStatus {
148 pub fn word(self) -> &'static str {
150 match self {
151 PlanStatus::Added => "added",
152 PlanStatus::Changed => "changed",
153 PlanStatus::Removed => "removed",
154 PlanStatus::Same => "same",
155 PlanStatus::Unmanaged => "unmanaged",
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(rename_all = "snake_case")]
163pub enum CoreRelation {
164 None,
166 Identical,
168 Differs,
170 Hidden,
172 ShadowsRemoval,
175}
176
177impl CoreRelation {
178 pub fn word(self) -> &'static str {
180 match self {
181 CoreRelation::None => "none",
182 CoreRelation::Identical => "identical",
183 CoreRelation::Differs => "differs",
184 CoreRelation::Hidden => "hidden",
185 CoreRelation::ShadowsRemoval => "shadows_removal",
186 }
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
192pub struct PlanFile {
193 pub path: String,
195 pub live_sha256: Option<String>,
197 pub desired_sha256: Option<String>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
203pub struct PlanEntry {
204 pub key: String,
206 pub module: String,
208 #[serde(rename = "type")]
210 pub ty: String,
211 pub name: Option<String>,
213 pub status: PlanStatus,
215 pub live_in: Vec<String>,
217 pub core: CoreRelation,
219 pub destructive: bool,
221 pub destructive_reason: Option<String>,
223 pub lockout_risk: bool,
226 pub drift: Option<bool>,
230 pub files: Vec<PlanFile>,
232 pub diff: Option<String>,
236}
237
238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
240pub struct PlanSummary {
241 pub added: usize,
243 pub changed: usize,
245 pub removed: usize,
247 pub same: usize,
249 pub unmanaged: usize,
251 pub has_changes: bool,
254 pub drifted: Option<usize>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260pub struct Plan {
261 pub entries: Vec<PlanEntry>,
263 pub summary: PlanSummary,
265 pub delete_set: DeleteSet,
267 pub plan_sha256: String,
269 pub warnings: Vec<String>,
271}
272
273pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
278 let parts: Vec<&str> = path.components().collect();
279 if parts.len() < 2 {
280 return path.as_str().to_string();
281 }
282 let parent_len = parts.len() - 1;
283 for end in (2..=parent_len).rev() {
284 let dir = parts[..end].join("/");
285 for marker in ["resource.json", "unary-resource.json"] {
286 if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
287 && layer.contains_key(&candidate)
288 {
289 return dir;
290 }
291 }
292 }
293 parts[..parent_len].join("/")
294}
295
296type Units = BTreeMap<String, BTreeMap<String, String>>;
298
299fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
300 let mut units = Units::new();
301 for (path, value) in layer {
302 units
303 .entry(unit_key(path, layer))
304 .or_default()
305 .insert(path.as_str().to_string(), digest(value));
306 }
307 units
308}
309
310fn blob_digest(blob: &FileBlob) -> String {
311 blob.sha256.clone()
312}
313
314fn is_tag_folder(key: &str) -> bool {
318 let mut parts = key.split('/');
319 parts.next() == Some("ignition")
320 && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
321 && parts.next().is_some()
322}
323
324fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
326 core.keys().any(|path| !rendered.contains_key(path))
327}
328
329fn in_scope(scope: &Scope, path: &RelPath) -> bool {
333 let parts: Vec<&str> = path.components().collect();
334 if parts.len() < 3 {
335 return false;
336 }
337 let (module, ty) = (parts[0], parts[1]);
338 if scope.types.iter().any(|(m, t)| m == module && t == ty) {
339 return true;
340 }
341 module == "ignition"
342 && TAG_TYPES.contains(&ty)
343 && scope.tag_providers.iter().any(|p| p == parts[2])
344}
345
346fn delete_set(
349 rendered: &RenderedTree,
350 observed: &ObservedFiles,
351 ledger: Option<&Ledger>,
352 scope: &Scope,
353) -> (Vec<RelPath>, Vec<String>) {
354 let Some(ledger) = ledger else {
355 return (Vec::new(), Vec::new());
356 };
357 let rendered_folded: BTreeSet<String> = rendered
358 .files
359 .keys()
360 .map(|p| p.as_str().to_ascii_lowercase())
361 .collect();
362 let mut listed: Vec<&String> = ledger.files.iter().collect();
363 listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
364 listed.dedup();
365
366 let mut delete = Vec::new();
367 let mut warnings = Vec::new();
368 for raw in listed {
369 let Ok(path) = RelPath::new(raw) else {
370 continue;
371 };
372 if path
373 .components()
374 .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
375 {
376 continue;
377 }
378 if !in_scope(scope, &path) {
379 warnings.push(format!(
380 "ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
381 ));
382 continue;
383 }
384 if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
385 continue;
386 }
387 if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
388 warnings.push(format!(
389 "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"
390 ));
391 continue;
392 }
393 delete.push(path);
394 }
395 (delete, warnings)
396}
397
398fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
400 match (module, ty) {
401 ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
402 "tag_data_loss"
403 }
404 ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
405 "connection_removed"
406 }
407 ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
408 _ if status == PlanStatus::Removed => "resource_removed",
409 _ => "files_removed",
410 }
411}
412
413pub fn plan(
417 rendered: &RenderedTree,
418 observed: &ObservedFiles,
419 ledger: Option<&Ledger>,
420 scope: &Scope,
421) -> Plan {
422 let desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
423 let core = group(&observed.core, blob_digest);
424 let mode = group(&observed.mode, blob_digest);
425 let (delete, ledger_warnings) = delete_set(rendered, observed, ledger, scope);
426 let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
427 for path in &delete {
428 *deleted.entry(unit_key(path, &observed.mode)).or_default() += 1;
429 }
430
431 let keys: BTreeSet<&String> = desired
432 .keys()
433 .chain(core.keys())
434 .chain(mode.keys())
435 .collect();
436 let empty = BTreeMap::new();
437
438 let mut entries = Vec::with_capacity(keys.len());
439 for key in keys {
440 let wanted = desired.get(key);
441 let core_copy = core.get(key);
442 let mode_copy = mode.get(key);
443 let effective = mode_copy.or(core_copy);
444
445 let mut live_in = Vec::new();
446 if core_copy.is_some() {
447 live_in.push("core".to_string());
448 }
449 if mode_copy.is_some() {
450 live_in.push("mode".to_string());
451 }
452
453 let deleted_here = deleted.get(key).copied().unwrap_or(0);
454 let status = match (wanted, effective) {
455 (Some(_), None) => PlanStatus::Added,
456 (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
457 (Some(_), Some(_)) => PlanStatus::Changed,
458 (None, _) if deleted_here > 0 => PlanStatus::Removed,
459 (None, _) => PlanStatus::Unmanaged,
460 };
461 let rendered_files = wanted.unwrap_or(&empty);
462 let core_relation = match core_copy {
463 None => CoreRelation::None,
464 Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
465 Some(c) if is_tag_folder(key) && mode_copy.is_some() && hides(c, rendered_files) => {
466 CoreRelation::Hidden
467 }
468 Some(c) if c == rendered_files => CoreRelation::Identical,
469 Some(_) => CoreRelation::Differs,
470 };
471
472 let paths: BTreeSet<&String> = wanted
473 .into_iter()
474 .chain(effective)
475 .flat_map(BTreeMap::keys)
476 .collect();
477 let files = paths
478 .into_iter()
479 .map(|path| PlanFile {
480 path: path.clone(),
481 live_sha256: effective.and_then(|e| e.get(path)).cloned(),
482 desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
483 })
484 .collect();
485
486 let parts: Vec<&str> = key.split('/').collect();
487 let module = parts.first().copied().unwrap_or_default();
488 let ty = parts.get(1).copied().unwrap_or_default();
489 let destructive = status == PlanStatus::Removed || deleted_here > 0;
490 let lockout_risk = matches!(
491 status,
492 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
493 ) && module == "ignition"
494 && matches!(ty, "identity-provider" | "security-zone");
495 entries.push(PlanEntry {
496 key: key.clone(),
497 module: module.to_string(),
498 ty: ty.to_string(),
499 name: (parts.len() >= 3).then(|| parts[2..].join("/")),
500 status,
501 live_in,
502 core: core_relation,
503 destructive,
504 destructive_reason: destructive
505 .then(|| destructive_reason(module, ty, status).to_string()),
506 lockout_risk,
507 drift: None,
508 files,
509 diff: None,
510 });
511 }
512
513 let mut summary = PlanSummary::default();
514 for entry in &entries {
515 match entry.status {
516 PlanStatus::Added => summary.added += 1,
517 PlanStatus::Changed => summary.changed += 1,
518 PlanStatus::Removed => summary.removed += 1,
519 PlanStatus::Same => summary.same += 1,
520 PlanStatus::Unmanaged => summary.unmanaged += 1,
521 }
522 }
523 summary.has_changes = summary.added + summary.changed + summary.removed > 0;
524
525 let mut warnings: Vec<String> = observed
526 .local_units
527 .iter()
528 .map(|unit| {
529 format!(
530 "{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"
531 )
532 })
533 .collect();
534 warnings.extend(ledger_warnings);
535
536 let plan_sha256 = sha256_hex(&canonical_text(
537 &rendered.render_sha256(),
538 &entries,
539 &delete,
540 ));
541 Plan {
542 entries,
543 summary,
544 delete_set: DeleteSet { files: delete },
545 plan_sha256,
546 warnings,
547 }
548}
549
550#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct BaseTree {
561 pub dir: String,
563 pub render_sha256: String,
565 pub files: BTreeMap<RelPath, String>,
567}
568
569fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
571 let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
572 CoreError::InvalidInput {
573 reason: format!("--base {}: {problem}{at}", dir.display()),
574 }
575}
576
577fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
579 a.symmetric_difference(b)
580 .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
581}
582
583impl BaseTree {
584 pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
596 let meta = match std::fs::symlink_metadata(dir) {
597 Ok(meta) => meta,
598 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
599 return Err(base_refusal(dir, "does not exist", None));
600 }
601 Err(e) => {
602 return Err(base_refusal(
603 dir,
604 format!("cannot inspect it ({})", e.kind()),
605 None,
606 ));
607 }
608 };
609 if meta.file_type().is_symlink() {
610 return Err(base_refusal(
611 dir,
612 "is a symlink; symlinks are refused",
613 None,
614 ));
615 }
616 if !meta.file_type().is_dir() {
617 return Err(base_refusal(dir, "is not a directory", None));
618 }
619
620 let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
621 let mut dirs: BTreeSet<String> = BTreeSet::new();
622 walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
623 .map_err(|why| base_refusal(dir, why, None))?;
624
625 let Some(marker_path) = walked.remove(RENDER_MARKER) else {
626 return Err(base_refusal(
627 dir,
628 format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
629 Some(RENDER_MARKER),
630 ));
631 };
632 let marker = read_marker(dir, &marker_path)?;
633 if marker.schema_version != 1 {
634 return Err(base_refusal(
635 dir,
636 format!(
637 "{RENDER_MARKER} has schema_version {}, expected 1",
638 marker.schema_version
639 ),
640 None,
641 ));
642 }
643 for (what, found, expected) in [
644 ("gateway", &marker.gateway, gateway),
645 ("env", &marker.env, env),
646 ("mode", &marker.mode, mode),
647 ] {
648 if found != expected {
649 return Err(base_refusal(
650 dir,
651 format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
652 None,
653 ));
654 }
655 }
656
657 let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
658 for entry in &marker.files {
659 if listed.insert(entry.path.as_str(), entry).is_some() {
660 return Err(base_refusal(
661 dir,
662 format!("{RENDER_MARKER} lists a path twice"),
663 Some(&entry.path),
664 ));
665 }
666 }
667 let found: BTreeSet<String> = walked.keys().cloned().collect();
668 let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
669 if let Some(path) = first_difference(&found, &named) {
670 let problem = if path.ends_with(TMP_SUFFIX) {
671 "holds a leftover temporary file from an interrupted render".to_string()
672 } else if found.contains(path) {
673 format!("holds a file not listed in {RENDER_MARKER}")
674 } else {
675 format!("is missing a file listed in {RENDER_MARKER}")
676 };
677 return Err(base_refusal(dir, problem, Some(path)));
678 }
679 for d in &dirs {
680 let prefix = format!("{d}/");
681 if !named.iter().any(|p| p.starts_with(&prefix)) {
682 return Err(base_refusal(
683 dir,
684 "holds a directory with no rendered file",
685 Some(d),
686 ));
687 }
688 }
689
690 let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
691 let mut total: u64 = 0;
692 for (rel, path) in &walked {
693 let entry = listed[rel.as_str()];
694 let key = RelPath::new(rel).map_err(|why| {
695 base_refusal(
696 dir,
697 format!("{RENDER_MARKER} lists an unusable path ({why})"),
698 Some(rel),
699 )
700 })?;
701 let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
702 if sha256 != entry.sha256 || len != entry.bytes {
703 return Err(base_refusal(
704 dir,
705 format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
706 Some(rel),
707 ));
708 }
709 files.insert(key, sha256);
710 }
711
712 let render_sha256 = listing_digest(&files);
713 if render_sha256 != marker.render_sha256 {
714 return Err(base_refusal(
715 dir,
716 format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
717 None,
718 ));
719 }
720 Ok(BaseTree {
721 dir: dir.to_string_lossy().into_owned(),
722 render_sha256,
723 files,
724 })
725 }
726}
727
728fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
731 let before = std::fs::symlink_metadata(path).map_err(|e| {
732 base_refusal(
733 dir,
734 format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
735 None,
736 )
737 })?;
738 let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
739 if held.len() > READ_MAX_FILE_BYTES {
740 return Err(base_refusal(
741 dir,
742 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
743 None,
744 ));
745 }
746 let mut bytes = Vec::new();
747 file.take(READ_MAX_FILE_BYTES + 1)
748 .read_to_end(&mut bytes)
749 .map_err(|e| {
750 base_refusal(
751 dir,
752 format!("cannot read {RENDER_MARKER} ({})", e.kind()),
753 None,
754 )
755 })?;
756 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
757 return Err(base_refusal(
758 dir,
759 format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
760 None,
761 ));
762 }
763 serde_json::from_slice(&bytes).map_err(|_| {
764 base_refusal(
765 dir,
766 format!("{RENDER_MARKER} is not a valid render marker"),
767 None,
768 )
769 })
770}
771
772fn open_base_file(
773 dir: &Path,
774 rel: &str,
775 before: &std::fs::Metadata,
776) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
777 open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
778 OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
779 OpenError::Io(e) => {
780 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
781 }
782 })
783}
784
785fn hash_base_file(
788 dir: &Path,
789 rel: &str,
790 path: &Path,
791 total: &mut u64,
792) -> Result<(String, u64), CoreError> {
793 let before = std::fs::symlink_metadata(path).map_err(|e| {
794 base_refusal(
795 dir,
796 format!("cannot inspect a file ({})", e.kind()),
797 Some(rel),
798 )
799 })?;
800 if !before.file_type().is_file() {
801 return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
802 }
803 let (file, held) = open_base_file(dir, rel, &before)?;
804 if held.len() > READ_MAX_FILE_BYTES {
805 return Err(base_refusal(
806 dir,
807 format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
808 Some(rel),
809 ));
810 }
811 *total += held.len();
812 if *total > READ_MAX_TOTAL_BYTES {
813 return Err(base_refusal(
814 dir,
815 format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
816 Some(rel),
817 ));
818 }
819 let mut hasher = Sha256::new();
820 let mut buf = [0u8; 64 * 1024];
821 let mut len: u64 = 0;
822 let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
823 loop {
824 let n = reader.read(&mut buf).map_err(|e| {
825 base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
826 })?;
827 if n == 0 {
828 break;
829 }
830 len += n as u64;
831 if len > READ_MAX_FILE_BYTES {
832 return Err(base_refusal(
833 dir,
834 format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
835 Some(rel),
836 ));
837 }
838 hasher.update(&buf[..n]);
839 }
840 Ok((format!("{:x}", hasher.finalize()), len))
841}
842
843fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
846 let mut hasher = Sha256::new();
847 for (path, sha256) in files {
848 hasher.update(path.as_str().as_bytes());
849 hasher.update([0u8]);
850 hasher.update(sha256.as_bytes());
851 hasher.update(b"\n");
852 }
853 format!("{:x}", hasher.finalize())
854}
855
856pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
864 let mut drifted = 0;
865 for entry in &mut plan.entries {
866 let drift = entry.files.iter().any(|file| {
867 let base_sha = RelPath::new(&file.path)
868 .ok()
869 .and_then(|path| base.files.get(&path));
870 file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
871 });
872 entry.drift = Some(drift);
873 drifted += usize::from(drift);
874 }
875 plan.summary.drifted = Some(drifted);
876}
877
878pub const DIFF_MAX_BYTES: usize = 65_536;
881
882pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
884
885pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
917 for entry in &mut plan.entries {
918 if !matches!(
919 entry.status,
920 PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
921 ) {
922 continue;
923 }
924 let layer = if entry.live_in.iter().any(|l| l == "mode") {
925 &observed.mode
926 } else {
927 &observed.core
928 };
929 let mut text = String::new();
930 for file in &entry.files {
931 if file.live_sha256 == file.desired_sha256 {
932 continue;
933 }
934 let Ok(path) = RelPath::new(&file.path) else {
935 continue;
936 };
937 let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
938 let desired = rendered.files.get(&path).map(Vec::as_slice);
939 text.push_str(&file_diff(
940 &path,
941 live,
942 desired,
943 file.live_sha256.as_deref(),
944 file.desired_sha256.as_deref(),
945 ));
946 }
947 entry.diff = (!text.is_empty()).then(|| cap_diff(text));
948 }
949}
950
951fn file_diff(
953 path: &RelPath,
954 live: Option<&[u8]>,
955 desired: Option<&[u8]>,
956 live_sha: Option<&str>,
957 desired_sha: Option<&str>,
958) -> String {
959 let shas = format!(
960 "live {}, desired {}",
961 live_sha.unwrap_or("-"),
962 desired_sha.unwrap_or("-")
963 );
964 let hash_only = || format!("{path}: differs ({shas})\n");
965 if !path.as_str().ends_with(".json") {
966 return hash_only();
967 }
968 let mut locations = 0;
969 for bytes in [live, desired].into_iter().flatten() {
970 let Ok((_, node)) = classify(bytes) else {
971 return hash_only();
972 };
973 locations += secret_locations(path, &node) + sensitive_key_hits(&node);
974 }
975 if locations > 0 {
976 return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
977 }
978 let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
980 let new = desired
981 .and_then(|b| std::str::from_utf8(b).ok())
982 .unwrap_or("");
983 let diff = TextDiff::from_lines(old, new);
984 let mut unified = diff.unified_diff();
985 unified
986 .context_radius(3)
987 .header(&format!("live/{path}"), &format!("desired/{path}"));
988 let mut text = unified.to_string();
989 if !text.ends_with('\n') {
990 text.push('\n');
991 }
992 text
993}
994
995fn secret_locations(path: &RelPath, node: &Node) -> usize {
998 let origin = path.as_str();
999 let parts: Vec<&str> = path.components().collect();
1000 let tag_file =
1001 parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1002 let scan = if tag_file {
1003 scan_embedded(origin, node)
1004 } else {
1005 let key = resource_key(path);
1006 let skip = if key.name.is_some() { 3 } else { 2 };
1007 let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1008 scan_secrets(origin, &key, &rest.join("/"), node)
1009 };
1010 scan.refused.len() + scan.warnings.len()
1011}
1012
1013const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1015 "password",
1016 "passwd",
1017 "secret",
1018 "token",
1019 "apikey",
1020 "api_key",
1021 "credential",
1022 "privatekey",
1023 "private_key",
1024];
1025
1026fn sensitive_key_hits(node: &Node) -> usize {
1037 sensitive_walk(node, false)
1038}
1039
1040fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1041 match node {
1042 Node::Obj(members) => members
1043 .iter()
1044 .map(|(key, value)| {
1045 let lowered = key.to_ascii_lowercase();
1046 let secret = under_secret_key
1047 || SENSITIVE_KEY_FRAGMENTS
1048 .iter()
1049 .any(|fragment| lowered.contains(fragment));
1050 sensitive_walk(value, secret)
1051 })
1052 .sum(),
1053 Node::Arr(items) => items
1054 .iter()
1055 .map(|item| sensitive_walk(item, under_secret_key))
1056 .sum(),
1057 Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1058 Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1059 }
1060}
1061
1062fn cap_diff(mut text: String) -> String {
1065 if text.len() > DIFF_MAX_BYTES {
1066 let mut cut = DIFF_MAX_BYTES;
1067 while !text.is_char_boundary(cut) {
1068 cut -= 1;
1069 }
1070 text.truncate(cut);
1071 if !text.ends_with('\n') {
1072 text.push('\n');
1073 }
1074 text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1075 } else {
1076 while text.ends_with('\n') {
1077 text.pop();
1078 }
1079 }
1080 text
1081}
1082
1083fn canonical_text(render_sha256: &str, entries: &[PlanEntry], delete_set: &[RelPath]) -> Vec<u8> {
1085 let mut text: Vec<u8> = Vec::new();
1086 text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1087 text.push(b'\n');
1088 text.extend_from_slice(b"render_sha256\0");
1089 text.extend_from_slice(render_sha256.as_bytes());
1090 text.push(b'\n');
1091 for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1092 text.extend_from_slice(b"E\0");
1093 text.extend_from_slice(entry.key.as_bytes());
1094 text.push(0);
1095 text.extend_from_slice(entry.status.word().as_bytes());
1096 text.push(0);
1097 text.extend_from_slice(entry.core.word().as_bytes());
1098 text.push(0);
1099 text.extend_from_slice(entry.live_in.join(",").as_bytes());
1100 text.push(b'\n');
1101 for file in &entry.files {
1102 text.extend_from_slice(b"F\0");
1103 text.extend_from_slice(file.path.as_bytes());
1104 text.push(0);
1105 text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1106 text.push(0);
1107 text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1108 text.push(b'\n');
1109 }
1110 }
1111 for path in delete_set {
1112 text.extend_from_slice(b"D\0");
1113 text.extend_from_slice(path.as_str().as_bytes());
1114 text.push(b'\n');
1115 }
1116 text
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122
1123 fn rp(s: &str) -> RelPath {
1124 RelPath::new(s).unwrap()
1125 }
1126
1127 fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1128 paths.iter().map(|p| (rp(p), ())).collect()
1129 }
1130
1131 #[test]
1132 fn resource_files_group_under_their_resource_directory() {
1133 let l = layer(&[
1134 "ignition/database-translator/POSTGRES/config.json",
1135 "ignition/database-translator/POSTGRES/resource.json",
1136 ]);
1137 let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1138 assert_eq!(key, "ignition/database-translator/POSTGRES");
1139 }
1140
1141 #[test]
1142 fn singletons_group_per_type() {
1143 let l = layer(&[
1144 "ignition/general-alarm-settings/config.json",
1145 "ignition/general-alarm-settings/resource.json",
1146 ]);
1147 let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1148 assert_eq!(key, "ignition/general-alarm-settings");
1149 }
1150
1151 #[test]
1152 fn nearest_marker_wins_for_tag_folders() {
1153 let l = layer(&[
1154 "ignition/tag-definition/P/unary-resource.json",
1155 "ignition/tag-definition/P/A/unary-resource.json",
1156 "ignition/tag-definition/P/A/tags.json",
1157 "ignition/tag-definition/P/B/tags.json",
1158 ]);
1159 assert_eq!(
1160 unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1161 "ignition/tag-definition/P/A"
1162 );
1163 assert_eq!(
1165 unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1166 "ignition/tag-definition/P"
1167 );
1168 }
1169
1170 #[test]
1171 fn no_marker_falls_back_to_the_parent() {
1172 let l = layer(&["ignition/database-translator/X/config.json"]);
1173 assert_eq!(
1174 unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1175 "ignition/database-translator/X"
1176 );
1177 }
1178
1179 #[test]
1180 fn empty_inputs_plan_empty() {
1181 let scope = Scope {
1182 mode: "dev".to_string(),
1183 types: Vec::new(),
1184 tag_providers: Vec::new(),
1185 };
1186 let p = plan(
1187 &RenderedTree::default(),
1188 &ObservedFiles::default(),
1189 None,
1190 &scope,
1191 );
1192 assert!(p.entries.is_empty());
1193 assert!(!p.summary.has_changes);
1194 }
1195}