1use crate::{Error, Result};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::{
6 collections::BTreeMap,
7 fs::{self, File, OpenOptions},
8 io::{Read, Write},
9 path::{Path, PathBuf},
10};
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct Manifest {
16 pub schema: u32,
18 pub target: String,
20 pub revision: String,
22 pub url: String,
24 pub archive_sha256: String,
26 pub files: BTreeMap<String, String>,
28}
29
30pub fn digest(bytes: &[u8]) -> String {
32 format!("{:x}", Sha256::digest(bytes))
33}
34pub fn file_digest(path: &Path) -> Result<String> {
36 let mut hash = Sha256::new();
37 let mut file = File::open(path)?;
38 let mut buf = vec![0u8; 64 * 1024];
39 loop {
40 let n = file.read(&mut buf)?;
41 if n == 0 {
42 break;
43 }
44 hash.update(&buf[..n]);
45 }
46 Ok(format!("{:x}", hash.finalize()))
47}
48pub fn cache_root() -> Result<PathBuf> {
56 let base = std::env::var_os("XDG_CACHE_HOME")
57 .map(PathBuf::from)
58 .filter(|path| path.is_absolute())
59 .or_else(|| {
60 std::env::var_os("HOME")
61 .map(PathBuf::from)
62 .filter(|path| path.is_absolute())
63 .map(|home| home.join(".cache"))
64 })
65 .ok_or_else(|| {
66 Error::Message(
67 "set XDG_CACHE_HOME or HOME to an absolute path: no cache directory available"
68 .into(),
69 )
70 })?;
71 Ok(base.join("hrx"))
72}
73
74pub fn kernel_cache() -> Result<PathBuf> {
81 Ok(cache_root()?.join("kernels"))
82}
83
84pub struct Lock {
87 _file: File,
88}
89impl Lock {
90 pub fn acquire(path: &Path) -> Result<Self> {
92 use std::os::fd::AsRawFd;
93 use std::os::unix::fs::OpenOptionsExt;
94 let file = OpenOptions::new()
95 .mode(0o600)
96 .custom_flags(libc::O_NOFOLLOW)
97 .read(true)
98 .write(true)
99 .create(true)
100 .truncate(false)
101 .open(path)?;
102 loop {
103 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 {
104 return Ok(Self { _file: file });
105 }
106 let e = std::io::Error::last_os_error();
107 if e.kind() != std::io::ErrorKind::Interrupted {
108 return Err(e.into());
109 }
110 }
111 }
112}
113
114impl Manifest {
115 pub fn parse(bytes: &[u8]) -> Result<Self> {
117 let m: Self = serde_json::from_slice(bytes)
118 .map_err(|e| Error::from(e).context("runtime manifest"))?;
119 m.validate()?;
120 Ok(m)
121 }
122 pub fn validate(&self) -> Result<()> {
124 let m = self;
125 if m.schema != 1 {
126 return Err(Error::Message(
127 "unsupported runtime manifest schema or target".into(),
128 ));
129 }
130 m.gpu_target()?;
131 if !valid_digest(&m.archive_sha256) || m.files.is_empty() {
132 return Err(Error::Message(
133 "invalid bundle digest or empty bundle".into(),
134 ));
135 }
136 for (name, hash) in &m.files {
137 if !valid_name(name) || !valid_digest(hash) {
138 return Err(Error::Message(format!("invalid bundle entry {name}")));
139 }
140 }
141 for name in ["libamdf.so", "libhrx_fabric.so", "libloomc.so"] {
142 if !m.files.contains_key(name) {
143 return Err(Error::Message(format!("bundle is missing {name}")));
144 }
145 }
146 Ok(())
147 }
148 pub fn gpu_target(&self) -> Result<crate::Target> {
150 let key = self
151 .target
152 .strip_prefix("x86_64-unknown-linux-gnu-")
153 .ok_or_else(|| Error::Message("unsupported bundle platform".into()))?;
154 crate::Target::new(key)
155 }
156 pub fn verify(&self, directory: &Path) -> Result<()> {
158 self.validate()?;
159 for (name, expected) in &self.files {
160 let path = directory.join(name);
161 if !fs::symlink_metadata(&path)?.is_file() || &file_digest(&path)? != expected {
162 return Err(Error::Message(format!(
163 "runtime integrity check failed: {}",
164 path.display()
165 )));
166 }
167 }
168 Ok(())
169 }
170 pub fn install(&self, archive: &Path, root: &Path) -> Result<PathBuf> {
173 self.validate()?;
174 create_cache_dir(root)?;
175 let _lock = Lock::acquire(&root.join(format!("{}.lock", self.archive_sha256)))?;
176 let destination = root.join(&self.archive_sha256);
177 if self.verify(&destination).is_ok() {
178 return Ok(destination);
179 }
180 if file_digest(archive)? != self.archive_sha256 {
181 return Err(Error::Message("runtime archive SHA-256 mismatch".into()));
182 }
183 let staging = tempfile::tempdir_in(root)?;
184 let decoder = flate2::read::GzDecoder::new(File::open(archive)?);
185 let mut tar = tar::Archive::new(decoder);
186 let mut seen = std::collections::BTreeSet::new();
187 for entry in tar.entries()? {
188 let mut entry = entry?;
189 let name = entry.path()?.to_string_lossy().into_owned();
190 if !valid_name(&name)
191 || !entry.header().entry_type().is_file()
192 || !self.files.contains_key(&name)
193 || !seen.insert(name.clone())
194 {
195 return Err(Error::Message(format!(
196 "unexpected runtime archive entry: {name}"
197 )));
198 }
199 let path = staging.path().join(&name);
200 let mut out = File::create(&path)?;
201 std::io::copy(&mut entry, &mut out)?;
202 use std::os::unix::fs::PermissionsExt;
203 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
204 out.sync_all()?;
205 }
206 self.verify(staging.path())?;
207 fs::write(
208 staging.path().join("manifest.json"),
209 serde_json::to_vec_pretty(self)?,
210 )?;
211 if destination.exists() {
213 fs::remove_dir_all(&destination)?;
214 }
215 File::open(staging.path().join("manifest.json"))?.sync_all()?;
216 File::open(staging.path())?.sync_all()?;
217 publish(staging, &destination)?;
218 File::open(root)?.sync_all()?;
219 Ok(destination)
220 }
221}
222#[derive(Clone, Copy, Debug, Default)]
224pub struct Reclaimed {
225 pub bundles: usize,
227 pub bundle_bytes: u64,
229 pub artifacts: usize,
231 pub artifact_bytes: u64,
233}
234
235fn directory_bytes(path: &Path) -> u64 {
236 let Ok(entries) = fs::read_dir(path) else {
237 return 0;
238 };
239 entries
240 .flatten()
241 .map(|entry| match entry.file_type() {
242 Ok(kind) if kind.is_dir() => directory_bytes(&entry.path()),
243 Ok(kind) if kind.is_file() => entry.metadata().map(|m| m.len()).unwrap_or(0),
244 _ => 0,
245 })
246 .sum()
247}
248
249fn last_access(path: &Path) -> Option<std::time::SystemTime> {
261 let own = fs::metadata(path).and_then(|m| m.accessed()).ok();
263 let Ok(entries) = fs::read_dir(path) else {
264 return own;
265 };
266 entries
267 .flatten()
268 .filter_map(|entry| entry.metadata().and_then(|m| m.accessed()).ok())
269 .max()
270 .or(own)
271}
272
273pub fn collect(cache: &Path, keep: &str, unused_for: std::time::Duration) -> Result<Reclaimed> {
282 let cutoff = std::time::SystemTime::now()
283 .checked_sub(unused_for)
284 .ok_or_else(|| Error::Message("cache age exceeds the system clock".into()))?;
285 let mut reclaimed = Reclaimed::default();
286 let runtime = cache.join("runtime");
287 if let Ok(entries) = fs::read_dir(&runtime) {
288 for entry in entries.flatten() {
289 let name = entry.file_name();
290 let Some(name) = name.to_str() else { continue };
291 if name == keep
296 || !valid_digest(name)
297 || !entry.file_type().is_ok_and(|kind| kind.is_dir())
298 {
299 continue;
300 }
301 let _lock = Lock::acquire(&runtime.join(format!("{name}.lock")))?;
302 if !entry.path().is_dir() {
303 continue; }
305 let bytes = directory_bytes(&entry.path());
306 fs::remove_dir_all(entry.path())?;
307 reclaimed.bundles += 1;
308 reclaimed.bundle_bytes += bytes;
309 }
310 }
311 for kernels in [cache.join("kernels"), cache.join("kernels/npu")] {
314 if let Ok(entries) = fs::read_dir(&kernels) {
315 for entry in entries.flatten() {
316 let name = entry.file_name();
317 let Some(name) = name.to_str() else { continue };
318 if !valid_digest(name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
319 continue;
320 }
321 let _lock = Lock::acquire(&kernels.join(format!("{name}.lock")))?;
324 if !entry.path().is_dir() {
325 continue;
326 }
327 if last_access(&entry.path()).is_none_or(|used| used >= cutoff) {
331 continue;
332 }
333 let bytes = directory_bytes(&entry.path());
334 fs::remove_dir_all(entry.path())?;
335 reclaimed.artifacts += 1;
336 reclaimed.artifact_bytes += bytes;
337 }
338 }
339 }
340 Ok(reclaimed)
341}
342
343fn valid_digest(s: &str) -> bool {
344 s.len() == 64
345 && s.bytes()
346 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
347}
348
349fn valid_name(name: &str) -> bool {
350 !name.is_empty()
351 && name != "."
352 && name != ".."
353 && name
354 .bytes()
355 .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
356}
357
358pub fn default_manifest() -> Result<Manifest> {
360 if let Some(path) = std::env::var_os("HRX_BUNDLE_MANIFEST") {
361 return Manifest::parse(&fs::read(path)?);
362 }
363 Manifest::parse(include_bytes!("../bundle.json"))
364}
365
366pub fn resolve() -> Result<PathBuf> {
369 if !cfg!(all(target_os = "linux", target_arch = "x86_64")) {
370 return Err(Error::Message(
371 "this HRX release supports Linux x86_64".into(),
372 ));
373 }
374 if let Some(path) = std::env::var_os("HRX_RUNTIME_DIR") {
375 let path = fs::canonicalize(path)?;
376 if path.join("manifest.json").is_file() {
377 Manifest::parse(&fs::read(path.join("manifest.json"))?)?.verify(&path)?;
378 }
379 return Ok(path);
380 }
381 prepare(
382 &default_manifest()?,
383 &cache_root()?.join("runtime"),
384 std::env::var_os("HRX_OFFLINE").is_some(),
385 )
386}
387
388pub fn prepare(manifest: &Manifest, root: &Path, offline: bool) -> Result<PathBuf> {
390 manifest.validate()?;
391 let destination = root.join(&manifest.archive_sha256);
392 if manifest.verify(&destination).is_ok() {
393 return Ok(destination);
394 }
395 create_cache_dir(root)?;
396 let _download =
398 Lock::acquire(&root.join(format!("{}.download.lock", manifest.archive_sha256)))?;
399 if manifest.verify(&destination).is_ok() {
400 return Ok(destination);
401 }
402 if offline {
403 return Err(Error::Message(format!(
404 "runtime bundle is missing or corrupt in {}; run `hrx prepare` online or set HRX_RUNTIME_DIR",
405 destination.display()
406 )));
407 }
408 let mut archive = tempfile::NamedTempFile::new_in(root)?;
409 if let Some(path) = manifest.url.strip_prefix("file://") {
410 std::io::copy(&mut File::open(path)?, &mut archive)?;
411 } else {
412 if !manifest.url.starts_with("https://") {
413 return Err(Error::Message(
414 "runtime bundle URL must use HTTPS or file://".into(),
415 ));
416 }
417 let mut response = ureq::get(&manifest.url).call().map_err(|e| {
418 Error::Download(Box::new(e)).context(format!("download {}", manifest.url))
419 })?;
420 std::io::copy(&mut response.body_mut().as_reader(), &mut archive)?;
421 }
422 archive.flush()?;
423 manifest.install(archive.path(), root)
424}
425
426pub(crate) fn publish(staging: tempfile::TempDir, destination: &Path) -> Result<()> {
429 fs::rename(staging.path(), destination)?;
430 let _ = staging.keep();
431 Ok(())
432}
433
434pub(crate) fn create_cache_dir(path: &Path) -> Result<()> {
435 use std::os::unix::fs::DirBuilderExt;
436 fs::DirBuilder::new()
437 .recursive(true)
438 .mode(0o700)
439 .create(path)?;
440 Ok(())
441}
442
443#[cfg(test)]
444mod gc_tests {
445 use super::*;
446
447 #[test]
448 fn active_installation_and_compilation_staging_survive_collection() {
449 let cache = tempfile::tempdir().unwrap();
450 for name in ["runtime", "kernels"] {
451 fs::create_dir(cache.path().join(name)).unwrap();
452 }
453 let installation = tempfile::tempdir_in(cache.path().join("runtime")).unwrap();
454 let compilation = tempfile::tempdir_in(cache.path().join("kernels")).unwrap();
455 fs::write(installation.path().join("libamdf.so"), b"in progress").unwrap();
456 fs::write(compilation.path().join("kernel.hsaco"), b"in progress").unwrap();
457
458 let reclaimed = collect(
459 cache.path(),
460 &digest(b"pinned"),
461 std::time::Duration::from_secs(30 * 24 * 60 * 60),
462 )
463 .unwrap();
464 assert_eq!((reclaimed.bundles, reclaimed.artifacts), (0, 0));
465 assert!(installation.path().join("libamdf.so").is_file());
466 assert!(compilation.path().join("kernel.hsaco").is_file());
467 }
468
469 #[test]
470 fn artifact_collection_waits_for_the_writer_and_rechecks_age() {
471 let cache = tempfile::tempdir().unwrap();
472 let kernels = cache.path().join("kernels");
473 let key = digest(b"being repaired");
474 let dir = kernels.join(&key);
475 fs::create_dir_all(&dir).unwrap();
476 let lock = Lock::acquire(&kernels.join(format!("{key}.lock"))).unwrap();
477 let (started_tx, started_rx) = std::sync::mpsc::channel();
478 let (done_tx, done_rx) = std::sync::mpsc::channel();
479 std::thread::scope(|scope| {
480 scope.spawn(|| {
481 started_tx.send(()).unwrap();
482 let result = collect(
483 cache.path(),
484 &digest(b"pinned"),
485 std::time::Duration::from_secs(30 * 24 * 60 * 60),
486 );
487 done_tx.send(result).unwrap();
488 });
489 started_rx.recv().unwrap();
490 let early = done_rx.recv_timeout(std::time::Duration::from_millis(100));
491 let write = fs::write(dir.join("artifact.json"), b"{}");
493 drop(lock);
494 assert!(matches!(
495 early,
496 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
497 ));
498 write.unwrap();
499 let reclaimed = done_rx.recv().unwrap().unwrap();
500 assert_eq!(reclaimed.artifacts, 0);
501 assert!(dir.join("artifact.json").is_file());
502 });
503 }
504
505 #[test]
506 fn sweeps_superseded_bundles_and_stale_artifacts_but_never_the_pinned_one() {
507 let cache = tempfile::tempdir().unwrap();
508 let runtime = cache.path().join("runtime");
509 for name in ["keepme", "oldone", "olderone"] {
510 fs::create_dir_all(runtime.join(digest(name.as_bytes()))).unwrap();
511 fs::write(
512 runtime.join(digest(name.as_bytes())).join("libamdf.so"),
513 vec![0u8; 1024],
514 )
515 .unwrap();
516 }
517 let kernels = cache.path().join("kernels");
518 for (name, age_days, extra) in [
521 ("fresh", 0u64, "artifact.json"),
522 ("stale", 90, "artifact.json"),
523 ("legacy", 90, "kernel.sha256"),
524 ] {
525 let dir = kernels.join(digest(name.as_bytes()));
526 fs::create_dir_all(&dir).unwrap();
527 let when = std::time::SystemTime::now()
528 - std::time::Duration::from_secs(age_days * 24 * 60 * 60);
529 for file in ["kernel.hsaco", extra] {
530 let path = dir.join(file);
531 fs::write(&path, vec![0u8; 512]).unwrap();
532 File::options()
533 .write(true)
534 .open(&path)
535 .unwrap()
536 .set_times(fs::FileTimes::new().set_accessed(when).set_modified(when))
537 .unwrap();
538 }
539 File::open(&dir)
540 .unwrap()
541 .set_times(fs::FileTimes::new().set_accessed(when).set_modified(when))
542 .unwrap();
543 }
544 let npu = kernels.join("npu").join(digest(b"stale-npu"));
545 fs::create_dir_all(&npu).unwrap();
546 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(90 * 24 * 60 * 60);
547 let image = npu.join("x.xclbin");
548 fs::write(&image, b"old image").unwrap();
549 File::options()
550 .write(true)
551 .open(image)
552 .unwrap()
553 .set_times(fs::FileTimes::new().set_accessed(old).set_modified(old))
554 .unwrap();
555 let reclaimed = collect(
556 cache.path(),
557 &digest(b"keepme"),
558 std::time::Duration::from_secs(30 * 24 * 60 * 60),
559 )
560 .unwrap();
561 assert_eq!(reclaimed.bundles, 2);
562 assert!(reclaimed.bundle_bytes >= 2048);
563 assert_eq!(
564 reclaimed.artifacts, 3,
565 "stale GPU, legacy and NPU entries go"
566 );
567 assert!(!npu.exists());
568 assert!(
569 runtime.join(digest(b"keepme")).is_dir(),
570 "pinned bundle survives"
571 );
572 assert!(!runtime.join(digest(b"oldone")).exists());
573 assert!(kernels.join(digest(b"fresh")).is_dir());
574 assert!(!kernels.join(digest(b"stale")).exists());
575 assert!(
576 !kernels.join(digest(b"legacy")).exists(),
577 "an older layout is dated by access time like any other entry"
578 );
579 let again = collect(
581 cache.path(),
582 &digest(b"keepme"),
583 std::time::Duration::from_secs(30 * 24 * 60 * 60),
584 )
585 .unwrap();
586 assert_eq!((again.bundles, again.artifacts), (0, 0));
587 }
588
589 #[test]
590 fn empty_artifacts_are_dated_before_scanning_their_directories() {
591 let cache = tempfile::tempdir().unwrap();
592 let kernels = cache.path().join("kernels");
593 for (name, days) in [("empty-stale", 90), ("empty-fresh", 1)] {
594 let dir = kernels.join(digest(name.as_bytes()));
595 fs::create_dir_all(&dir).unwrap();
596 let when =
597 std::time::SystemTime::now() - std::time::Duration::from_secs(days * 24 * 60 * 60);
598 File::open(&dir)
599 .unwrap()
600 .set_times(fs::FileTimes::new().set_accessed(when).set_modified(when))
601 .unwrap();
602 }
603
604 let reclaimed = collect(
605 cache.path(),
606 &digest(b"keepme"),
607 std::time::Duration::from_secs(30 * 24 * 60 * 60),
608 )
609 .unwrap();
610 assert_eq!(reclaimed.artifacts, 1);
611 assert!(!kernels.join(digest(b"empty-stale")).exists());
612 assert!(kernels.join(digest(b"empty-fresh")).is_dir());
613 }
614
615 #[test]
618 fn a_sweep_is_not_a_use_of_what_it_spares() {
619 let cache = tempfile::tempdir().unwrap();
620 let kernels = cache.path().join("kernels");
621 let dir = kernels.join(digest(b"idle"));
622 fs::create_dir_all(&dir).unwrap();
623 let when = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
624 for file in ["kernel.hsaco", "artifact.json"] {
625 let path = dir.join(file);
626 fs::write(&path, vec![0u8; 16]).unwrap();
627 File::options()
628 .write(true)
629 .open(&path)
630 .unwrap()
631 .set_times(fs::FileTimes::new().set_accessed(when).set_modified(when))
632 .unwrap();
633 }
634 File::open(&dir)
635 .unwrap()
636 .set_times(fs::FileTimes::new().set_accessed(when).set_modified(when))
637 .unwrap();
638
639 let spared = collect(
640 cache.path(),
641 &digest(b"keepme"),
642 std::time::Duration::from_secs(60),
643 )
644 .unwrap();
645 assert_eq!(spared.artifacts, 0, "still inside the window");
646
647 let swept = collect(
649 cache.path(),
650 &digest(b"keepme"),
651 std::time::Duration::from_secs(10),
652 )
653 .unwrap();
654 assert_eq!(swept.artifacts, 1, "a previous sweep is not a use");
655 assert!(!dir.exists());
656 }
657}