use std::collections::BTreeMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use serde::Serialize;
use tar::Archive;
use zoi_core::hash::{HashAlgorithm, calculate_file_hash};
use zoi_core::types::InstallManifest;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VerifyStatus {
Ok,
Modified,
Missing,
Unverified
}
impl VerifyStatus {
#[must_use]
pub fn as_tag(&self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Modified => "modified",
Self::Missing => "missing",
Self::Unverified => "unverified"
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FileStatus {
pub path: String,
pub status: VerifyStatus
}
#[derive(Debug, Serialize)]
pub struct ArchiveReport {
pub archive: String,
pub signed_embed: bool,
pub checked: usize,
pub issues: Vec<String>,
pub ok: bool
}
pub fn verify_archive(archive_path: &Path) -> Result<ArchiveReport> {
let file = File::open(archive_path)?;
let decoder = zstd::stream::read::Decoder::new(file)?;
let mut archive = Archive::new(decoder);
let mut manifest_json: Option<String> = None;
let mut signed_embed = false;
let mut observed: BTreeMap<String, (u64, String)> = BTreeMap::new();
for entry in archive.entries()? {
let mut entry = entry?;
let name = entry.path()?.to_string_lossy().to_string();
let Some(file_name) = Path::new(&name)
.file_name()
.map(|f| f.to_string_lossy().to_string())
else {
continue;
};
if file_name == "manifest.json" && manifest_json.is_none() {
let mut json = String::new();
entry.read_to_string(&mut json)?;
manifest_json = Some(json);
continue;
}
if file_name == "manifest.sig" {
signed_embed = true;
continue;
}
let Some(expected_hash) = file_name.strip_prefix("sha256-") else {
continue;
};
let trimmed = name.trim_start_matches("./");
let canonical = format!("pool/sha256-{expected_hash}");
if trimmed != canonical {
continue;
}
let (len, hash) = zoi_core::hash::calculate_reader_hash(
&mut entry,
HashAlgorithm::Sha256
)?;
observed.insert(expected_hash.to_string(), (len, hash));
}
let manifest_json = manifest_json.ok_or_else(|| {
anyhow!(
"No manifest.json found in '{}' - not a pooled ZPA archive",
archive_path.display()
)
})?;
let manifest: zoi_core::types::PooledZpaManifest =
serde_json::from_str(&manifest_json)?;
let mut issues = Vec::new();
let mut checked = 0usize;
for (key, entry) in &manifest.pool {
let expected_hash = key
.strip_prefix("sha256-")
.ok_or_else(|| anyhow!("Unsupported pool hash format: {key}"))?;
let Some((size, hash)) = observed.get(expected_hash) else {
issues.push(format!("{key}: missing from archive"));
continue;
};
checked += 1;
if *size != entry.size {
issues.push(format!(
"{key}: size mismatch (manifest {}, archive {size})",
entry.size
));
continue;
}
if hash != expected_hash {
issues.push(format!("{key}: content hash mismatch"));
}
}
let ok = issues.is_empty();
Ok(ArchiveReport {
archive: archive_path.to_string_lossy().to_string(),
signed_embed,
checked,
issues,
ok
})
}
pub fn verify_installed(manifest: &InstallManifest) -> Result<Vec<FileStatus>> {
let version_dir = zoi_resolver::local::get_package_version_dir(
manifest.scope,
&manifest.registry_handle,
&manifest.repo,
&manifest.name,
&manifest.version
)?;
let Some(digests) =
manifest.file_digests.as_ref().filter(|d| !d.is_empty())
else {
return Ok(manifest
.installed_files
.iter()
.map(|path| FileStatus {
path: path.clone(),
status: VerifyStatus::Unverified
})
.collect());
};
let mut statuses = Vec::new();
for path in &manifest.installed_files {
let Some(expected) = digests.get(path) else {
continue;
};
let expanded = zoi_core::utils::expand_placeholders(
path,
&version_dir,
manifest.scope
)?;
let fs_path = PathBuf::from(expanded);
if !fs_path.exists() && !fs_path.is_symlink() {
statuses.push(FileStatus {
path: path.clone(),
status: VerifyStatus::Missing
});
continue;
}
if fs_path.is_dir() || fs_path.is_symlink() {
continue;
}
let actual = calculate_file_hash(&fs_path, HashAlgorithm::Sha256)?;
let expected_hex = expected.strip_prefix("sha256-").unwrap_or(expected);
statuses.push(FileStatus {
path: path.clone(),
status: if actual == expected_hex {
VerifyStatus::Ok
} else {
VerifyStatus::Modified
}
});
}
Ok(statuses)
}