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",
"Bender.yml",
".bender.yml",
"Bender.lock",
"hw",
"target",
".aegis",
];
#[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)?;
run_command(
Command::new(bender).args(["-d", &aegis.to_string_lossy(), "update"]),
"bender update failed in AegisRTL",
)?;
if !aegis.join("Bender.lock").is_file() {
bail!("bender update did not create AegisRTL/Bender.lock");
}
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 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<PathBuf>,
#[serde(default)]
metadata: String,
}
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 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)?;
}
copy_file(&root.join("Bender.yml"), &aegis.join("Bender.yml"))?;
copy_file(&root.join(".bender.yml"), &aegis.join(".bender.yml"))?;
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())
}
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 (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)
.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_dependency_section(&mut bender, "dependencies", &git_paths, &prefix)?;
rewrite_dependency_section(&mut overrides, "overrides", &git_paths, &prefix)?;
rewrite_root_paths(&mut bender, &local_dirs, &prefix);
let bender_output = serde_yaml::to_string(&bender)?;
let override_output = serde_yaml::to_string(&overrides)?;
fs::write(&bender_path, &bender_output)?;
fs::write(&override_path, &override_output)?;
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_string(), relative).is_some() {
bail!("multiple checkouts found for package {name}");
}
}
Ok(packages)
}
fn lock_sources(lock: &Value) -> Result<(BTreeMap<String, String>, BTreeSet<String>)> {
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((git_urls, local_dirs))
}
fn rewrite_dependency_section(
document: &mut Value,
section: &str,
git_paths: &BTreeMap<String, String>,
prefix: &str,
) -> Result<()> {
let Some(dependencies) = document.get_mut(section).and_then(Value::as_mapping_mut) else {
return Ok(());
};
for specification in dependencies.values_mut() {
let Some(mapping) = specification.as_mapping_mut() else {
continue;
};
let git_key = Value::String("git".to_string());
let path_key = Value::String("path".to_string());
let replacement = if let Some(url) = mapping.get(&git_key).and_then(Value::as_str) {
Some(
git_paths
.get(url)
.with_context(|| format!("no locked package found for Git dependency {url}"))?
.clone(),
)
} else {
mapping
.get(&path_key)
.and_then(Value::as_str)
.map(|path| format!("{prefix}/{path}"))
};
if let Some(path) = replacement {
mapping.clear();
mapping.insert(path_key, Value::String(path));
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::{
activate_local_configs, build_manifest, execute_hardener, prepare_workspace,
run_with_bender, HardeningStatus,
};
use std::fs;
use std::path::PathBuf;
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(aegis.join("scripts")).unwrap();
fs::create_dir_all(aegis.join(".bender/old")).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.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/old/data"), "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("scripts/harden.sh").is_file());
assert!(!aegis.join(".bender").exists());
assert!(!aegis.join("Bender.lock").exists());
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\":[{}],\"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();
}
#[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");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&checkout).unwrap();
fs::create_dir_all(aegis.join("hw/local")).unwrap();
fs::write(checkout.join("Bender.yml"), "package:\n name: core\n").unwrap();
fs::write(
aegis.join("Bender.lock"),
"packages:\n core:\n revision: abc\n source:\n Git: ssh://ihep/core.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/.bender/git/checkouts/core-abcd"));
assert!(root_bender.contains("../aegisrtl/hw/local"));
assert!(root_bender.contains("../aegisrtl/hw/top.sv"));
assert!(root_override.contains("../aegisrtl/.bender/git/checkouts/core-abcd"));
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(&aegis).unwrap();
fs::write(root.join("hw/core.sv"), "module core; endmodule\n").unwrap();
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" = "-d" ] && [ "$3" = "update" ]; then
mkdir -p "$2/.bender/git/checkouts/core-abcd/rtl"
printf 'package:\n name: core\n' > "$2/.bender/git/checkouts/core-abcd/Bender.yml"
cp "$2/hw/core.sv" "$2/.bender/git/checkouts/core-abcd/rtl/core.sv"
printf 'packages:\n core:\n revision: abc\n source:\n Git: ssh://ihep/core.git\n' > "$2/Bender.lock"
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("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());
assert!(fs::read_to_string(root.join("Bender.yml"))
.unwrap()
.contains("../aegisrtl/.bender"));
fs::remove_dir_all(base).unwrap();
}
}