use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::Path;
use serde::Serialize;
use sha2::{Digest, Sha256};
use similar::TextDiff;
use super::exclude;
use super::layout::{RelPath, TAG_TYPES, resource_key};
use super::ledger::Ledger;
use super::lint::{scan_embedded, scan_secrets};
use super::node::{Node, classify};
use super::render::member_named_tag_folder;
use super::rules::GATEWAY_OWNED_FILES;
use super::transport::{
DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
READ_MAX_TOTAL_BYTES, Scope,
};
use super::tree::{
MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
open_regular_file, sha256_hex, walk_capped,
};
use crate::error::{CoreError, DeployFinding};
pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PlanStatus {
Added,
Changed,
Removed,
Same,
Unmanaged,
}
impl PlanStatus {
pub fn word(self) -> &'static str {
match self {
PlanStatus::Added => "added",
PlanStatus::Changed => "changed",
PlanStatus::Removed => "removed",
PlanStatus::Same => "same",
PlanStatus::Unmanaged => "unmanaged",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CoreRelation {
None,
Identical,
Differs,
Hidden,
ShadowsRemoval,
}
impl CoreRelation {
pub fn word(self) -> &'static str {
match self {
CoreRelation::None => "none",
CoreRelation::Identical => "identical",
CoreRelation::Differs => "differs",
CoreRelation::Hidden => "hidden",
CoreRelation::ShadowsRemoval => "shadows_removal",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlanFile {
pub path: String,
pub live_sha256: Option<String>,
pub desired_sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlanEntry {
pub key: String,
pub module: String,
#[serde(rename = "type")]
pub ty: String,
pub name: Option<String>,
pub status: PlanStatus,
pub live_in: Vec<String>,
pub core: CoreRelation,
pub destructive: bool,
pub destructive_reason: Option<String>,
pub lockout_risk: bool,
pub drift: Option<bool>,
pub files: Vec<PlanFile>,
pub diff: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct PlanSummary {
pub added: usize,
pub changed: usize,
pub removed: usize,
pub same: usize,
pub unmanaged: usize,
pub unreadable: usize,
pub has_changes: bool,
pub drifted: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Plan {
pub entries: Vec<PlanEntry>,
pub summary: PlanSummary,
pub delete_set: DeleteSet,
pub plan_sha256: String,
pub warnings: Vec<String>,
pub unreadable: Vec<UnreadableUnit>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UnreadableUnit {
pub key: String,
pub live_in: Vec<String>,
pub declared: bool,
pub in_ledger: bool,
pub paths: Vec<String>,
pub longest: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UnreadableKey {
pub collections: BTreeSet<String>,
pub paths: Vec<String>,
pub longest: usize,
}
pub fn unit_key_layer(observed: &ObservedFiles, collection: &str) -> BTreeMap<RelPath, ()> {
let files = if collection == "core" {
&observed.core
} else {
&observed.mode
};
files
.keys()
.cloned()
.chain(
observed
.unreadable
.iter()
.filter(|u| u.collection == collection)
.map(|u| u.path.clone()),
)
.map(|p| (p, ()))
.collect()
}
pub fn unreadable_unit_keys(observed: &ObservedFiles) -> BTreeMap<String, UnreadableKey> {
let mut out: BTreeMap<String, UnreadableKey> = BTreeMap::new();
if observed.unreadable.is_empty() {
return out;
}
let core_layer = unit_key_layer(observed, "core");
let mode_layer = unit_key_layer(observed, "mode");
for u in &observed.unreadable {
let layer = match u.collection.as_str() {
"core" => &core_layer,
"mode" => &mode_layer,
_ => continue,
};
let info = out.entry(unit_key(&u.path, layer)).or_default();
info.collections.insert(u.collection.clone());
info.paths.push(u.path.as_str().to_string());
info.longest = info.longest.max(u.length);
}
for info in out.values_mut() {
info.paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
info.paths.dedup();
}
out
}
pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
let parts: Vec<&str> = path.components().collect();
if parts.len() < 2 {
return path.as_str().to_string();
}
let parent_len = parts.len() - 1;
for end in (2..=parent_len).rev() {
let dir = parts[..end].join("/");
for marker in ["resource.json", "unary-resource.json"] {
if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
&& layer.contains_key(&candidate)
{
return dir;
}
}
}
parts[..parent_len].join("/")
}
type Units = BTreeMap<String, BTreeMap<String, String>>;
fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
let mut units = Units::new();
for (path, value) in layer {
units
.entry(unit_key(path, layer))
.or_default()
.insert(path.as_str().to_string(), digest(value));
}
units
}
fn group_keyed<'a, V: 'a>(
layer: &'a BTreeMap<RelPath, V>,
keys: &BTreeMap<RelPath, ()>,
digest: impl Fn(&'a V) -> String,
) -> Units {
let mut units = Units::new();
for (path, value) in layer {
units
.entry(unit_key(path, keys))
.or_default()
.insert(path.as_str().to_string(), digest(value));
}
units
}
fn blob_digest(blob: &FileBlob) -> String {
blob.sha256.clone()
}
fn is_tag_folder(key: &str) -> bool {
let mut parts = key.split('/');
parts.next() == Some("ignition")
&& matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
&& parts.next().is_some()
}
fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
core.keys().any(|path| !rendered.contains_key(path))
}
const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
fn tag_data_dropped(
key: &str,
effective: Option<&BTreeMap<String, String>>,
rendered: &BTreeMap<String, String>,
) -> Option<String> {
let live: Vec<&str> = effective
.into_iter()
.flat_map(BTreeMap::keys)
.filter(|path| {
let name = path.rsplit('/').next().unwrap_or_default();
TAG_MEMBER_FILES
.iter()
.any(|m| m.eq_ignore_ascii_case(name))
&& !rendered.contains_key(*path)
})
.map(String::as_str)
.collect();
if live.is_empty() {
return None;
}
Some(format!(
"{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",
if live.len() == 1 {
format!("{} is", live[0])
} else {
format!("{} are", live.join(", "))
}
))
}
fn in_scope(scope: &Scope, path: &RelPath) -> bool {
let parts: Vec<&str> = path.components().collect();
if parts.len() < 3 {
return false;
}
let (module, ty) = (parts[0], parts[1]);
if scope.types.iter().any(|(m, t)| m == module && t == ty) {
return true;
}
module == "ignition"
&& TAG_TYPES.contains(&ty)
&& scope.tag_providers.iter().any(|p| p == parts[2])
}
struct KeyLayers<'a> {
core: &'a BTreeMap<RelPath, ()>,
mode: &'a BTreeMap<RelPath, ()>,
}
fn delete_set(
rendered: &RenderedTree,
observed: &ObservedFiles,
ledger: Option<&Ledger>,
scope: &Scope,
excluded: &[String],
layers: &KeyLayers<'_>,
unreadable: &BTreeMap<String, UnreadableKey>,
) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
let Some(ledger) = ledger else {
return (Vec::new(), Vec::new(), BTreeSet::new());
};
let rendered_folded: BTreeSet<String> = rendered
.files
.keys()
.map(|p| p.as_str().to_ascii_lowercase())
.collect();
let mut listed: Vec<&String> = ledger.files.iter().collect();
listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
listed.dedup();
let mut delete = Vec::new();
let mut warnings = Vec::new();
let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
let mut ledgered: BTreeSet<String> = BTreeSet::new();
for raw in listed {
let Ok(path) = RelPath::new(raw) else {
continue;
};
if path
.components()
.any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
{
continue;
}
if let Some(provider) = exclude::excluded_provider(&path, excluded) {
let entry = excluded
.iter()
.find(|e| e.eq_ignore_ascii_case(provider))
.map_or(provider, String::as_str);
*excluded_files.entry(entry.to_string()).or_default() += 1;
continue;
}
if !in_scope(scope, &path) {
warnings.push(format!(
"ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
));
continue;
}
if !unreadable.is_empty() {
let hit = [unit_key(&path, layers.mode), unit_key(&path, layers.core)]
.into_iter()
.find(|key| unreadable.contains_key(key));
if let Some(key) = hit {
ledgered.insert(key);
continue;
}
}
if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
continue;
}
if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
warnings.push(format!(
"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"
));
continue;
}
delete.push(path);
}
for (provider, count) in excluded_files {
warnings.push(format!(
"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"
));
}
(delete, warnings, ledgered)
}
fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
match (module, ty) {
("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
"tag_data_loss"
}
("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
"connection_removed"
}
("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
_ if status == PlanStatus::Removed => "resource_removed",
_ => "files_removed",
}
}
pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
let live = observed
.core
.keys()
.map(|path| ("core", path))
.chain(observed.mode.keys().map(|path| (mode, path)))
.chain(observed.unreadable.iter().map(|u| {
let collection = if u.collection == "core" { "core" } else { mode };
(collection, &u.path)
}));
let folders: BTreeSet<(&str, String)> = live
.filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
.collect();
folders
.into_iter()
.map(|(collection, dir)| DeployFinding {
file: format!("config/resources/{collection}/{dir}/"),
line: None,
column: None,
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(),
})
.collect()
}
pub fn plan(
rendered: &RenderedTree,
observed: &ObservedFiles,
ledger: Option<&Ledger>,
scope: &Scope,
excluded: &[String],
) -> Plan {
let unreadable_keys = unreadable_unit_keys(observed);
let core_layer = unit_key_layer(observed, "core");
let mode_layer = unit_key_layer(observed, "mode");
let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
let layers = KeyLayers {
core: &core_layer,
mode: &mode_layer,
};
let (delete, ledger_warnings, ledgered) = delete_set(
rendered,
observed,
ledger,
scope,
excluded,
&layers,
&unreadable_keys,
);
let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
for path in &delete {
*deleted.entry(unit_key(path, &mode_layer)).or_default() += 1;
}
let mut unreadable = Vec::with_capacity(unreadable_keys.len());
for (key, info) in &unreadable_keys {
let declared = desired.remove(key).is_some();
let in_core = core.remove(key).is_some() || info.collections.contains("core");
let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
let mut live_in = Vec::new();
if in_core {
live_in.push("core".to_string());
}
if in_mode {
live_in.push("mode".to_string());
}
unreadable.push(UnreadableUnit {
key: key.clone(),
live_in,
declared,
in_ledger: ledgered.contains(key),
paths: info.paths.clone(),
longest: info.longest,
});
}
let keys: BTreeSet<&String> = desired
.keys()
.chain(core.keys())
.chain(mode.keys())
.collect();
let empty = BTreeMap::new();
let mut entries = Vec::with_capacity(keys.len());
let mut tag_loss_warnings: Vec<String> = Vec::new();
for key in keys {
let wanted = desired.get(key);
let core_copy = core.get(key);
let mode_copy = mode.get(key);
let effective = mode_copy.or(core_copy);
let mut live_in = Vec::new();
if core_copy.is_some() {
live_in.push("core".to_string());
}
if mode_copy.is_some() {
live_in.push("mode".to_string());
}
let deleted_here = deleted.get(key).copied().unwrap_or(0);
let status = match (wanted, effective) {
(Some(_), None) => PlanStatus::Added,
(Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
(Some(_), Some(_)) => PlanStatus::Changed,
(None, _) if deleted_here > 0 => PlanStatus::Removed,
(None, _) => PlanStatus::Unmanaged,
};
let rendered_files = wanted.unwrap_or(&empty);
let core_relation = match core_copy {
None => CoreRelation::None,
Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
Some(c)
if is_tag_folder(key)
&& (mode_copy.is_some() || wanted.is_some())
&& hides(c, rendered_files) =>
{
CoreRelation::Hidden
}
Some(c) if c == rendered_files => CoreRelation::Identical,
Some(_) => CoreRelation::Differs,
};
let paths: BTreeSet<&String> = wanted
.into_iter()
.chain(effective)
.flat_map(BTreeMap::keys)
.collect();
let files = paths
.into_iter()
.map(|path| PlanFile {
path: path.clone(),
live_sha256: effective.and_then(|e| e.get(path)).cloned(),
desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
})
.collect();
let parts: Vec<&str> = key.split('/').collect();
let module = parts.first().copied().unwrap_or_default();
let ty = parts.get(1).copied().unwrap_or_default();
let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
&& is_tag_folder(key))
.then(|| tag_data_dropped(key, effective, rendered_files))
.flatten();
let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
tag_loss_warnings.extend(dropped);
let lockout_risk = matches!(
status,
PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
) && module == "ignition"
&& matches!(ty, "identity-provider" | "security-zone");
entries.push(PlanEntry {
key: key.clone(),
module: module.to_string(),
ty: ty.to_string(),
name: (parts.len() >= 3).then(|| parts[2..].join("/")),
status,
live_in,
core: core_relation,
destructive,
destructive_reason: destructive
.then(|| destructive_reason(module, ty, status).to_string()),
lockout_risk,
drift: None,
files,
diff: None,
});
}
let mut summary = PlanSummary::default();
for entry in &entries {
match entry.status {
PlanStatus::Added => summary.added += 1,
PlanStatus::Changed => summary.changed += 1,
PlanStatus::Removed => summary.removed += 1,
PlanStatus::Same => summary.same += 1,
PlanStatus::Unmanaged => summary.unmanaged += 1,
}
}
summary.unreadable = unreadable.len();
summary.has_changes = summary.added + summary.changed + summary.removed > 0
|| unreadable.iter().any(|u| u.declared || u.in_ledger);
let mut warnings: Vec<String> = observed
.local_units
.iter()
.map(|unit| {
format!(
"{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"
)
})
.collect();
warnings.extend(ledger_warnings);
warnings.extend(tag_loss_warnings);
for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
warnings.push(format!(
"{} 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",
unit.key, unit.longest
));
}
let plan_sha256 = sha256_hex(&canonical_text(
&rendered.render_sha256(),
&entries,
&delete,
&unreadable,
));
Plan {
entries,
summary,
delete_set: DeleteSet { files: delete },
plan_sha256,
warnings,
unreadable,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaseTree {
pub dir: String,
pub render_sha256: String,
pub files: BTreeMap<RelPath, String>,
}
fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
CoreError::InvalidInput {
reason: format!("--base {}: {problem}{at}", dir.display()),
}
}
fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
a.symmetric_difference(b)
.min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
}
impl BaseTree {
pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
let meta = match std::fs::symlink_metadata(dir) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(base_refusal(dir, "does not exist", None));
}
Err(e) => {
return Err(base_refusal(
dir,
format!("cannot inspect it ({})", e.kind()),
None,
));
}
};
if meta.file_type().is_symlink() {
return Err(base_refusal(
dir,
"is a symlink; symlinks are refused",
None,
));
}
if !meta.file_type().is_dir() {
return Err(base_refusal(dir, "is not a directory", None));
}
let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
let mut dirs: BTreeSet<String> = BTreeSet::new();
walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
.map_err(|why| base_refusal(dir, why, None))?;
let Some(marker_path) = walked.remove(RENDER_MARKER) else {
return Err(base_refusal(
dir,
format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
Some(RENDER_MARKER),
));
};
let marker = read_marker(dir, &marker_path)?;
if marker.schema_version != 1 {
return Err(base_refusal(
dir,
format!(
"{RENDER_MARKER} has schema_version {}, expected 1",
marker.schema_version
),
None,
));
}
for (what, found, expected) in [
("gateway", &marker.gateway, gateway),
("env", &marker.env, env),
("mode", &marker.mode, mode),
] {
if found != expected {
return Err(base_refusal(
dir,
format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
None,
));
}
}
let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
for entry in &marker.files {
if listed.insert(entry.path.as_str(), entry).is_some() {
return Err(base_refusal(
dir,
format!("{RENDER_MARKER} lists a path twice"),
Some(&entry.path),
));
}
}
let found: BTreeSet<String> = walked.keys().cloned().collect();
let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
if let Some(path) = first_difference(&found, &named) {
let problem = if path.ends_with(TMP_SUFFIX) {
"holds a leftover temporary file from an interrupted render".to_string()
} else if found.contains(path) {
format!("holds a file not listed in {RENDER_MARKER}")
} else {
format!("is missing a file listed in {RENDER_MARKER}")
};
return Err(base_refusal(dir, problem, Some(path)));
}
for d in &dirs {
let prefix = format!("{d}/");
if !named.iter().any(|p| p.starts_with(&prefix)) {
return Err(base_refusal(
dir,
"holds a directory with no rendered file",
Some(d),
));
}
}
let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
let mut total: u64 = 0;
for (rel, path) in &walked {
let entry = listed[rel.as_str()];
let key = RelPath::new(rel).map_err(|why| {
base_refusal(
dir,
format!("{RENDER_MARKER} lists an unusable path ({why})"),
Some(rel),
)
})?;
let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
if sha256 != entry.sha256 || len != entry.bytes {
return Err(base_refusal(
dir,
format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
Some(rel),
));
}
files.insert(key, sha256);
}
let render_sha256 = listing_digest(&files);
if render_sha256 != marker.render_sha256 {
return Err(base_refusal(
dir,
format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
None,
));
}
Ok(BaseTree {
dir: dir.to_string_lossy().into_owned(),
render_sha256,
files,
})
}
}
fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
let before = std::fs::symlink_metadata(path).map_err(|e| {
base_refusal(
dir,
format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
None,
)
})?;
let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
if held.len() > READ_MAX_FILE_BYTES {
return Err(base_refusal(
dir,
format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
None,
));
}
let mut bytes = Vec::new();
file.take(READ_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| {
base_refusal(
dir,
format!("cannot read {RENDER_MARKER} ({})", e.kind()),
None,
)
})?;
if bytes.len() as u64 > READ_MAX_FILE_BYTES {
return Err(base_refusal(
dir,
format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
None,
));
}
serde_json::from_slice(&bytes).map_err(|_| {
base_refusal(
dir,
format!("{RENDER_MARKER} is not a valid render marker"),
None,
)
})
}
fn open_base_file(
dir: &Path,
rel: &str,
before: &std::fs::Metadata,
) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
OpenError::Io(e) => {
base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
}
})
}
fn hash_base_file(
dir: &Path,
rel: &str,
path: &Path,
total: &mut u64,
) -> Result<(String, u64), CoreError> {
let before = std::fs::symlink_metadata(path).map_err(|e| {
base_refusal(
dir,
format!("cannot inspect a file ({})", e.kind()),
Some(rel),
)
})?;
if !before.file_type().is_file() {
return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
}
let (file, held) = open_base_file(dir, rel, &before)?;
if held.len() > READ_MAX_FILE_BYTES {
return Err(base_refusal(
dir,
format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
Some(rel),
));
}
*total += held.len();
if *total > READ_MAX_TOTAL_BYTES {
return Err(base_refusal(
dir,
format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
Some(rel),
));
}
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
let mut len: u64 = 0;
let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
loop {
let n = reader.read(&mut buf).map_err(|e| {
base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
})?;
if n == 0 {
break;
}
len += n as u64;
if len > READ_MAX_FILE_BYTES {
return Err(base_refusal(
dir,
format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
Some(rel),
));
}
hasher.update(&buf[..n]);
}
Ok((format!("{:x}", hasher.finalize()), len))
}
fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
let mut hasher = Sha256::new();
for (path, sha256) in files {
hasher.update(path.as_str().as_bytes());
hasher.update([0u8]);
hasher.update(sha256.as_bytes());
hasher.update(b"\n");
}
format!("{:x}", hasher.finalize())
}
pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
let mut drifted = 0;
for entry in &mut plan.entries {
let drift = entry.files.iter().any(|file| {
let base_sha = RelPath::new(&file.path)
.ok()
.and_then(|path| base.files.get(&path));
file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
});
entry.drift = Some(drift);
drifted += usize::from(drift);
}
plan.summary.drifted = Some(drifted);
}
pub const DIFF_MAX_BYTES: usize = 65_536;
pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
for entry in &mut plan.entries {
if !matches!(
entry.status,
PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
) {
continue;
}
let layer = if entry.live_in.iter().any(|l| l == "mode") {
&observed.mode
} else {
&observed.core
};
let mut text = String::new();
for file in &entry.files {
if file.live_sha256 == file.desired_sha256 {
continue;
}
let Ok(path) = RelPath::new(&file.path) else {
continue;
};
let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
let desired = rendered.files.get(&path).map(Vec::as_slice);
text.push_str(&file_diff(
&path,
live,
desired,
file.live_sha256.as_deref(),
file.desired_sha256.as_deref(),
));
}
entry.diff = (!text.is_empty()).then(|| cap_diff(text));
}
}
fn file_diff(
path: &RelPath,
live: Option<&[u8]>,
desired: Option<&[u8]>,
live_sha: Option<&str>,
desired_sha: Option<&str>,
) -> String {
let shas = format!(
"live {}, desired {}",
live_sha.unwrap_or("-"),
desired_sha.unwrap_or("-")
);
let hash_only = || format!("{path}: differs ({shas})\n");
if !path.as_str().ends_with(".json") {
return hash_only();
}
let mut locations = 0;
for bytes in [live, desired].into_iter().flatten() {
let Ok((_, node)) = classify(bytes) else {
return hash_only();
};
locations += secret_locations(path, &node) + sensitive_key_hits(&node);
}
if locations > 0 {
return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
}
let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
let new = desired
.and_then(|b| std::str::from_utf8(b).ok())
.unwrap_or("");
let diff = TextDiff::from_lines(old, new);
let mut unified = diff.unified_diff();
unified
.context_radius(3)
.header(&format!("live/{path}"), &format!("desired/{path}"));
let mut text = unified.to_string();
if !text.ends_with('\n') {
text.push('\n');
}
text
}
fn secret_locations(path: &RelPath, node: &Node) -> usize {
let origin = path.as_str();
let parts: Vec<&str> = path.components().collect();
let tag_file =
parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
let scan = if tag_file {
scan_embedded(origin, node)
} else {
let key = resource_key(path);
let skip = if key.name.is_some() { 3 } else { 2 };
let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
scan_secrets(origin, &key, &rest.join("/"), node)
};
scan.refused.len() + scan.warnings.len()
}
const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
"password",
"passwd",
"secret",
"token",
"apikey",
"api_key",
"credential",
"privatekey",
"private_key",
];
fn sensitive_key_hits(node: &Node) -> usize {
sensitive_walk(node, false)
}
fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
match node {
Node::Obj(members) => members
.iter()
.map(|(key, value)| {
let lowered = key.to_ascii_lowercase();
let secret = under_secret_key
|| SENSITIVE_KEY_FRAGMENTS
.iter()
.any(|fragment| lowered.contains(fragment));
sensitive_walk(value, secret)
})
.sum(),
Node::Arr(items) => items
.iter()
.map(|item| sensitive_walk(item, under_secret_key))
.sum(),
Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
}
}
fn cap_diff(mut text: String) -> String {
if text.len() > DIFF_MAX_BYTES {
let mut cut = DIFF_MAX_BYTES;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
} else {
while text.ends_with('\n') {
text.pop();
}
}
text
}
fn canonical_text(
render_sha256: &str,
entries: &[PlanEntry],
delete_set: &[RelPath],
unreadable: &[UnreadableUnit],
) -> Vec<u8> {
let mut text: Vec<u8> = Vec::new();
text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
text.push(b'\n');
text.extend_from_slice(b"render_sha256\0");
text.extend_from_slice(render_sha256.as_bytes());
text.push(b'\n');
for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
text.extend_from_slice(b"E\0");
text.extend_from_slice(entry.key.as_bytes());
text.push(0);
text.extend_from_slice(entry.status.word().as_bytes());
text.push(0);
text.extend_from_slice(entry.core.word().as_bytes());
text.push(0);
text.extend_from_slice(entry.live_in.join(",").as_bytes());
text.push(b'\n');
for file in &entry.files {
text.extend_from_slice(b"F\0");
text.extend_from_slice(file.path.as_bytes());
text.push(0);
text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
text.push(0);
text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
text.push(b'\n');
}
}
for path in delete_set {
text.extend_from_slice(b"D\0");
text.extend_from_slice(path.as_str().as_bytes());
text.push(b'\n');
}
for unit in unreadable {
text.extend_from_slice(b"U\0");
text.extend_from_slice(unit.key.as_bytes());
text.push(0);
text.extend_from_slice(unit.live_in.join(",").as_bytes());
text.push(0);
text.push(if unit.declared { b'1' } else { b'0' });
text.push(0);
text.push(if unit.in_ledger { b'1' } else { b'0' });
text.push(b'\n');
}
text
}
#[cfg(test)]
mod tests {
use super::*;
fn rp(s: &str) -> RelPath {
RelPath::new(s).unwrap()
}
fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
paths.iter().map(|p| (rp(p), ())).collect()
}
#[test]
fn resource_files_group_under_their_resource_directory() {
let l = layer(&[
"ignition/database-translator/POSTGRES/config.json",
"ignition/database-translator/POSTGRES/resource.json",
]);
let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
assert_eq!(key, "ignition/database-translator/POSTGRES");
}
#[test]
fn singletons_group_per_type() {
let l = layer(&[
"ignition/general-alarm-settings/config.json",
"ignition/general-alarm-settings/resource.json",
]);
let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
assert_eq!(key, "ignition/general-alarm-settings");
}
#[test]
fn nearest_marker_wins_for_tag_folders() {
let l = layer(&[
"ignition/tag-definition/P/unary-resource.json",
"ignition/tag-definition/P/A/unary-resource.json",
"ignition/tag-definition/P/A/tags.json",
"ignition/tag-definition/P/B/tags.json",
]);
assert_eq!(
unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
"ignition/tag-definition/P/A"
);
assert_eq!(
unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
"ignition/tag-definition/P"
);
}
#[test]
fn cd5_unit_keys() {
let l = layer(&[
"ignition/tag-definition/P/unary-resource.json",
"ignition/tag-definition/P/tags.json",
"ignition/tag-definition/P/udts.json",
"ignition/tag-type-definition/P/unary-resource.json",
"ignition/tag-type-definition/P/udts.json",
"ignition/tag-type-definition/P/A/unary-resource.json",
"ignition/tag-type-definition/P/A/B/unary-resource.json",
"ignition/tag-type-definition/P/A/B/udts.json",
]);
for (path, key) in [
(
"ignition/tag-definition/P/tags.json",
"ignition/tag-definition/P",
),
(
"ignition/tag-definition/P/udts.json",
"ignition/tag-definition/P",
),
(
"ignition/tag-type-definition/P/udts.json",
"ignition/tag-type-definition/P",
),
(
"ignition/tag-type-definition/P/A/unary-resource.json",
"ignition/tag-type-definition/P/A",
),
(
"ignition/tag-type-definition/P/A/B/udts.json",
"ignition/tag-type-definition/P/A/B",
),
(
"ignition/tag-type-definition/P/A/B/unary-resource.json",
"ignition/tag-type-definition/P/A/B",
),
] {
assert_eq!(unit_key(&rp(path), &l), key, "{path}");
}
let l = layer(&[
"ignition/tag-type-definition/P/unary-resource.json",
"ignition/tag-type-definition/P/A/unary-resource.json",
"ignition/tag-type-definition/P/A/B/udts.json",
]);
assert_eq!(
unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
"ignition/tag-type-definition/P/A"
);
let l = layer(&[
"ignition/tag-type-definition/P/unary-resource.json",
"ignition/tag-type-definition/P/A/B/udts.json",
]);
assert_eq!(
unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
"ignition/tag-type-definition/P"
);
}
#[test]
fn no_marker_falls_back_to_the_parent() {
let l = layer(&["ignition/database-translator/X/config.json"]);
assert_eq!(
unit_key(&rp("ignition/database-translator/X/config.json"), &l),
"ignition/database-translator/X"
);
}
#[test]
fn empty_inputs_plan_empty() {
let scope = Scope {
mode: "dev".to_string(),
types: Vec::new(),
tag_providers: Vec::new(),
};
let p = plan(
&RenderedTree::default(),
&ObservedFiles::default(),
None,
&scope,
&["System".to_string()],
);
assert!(p.entries.is_empty());
assert!(!p.summary.has_changes);
}
}