145 lines
4.3 KiB
Rust
145 lines
4.3 KiB
Rust
|
|
use std::collections::BTreeSet;
|
||
|
|
use std::env;
|
||
|
|
use std::fs;
|
||
|
|
use std::io::Read;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
|
||
|
|
use rrt_model::{
|
||
|
|
BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH,
|
||
|
|
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS, load_binary_summary, load_function_map,
|
||
|
|
};
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
|
||
|
|
fn main() {
|
||
|
|
if let Err(err) = real_main() {
|
||
|
|
eprintln!("error: {err}");
|
||
|
|
std::process::exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn real_main() -> Result<(), Box<dyn std::error::Error>> {
|
||
|
|
let repo_root = parse_repo_root()?;
|
||
|
|
validate_required_files(&repo_root)?;
|
||
|
|
validate_binary_summary(&repo_root)?;
|
||
|
|
validate_function_map(&repo_root)?;
|
||
|
|
validate_control_loop_atlas(&repo_root)?;
|
||
|
|
println!("baseline validation passed");
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn parse_repo_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||
|
|
let mut args = env::args().skip(1);
|
||
|
|
match (args.next().as_deref(), args.next(), args.next()) {
|
||
|
|
(None, None, None) => Ok(env::current_dir()?),
|
||
|
|
(Some("validate"), None, None) => Ok(env::current_dir()?),
|
||
|
|
(Some("validate"), Some(path), None) => Ok(PathBuf::from(path)),
|
||
|
|
_ => Err("usage: rrt-cli [validate [repo-root]]".into()),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn validate_required_files(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||
|
|
let mut missing = Vec::new();
|
||
|
|
for relative in REQUIRED_EXPORTS {
|
||
|
|
let path = repo_root.join(relative);
|
||
|
|
if !path.exists() {
|
||
|
|
missing.push(path.display().to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if !missing.is_empty() {
|
||
|
|
return Err(format!("missing required exports: {}", missing.join(", ")).into());
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn validate_binary_summary(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||
|
|
let summary = load_binary_summary(&repo_root.join(BINARY_SUMMARY_PATH))?;
|
||
|
|
let actual_exe = repo_root.join(CANONICAL_EXE_PATH);
|
||
|
|
if !actual_exe.exists() {
|
||
|
|
return Err(format!("canonical exe missing: {}", actual_exe.display()).into());
|
||
|
|
}
|
||
|
|
|
||
|
|
let actual_hash = sha256_file(&actual_exe)?;
|
||
|
|
if actual_hash != summary.sha256 {
|
||
|
|
return Err(format!(
|
||
|
|
"hash mismatch for {}: summary has {}, actual file is {}",
|
||
|
|
actual_exe.display(),
|
||
|
|
summary.sha256,
|
||
|
|
actual_hash
|
||
|
|
)
|
||
|
|
.into());
|
||
|
|
}
|
||
|
|
|
||
|
|
let docs_readme = fs::read_to_string(repo_root.join("docs/README.md"))?;
|
||
|
|
if !docs_readme.contains(&summary.sha256) {
|
||
|
|
return Err("docs/README.md does not include the canonical SHA-256".into());
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn validate_function_map(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||
|
|
let records = load_function_map(&repo_root.join(FUNCTION_MAP_PATH))?;
|
||
|
|
let mut seen = BTreeSet::new();
|
||
|
|
|
||
|
|
for record in records {
|
||
|
|
if !(1..=5).contains(&record.confidence) {
|
||
|
|
return Err(format!(
|
||
|
|
"invalid confidence {} for {} {}",
|
||
|
|
record.confidence, record.address, record.name
|
||
|
|
)
|
||
|
|
.into());
|
||
|
|
}
|
||
|
|
|
||
|
|
if !seen.insert(record.address) {
|
||
|
|
return Err(format!("duplicate function address {}", record.address).into());
|
||
|
|
}
|
||
|
|
|
||
|
|
if record.name.trim().is_empty() {
|
||
|
|
return Err(format!("blank function name at {}", record.address).into());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn validate_control_loop_atlas(repo_root: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||
|
|
let atlas = fs::read_to_string(repo_root.join(CONTROL_LOOP_ATLAS_PATH))?;
|
||
|
|
for heading in REQUIRED_ATLAS_HEADINGS {
|
||
|
|
if !atlas.contains(heading) {
|
||
|
|
return Err(format!("missing atlas heading `{heading}`").into());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for marker in [
|
||
|
|
"- Roots:",
|
||
|
|
"- Trigger/Cadence:",
|
||
|
|
"- Key Dispatchers:",
|
||
|
|
"- State Anchors:",
|
||
|
|
"- Subsystem Handoffs:",
|
||
|
|
"- Evidence:",
|
||
|
|
"- Open Questions:",
|
||
|
|
] {
|
||
|
|
if !atlas.contains(marker) {
|
||
|
|
return Err(format!("atlas is missing field marker `{marker}`").into());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
||
|
|
let mut file = fs::File::open(path)?;
|
||
|
|
let mut hasher = Sha256::new();
|
||
|
|
let mut buffer = [0_u8; 8192];
|
||
|
|
loop {
|
||
|
|
let read = file.read(&mut buffer)?;
|
||
|
|
if read == 0 {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
hasher.update(&buffer[..read]);
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(format!("{:x}", hasher.finalize()))
|
||
|
|
}
|