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),
MissingLock(PathBuf),
}
pub fn verify_state_integrity(state_dir: &Path) -> Vec<IntegrityResult> {
let mut results = Vec::new();
results.extend(check_lock_slot(&state_dir.join("forjar.lock.yaml")));
if let Ok(entries) = std::fs::read_dir(state_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
results.extend(check_lock_slot(&path.join("state.lock.yaml")));
}
}
}
results
}
fn check_lock_slot(lock_path: &Path) -> Vec<IntegrityResult> {
if lock_path.exists() {
check_lock_file(lock_path)
} else if sidecar_path(lock_path).exists() {
vec![IntegrityResult::MissingLock(lock_path.to_path_buf())]
} else {
Vec::new()
}
}
fn check_lock_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);
}
IntegrityResult::MissingLock(p) => {
eprintln!(
"ERROR: lock file {} is missing but its BLAKE3 sidecar survives — \
the lock was deleted",
p.display()
);
}
_ => {}
}
}
}
pub fn has_errors(results: &[IntegrityResult]) -> bool {
results.iter().any(|r| {
matches!(
r,
IntegrityResult::HashMismatch { .. }
| IntegrityResult::InvalidYaml(..)
| IntegrityResult::MissingLock(..)
)
})
}
pub fn failure_reason(result: &IntegrityResult) -> Option<String> {
match result {
IntegrityResult::Ok => None,
IntegrityResult::MissingSidecar(p) => Some(format!(
"no BLAKE3 sidecar for {} — integrity cannot be verified",
p.display()
)),
IntegrityResult::MissingLock(p) => Some(format!(
"lock file {} is missing but its BLAKE3 sidecar survives — the lock was deleted",
p.display()
)),
IntegrityResult::HashMismatch {
file,
expected,
actual,
} => Some(format!(
"BLAKE3 mismatch for {} — sidecar says {}, file hashes to {}",
file.display(),
expected,
actual
)),
IntegrityResult::InvalidYaml(p, e) => {
Some(format!("corrupt state file {}: {}", p.display(), e))
}
}
}
pub fn result_path(result: &IntegrityResult) -> Option<&Path> {
match result {
IntegrityResult::Ok => None,
IntegrityResult::MissingSidecar(p)
| IntegrityResult::MissingLock(p)
| IntegrityResult::InvalidYaml(p, _) => Some(p),
IntegrityResult::HashMismatch { file, .. } => Some(file),
}
}
pub fn failure_reasons(results: &[IntegrityResult]) -> Vec<String> {
results.iter().filter_map(failure_reason).collect()
}