use std::path::{Path, PathBuf};
pub fn write_b3_sidecar(lock_path: &Path) -> Result<(), String> {
let content = std::fs::read(lock_path)
.map_err(|e| format!("cannot read {}: {}", lock_path.display(), e))?;
let hash = blake3::hash(&content);
let sidecar = sidecar_path(lock_path);
std::fs::write(&sidecar, hash.to_hex().as_str())
.map_err(|e| format!("cannot write {}: {}", sidecar.display(), e))?;
Ok(())
}
fn sidecar_path(lock_path: &Path) -> PathBuf {
let mut p = lock_path.as_os_str().to_owned();
p.push(".b3");
PathBuf::from(p)
}
#[derive(Debug)]
pub enum IntegrityResult {
Ok,
MissingSidecar(PathBuf),
HashMismatch {
file: PathBuf,
expected: String,
actual: String,
},
InvalidYaml(PathBuf, String),
}
pub fn verify_state_integrity(state_dir: &Path) -> Vec<IntegrityResult> {
let mut results = Vec::new();
let global_lock = state_dir.join("forjar.lock.yaml");
if global_lock.exists() {
results.extend(check_file(&global_lock));
}
if let Ok(entries) = std::fs::read_dir(state_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let lock = path.join("state.lock.yaml");
if lock.exists() {
results.extend(check_file(&lock));
}
}
}
}
results
}
fn check_file(lock_path: &Path) -> Vec<IntegrityResult> {
let mut results = Vec::new();
let content = match std::fs::read_to_string(lock_path) {
Ok(c) => c,
Err(e) => {
results.push(IntegrityResult::InvalidYaml(
lock_path.to_path_buf(),
e.to_string(),
));
return results;
}
};
if let Err(e) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content) {
results.push(IntegrityResult::InvalidYaml(
lock_path.to_path_buf(),
e.to_string(),
));
return results;
}
let sidecar = sidecar_path(lock_path);
if !sidecar.exists() {
results.push(IntegrityResult::MissingSidecar(lock_path.to_path_buf()));
return results;
}
let expected_hash = match std::fs::read_to_string(&sidecar) {
Ok(h) => h.trim().to_string(),
Err(_) => {
results.push(IntegrityResult::MissingSidecar(lock_path.to_path_buf()));
return results;
}
};
let content_bytes = content.into_bytes();
let actual_hash = blake3::hash(&content_bytes).to_hex().to_string();
if expected_hash != actual_hash {
results.push(IntegrityResult::HashMismatch {
file: lock_path.to_path_buf(),
expected: expected_hash,
actual: actual_hash,
});
} else {
results.push(IntegrityResult::Ok);
}
results
}
pub fn print_issues(results: &[IntegrityResult], verbose: bool) {
for issue in results {
match issue {
IntegrityResult::MissingSidecar(p) if verbose => {
eprintln!("warning: no integrity sidecar for {}", p.display());
}
IntegrityResult::HashMismatch {
file,
expected,
actual,
} => {
eprintln!(
"ERROR: integrity check failed for {}: expected {}, got {}",
file.display(),
expected,
actual
);
}
IntegrityResult::InvalidYaml(p, e) => {
eprintln!("ERROR: corrupt state file {}: {}", p.display(), e);
}
_ => {}
}
}
}
pub fn has_errors(results: &[IntegrityResult]) -> bool {
results.iter().any(|r| {
matches!(
r,
IntegrityResult::HashMismatch { .. } | IntegrityResult::InvalidYaml(..)
)
})
}