use crate::{Error, Result, bundle};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
io::Write,
path::{Component, Path, PathBuf},
};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: u32,
pub component: String,
pub revision: String,
pub url: String,
pub archive_sha256: String,
pub files: BTreeMap<PathBuf, String>,
}
fn hash(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
pub fn default_manifest() -> Result<Manifest> {
let manifest: Manifest = serde_json::from_str(include_str!("../../npu-bundle.json"))?;
manifest.validate()?;
Ok(manifest)
}
pub fn runtime_manifest() -> Result<Manifest> {
let manifest = match std::env::var_os("HRX_NPU_BUNDLE_MANIFEST") {
Some(path) => Manifest::load(path)?,
None => default_manifest()?,
};
if manifest.component != "npu-runtime" {
return Err(Error::Message("expected an npu-runtime manifest".into()));
}
Ok(manifest)
}
pub fn resolve() -> Result<PathBuf> {
if !cfg!(all(target_os = "linux", target_arch = "x86_64")) {
return Err(Error::Unsupported(
"the NPU runtime supports Linux x86_64".into(),
));
}
if let Some(path) = std::env::var_os("HRX_NPU_RUNTIME_DIR") {
return Ok(PathBuf::from(path));
}
runtime_manifest()?.prepare(std::env::var_os("HRX_OFFLINE").is_some())
}
pub fn probe_runtime(directory: impl AsRef<Path>) -> Result<()> {
super::load_shim_from(directory.as_ref()).map(|_| ())
}
impl Manifest {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let manifest: Self = serde_json::from_slice(&fs::read(path)?)?;
manifest.validate()?;
Ok(manifest)
}
fn validate(&self) -> Result<()> {
if self.schema != 1
|| !matches!(self.component.as_str(), "npu-runtime" | "npu-compiler")
|| !hash(&self.archive_sha256)
|| self.files.is_empty()
{
return Err(Error::Message("invalid NPU component manifest".into()));
}
for (path, digest) in &self.files {
if path.as_os_str().is_empty()
|| path
.components()
.any(|c| !matches!(c, Component::Normal(_)))
|| !hash(digest)
{
return Err(Error::Message("invalid component file or digest".into()));
}
}
if self.component == "npu-runtime" && !self.files.contains_key(Path::new("libhrx_npu.so.1"))
{
return Err(Error::Message("NPU runtime lacks ABI shim".into()));
}
Ok(())
}
pub fn verify(&self, root: &Path) -> Result<()> {
self.validate()?;
for (path, expected) in &self.files {
let mut parent = root.to_path_buf();
for component in path.components() {
parent.push(component);
if fs::symlink_metadata(&parent)?.file_type().is_symlink() {
return Err(Error::Message("component contains a symbolic link".into()));
}
}
if !fs::metadata(root.join(path))?.is_file()
|| bundle::file_digest(&root.join(path))? != *expected
{
return Err(Error::Message(format!(
"corrupt component file {}",
path.display()
)));
}
}
Ok(())
}
pub fn install(&self, archive: &Path, cache: &Path) -> Result<PathBuf> {
self.validate()?;
bundle::create_cache_dir(cache)?;
let _lock = bundle::Lock::acquire(&cache.join(format!("{}.lock", self.archive_sha256)))?;
let destination = cache.join(&self.archive_sha256);
if self.verify(&destination).is_ok() {
return Ok(destination);
}
if bundle::file_digest(archive)? != self.archive_sha256 {
return Err(Error::Message("NPU archive digest mismatch".into()));
}
let staging = tempfile::tempdir_in(cache)?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(fs::File::open(archive)?));
let mut seen = BTreeSet::new();
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.into_owned();
if !entry.header().entry_type().is_file()
|| !self.files.contains_key(&path)
|| !seen.insert(path.clone())
{
return Err(Error::Message(
"unexpected, duplicate or nonregular component archive entry".into(),
));
}
let destination = staging.path().join(&path);
fs::create_dir_all(destination.parent().unwrap())?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(destination)?;
std::io::copy(&mut entry, &mut file)?;
file.flush()?;
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(entry.header().mode()? & 0o777))?;
}
self.verify(staging.path())?;
fs::write(
staging.path().join("component.json"),
serde_json::to_vec_pretty(self)?,
)?;
if destination.exists() {
fs::remove_dir_all(&destination)?;
}
bundle::publish(staging, &destination)?;
Ok(destination)
}
pub fn prepare(&self, offline: bool) -> Result<PathBuf> {
self.validate()?;
let root = bundle::cache_root()?.join(&self.component);
let destination = root.join(&self.archive_sha256);
if self.verify(&destination).is_ok() {
return Ok(destination);
}
if offline {
return Err(Error::Message(format!(
"{} is not prepared for offline use",
self.component
)));
}
bundle::create_cache_dir(&root)?;
let mut archive = tempfile::NamedTempFile::new_in(&root)?;
if let Some(path) = self.url.strip_prefix("file://") {
std::io::copy(&mut fs::File::open(path)?, &mut archive)?;
} else {
if !self.url.starts_with("https://") {
return Err(Error::Message(
"NPU component URL must use HTTPS or file://".into(),
));
}
#[cfg(feature = "download")]
{
let mut response = ureq::get(&self.url)
.call()
.map_err(|e| Error::Download(Box::new(e)))?;
std::io::copy(&mut response.body_mut().as_reader(), &mut archive)?;
}
#[cfg(not(feature = "download"))]
{
return Err(Error::Unsupported(
"enable download or supply a local component archive".into(),
));
}
}
archive.flush()?;
self.install(archive.path(), &root)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn archive(root: &Path) -> (Manifest, PathBuf) {
let archive = root.join("component.tar.gz");
let gzip = flate2::write::GzEncoder::new(
fs::File::create(&archive).unwrap(),
flate2::Compression::default(),
);
let mut tar = tar::Builder::new(gzip);
let contents = b"test native ABI shim";
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header, "libhrx_npu.so.1", &contents[..])
.unwrap();
tar.into_inner().unwrap().finish().unwrap();
(
Manifest {
schema: 1,
component: "npu-runtime".into(),
revision: "fixture".into(),
url: format!("file://{}", archive.display()),
archive_sha256: bundle::file_digest(&archive).unwrap(),
files: BTreeMap::from([("libhrx_npu.so.1".into(), bundle::digest(contents))]),
},
archive,
)
}
#[test]
fn pinned_runtime_includes_its_xrt_and_driver() {
let manifest = default_manifest().unwrap();
assert_eq!(manifest.component, "npu-runtime");
assert!(manifest.url.starts_with("https://"));
for path in [
"libhrx_npu.so.1",
"lib/libxrt_coreutil.so.2",
"lib/libxrt_core.so.2",
"lib/libxrt_driver_xdna.so.2",
"lib/libuuid.so.1",
"THIRD-PARTY.json",
"NOTICE",
] {
assert!(
manifest.files.contains_key(Path::new(path)),
"missing {path}"
);
}
}
#[test]
fn verified_install_reuses_and_repairs_without_exposing_partial_files() {
let root = tempfile::tempdir().unwrap();
let (manifest, archive) = archive(root.path());
let cache = root.path().join("cache");
let installed = manifest.install(&archive, &cache).unwrap();
manifest.verify(&installed).unwrap();
fs::write(installed.join("libhrx_npu.so.1"), "corrupt").unwrap();
assert!(manifest.verify(&installed).is_err());
assert_eq!(manifest.install(&archive, &cache).unwrap(), installed);
manifest.verify(&installed).unwrap();
fs::write(&archive, "wrong archive").unwrap();
fs::remove_dir_all(&installed).unwrap();
assert!(manifest.install(&archive, &cache).is_err());
assert!(!installed.exists());
}
#[test]
fn manifest_traversal_and_installed_links_are_refused() {
let root = tempfile::tempdir().unwrap();
let (mut manifest, archive) = archive(root.path());
manifest
.files
.insert("../outside".into(), bundle::digest(b"bad"));
assert!(manifest.validate().is_err());
manifest.files.remove(Path::new("../outside"));
let installed = manifest
.install(&archive, &root.path().join("cache"))
.unwrap();
fs::remove_file(installed.join("libhrx_npu.so.1")).unwrap();
std::os::unix::fs::symlink(&archive, installed.join("libhrx_npu.so.1")).unwrap();
assert!(manifest.verify(&installed).is_err());
}
}