feat(detmir): add rust-first operations tooling

This commit is contained in:
igor04091968
2026-06-02 17:57:58 +03:00
parent 60670d30a8
commit 19e3682bc8
263 changed files with 51678 additions and 718 deletions
@@ -0,0 +1,611 @@
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<String>,
}
#[derive(Debug, Default, Serialize)]
struct CompletenessReport {
ok: bool,
tracked_files: usize,
missing_listed: Vec<String>,
unlisted_files: Vec<String>,
errors: Vec<String>,
}
#[derive(Debug, Default, Serialize)]
struct ArchiveReport {
ok: bool,
files: usize,
errors: Vec<String>,
}
#[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<i32> {
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<Vec<ManifestEntry>> {
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<String> = 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<String> = listed.difference(&actual).cloned().collect();
let unlisted_files: Vec<String> = 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<BTreeSet<String>> {
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<Vec<String>> {
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<Vec<String>> {
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<PathBuf>) -> 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<String> {
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::<Vec<_>>()
.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();
}
}