use std::collections::BTreeSet; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use clap::Parser; use flate2::read::GzDecoder; use serde::Serialize; use sha2::{Digest, Sha256}; const DEFAULT_KIT_DIR: &str = "install-kit-awindows-20260427-211240"; const DEFAULT_ZIP_ARCHIVE: &str = "install-kit-awindows-20260427-211240.zip"; const DEFAULT_TAR_ARCHIVE: &str = "install-kit-awindows-20260427-211240.tar.gz"; const MANIFEST_NAME: &str = "MANIFEST.txt"; const REQUIRED_RELATIVE_FILES: &[&str] = &[ "MANIFEST.txt", "README-INSTALL-KIT.txt", "windows/deploy-ensemble.ps1", "windows/validate-deployment.ps1", "ansible/deploy_aw_windows.yml", "aw-server/install_aw_server.sh", "scripts/rebuild_install_kit.sh", "scripts/validate_install_kit.sh", "scripts/check_install_kit_vs_repo.sh", "scripts/quality-gate.sh", ]; #[derive(Debug, Parser)] #[command(about = "Validate ActivityWatch-Russian Windows install-kit artifacts")] struct Cli { #[arg(long, default_value = ".")] root: PathBuf, #[arg(long, default_value = DEFAULT_KIT_DIR)] kit_dir: PathBuf, #[arg(long, default_value = DEFAULT_ZIP_ARCHIVE)] zip_archive: PathBuf, #[arg(long, default_value = DEFAULT_TAR_ARCHIVE)] tar_archive: PathBuf, #[arg(long, default_value_t = false)] json: bool, } #[derive(Debug, Serialize)] struct ValidationReport { ok: bool, required_files: StageReport, manifest_checksums: StageReport, manifest_completeness: CompletenessReport, archive_composition: ArchiveReport, } #[derive(Debug, Default, Serialize)] struct StageReport { ok: bool, checked: usize, errors: Vec, } #[derive(Debug, Default, Serialize)] struct CompletenessReport { ok: bool, tracked_files: usize, missing_listed: Vec, unlisted_files: Vec, errors: Vec, } #[derive(Debug, Default, Serialize)] struct ArchiveReport { ok: bool, files: usize, errors: Vec, } #[derive(Debug, Clone)] struct ManifestEntry { digest: String, path: String, } fn main() { let code = match run() { Ok(code) => code, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result { let cli = Cli::parse(); let root = cli .root .canonicalize() .with_context(|| format!("canonicalize root {}", cli.root.display()))?; let cfg = Config::new(root, cli.kit_dir, cli.zip_archive, cli.tar_archive); let report = validate(&cfg); print_report(&report, cli.json)?; Ok(if report.ok { 0 } else { 1 }) } #[derive(Debug)] struct Config { root: PathBuf, kit_dir: PathBuf, zip_archive: PathBuf, tar_archive: PathBuf, } impl Config { fn new(root: PathBuf, kit_dir: PathBuf, zip_archive: PathBuf, tar_archive: PathBuf) -> Self { Self { kit_dir: resolve_path(&root, kit_dir), zip_archive: resolve_path(&root, zip_archive), tar_archive: resolve_path(&root, tar_archive), root, } } fn manifest(&self) -> PathBuf { self.kit_dir.join(MANIFEST_NAME) } } fn validate(cfg: &Config) -> ValidationReport { let required_files = check_required_files(cfg); let manifest_entries = read_manifest(&cfg.manifest()); let manifest_checksums = match &manifest_entries { Ok(entries) => check_manifest_checksums(cfg, entries), Err(err) => StageReport { ok: false, checked: 0, errors: vec![err.to_string()], }, }; let manifest_completeness = match &manifest_entries { Ok(entries) => check_manifest_completeness(cfg, entries), Err(err) => CompletenessReport { ok: false, errors: vec![err.to_string()], ..CompletenessReport::default() }, }; let archive_composition = check_archive_composition(cfg); let ok = required_files.ok && manifest_checksums.ok && manifest_completeness.ok && archive_composition.ok; ValidationReport { ok, required_files, manifest_checksums, manifest_completeness, archive_composition, } } fn check_required_files(cfg: &Config) -> StageReport { let mut errors = Vec::new(); for rel in REQUIRED_RELATIVE_FILES { let path = cfg.kit_dir.join(rel); if !path.is_file() { errors.push(format!("Missing required file: {}", path.display())); } } StageReport { ok: errors.is_empty(), checked: REQUIRED_RELATIVE_FILES.len(), errors, } } fn read_manifest(path: &Path) -> Result> { let file = File::open(path).with_context(|| format!("open manifest {}", path.display()))?; let reader = BufReader::new(file); let mut entries = Vec::new(); for (idx, line) in reader.lines().enumerate() { let line = line.with_context(|| format!("read manifest line {}", idx + 1))?; let line = line.trim(); if line.is_empty() { continue; } let Some((digest, path)) = line.split_once(" ") else { bail!("Invalid MANIFEST line {}: {}", idx + 1, line); }; if digest.len() != 64 || !digest.chars().all(|ch| ch.is_ascii_hexdigit()) { bail!("Invalid MANIFEST digest on line {}: {}", idx + 1, digest); } entries.push(ManifestEntry { digest: digest.to_ascii_lowercase(), path: path.to_string(), }); } Ok(entries) } fn check_manifest_checksums(cfg: &Config, entries: &[ManifestEntry]) -> StageReport { let mut errors = Vec::new(); for entry in entries { let path = cfg.root.join(&entry.path); if !path.is_file() { errors.push(format!("Manifest file missing: {}", entry.path)); continue; } match sha256_file(&path) { Ok(actual) if actual == entry.digest => {} Ok(actual) => errors.push(format!( "Checksum mismatch: {} expected {} got {}", entry.path, entry.digest, actual )), Err(err) => errors.push(err.to_string()), } } StageReport { ok: errors.is_empty(), checked: entries.len(), errors, } } fn check_manifest_completeness(cfg: &Config, entries: &[ManifestEntry]) -> CompletenessReport { let listed: BTreeSet = entries.iter().map(|entry| entry.path.clone()).collect(); let actual = match collect_kit_manifest_paths(&cfg.kit_dir) { Ok(paths) => paths, Err(err) => { return CompletenessReport { ok: false, errors: vec![err.to_string()], ..CompletenessReport::default() }; } }; let missing_listed: Vec = listed.difference(&actual).cloned().collect(); let unlisted_files: Vec = actual.difference(&listed).cloned().collect(); CompletenessReport { ok: missing_listed.is_empty() && unlisted_files.is_empty(), tracked_files: actual.len(), missing_listed, unlisted_files, errors: Vec::new(), } } fn collect_kit_manifest_paths(kit_dir: &Path) -> Result> { let mut files = Vec::new(); collect_files(kit_dir, &mut files)?; let mut out = BTreeSet::new(); for file in files { let rel = file .strip_prefix(kit_dir) .with_context(|| format!("strip kit prefix from {}", file.display()))?; if rel.file_name().and_then(|name| name.to_str()) == Some(MANIFEST_NAME) { continue; } out.insert(format!("{}/{}", DEFAULT_KIT_DIR, slash_path(rel))); } Ok(out) } fn check_archive_composition(cfg: &Config) -> ArchiveReport { let zip_files = match read_zip_files(&cfg.zip_archive) { Ok(files) => files, Err(err) => { return ArchiveReport { ok: false, errors: vec![err.to_string()], ..ArchiveReport::default() }; } }; let tar_files = match read_tar_files(&cfg.tar_archive) { Ok(files) => files, Err(err) => { return ArchiveReport { ok: false, errors: vec![err.to_string()], ..ArchiveReport::default() }; } }; let mut errors = Vec::new(); if zip_files != tar_files { errors.push("ZIP and TAR contents differ".to_string()); } let expected_prefix = format!("{DEFAULT_KIT_DIR}/"); if !zip_files .iter() .all(|path| path.starts_with(&expected_prefix)) { errors.push("Unexpected archive prefix layout".to_string()); } ArchiveReport { ok: errors.is_empty(), files: zip_files.len(), errors, } } fn read_zip_files(path: &Path) -> Result> { let file = File::open(path).with_context(|| format!("open zip {}", path.display()))?; let mut archive = zip::ZipArchive::new(file).with_context(|| format!("read zip {}", path.display()))?; let mut files = Vec::new(); for idx in 0..archive.len() { let file = archive .by_index(idx) .with_context(|| format!("read zip entry {idx}"))?; let name = normalize_archive_path(file.name()); if !name.ends_with('/') { files.push(name); } } files.sort(); Ok(files) } fn read_tar_files(path: &Path) -> Result> { let file = File::open(path).with_context(|| format!("open tar {}", path.display()))?; let decoder = GzDecoder::new(file); let mut archive = tar::Archive::new(decoder); let mut files = Vec::new(); for entry in archive .entries() .with_context(|| format!("read tar {}", path.display()))? { let entry = entry.with_context(|| format!("read tar entry {}", path.display()))?; if entry.header().entry_type().is_file() { let path = entry.path().context("read tar entry path")?; files.push(normalize_archive_path(&path.to_string_lossy())); } } files.sort(); Ok(files) } fn collect_files(path: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(path).with_context(|| format!("read dir {}", path.display()))? { let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?; let entry_path = entry.path(); let file_type = entry .file_type() .with_context(|| format!("read file type {}", entry_path.display()))?; if file_type.is_dir() { collect_files(&entry_path, out)?; } else if (file_type.is_file() || file_type.is_symlink()) && entry_path.is_file() { out.push(entry_path); } } Ok(()) } fn sha256_file(path: &Path) -> Result { let file = File::open(path).with_context(|| format!("open {}", path.display()))?; let mut reader = BufReader::new(file); let mut hasher = Sha256::new(); let mut buf = [0_u8; 64 * 1024]; loop { let read = reader .read(&mut buf) .with_context(|| format!("read {}", path.display()))?; if read == 0 { break; } hasher.update(&buf[..read]); } Ok(format!("{:x}", hasher.finalize())) } fn print_report(report: &ValidationReport, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(report)?); return Ok(()); } println!("[1/4] Required files presence"); for err in &report.required_files.errors { println!("{err}"); } println!("[2/4] Manifest checksum verification"); for err in &report.manifest_checksums.errors { println!("{err}"); } println!("[3/4] Manifest completeness"); if report.manifest_completeness.ok { println!( "MANIFEST complete: {} files tracked", report.manifest_completeness.tracked_files ); } else { println!( "Missing files listed in MANIFEST: {:?}", report.manifest_completeness.missing_listed ); println!( "Files not listed in MANIFEST: {:?}", report.manifest_completeness.unlisted_files ); for err in &report.manifest_completeness.errors { println!("{err}"); } } println!("[4/4] Archive composition check"); if report.archive_composition.ok { println!("Archives match: {} files", report.archive_composition.files); } else { for err in &report.archive_composition.errors { println!("{err}"); } } if report.ok { println!("validate_install_kit: OK"); } Ok(()) } fn resolve_path(root: &Path, path: PathBuf) -> PathBuf { if path.is_absolute() { path } else { root.join(path) } } fn normalize_archive_path(path: &str) -> String { path.replace('\\', "/").trim_start_matches("./").to_string() } fn slash_path(path: &Path) -> String { path.components() .map(|component| component.as_os_str().to_string_lossy()) .collect::>() .join("/") } #[cfg(test)] mod tests { use std::fs; use std::io::Write; use flate2::Compression; use flate2::write::GzEncoder; use tempfile::tempdir; use zip::write::SimpleFileOptions; use super::{Config, DEFAULT_KIT_DIR, check_archive_composition, read_manifest, validate}; #[test] fn manifest_parser_rejects_bad_line() { let tmp = tempdir().unwrap(); let manifest = tmp.path().join("MANIFEST.txt"); fs::write(&manifest, "not-a-manifest-line\n").unwrap(); let err = read_manifest(&manifest).unwrap_err(); assert!(err.to_string().contains("Invalid MANIFEST line")); } #[test] fn validates_clean_fixture() { let tmp = tempdir().unwrap(); create_fixture(tmp.path(), true); let cfg = Config::new( tmp.path().to_path_buf(), DEFAULT_KIT_DIR.into(), format!("{DEFAULT_KIT_DIR}.zip").into(), format!("{DEFAULT_KIT_DIR}.tar.gz").into(), ); let report = validate(&cfg); assert!(report.ok, "{report:#?}"); assert_eq!(report.manifest_completeness.tracked_files, 9); assert_eq!(report.archive_composition.files, 10); } #[test] fn detects_manifest_extra_file() { let tmp = tempdir().unwrap(); create_fixture(tmp.path(), true); fs::write( tmp.path().join(DEFAULT_KIT_DIR).join("unlisted.txt"), "extra", ) .unwrap(); let cfg = Config::new( tmp.path().to_path_buf(), DEFAULT_KIT_DIR.into(), format!("{DEFAULT_KIT_DIR}.zip").into(), format!("{DEFAULT_KIT_DIR}.tar.gz").into(), ); let report = validate(&cfg); assert!(!report.ok); assert!( report .manifest_completeness .unlisted_files .contains(&format!("{DEFAULT_KIT_DIR}/unlisted.txt")) ); } #[test] fn detects_archive_mismatch() { let tmp = tempdir().unwrap(); create_fixture(tmp.path(), false); let cfg = Config::new( tmp.path().to_path_buf(), DEFAULT_KIT_DIR.into(), format!("{DEFAULT_KIT_DIR}.zip").into(), format!("{DEFAULT_KIT_DIR}.tar.gz").into(), ); let report = check_archive_composition(&cfg); assert!(!report.ok); assert!(report.errors.iter().any(|err| err.contains("differ"))); } fn create_fixture(root: &std::path::Path, matching_archives: bool) { let kit = root.join(DEFAULT_KIT_DIR); for rel in [ "windows/deploy-ensemble.ps1", "windows/validate-deployment.ps1", "ansible/deploy_aw_windows.yml", "aw-server/install_aw_server.sh", "scripts/rebuild_install_kit.sh", "scripts/validate_install_kit.sh", "scripts/check_install_kit_vs_repo.sh", "scripts/quality-gate.sh", ] { let path = kit.join(rel); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, rel).unwrap(); } fs::write(kit.join("README-INSTALL-KIT.txt"), "readme").unwrap(); let files = [ "README-INSTALL-KIT.txt", "windows/deploy-ensemble.ps1", "windows/validate-deployment.ps1", "ansible/deploy_aw_windows.yml", "aw-server/install_aw_server.sh", "scripts/rebuild_install_kit.sh", "scripts/validate_install_kit.sh", "scripts/check_install_kit_vs_repo.sh", "scripts/quality-gate.sh", ]; let mut manifest = String::new(); for rel in files { let path = kit.join(rel); let digest = super::sha256_file(&path).unwrap(); manifest.push_str(&format!("{digest} {DEFAULT_KIT_DIR}/{rel}\n")); } fs::write(kit.join("MANIFEST.txt"), manifest).unwrap(); write_zip(root, matching_archives); write_tar(root, matching_archives); } fn archive_files(matching: bool) -> Vec<(&'static str, &'static [u8])> { let mut files = vec![ ("README-INSTALL-KIT.txt", b"readme".as_slice()), ("MANIFEST.txt", b"manifest".as_slice()), ("windows/deploy-ensemble.ps1", b"deploy".as_slice()), ("windows/validate-deployment.ps1", b"validate".as_slice()), ("ansible/deploy_aw_windows.yml", b"ansible".as_slice()), ("aw-server/install_aw_server.sh", b"server".as_slice()), ("scripts/rebuild_install_kit.sh", b"rebuild".as_slice()), ( "scripts/validate_install_kit.sh", b"validate-kit".as_slice(), ), ("scripts/check_install_kit_vs_repo.sh", b"check".as_slice()), ("scripts/quality-gate.sh", b"quality".as_slice()), ]; if !matching { files.pop(); files.push(("scripts/other.sh", b"other".as_slice())); } files } fn write_zip(root: &std::path::Path, _matching: bool) { let path = root.join(format!("{DEFAULT_KIT_DIR}.zip")); let file = fs::File::create(path).unwrap(); let mut zip = zip::ZipWriter::new(file); let options = SimpleFileOptions::default(); for (rel, data) in archive_files(true) { zip.start_file(format!("{DEFAULT_KIT_DIR}/{rel}"), options) .unwrap(); zip.write_all(data).unwrap(); } zip.finish().unwrap(); } fn write_tar(root: &std::path::Path, matching: bool) { let path = root.join(format!("{DEFAULT_KIT_DIR}.tar.gz")); let file = fs::File::create(path).unwrap(); let encoder = GzEncoder::new(file, Compression::default()); let mut builder = tar::Builder::new(encoder); for (rel, data) in archive_files(matching) { let mut header = tar::Header::new_gnu(); header.set_size(data.len() as u64); header.set_mode(0o644); header.set_cksum(); builder .append_data(&mut header, format!("{DEFAULT_KIT_DIR}/{rel}"), data) .unwrap(); } builder.finish().unwrap(); } }