use super::census::{DriftCensus, SkipReason};
use super::ignore::should_ignore_drift;
use super::unmeasured::{self, Reading};
use super::{DriftFinding, DRIFT_QUERY_TIMEOUT_SECS};
use crate::core::types::{Machine, Resource, ResourceStatus, ResourceType, StateLock};
use crate::tripwire::hasher;
use std::path::Path;
pub fn check_file_drift(
resource_id: &str,
path: &str,
expected_hash: &str,
) -> Option<DriftFinding> {
let file_path = Path::new(path);
if !file_path.exists() {
return Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::File,
expected_hash: expected_hash.to_string(),
actual_hash: "MISSING".to_string(),
detail: format!("{path} does not exist"),
});
}
let actual = if file_path.is_dir() {
hasher::hash_directory(file_path).unwrap_or_else(|e| format!("ERROR:{e}"))
} else {
hasher::hash_file(file_path).unwrap_or_else(|e| format!("ERROR:{e}"))
};
if actual != expected_hash {
Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::File,
expected_hash: expected_hash.to_string(),
actual_hash: actual,
detail: format!("{path} content changed"),
})
} else {
None
}
}
#[derive(Debug)]
pub(super) enum RemoteContent {
Digest(String),
Failed(String),
Unmeasured(String),
}
fn remote_content(
out: &crate::transport::ExecOutput,
path: &str,
machine: &Machine,
) -> RemoteContent {
if out.stdout.trim() != "__DIR__" {
return RemoteContent::Digest(hasher::hash_string_or_sentinel(&out.stdout));
}
listing_digest(unmeasured::read(machine, &format!("ls -la '{path}'")))
}
pub(super) fn listing_digest(listing: Reading) -> RemoteContent {
match listing {
Reading::Answered(ls) if ls.success() => {
RemoteContent::Digest(hasher::hash_string_or_sentinel(&ls.stdout))
}
Reading::Answered(ls) => RemoteContent::Failed(ls.stderr.trim().to_string()),
Reading::Unmeasured(why) => RemoteContent::Unmeasured(why),
}
}
fn file_drift_finding(
resource_id: &str,
expected_hash: &str,
actual_hash: String,
detail: String,
) -> DriftFinding {
DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::File,
expected_hash: expected_hash.to_string(),
actual_hash,
detail,
}
}
pub fn remote_path_digest(path: &str, machine: &Machine) -> Option<String> {
let script = format!(
"set -euo pipefail\nif [ -d '{path}' ]; then echo '__DIR__'; else cat '{path}'; fi"
);
match crate::transport::exec_script_timeout(machine, &script, Some(DRIFT_QUERY_TIMEOUT_SECS)) {
Ok(out) if out.success() => match remote_content(&out, path, machine) {
RemoteContent::Digest(digest) => Some(digest),
RemoteContent::Failed(_) | RemoteContent::Unmeasured(_) => None,
},
_ => None,
}
}
pub fn check_file_drift_via_transport(
resource_id: &str,
path: &str,
expected_hash: &str,
machine: &Machine,
) -> Option<DriftFinding> {
let script = format!(
"set -euo pipefail\nif [ -d '{path}' ]; then echo '__DIR__'; else cat '{path}'; fi"
);
let out = match unmeasured::read(machine, &script) {
Reading::Answered(out) => out,
Reading::Unmeasured(why) => {
return Some(DriftFinding::unmeasured(
resource_id,
ResourceType::File,
expected_hash,
format!("{path} not measured: {why}"),
))
}
};
if !out.success() {
return Some(file_drift_finding(
resource_id,
expected_hash,
"MISSING".to_string(),
format!("{} not accessible: {}", path, out.stderr.trim()),
));
}
content_verdict(
resource_id,
path,
expected_hash,
remote_content(&out, path, machine),
)
}
pub(super) fn content_verdict(
resource_id: &str,
path: &str,
expected_hash: &str,
content: RemoteContent,
) -> Option<DriftFinding> {
match content {
RemoteContent::Digest(actual) if actual == expected_hash => None,
RemoteContent::Digest(actual) => Some(file_drift_finding(
resource_id,
expected_hash,
actual,
format!("{path} content changed"),
)),
RemoteContent::Unmeasured(why) => Some(DriftFinding::unmeasured(
resource_id,
ResourceType::File,
expected_hash,
format!("{path} listing not measured: {why}"),
)),
RemoteContent::Failed(stderr) => Some(file_drift_finding(
resource_id,
expected_hash,
"ERROR".to_string(),
format!("{path} listing failed: {stderr}"),
)),
}
}
pub(super) fn detect_drift_with_lifecycle(
lock: &StateLock,
machine: Option<&Machine>,
resources: &indexmap::IndexMap<String, Resource>,
census: &mut DriftCensus,
) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
if rl.resource_type != ResourceType::File {
continue;
}
if rl.status != ResourceStatus::Converged {
census.skipped(id, &rl.resource_type, SkipReason::NotConverged);
continue;
}
if should_ignore_drift(id, resources) {
census.skipped(id, &rl.resource_type, SkipReason::IgnoreDrift);
continue;
}
let Some((path, expected)) = locked_file_target(rl) else {
census.skipped(id, &rl.resource_type, SkipReason::NoLockedHash);
continue;
};
census.inspected(id, &rl.resource_type);
if let Some(f) = check_file_resource_drift(id, path, expected, machine) {
findings.push(f);
}
}
findings
}
pub(super) fn locked_file_target(rl: &crate::core::types::ResourceLock) -> Option<(&str, &str)> {
let path = match rl.details.get("path") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => return None,
};
let expected = match rl.details.get("content_hash") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => return None,
};
Some((path, expected))
}
pub(super) fn check_file_resource_drift(
id: &str,
path: &str,
expected: &str,
machine: Option<&Machine>,
) -> Option<DriftFinding> {
match machine {
Some(m) if !reads_the_controller(m) => {
check_file_drift_via_transport(id, path, expected, m)
}
_ => check_file_drift(id, path, expected),
}
}
pub(super) fn detect_drift_impl(
lock: &StateLock,
machine: Option<&Machine>,
census: &mut DriftCensus,
) -> Vec<DriftFinding> {
detect_drift_with_lifecycle(lock, machine, &indexmap::IndexMap::new(), census)
}
pub(super) fn reads_the_controller(m: &Machine) -> bool {
crate::transport::controller_answers_for(m)
}