use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::constants::{THESA_FORMAT_VERSION, THESA_METADATA_DIR};
use crate::error::{Result, ThesaError};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ArchiveManifest {
pub(crate) format_version: String,
pub(crate) tool: ManifestTool,
pub(crate) source: ManifestSource,
pub(crate) archive: ManifestArchive,
pub(crate) files: Vec<ManifestFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ManifestTool {
pub(crate) name: String,
pub(crate) version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ManifestSource {
pub(crate) platform: String,
pub(crate) target: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ManifestArchive {
pub(crate) created_at_unix: u64,
pub(crate) complete: bool,
pub(crate) file_count: usize,
pub(crate) total_bytes: u64,
pub(crate) checksum_algorithm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ManifestFile {
pub(crate) path: String,
pub(crate) size: u64,
#[serde(default)]
pub(crate) modified_unix_nanos: Option<u64>,
pub(crate) blake3: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct VerifyReport {
pub(crate) archive: String,
pub(crate) ok: bool,
pub(crate) files_verified: usize,
pub(crate) bytes_verified: u64,
pub(crate) manifest_valid: bool,
pub(crate) checksums_valid: bool,
pub(crate) path_safety_valid: bool,
pub(crate) warnings: Vec<String>,
pub(crate) errors: Vec<String>,
}
pub(crate) fn prepare_output_dir(output: &Path) -> Result<PathBuf> {
if output.exists() && !output.is_dir() {
return Err(ThesaError::OutputNotDirectory(output.display().to_string()));
}
fs::create_dir_all(output)?;
Ok(output.to_path_buf())
}
pub(crate) fn write_archive_manifest(
output: &Path,
platform: &str,
target: &str,
complete: bool,
) -> Result<ArchiveManifest> {
let previous_manifest = read_archive_manifest_sidecar(output).ok();
let manifest = build_archive_manifest_with_previous(
output,
platform,
target,
complete,
previous_manifest.as_ref(),
)?;
let metadata_dir = output.join(THESA_METADATA_DIR);
fs::create_dir_all(&metadata_dir)?;
let manifest_path = metadata_dir.join("manifest.json");
let tmp_manifest_path = metadata_dir.join("manifest.json.tmp");
let manifest_json = serde_json::to_string(&manifest)
.map_err(|err| ThesaError::Message(format!("manifest serialization failed: {err}")))?;
fs::write(&tmp_manifest_path, manifest_json)?;
fs::rename(&tmp_manifest_path, &manifest_path)?;
let checksums_path = metadata_dir.join("checksums.blake3");
let tmp_checksums_path = metadata_dir.join("checksums.blake3.tmp");
let mut checksums = String::new();
for file in &manifest.files {
checksums.push_str(&file.blake3);
checksums.push_str(" ");
checksums.push_str(&file.path);
checksums.push('\n');
}
fs::write(&tmp_checksums_path, checksums)?;
fs::rename(&tmp_checksums_path, &checksums_path)?;
Ok(manifest)
}
#[cfg(test)]
pub(crate) fn build_archive_manifest(
output: &Path,
platform: &str,
target: &str,
complete: bool,
) -> Result<ArchiveManifest> {
build_archive_manifest_with_previous(output, platform, target, complete, None)
}
fn build_archive_manifest_with_previous(
output: &Path,
platform: &str,
target: &str,
complete: bool,
previous: Option<&ArchiveManifest>,
) -> Result<ArchiveManifest> {
let mut files = collect_manifest_files(output, previous)?;
files.sort_by(|a, b| a.path.cmp(&b.path));
let total_bytes = files.iter().map(|file| file.size).sum();
let created_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| ThesaError::Message(format!("system clock is before UNIX epoch: {err}")))?
.as_secs();
Ok(ArchiveManifest {
format_version: THESA_FORMAT_VERSION.to_string(),
tool: ManifestTool {
name: "thesa".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
source: ManifestSource {
platform: platform.to_string(),
target: target.to_string(),
},
archive: ManifestArchive {
created_at_unix,
complete,
file_count: files.len(),
total_bytes,
checksum_algorithm: "blake3".to_string(),
},
files,
})
}
fn collect_manifest_files(
root: &Path,
previous: Option<&ArchiveManifest>,
) -> Result<Vec<ManifestFile>> {
let mut files = Vec::new();
let previous = previous_manifest_file_map(previous);
collect_manifest_files_inner(root, root, &previous, &mut files)?;
Ok(files)
}
fn previous_manifest_file_map(
previous: Option<&ArchiveManifest>,
) -> BTreeMap<String, ManifestFile> {
previous
.into_iter()
.flat_map(|manifest| manifest.files.iter().cloned())
.filter(|file| validate_manifest_relative_path(&file.path).is_ok())
.map(|file| (file.path.clone(), file))
.collect()
}
fn collect_manifest_files_inner(
root: &Path,
current: &Path,
previous: &BTreeMap<String, ManifestFile>,
files: &mut Vec<ManifestFile>,
) -> Result<()> {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
if path.is_dir() {
if name == THESA_METADATA_DIR {
continue;
}
collect_manifest_files_inner(root, &path, previous, files)?;
continue;
}
if !path.is_file() {
continue;
}
let metadata = fs::metadata(&path)?;
let relative = path
.strip_prefix(root)
.map_err(|err| ThesaError::Message(format!("manifest path error: {err}")))?;
let path = manifest_path(relative)?;
let modified_unix_nanos = metadata_modified_unix_nanos(&metadata);
let blake3 = if let Some(cached) = previous.get(&path) {
if cached.size == metadata.len() && cached.modified_unix_nanos == modified_unix_nanos {
cached.blake3.clone()
} else {
blake3_hash_file(&path_from_root(root, &path)?)?
}
} else {
blake3_hash_file(&path_from_root(root, &path)?)?
};
files.push(ManifestFile {
path,
size: metadata.len(),
modified_unix_nanos,
blake3,
});
}
Ok(())
}
fn path_from_root(root: &Path, manifest_path: &str) -> Result<PathBuf> {
let relative = validate_manifest_relative_path(manifest_path).map_err(|message| {
ThesaError::Message(format!(
"invalid generated manifest path '{manifest_path}': {message}"
))
})?;
Ok(root.join(relative))
}
fn metadata_modified_unix_nanos(metadata: &fs::Metadata) -> Option<u64> {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.and_then(|duration| u64::try_from(duration.as_nanos()).ok())
}
fn blake3_hash_file(path: &Path) -> Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
io::copy(&mut file, &mut hasher)?;
Ok(hasher.finalize().to_hex().to_string())
}
fn manifest_path(path: &Path) -> Result<String> {
let normalized = path
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
validate_manifest_relative_path(&normalized).map_err(|message| {
ThesaError::Message(format!(
"refusing to write unsafe manifest path '{normalized}': {message}"
))
})?;
Ok(normalized)
}
pub(crate) fn read_archive_manifest_sidecar(root: &Path) -> Result<ArchiveManifest> {
let manifest_path = root.join(THESA_METADATA_DIR).join("manifest.json");
let contents = fs::read_to_string(&manifest_path)?;
serde_json::from_str::<ArchiveManifest>(&contents).map_err(|err| {
ThesaError::Message(format!(
"unable to parse existing manifest {}: {err}",
manifest_path.display()
))
})
}
pub(crate) fn run_verify_command(archive: &Path, json: bool) -> Result<()> {
let report = verify_archive(archive);
if json {
let payload = serde_json::to_string_pretty(&report).map_err(|err| {
ThesaError::Message(format!("verification report serialization failed: {err}"))
})?;
println!("{payload}");
} else {
print_verify_report(&report);
}
if report.ok {
Ok(())
} else {
Err(ThesaError::Message(
"archive verification failed".to_string(),
))
}
}
pub(crate) fn verify_archive(input: &Path) -> VerifyReport {
let archive_root = archive_root_from_input(input);
let mut report = VerifyReport {
archive: archive_root.display().to_string(),
ok: false,
files_verified: 0,
bytes_verified: 0,
manifest_valid: false,
checksums_valid: false,
path_safety_valid: true,
warnings: Vec::new(),
errors: Vec::new(),
};
if !archive_root.is_dir() {
report.errors.push(format!(
"archive path is not a directory: {}",
archive_root.display()
));
return report;
}
let metadata_dir = archive_root.join(THESA_METADATA_DIR);
let manifest_path = metadata_dir.join("manifest.json");
let manifest = match fs::read_to_string(&manifest_path) {
Ok(contents) => match serde_json::from_str::<ArchiveManifest>(&contents) {
Ok(manifest) => manifest,
Err(err) => {
report
.errors
.push(format!("manifest is not valid JSON: {err}"));
return report;
}
},
Err(err) => {
report.errors.push(format!(
"unable to read manifest {}: {err}",
manifest_path.display()
));
return report;
}
};
report.manifest_valid = true;
if manifest.format_version != THESA_FORMAT_VERSION {
report.errors.push(format!(
"unsupported archive format version '{}', expected '{}'",
manifest.format_version, THESA_FORMAT_VERSION
));
}
if manifest.archive.checksum_algorithm != "blake3" {
report.errors.push(format!(
"unsupported checksum algorithm '{}', expected 'blake3'",
manifest.archive.checksum_algorithm
));
}
if !manifest.archive.complete {
report
.errors
.push("manifest marks this archive as incomplete".to_string());
}
if manifest.archive.file_count != manifest.files.len() {
report.errors.push(format!(
"manifest file_count {} does not match listed files {}",
manifest.archive.file_count,
manifest.files.len()
));
}
let manifest_total_bytes = manifest.files.iter().map(|file| file.size).sum::<u64>();
if manifest.archive.total_bytes != manifest_total_bytes {
report.errors.push(format!(
"manifest total_bytes {} does not match listed file bytes {}",
manifest.archive.total_bytes, manifest_total_bytes
));
}
let checksums_path = metadata_dir.join("checksums.blake3");
let checksums = match read_checksum_sidecar(&checksums_path) {
Ok(checksums) => {
report.checksums_valid = true;
checksums
}
Err(message) => {
report.errors.push(message);
BTreeMap::new()
}
};
let mut manifest_paths = BTreeSet::new();
for file in &manifest.files {
if !manifest_paths.insert(file.path.clone()) {
report
.errors
.push(format!("duplicate manifest path: {}", file.path));
continue;
}
let relative = match validate_manifest_relative_path(&file.path) {
Ok(relative) => relative,
Err(message) => {
report.path_safety_valid = false;
report
.errors
.push(format!("unsafe manifest path '{}': {message}", file.path));
continue;
}
};
if let Some(sidecar_hash) = checksums.get(&file.path) {
if !sidecar_hash.eq_ignore_ascii_case(&file.blake3) {
report.errors.push(format!(
"checksum sidecar disagrees with manifest for {}",
file.path
));
report.checksums_valid = false;
}
} else if !checksums.is_empty() {
report
.errors
.push(format!("checksum sidecar is missing path {}", file.path));
report.checksums_valid = false;
}
let path = archive_root.join(&relative);
let metadata = match fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => metadata,
Ok(_) => {
report.errors.push(format!(
"manifest path is not a regular file: {}",
file.path
));
continue;
}
Err(err) => {
report
.errors
.push(format!("missing manifest file {}: {err}", file.path));
continue;
}
};
if metadata.len() != file.size {
report.errors.push(format!(
"size mismatch for {}: manifest {}, actual {}",
file.path,
file.size,
metadata.len()
));
continue;
}
if let Some(expected_mtime) = file.modified_unix_nanos {
let actual_mtime = metadata_modified_unix_nanos(&metadata);
if actual_mtime != Some(expected_mtime) {
report.errors.push(format!(
"modified timestamp mismatch for {}: manifest {:?}, actual {:?}",
file.path,
Some(expected_mtime),
actual_mtime
));
continue;
}
}
match blake3_hash_file(&path) {
Ok(actual) if actual.eq_ignore_ascii_case(&file.blake3) => {
report.files_verified += 1;
report.bytes_verified += file.size;
}
Ok(actual) => report.errors.push(format!(
"checksum mismatch for {}: manifest {}, actual {}",
file.path, file.blake3, actual
)),
Err(err) => report
.errors
.push(format!("unable to hash {}: {err}", file.path)),
}
}
for path in checksums.keys() {
if !manifest_paths.contains(path) {
report.errors.push(format!(
"checksum sidecar contains path not in manifest: {path}"
));
report.checksums_valid = false;
}
}
report.ok = report.errors.is_empty()
&& report.manifest_valid
&& report.checksums_valid
&& report.path_safety_valid
&& report.files_verified == manifest.files.len();
report
}
fn archive_root_from_input(input: &Path) -> PathBuf {
if input.file_name().and_then(|name| name.to_str()) == Some(THESA_METADATA_DIR) {
input.parent().unwrap_or(input).to_path_buf()
} else {
input.to_path_buf()
}
}
fn read_checksum_sidecar(path: &Path) -> std::result::Result<BTreeMap<String, String>, String> {
let contents = fs::read_to_string(path)
.map_err(|err| format!("unable to read checksum sidecar {}: {err}", path.display()))?;
let mut checksums = BTreeMap::new();
for (line_index, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
let (hash, path) = line.split_once(" ").ok_or_else(|| {
format!(
"invalid checksum sidecar line {}: expected '<blake3> <path>'",
line_index + 1
)
})?;
if hash.len() != 64 || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(format!(
"invalid BLAKE3 hash on checksum sidecar line {}",
line_index + 1
));
}
validate_manifest_relative_path(path).map_err(|message| {
format!(
"unsafe path in checksum sidecar line {}: {path}: {message}",
line_index + 1
)
})?;
if checksums
.insert(path.to_string(), hash.to_ascii_lowercase())
.is_some()
{
return Err(format!(
"duplicate path in checksum sidecar line {}: {path}",
line_index + 1
));
}
}
Ok(checksums)
}
fn validate_manifest_relative_path(path: &str) -> std::result::Result<PathBuf, String> {
if path.is_empty() {
return Err("path is empty".to_string());
}
if path.contains('\\') {
return Err("backslashes are not allowed in manifest paths".to_string());
}
let path_ref = Path::new(path);
if path_ref.is_absolute() {
return Err("absolute paths are not allowed".to_string());
}
let mut safe = PathBuf::new();
for (index, component) in path_ref.components().enumerate() {
match component {
Component::Normal(value) => {
if index == 0 && value == THESA_METADATA_DIR {
return Err("manifest paths may not target .thesa metadata".to_string());
}
safe.push(value);
}
Component::CurDir => {
return Err("current-directory components are not allowed".to_string());
}
Component::ParentDir => {
return Err("parent-directory components are not allowed".to_string());
}
Component::RootDir | Component::Prefix(_) => {
return Err("root or prefix components are not allowed".to_string());
}
}
}
if safe.as_os_str().is_empty() {
return Err("path has no normal components".to_string());
}
Ok(safe)
}
fn print_verify_report(report: &VerifyReport) {
println!("Archive: {}", report.archive);
println!("Status: {}", if report.ok { "OK" } else { "FAILED" });
println!("Files: {} verified", report.files_verified);
println!("Bytes: {} verified", report.bytes_verified);
println!(
"Manifest: {}",
if report.manifest_valid {
"valid"
} else {
"invalid"
}
);
println!(
"Checksums: {}",
if report.checksums_valid {
"valid"
} else {
"invalid"
}
);
println!(
"Path safety: {}",
if report.path_safety_valid {
"valid"
} else {
"invalid"
}
);
println!("Warnings: {}", report.warnings.len());
for warning in &report.warnings {
println!(" - warning: {warning}");
}
println!("Errors: {}", report.errors.len());
for error in &report.errors {
println!(" - error: {error}");
}
}