use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
const GENERATED_ENTRIES: &[&str] = &[
".bender/git/checkouts",
"Bender.yml",
".bender.yml",
"Bender.lock",
"hw",
"target",
".aegis",
];
const PROTECTED_AEGIS_ENTRIES: &[&str] = &[
".git",
".gitignore",
"README.md",
"LICENSE",
"scripts",
"src",
"config",
"notes",
];
#[derive(Debug, Serialize)]
pub struct EffectiveRtlManifest {
pub inputs: Vec<EffectiveInput>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EffectiveInput {
pub path: String,
pub role: String,
pub candidate: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HardeningStatus {
Completed,
Skipped,
}
#[derive(Debug, Serialize)]
pub struct HardeningResult {
pub status: HardeningStatus,
pub changed_files: Vec<String>,
}
pub fn run(root: &Path) -> Result<HardeningResult> {
run_with_bender(root, Path::new("bender"))
}
pub fn run_with_bender(root: &Path, bender: &Path) -> Result<HardeningResult> {
let root = root
.canonicalize()
.with_context(|| format!("failed to resolve project root: {}", root.display()))?;
let aegis = root
.parent()
.context("project root has no parent directory")?
.join("aegisrtl");
prepare_workspace(&root, &aegis)?;
materialize_aegis_checkouts(bender, &root, &aegis)?;
let smoke = template_json(
bender,
&aegis,
&["-t", "rtl", "-t", "test", "-t", "rtl_sim"],
)?;
let fpga = template_json(bender, &aegis, &["-t", "fpga", "-t", "xilinx"])?;
let tcl = aegis.join("target/fpga/kcu105/tcl/run.tcl");
let manifest = build_manifest(&aegis, &smoke, &fpga, &tcl)?;
let result = execute_hardener(&aegis, &manifest)?;
activate_local_configs(&aegis, &root)?;
let build_config = root.join("build/questasim/.build-config");
if build_config.exists() {
fs::remove_file(&build_config)
.with_context(|| format!("failed to remove {}", build_config.display()))?;
}
let mut final_update = Command::new(bender);
final_update.args(["--local", "update"]).current_dir(&root);
run_command(
&mut final_update,
"local bender update failed in project root",
)?;
if !root.join("Bender.lock").is_file() {
bail!("local bender update did not create project Bender.lock");
}
Ok(result)
}
fn materialize_aegis_checkouts(bender: &Path, root: &Path, aegis: &Path) -> Result<()> {
let used_remote = checkout_aegis(bender, aegis)?;
if root_checkouts_are_clean_and_complete(root)? {
overlay_checkout_files(root, aegis)?;
} else if !used_remote {
println!("Local dependency checkout is modified; fetching a clean checkout");
remove_generated_entry(aegis, ".bender/git/checkouts")?;
checkout_aegis_remote(bender, aegis)?;
}
Ok(())
}
fn checkout_aegis(bender: &Path, aegis: &Path) -> Result<bool> {
let lock_path = aegis.join("Bender.lock");
let expected_lock = fs::read(&lock_path)
.with_context(|| format!("failed to read {}", lock_path.display()))?;
let local = Command::new(bender)
.args(["--local", "--git-submodules", "false", "checkout"])
.current_dir(aegis)
.output()
.context("failed to run local bender checkout in AegisRTL")?;
if !local.status.success() {
println!("Local checkout could not be completed; retrying with remote access");
checkout_aegis_remote(bender, aegis)?;
return Ok(true);
}
let actual_lock = fs::read(&lock_path)
.with_context(|| format!("failed to read {}", lock_path.display()))?;
if actual_lock != expected_lock {
bail!("bender checkout changed AegisRTL/Bender.lock");
}
Ok(false)
}
fn checkout_aegis_remote(bender: &Path, aegis: &Path) -> Result<()> {
let lock_path = aegis.join("Bender.lock");
let expected_lock = fs::read(&lock_path)
.with_context(|| format!("failed to read {}", lock_path.display()))?;
let remote = Command::new(bender)
.arg("checkout")
.current_dir(aegis)
.output()
.context("failed to retry AegisRTL checkout with remote access")?;
if !remote.status.success() {
eprint!("{}", String::from_utf8_lossy(&remote.stdout));
eprint!("{}", String::from_utf8_lossy(&remote.stderr));
bail!("bender checkout failed in AegisRTL");
}
let actual_lock = fs::read(&lock_path)
.with_context(|| format!("failed to read {}", lock_path.display()))?;
if actual_lock != expected_lock {
bail!("bender checkout changed AegisRTL/Bender.lock");
}
Ok(())
}
fn template_json(bender: &Path, aegis: &Path, targets: &[&str]) -> Result<String> {
let mut command = Command::new(bender);
command
.args(["-d", &aegis.to_string_lossy(), "script", "template-json"])
.args(targets);
let output = command
.output()
.context("failed to run bender script template-json")?;
if !output.status.success() {
bail!(
"bender script template-json failed; update Bender before using bendis update --hard"
);
}
String::from_utf8(output.stdout).context("bender template-json output is not UTF-8")
}
fn run_command(command: &mut Command, context: &str) -> Result<()> {
let status = command.status().with_context(|| context.to_string())?;
if !status.success() {
bail!("{context} with status {status}");
}
Ok(())
}
pub fn execute_hardener(aegis: &Path, manifest: &EffectiveRtlManifest) -> Result<HardeningResult> {
let state_dir = aegis.join(".aegis");
fs::create_dir_all(&state_dir)
.with_context(|| format!("failed to create {}", state_dir.display()))?;
let manifest_path = state_dir.join("effective-rtl.json");
fs::write(&manifest_path, serde_json::to_vec_pretty(manifest)?)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
let before_sources = source_hashes(aegis, manifest)?;
let before_packages = package_manifest_hashes(aegis)?;
let script = aegis.join("scripts/harden.sh");
let status = if script.is_file() {
let result = Command::new(&script)
.arg(".aegis/effective-rtl.json")
.current_dir(aegis)
.status()
.with_context(|| format!("failed to run {}", script.display()))?;
if !result.success() {
bail!("AegisRTL hardening script failed with status {result}");
}
HardeningStatus::Completed
} else {
HardeningStatus::Skipped
};
let after_sources = source_hashes(aegis, manifest)?;
let after_packages = package_manifest_hashes(aegis)?;
if before_packages != after_packages {
bail!("AegisRTL hardening script changed a package Bender.yml");
}
let changed_files = before_sources
.iter()
.filter_map(|(path, hash)| (after_sources.get(path) != Some(hash)).then_some(path.clone()))
.collect();
let result = HardeningResult {
status,
changed_files,
};
let result_path = state_dir.join("hardening-result.json");
fs::write(&result_path, serde_json::to_vec_pretty(&result)?)
.with_context(|| format!("failed to write {}", result_path.display()))?;
Ok(result)
}
fn source_hashes(
aegis: &Path,
manifest: &EffectiveRtlManifest,
) -> Result<BTreeMap<String, String>> {
let mut hashes = BTreeMap::new();
for input in manifest.inputs.iter().filter(|input| input.candidate) {
let path = aegis.join(&input.path);
if !path.is_file() {
bail!("hardening candidate does not exist: {}", input.path);
}
hashes.insert(input.path.clone(), file_hash(&path)?);
}
Ok(hashes)
}
fn package_manifest_hashes(aegis: &Path) -> Result<BTreeMap<String, String>> {
let mut hashes = BTreeMap::new();
let root_manifest = aegis.join("Bender.yml");
hashes.insert("Bender.yml".to_string(), file_hash(&root_manifest)?);
let checkouts = aegis.join(".bender/git/checkouts");
if checkouts.is_dir() {
for entry in fs::read_dir(&checkouts)
.with_context(|| format!("failed to read {}", checkouts.display()))?
{
let manifest = entry?.path().join("Bender.yml");
if manifest.is_file() {
let relative = manifest
.strip_prefix(aegis)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
hashes.insert(relative, file_hash(&manifest)?);
}
}
}
for entry in
fs::read_dir(aegis).with_context(|| format!("failed to read {}", aegis.display()))?
{
let path = entry?.path();
let name = path.file_name().and_then(|name| name.to_str());
if path.is_dir() && !matches!(name, Some(".bender" | ".git" | ".aegis" | "scripts")) {
collect_local_package_manifests(aegis, &path, &mut hashes)?;
}
}
Ok(hashes)
}
fn collect_local_package_manifests(
base: &Path,
directory: &Path,
hashes: &mut BTreeMap<String, String>,
) -> Result<()> {
for entry in fs::read_dir(directory)
.with_context(|| format!("failed to read {}", directory.display()))?
{
let path = entry?.path();
if path.is_dir() {
collect_local_package_manifests(base, &path, hashes)?;
} else if path.file_name().and_then(|name| name.to_str()) == Some("Bender.yml") {
let relative = path
.strip_prefix(base)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
hashes.insert(relative, file_hash(&path)?);
}
}
Ok(())
}
fn file_hash(path: &Path) -> Result<String> {
let content = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(format!("{:x}", Sha256::digest(content)))
}
#[derive(Deserialize)]
struct BenderScript {
#[serde(default)]
srcs: Vec<BenderSourceGroup>,
}
#[derive(Deserialize)]
struct BenderSourceGroup {
#[serde(default)]
files: Vec<BenderSourceFile>,
#[serde(default)]
metadata: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum BenderSourceFile {
Path(PathBuf),
Annotated { file: PathBuf },
}
impl BenderSourceFile {
fn into_path(self) -> PathBuf {
match self {
Self::Path(path) | Self::Annotated { file: path } => path,
}
}
}
pub fn build_manifest(
aegis: &Path,
smoke_json: &str,
fpga_json: &str,
kcu_tcl: &Path,
) -> Result<EffectiveRtlManifest> {
let aegis = aegis.to_path_buf();
let mut inputs = BTreeMap::new();
add_bender_inputs(&aegis, smoke_json, &mut inputs)?;
add_bender_inputs(&aegis, fpga_json, &mut inputs)?;
add_kcu105_inputs(&aegis, kcu_tcl, &mut inputs)?;
Ok(EffectiveRtlManifest {
inputs: inputs.into_values().collect(),
})
}
fn add_bender_inputs(
aegis: &Path,
json: &str,
inputs: &mut BTreeMap<String, EffectiveInput>,
) -> Result<()> {
let script: BenderScript =
serde_json::from_str(json).context("failed to parse bender template-json output")?;
for group in script.srcs {
let is_test = group.metadata.to_ascii_lowercase().contains("target(test");
for file in group.files {
let file = file.into_path();
let path = relative_to_aegis(aegis, &file)?;
let candidate = is_hdl(&path) && !is_test;
insert_input(
inputs,
path,
if is_test { "testbench" } else { "design" },
candidate,
);
}
}
Ok(())
}
fn add_kcu105_inputs(
aegis: &Path,
tcl_path: &Path,
inputs: &mut BTreeMap<String, EffectiveInput>,
) -> Result<()> {
let content = fs::read_to_string(tcl_path)
.with_context(|| format!("failed to read {}", tcl_path.display()))?;
let kcu_dir = tcl_path
.parent()
.and_then(Path::parent)
.context("invalid KCU105 Tcl path")?;
let mut variables = BTreeMap::new();
insert_input(inputs, relative_to_aegis(aegis, tcl_path)?, "script", false);
for line in content.lines().map(str::trim) {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() == 3
&& fields[0] == "set"
&& matches!(fields[1], "FPGA_RTL" | "FPGA_IPS" | "CONSTRS")
{
variables.insert(fields[1], fields[2]);
continue;
}
if fields.first() != Some(&"add_files") && fields.first() != Some(&"import_ip") {
continue;
}
let Some(raw_path) = fields.last() else {
continue;
};
let resolved = resolve_tcl_path(kcu_dir, raw_path, &variables)?;
let path = relative_to_aegis(aegis, &resolved)?;
let candidate = is_hdl(&path);
let role = if candidate { "design" } else { "collateral" };
insert_input(inputs, path, role, candidate);
}
Ok(())
}
fn resolve_tcl_path(base: &Path, value: &str, variables: &BTreeMap<&str, &str>) -> Result<PathBuf> {
if let Some(rest) = value.strip_prefix('$') {
let (name, suffix) = rest.split_once('/').unwrap_or((rest, ""));
let root = variables
.get(name)
.with_context(|| format!("unknown Tcl path variable: {name}"))?;
Ok(base.join(root).join(suffix))
} else {
Ok(base.join(value))
}
}
fn relative_to_aegis(aegis: &Path, path: &Path) -> Result<String> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
aegis.join(path)
};
let relative = absolute
.strip_prefix(aegis)
.with_context(|| format!("source path is outside AegisRTL: {}", absolute.display()))?;
Ok(relative.to_string_lossy().replace('\\', "/"))
}
fn is_hdl(path: &str) -> bool {
matches!(
Path::new(path).extension().and_then(|value| value.to_str()),
Some("v" | "sv" | "vhd" | "vhdl")
)
}
fn insert_input(
inputs: &mut BTreeMap<String, EffectiveInput>,
path: String,
role: &str,
candidate: bool,
) {
inputs
.entry(path.clone())
.and_modify(|input| input.candidate |= candidate)
.or_insert_with(|| EffectiveInput {
path,
role: role.to_string(),
candidate,
});
}
pub fn prepare_workspace(root: &Path, aegis: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
bail!("project root does not exist: {}", root.display());
}
if !aegis.is_dir() {
bail!("AegisRTL directory does not exist: {}", aegis.display());
}
let root = root
.canonicalize()
.with_context(|| format!("failed to resolve project root: {}", root.display()))?;
let aegis = aegis
.canonicalize()
.with_context(|| format!("failed to resolve AegisRTL directory: {}", aegis.display()))?;
if root == aegis {
bail!("project root and AegisRTL directory must be different");
}
let local_dirs = local_directory_set(&root.join("Bender.lock"))?;
for entry in GENERATED_ENTRIES {
remove_generated_entry(&aegis, entry)?;
}
for entry in &local_dirs {
if PROTECTED_AEGIS_ENTRIES.contains(&entry.as_str()) {
bail!("local dependency directory conflicts with AegisRTL tool content: {entry}");
}
remove_generated_entry(&aegis, entry)?;
}
seed_missing_databases(&root, &aegis)?;
copy_file(&root.join("Bender.yml"), &aegis.join("Bender.yml"))?;
copy_file(&root.join(".bender.yml"), &aegis.join(".bender.yml"))?;
copy_file(&root.join("Bender.lock"), &aegis.join("Bender.lock"))?;
for name in &local_dirs {
let source = root.join(name);
if source.exists() {
copy_dir_recursive(&source, &aegis.join(name))?;
}
}
Ok(local_dirs.into_iter().collect())
}
fn seed_missing_databases(root: &Path, aegis: &Path) -> Result<()> {
let source = root.join(".bender/git/db");
if !source.is_dir() {
return Ok(());
}
let destination = aegis.join(".bender/git/db");
fs::create_dir_all(&destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
for entry in fs::read_dir(&source)
.with_context(|| format!("failed to read {}", source.display()))?
{
let entry = entry?;
if !entry.path().is_dir() {
continue;
}
let target = destination.join(entry.file_name());
if !target.exists() {
copy_dir_recursive(&entry.path(), &target)?;
}
}
Ok(())
}
pub fn activate_local_configs(aegis: &Path, root: &Path) -> Result<()> {
let lock_path = aegis.join("Bender.lock");
let lock_content = fs::read_to_string(&lock_path)
.with_context(|| format!("failed to read {}", lock_path.display()))?;
let lock: Value = serde_yaml::from_str(&lock_content)
.with_context(|| format!("failed to parse {}", lock_path.display()))?;
let state_dir = aegis.join(".aegis");
fs::create_dir_all(&state_dir)
.with_context(|| format!("failed to create {}", state_dir.display()))?;
fs::write(state_dir.join("source-Bender.lock"), &lock_content)
.context("failed to preserve source Bender.lock")?;
let checkout_paths = checkout_paths_by_package(aegis)?;
let LockSources {
git_urls,
local_dirs,
} = lock_sources(&lock)?;
let prefix = format!(
"../{}",
aegis
.file_name()
.and_then(|name| name.to_str())
.context("invalid AegisRTL directory name")?
);
let mut git_paths = BTreeMap::new();
for (package, url) in git_urls {
let checkout = checkout_paths
.get(&package.to_ascii_lowercase())
.with_context(|| format!("no checkout found for locked package {package}"))?;
git_paths.insert(url, format!("{prefix}/{checkout}"));
}
write_source_metadata(aegis, &state_dir, &lock, &git_paths, &prefix)?;
let bender_path = aegis.join("Bender.yml");
let override_path = aegis.join(".bender.yml");
let mut bender: Value = serde_yaml::from_str(&fs::read_to_string(&bender_path)?)?;
let mut overrides: Value = serde_yaml::from_str(&fs::read_to_string(&override_path)?)?;
rewrite_root_paths(&mut bender, &local_dirs, &prefix);
replace_with_complete_local_overrides(&mut overrides, &lock, &checkout_paths, &prefix)?;
let bender_output = serde_yaml::to_string(&bender)?;
let override_output = serde_yaml::to_string(&overrides)?;
fs::write(root.join("Bender.yml"), bender_output)?;
fs::write(root.join(".bender.yml"), override_output)?;
Ok(())
}
fn write_source_metadata(
aegis: &Path,
state_dir: &Path,
lock: &Value,
git_paths: &BTreeMap<String, String>,
prefix: &str,
) -> Result<()> {
let mut records = Vec::new();
if let Some(packages) = lock.get("packages").and_then(Value::as_mapping) {
for (name, package) in packages {
let name = name
.as_str()
.context("invalid package name in Bender.lock")?;
let source = package
.get("source")
.context("package source missing in Bender.lock")?;
let (source_kind, source_value, resolved_path) =
if let Some(url) = source.get("Git").and_then(Value::as_str) {
let resolved = git_paths
.get(url)
.with_context(|| format!("no resolved path found for {name}"))?;
("git", url, resolved.clone())
} else if let Some(path) = source.get("Path").and_then(Value::as_str) {
("path", path, format!("{prefix}/{path}"))
} else {
bail!("unsupported source for locked package {name}");
};
let local_path = resolved_path
.strip_prefix(&format!("{prefix}/"))
.unwrap_or(&resolved_path);
let manifest = aegis.join(local_path).join("Bender.yml");
let manifest_sha256 = manifest
.is_file()
.then(|| file_hash(&manifest))
.transpose()?;
records.push(serde_json::json!({
"name": name,
"source_kind": source_kind,
"source": source_value,
"revision": package.get("revision"),
"version": package.get("version"),
"resolved_path": resolved_path,
"manifest_sha256": manifest_sha256,
}));
}
}
let path = state_dir.join("source-packages.json");
fs::write(&path, serde_json::to_vec_pretty(&records)?)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn checkout_paths_by_package(aegis: &Path) -> Result<BTreeMap<String, String>> {
let checkouts = aegis.join(".bender/git/checkouts");
let mut packages = BTreeMap::new();
for entry in fs::read_dir(&checkouts)
.with_context(|| format!("failed to read {}", checkouts.display()))?
{
let path = entry?.path();
let manifest = path.join("Bender.yml");
if !manifest.is_file() {
continue;
}
let yaml: Value = serde_yaml::from_str(&fs::read_to_string(&manifest)?)?;
let name = yaml
.get("package")
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
.with_context(|| format!("package name missing in {}", manifest.display()))?;
let relative = path
.strip_prefix(aegis)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if packages
.insert(name.to_ascii_lowercase(), relative)
.is_some()
{
bail!("multiple checkouts found for package {name}");
}
}
Ok(packages)
}
fn overlay_checkout_files(root: &Path, aegis: &Path) -> Result<()> {
let sources = checkout_paths_by_package(root)?;
let destinations = checkout_paths_by_package(aegis)?;
for (package, source) in sources {
let destination = destinations
.get(&package)
.with_context(|| format!("no AegisRTL checkout found for package {package}"))?;
copy_worktree_recursive(&root.join(source), &aegis.join(destination))?;
}
Ok(())
}
fn root_checkouts_are_clean_and_complete(root: &Path) -> Result<bool> {
let checkouts = root.join(".bender/git/checkouts");
if !checkouts.is_dir() {
return Ok(false);
}
for entry in fs::read_dir(&checkouts)
.with_context(|| format!("failed to read {}", checkouts.display()))?
{
let path = entry?.path();
if !path.is_dir() {
continue;
}
if path.join("Bender.yml").is_file() && !path.join(".git").exists() {
return Ok(false);
}
if !path.join(".git").exists() {
continue;
}
let status = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(&path)
.output()
.with_context(|| format!("failed to inspect {}", path.display()))?;
if !status.status.success() || !status.stdout.is_empty() {
return Ok(false);
}
let submodules = Command::new("git")
.args(["submodule", "status", "--recursive"])
.current_dir(&path)
.output()
.with_context(|| format!("failed to inspect submodules in {}", path.display()))?;
if !submodules.status.success()
|| submodules
.stdout
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.any(|line| line.first() != Some(&b' '))
{
return Ok(false);
}
}
Ok(true)
}
struct LockSources {
git_urls: BTreeMap<String, String>,
local_dirs: BTreeSet<String>,
}
fn lock_sources(lock: &Value) -> Result<LockSources> {
let mut git_urls = BTreeMap::new();
let mut local_dirs = BTreeSet::from(["hw".to_string(), "target".to_string()]);
if let Some(packages) = lock.get("packages").and_then(Value::as_mapping) {
for (name, package) in packages {
let name = name
.as_str()
.context("invalid package name in Bender.lock")?;
let source = package
.get("source")
.context("package source missing in Bender.lock")?;
if let Some(url) = source.get("Git").and_then(Value::as_str) {
git_urls.insert(name.to_string(), url.to_string());
} else if let Some(path) = source.get("Path").and_then(Value::as_str) {
local_dirs.insert(top_level_directory(path)?);
}
}
}
Ok(LockSources {
git_urls,
local_dirs,
})
}
fn replace_with_complete_local_overrides(
overrides: &mut Value,
lock: &Value,
checkout_paths: &BTreeMap<String, String>,
prefix: &str,
) -> Result<()> {
let packages = lock
.get("packages")
.and_then(Value::as_mapping)
.context("packages missing in Bender.lock")?;
let mut local_overrides = serde_yaml::Mapping::new();
for (name, package) in packages {
let name_text = name
.as_str()
.context("invalid package name in Bender.lock")?;
let source = package
.get("source")
.context("package source missing in Bender.lock")?;
let path = if source.get("Git").is_some() {
let checkout = checkout_paths
.get(&name_text.to_ascii_lowercase())
.with_context(|| format!("no checkout found for locked package {name_text}"))?;
format!("{prefix}/{checkout}")
} else if let Some(path) = source.get("Path").and_then(Value::as_str) {
format!("{prefix}/{path}")
} else {
bail!("unsupported source for locked package {name_text}");
};
let mut specification = serde_yaml::Mapping::new();
specification.insert(Value::String("path".to_string()), Value::String(path));
local_overrides.insert(name.clone(), Value::Mapping(specification));
}
let document = overrides
.as_mapping_mut()
.context("invalid .bender.yml document")?;
document.insert(
Value::String("overrides".to_string()),
Value::Mapping(local_overrides),
);
Ok(())
}
fn rewrite_root_paths(document: &mut Value, local_dirs: &BTreeSet<String>, prefix: &str) {
for key in ["sources", "export_include_dirs"] {
if let Some(value) = document.get_mut(key) {
rewrite_path_values(value, local_dirs, prefix);
}
}
}
fn rewrite_path_values(value: &mut Value, local_dirs: &BTreeSet<String>, prefix: &str) {
match value {
Value::String(path) => {
if let Some(Component::Normal(name)) = Path::new(path).components().next() {
if local_dirs.contains(name.to_string_lossy().as_ref()) {
*path = format!("{prefix}/{path}");
}
}
}
Value::Sequence(values) => {
for value in values {
rewrite_path_values(value, local_dirs, prefix);
}
}
Value::Mapping(values) => {
for value in values.values_mut() {
rewrite_path_values(value, local_dirs, prefix);
}
}
_ => {}
}
}
fn local_directory_set(lock_path: &Path) -> Result<BTreeSet<String>> {
let content = fs::read_to_string(lock_path)
.with_context(|| format!("failed to read {}", lock_path.display()))?;
let lock: Value = serde_yaml::from_str(&content)
.with_context(|| format!("failed to parse {}", lock_path.display()))?;
let mut dirs = BTreeSet::from(["hw".to_string(), "target".to_string()]);
if let Some(packages) = lock.get("packages").and_then(Value::as_mapping) {
for package in packages.values() {
let path = package
.get("source")
.and_then(|source| source.get("Path"))
.and_then(Value::as_str);
if let Some(path) = path {
dirs.insert(top_level_directory(path)?);
}
}
}
Ok(dirs)
}
fn top_level_directory(value: &str) -> Result<String> {
let path = Path::new(value);
if path.is_absolute() {
bail!("local dependency path must be relative: {value}");
}
match path.components().next() {
Some(Component::Normal(name)) => Ok(name.to_string_lossy().into_owned()),
_ => bail!("invalid local dependency path: {value}"),
}
}
fn remove_generated_entry(aegis: &Path, name: &str) -> Result<()> {
let path = aegis.join(name);
if path.is_dir() {
fs::remove_dir_all(&path)
.with_context(|| format!("failed to remove {}", path.display()))?;
} else if path.exists() {
fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
}
Ok(())
}
fn copy_file(source: &Path, destination: &Path) -> Result<()> {
fs::copy(source, destination).with_context(|| {
format!(
"failed to copy {} to {}",
source.display(),
destination.display()
)
})?;
Ok(())
}
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
for entry in
fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
{
let entry = entry?;
let source_path = entry.path();
let destination_path: PathBuf = destination.join(entry.file_name());
if source_path.is_dir() {
copy_dir_recursive(&source_path, &destination_path)?;
} else {
copy_file(&source_path, &destination_path)?;
}
}
Ok(())
}
fn copy_worktree_recursive(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
for entry in
fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
{
let entry = entry?;
if entry.file_name() == ".git" {
continue;
}
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if source_path.is_dir() {
copy_worktree_recursive(&source_path, &destination_path)?;
} else {
copy_file(&source_path, &destination_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
activate_local_configs, build_manifest, execute_hardener, materialize_aegis_checkouts,
overlay_checkout_files, prepare_workspace, root_checkouts_are_clean_and_complete,
run_with_bender, HardeningStatus,
};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("bendis-{name}-{nonce}"));
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn prepare_workspace_cleans_generated_content_and_copies_local_inputs() {
let base = temp_dir("prepare-workspace");
let root = base.join("heris-soc");
let aegis = base.join("aegisrtl");
fs::create_dir_all(root.join("hw/rtl")).unwrap();
fs::create_dir_all(root.join("target/sim")).unwrap();
fs::create_dir_all(root.join("sw/runtime")).unwrap();
fs::create_dir_all(root.join(".bender/git/db/new-core")).unwrap();
fs::create_dir_all(aegis.join("scripts")).unwrap();
fs::create_dir_all(aegis.join(".bender/git/db/core")).unwrap();
fs::create_dir_all(aegis.join(".bender/git/checkouts/old")).unwrap();
fs::create_dir_all(aegis.join("sw/runtime")).unwrap();
fs::write(root.join("hw/rtl/core.sv"), "module core; endmodule\n").unwrap();
fs::write(root.join("target/sim/tb.sv"), "module tb; endmodule\n").unwrap();
fs::write(root.join("sw/runtime/start.S"), "nop\n").unwrap();
fs::write(root.join(".bender/git/db/new-core/object"), "new\n").unwrap();
fs::write(root.join("Bender.yml"), "package:\n name: root\n").unwrap();
fs::write(root.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(
root.join("Bender.lock"),
"packages:\n runtime:\n source:\n Path: sw/runtime\n",
)
.unwrap();
fs::write(aegis.join("scripts/harden.sh"), "#!/bin/sh\n").unwrap();
fs::write(aegis.join(".bender/git/db/core/object"), "cached\n").unwrap();
fs::write(aegis.join(".bender/git/checkouts/old/data"), "stale\n").unwrap();
fs::write(aegis.join("sw/runtime/stale.S"), "stale\n").unwrap();
fs::write(aegis.join("Bender.lock"), "stale\n").unwrap();
let copied = prepare_workspace(&root, &aegis).unwrap();
assert_eq!(copied, vec!["hw", "sw", "target"]);
assert_eq!(
fs::read_to_string(aegis.join("hw/rtl/core.sv")).unwrap(),
"module core; endmodule\n"
);
assert!(aegis.join("target/sim/tb.sv").is_file());
assert!(aegis.join("sw/runtime/start.S").is_file());
assert!(!aegis.join("sw/runtime/stale.S").exists());
assert!(aegis.join("scripts/harden.sh").is_file());
assert!(aegis.join(".bender/git/db/core/object").is_file());
assert_eq!(
fs::read_to_string(aegis.join(".bender/git/db/new-core/object")).unwrap(),
"new\n"
);
assert!(!aegis.join(".bender/git/checkouts").exists());
assert_eq!(
fs::read_to_string(aegis.join("Bender.lock")).unwrap(),
fs::read_to_string(root.join("Bender.lock")).unwrap()
);
fs::remove_dir_all(base).unwrap();
}
#[test]
fn prepare_workspace_does_not_replace_aegis_tool_directories() {
let base = temp_dir("protected-local-directory");
let root = base.join("heris-soc");
let aegis = base.join("aegisrtl");
fs::create_dir_all(root.join("scripts/ip")).unwrap();
fs::create_dir_all(aegis.join("scripts")).unwrap();
fs::write(root.join("Bender.yml"), "package:\n name: root\n").unwrap();
fs::write(root.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(
root.join("Bender.lock"),
"packages:\n ip:\n source:\n Path: scripts/ip\n",
)
.unwrap();
fs::write(aegis.join("scripts/harden.sh"), "#!/bin/sh\n").unwrap();
let error = prepare_workspace(&root, &aegis).unwrap_err().to_string();
assert!(error.contains("conflicts with AegisRTL tool content: scripts"));
assert!(aegis.join("scripts/harden.sh").is_file());
fs::remove_dir_all(base).unwrap();
}
#[test]
fn manifest_combines_bender_sources_and_kcu105_manual_inputs() {
let base = temp_dir("manifest");
let aegis = base.join("aegisrtl");
let kcu = aegis.join("target/fpga/kcu105");
fs::create_dir_all(aegis.join(".bender/git/checkouts/core/rtl")).unwrap();
fs::create_dir_all(aegis.join("target/sim")).unwrap();
fs::create_dir_all(kcu.join("tcl")).unwrap();
let core = aegis.join(".bender/git/checkouts/core/rtl/core.sv");
let tb = aegis.join("target/sim/tb.sv");
fs::write(&core, "module core; endmodule\n").unwrap();
fs::write(&tb, "module tb; endmodule\n").unwrap();
fs::write(
kcu.join("tcl/run.tcl"),
"set FPGA_RTL rtl\nset FPGA_IPS ips\nset CONSTRS constraints\nadd_files -norecurse $FPGA_RTL/top.v\nimport_ip $FPGA_IPS/clock.xci\nadd_files -fileset constrs_1 -norecurse $CONSTRS/kcu105.xdc\n",
)
.unwrap();
let smoke = format!(
"{{\"srcs\":[{{\"file_type\":\"verilog\",\"files\":[{}],\"metadata\":\"Package(core) Target(rtl)\"}},{{\"file_type\":\"verilog\",\"files\":[{{\"comment\":null,\"file\":{}}}],\"metadata\":\"Package(root) Target(test)\"}}]}}",
serde_json::to_string(&core).unwrap(),
serde_json::to_string(&tb).unwrap()
);
let manifest =
build_manifest(&aegis, &smoke, "{\"srcs\":[]}", &kcu.join("tcl/run.tcl")).unwrap();
assert!(manifest
.inputs
.iter()
.any(|input| input.path.ends_with("core/rtl/core.sv") && input.candidate));
assert!(manifest
.inputs
.iter()
.any(|input| input.path == "target/sim/tb.sv" && !input.candidate));
assert!(manifest
.inputs
.iter()
.any(|input| input.path == "target/fpga/kcu105/rtl/top.v" && input.candidate));
assert!(manifest
.inputs
.iter()
.any(|input| input.path.ends_with("clock.xci") && !input.candidate));
assert!(manifest
.inputs
.iter()
.any(|input| input.path.ends_with("kcu105.xdc") && !input.candidate));
fs::remove_dir_all(base).unwrap();
}
#[test]
fn checkout_overlay_pairs_packages_and_excludes_git_metadata() {
let base = temp_dir("checkout-overlay");
let root = base.join("heris-soc");
let aegis = base.join("aegisrtl");
let source = root.join(".bender/git/checkouts/core-source/rtl");
let destination = aegis.join(".bender/git/checkouts/core-destination/rtl");
fs::create_dir_all(source.parent().unwrap().join(".git")).unwrap();
fs::create_dir_all(destination.parent().unwrap().join(".git")).unwrap();
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&destination).unwrap();
fs::write(
source.parent().unwrap().join("Bender.yml"),
"package:\n name: Core\n",
)
.unwrap();
fs::write(
destination.parent().unwrap().join("Bender.yml"),
"package:\n name: core\n",
)
.unwrap();
fs::write(source.join("core.sv"), "module core; endmodule\n").unwrap();
fs::write(source.parent().unwrap().join(".git/source"), "root metadata\n").unwrap();
fs::write(
destination.parent().unwrap().join(".git/destination"),
"aegis metadata\n",
)
.unwrap();
overlay_checkout_files(&root, &aegis).unwrap();
assert_eq!(
fs::read_to_string(destination.join("core.sv")).unwrap(),
"module core; endmodule\n"
);
assert!(destination.parent().unwrap().join(".git/destination").is_file());
assert!(!destination.parent().unwrap().join(".git/source").exists());
fs::remove_dir_all(base).unwrap();
}
#[test]
fn modified_root_checkout_is_not_a_clean_overlay_source() {
let base = temp_dir("dirty-overlay-source");
let root = base.join("heris-soc");
let checkout = root.join(".bender/git/checkouts/core-source");
fs::create_dir_all(&checkout).unwrap();
assert!(std::process::Command::new("git")
.args(["init", "-q"])
.arg(&checkout)
.status()
.unwrap()
.success());
fs::write(checkout.join("untracked.sv"), "module dirty; endmodule\n").unwrap();
assert!(!root_checkouts_are_clean_and_complete(&root).unwrap());
fs::remove_dir_all(base).unwrap();
}
#[cfg(unix)]
#[test]
fn modified_root_checkout_is_left_untouched_while_aegis_uses_remote_checkout() {
use std::os::unix::fs::PermissionsExt;
let base = temp_dir("dirty-overlay-remote");
let root = base.join("heris-soc");
let aegis = base.join("aegisrtl");
let checkout = root.join(".bender/git/checkouts/core-source");
let bin = base.join("fake-bender");
let log = base.join("calls.log");
fs::create_dir_all(&checkout).unwrap();
fs::create_dir_all(&aegis).unwrap();
assert!(std::process::Command::new("git")
.args(["init", "-q"])
.arg(&checkout)
.status()
.unwrap()
.success());
fs::write(checkout.join("untracked.sv"), "module dirty; endmodule\n").unwrap();
fs::write(
aegis.join("Bender.lock"),
"packages:\n core:\n revision: abc\n source:\n Git: ssh://ihep/core.git\n",
)
.unwrap();
fs::write(
&bin,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nmkdir -p .bender/git/checkouts/core-aegis\nprintf 'package:\\n name: core\\n' > .bender/git/checkouts/core-aegis/Bender.yml\n",
log.display()
),
)
.unwrap();
let mut permissions = fs::metadata(&bin).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&bin, permissions).unwrap();
materialize_aegis_checkouts(&bin, &root, &aegis).unwrap();
assert_eq!(
fs::read_to_string(&log).unwrap(),
"--local --git-submodules false checkout\ncheckout\n"
);
assert!(checkout.join("untracked.sv").is_file());
fs::remove_dir_all(base).unwrap();
}
#[cfg(unix)]
#[test]
fn hardener_receives_manifest_and_may_modify_candidate_in_place() {
use std::os::unix::fs::PermissionsExt;
let base = temp_dir("hardener");
let aegis = base.join("aegisrtl");
fs::create_dir_all(aegis.join("scripts")).unwrap();
fs::create_dir_all(aegis.join("rtl")).unwrap();
fs::write(aegis.join("Bender.yml"), "package:\n name: root\n").unwrap();
fs::write(aegis.join("rtl/core.sv"), "module core; endmodule\n").unwrap();
let script = aegis.join("scripts/harden.sh");
fs::write(
&script,
"#!/bin/sh\nset -eu\ntest \"$1\" = \".aegis/effective-rtl.json\"\nprintf '// hardened\\n' >> rtl/core.sv\n",
)
.unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let manifest = super::EffectiveRtlManifest {
inputs: vec![super::EffectiveInput {
path: "rtl/core.sv".to_string(),
role: "design".to_string(),
candidate: true,
}],
};
let result = execute_hardener(&aegis, &manifest).unwrap();
assert_eq!(result.status, HardeningStatus::Completed);
assert_eq!(result.changed_files, vec!["rtl/core.sv"]);
assert!(aegis.join(".aegis/effective-rtl.json").is_file());
fs::remove_dir_all(base).unwrap();
}
#[test]
fn activation_rewrites_git_and_root_local_paths_for_heris_soc() {
let base = temp_dir("activation");
let root = base.join("heris-soc");
let aegis = base.join("aegisrtl");
let checkout = aegis.join(".bender/git/checkouts/core-abcd");
let helper_checkout = aegis.join(".bender/git/checkouts/helper-efgh");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&checkout).unwrap();
fs::create_dir_all(&helper_checkout).unwrap();
fs::create_dir_all(aegis.join("hw/local")).unwrap();
fs::write(
aegis.join("hw/local/Bender.yml"),
"package:\n name: local\ndependencies:\n helper: { git: 'ssh://upstream/helper.git', version: 1.0.0 }\n",
)
.unwrap();
fs::write(
checkout.join("Bender.yml"),
"package:\n name: Core\ndependencies:\n helper: { git: 'ssh://upstream/helper.git', version: 1.0.0 }\n",
)
.unwrap();
fs::write(
helper_checkout.join("Bender.yml"),
"package:\n name: helper\n",
)
.unwrap();
fs::write(
aegis.join("Bender.lock"),
"packages:\n core:\n revision: abc\n source:\n Git: ssh://ihep/core.git\n helper:\n version: 1.0.0\n source:\n Git: ssh://ihep/helper.git\n local:\n source:\n Path: hw/local\n",
)
.unwrap();
fs::write(
aegis.join("Bender.yml"),
"package:\n name: root\ndependencies:\n core_alias: { git: 'ssh://ihep/core.git', rev: abc }\n local: { path: 'hw/local' }\nsources:\n - hw/top.sv\n",
)
.unwrap();
fs::write(
aegis.join(".bender.yml"),
"overrides:\n core: { git: 'ssh://ihep/core.git', rev: abc }\n",
)
.unwrap();
activate_local_configs(&aegis, &root).unwrap();
let root_bender = fs::read_to_string(root.join("Bender.yml")).unwrap();
let root_override = fs::read_to_string(root.join(".bender.yml")).unwrap();
assert!(root_bender.contains("../aegisrtl/hw/top.sv"));
assert!(root_override.contains("../aegisrtl/.bender/git/checkouts/core-abcd"));
assert!(root_override.contains("../aegisrtl/.bender/git/checkouts/helper-efgh"));
assert!(root_override.contains("../aegisrtl/hw/local"));
assert!(root_bender.contains("ssh://ihep/core.git"));
assert!(root_bender.contains("path: hw/local"));
assert_eq!(
fs::read_to_string(aegis.join("Bender.yml")).unwrap(),
"package:\n name: root\ndependencies:\n core_alias: { git: 'ssh://ihep/core.git', rev: abc }\n local: { path: 'hw/local' }\nsources:\n - hw/top.sv\n"
);
assert_eq!(
fs::read_to_string(aegis.join(".bender.yml")).unwrap(),
"overrides:\n core: { git: 'ssh://ihep/core.git', rev: abc }\n"
);
assert_eq!(
fs::read_to_string(checkout.join("Bender.yml")).unwrap(),
"package:\n name: Core\ndependencies:\n helper: { git: 'ssh://upstream/helper.git', version: 1.0.0 }\n"
);
assert_eq!(
fs::read_to_string(aegis.join("hw/local/Bender.yml")).unwrap(),
"package:\n name: local\ndependencies:\n helper: { git: 'ssh://upstream/helper.git', version: 1.0.0 }\n"
);
assert!(aegis.join(".aegis/source-Bender.lock").is_file());
let metadata = fs::read_to_string(aegis.join(".aegis/source-packages.json")).unwrap();
assert!(metadata.contains("ssh://ihep/core.git"));
assert!(metadata.contains("../aegisrtl/.bender/git/checkouts/core-abcd"));
fs::remove_dir_all(base).unwrap();
}
#[cfg(unix)]
#[test]
fn hard_update_orchestrates_bender_without_network_in_the_final_step() {
use std::os::unix::fs::PermissionsExt;
let base = temp_dir("orchestration");
let root = base.join("heris-soc");
let aegis = base.join("aegisrtl");
let bin = base.join("fake-bender");
let log = base.join("bender.log");
fs::create_dir_all(root.join("hw")).unwrap();
fs::create_dir_all(root.join("target/fpga/kcu105/tcl")).unwrap();
fs::create_dir_all(root.join("build/questasim")).unwrap();
fs::create_dir_all(root.join(".bender/git/checkouts/core-source/rtl")).unwrap();
fs::create_dir_all(&aegis).unwrap();
fs::write(root.join("hw/core.sv"), "module core; endmodule\n").unwrap();
fs::write(
root.join(".bender/git/checkouts/core-source/Bender.yml"),
"package:\n name: core\n",
)
.unwrap();
fs::write(
root.join(".bender/git/checkouts/core-source/rtl/core.sv"),
"module core_from_root_checkout; endmodule\n",
)
.unwrap();
let source_checkout = root.join(".bender/git/checkouts/core-source");
assert!(Command::new("git")
.args(["init", "-q"])
.arg(&source_checkout)
.status()
.unwrap()
.success());
assert!(Command::new("git")
.args(["-C", source_checkout.to_str().unwrap(), "add", "."])
.status()
.unwrap()
.success());
assert!(Command::new("git")
.args([
"-C",
source_checkout.to_str().unwrap(),
"-c",
"user.name=Bendis Test",
"-c",
"user.email=bendis@example.invalid",
"commit",
"-q",
"-m",
"fixture",
])
.status()
.unwrap()
.success());
fs::write(
root.join("target/fpga/kcu105/tcl/run.tcl"),
"set FPGA_RTL rtl\n",
)
.unwrap();
fs::write(root.join("build/questasim/.build-config"), "old\n").unwrap();
fs::write(root.join("Bender.yml"), "package:\n name: root\ndependencies:\n core: { git: 'ssh://ihep/core.git', rev: abc }\nsources:\n - hw/core.sv\n").unwrap();
fs::write(root.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(
root.join("Bender.lock"),
"packages:\n core:\n revision: abc\n source:\n Git: ssh://ihep/core.git\n",
)
.unwrap();
let script = format!(
r#"#!/bin/sh
set -eu
printf '%s\n' "$*" >> '{}'
if [ "$1" = "--local" ] && [ "$2" = "--git-submodules" ] && [ "$4" = "checkout" ]; then
mkdir -p .bender/git/checkouts/core-abcd/rtl
printf 'package:\n name: core\n' > .bender/git/checkouts/core-abcd/Bender.yml
printf 'module core_from_aegis_checkout; endmodule\n' > .bender/git/checkouts/core-abcd/rtl/core.sv
elif [ "$1" = "-d" ] && [ "$3" = "script" ]; then
printf '{{"srcs":[{{"files":["%s/.bender/git/checkouts/core-abcd/rtl/core.sv"],"metadata":"Package(core) Target(rtl)"}}]}}\n' "$2"
elif [ "$1" = "--local" ] && [ "$2" = "update" ]; then
printf 'packages: {{}}\n' > Bender.lock
else
exit 2
fi
"#,
log.display()
);
fs::write(&bin, script).unwrap();
let mut permissions = fs::metadata(&bin).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&bin, permissions).unwrap();
let result = run_with_bender(&root, &bin).unwrap();
assert_eq!(result.status, HardeningStatus::Skipped);
let calls = fs::read_to_string(&log).unwrap();
assert!(calls.contains("--local --git-submodules false checkout"));
assert!(!calls.lines().any(|line| line.ends_with(" update") && !line.starts_with("--local")));
assert!(calls.contains("script template-json -t rtl -t test -t rtl_sim"));
assert!(calls.contains("script template-json -t fpga -t xilinx"));
assert!(calls.lines().last().unwrap().starts_with("--local update"));
assert!(!root.join("build/questasim/.build-config").exists());
let root_bender = fs::read_to_string(root.join("Bender.yml")).unwrap();
let root_overrides = fs::read_to_string(root.join(".bender.yml")).unwrap();
assert!(root_bender.contains("ssh://ihep/core.git"));
assert!(root_bender.contains("../aegisrtl/hw/core.sv"));
assert!(root_overrides.contains("../aegisrtl/.bender"));
assert_eq!(
fs::read_to_string(aegis.join(".bender/git/checkouts/core-abcd/rtl/core.sv")).unwrap(),
"module core_from_root_checkout; endmodule\n"
);
fs::remove_dir_all(base).unwrap();
}
}