Skip to main content

hrx/
bundle.rs

1//! Reproducible native provisioning. No build script downloads or native link flags.
2use 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)]
14/// Archive identity and expected contents; validated again at every installation entry point.
15pub struct Manifest {
16    /// Manifest format version (currently one).
17    pub schema: u32,
18    /// Supported platform and GPU target.
19    pub target: String,
20    /// Native build revision or provenance description.
21    pub revision: String,
22    /// HTTPS download URL or local file URL.
23    pub url: String,
24    /// Lowercase SHA-256 of the compressed archive.
25    pub archive_sha256: String,
26    /// Flat archive filenames mapped to lowercase SHA-256 digests.
27    pub files: BTreeMap<String, String>,
28}
29
30/// Hash bytes as lowercase SHA-256 hex.
31pub fn digest(bytes: &[u8]) -> String {
32    format!("{:x}", Sha256::digest(bytes))
33}
34/// Stream a file through SHA-256 without loading it all into memory.
35pub 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}
48/// Resolve the cache path without creating directories.
49///
50/// Follows the XDG Base Directory specification: `$XDG_CACHE_HOME/hrx`, or
51/// `$HOME/.cache/hrx` when that is unset or empty. The specification requires
52/// these variables to hold absolute paths and says a relative one is invalid and
53/// must be ignored, so a relative `XDG_CACHE_HOME` falls back to `$HOME` rather
54/// than resolving against the working directory.
55pub 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
74/// The one cache for compiled kernels.
75///
76/// Artifacts are content-addressed — the key covers compiler identity, source,
77/// export, target and canonical configuration — so there is nothing for a
78/// per-consumer location to distinguish. Separate directories could only
79/// duplicate identical artifacts and hide them from `hrx gc`.
80pub fn kernel_cache() -> Result<PathBuf> {
81    Ok(cache_root()?.join("kernels"))
82}
83
84/// An independently opened flock also serializes separate Rust copies in cdylibs.
85/// Never unlink a lock file while another process might be waiting on its inode.
86pub struct Lock {
87    _file: File,
88}
89impl Lock {
90    /// Open and exclusively lock a file, refusing symlinks; unlocks on drop.
91    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    /// Deserialize a manifest and validate its platform, digests and filenames.
116    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    /// Validate public fields even when constructed directly or deserialized.
123    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    /// Parse the GPU architecture from the supported host platform identifier.
149    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    /// Validate the manifest and verify every expected regular file in a directory.
157    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    /// Verify the compressed archive before unpacking. Reject links, paths and
171    /// undeclared files, then publish the entire directory with one rename.
172    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        // Failed/incomplete installations never become candidates for dlopen.
212        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/// What a cache sweep reclaimed.
223#[derive(Clone, Copy, Debug, Default)]
224pub struct Reclaimed {
225    /// Superseded runtime bundle directories removed.
226    pub bundles: usize,
227    /// Bytes freed by removing those bundles.
228    pub bundle_bytes: u64,
229    /// Compiled kernel artifacts removed.
230    pub artifacts: usize,
231    /// Bytes freed by removing those artifacts.
232    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
249/// The most recent access to a file in `path`.
250///
251/// Reading a cached artifact updates the file's atime, not the directory's, so the entry's own
252/// timestamp would report when it was created — and, worse, the `read_dir` below updates it. A
253/// collector that counted the directory would refresh the clock it evicts by, and sweeping often
254/// enough would keep an unused artifact forever. Files are dated with `stat`, which updates
255/// nothing, so only the files are consulted; the directory is the fallback for an entry that holds
256/// no files at all.
257///
258/// `None` means no timestamp could be read, which is treated as recently used: a collector that
259/// deletes what it cannot date is not honouring the age it was given.
260fn last_access(path: &Path) -> Option<std::time::SystemTime> {
261    // Capture the fallback before read_dir can refresh an empty entry's atime.
262    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
273/// Remove cached runtime bundles other than `keep`, and compiled kernel
274/// artifacts not accessed for longer than `unused_for`.
275///
276/// Provisioning publishes but never evicts, so a developer machine accumulates
277/// every bundle it has ever prepared. This is deliberately explicit: nothing
278/// here runs from `prepare`, because another checkout may still be using a
279/// bundle this one has moved past. Removing a directory whose libraries are
280/// already mapped is safe; the mapping holds the inode open.
281pub 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            // Only published digest directories are candidates. Temporary
292            // directories belong to active installers and use the digest's lock,
293            // not a lock named after the temporary directory.
294            // Lock files stay: another process may be waiting on the inode.
295            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; // Another collector removed it while we waited.
304            }
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    // `cache` is the root, not necessarily the process's own: tests and tools
312    // collect a cache they were handed. `kernel_cache` names the default.
313    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                // Serialize with compilation, cache repair, and other collectors.
322                // Read the timestamp only after a waiting writer has finished.
323                let _lock = Lock::acquire(&kernels.join(format!("{name}.lock")))?;
324                if !entry.path().is_dir() {
325                    continue;
326                }
327                // Last access, not age, and taken from the filesystem rather than from
328                // any file this layout happens to contain: entries written by an older
329                // release are dated the same way as current ones.
330                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
358/// Read the configured manifest or the embedded release manifest.
359pub 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
366/// Explicit runtime directories are trusted developer overrides. Packaged
367/// directories with a manifest are verified just like the default cache.
368pub 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
388/// Reuse a verified bundle or provision it; offline mode never downloads.
389pub 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    // Download lock is distinct from the installation lock, and always taken first.
397    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
426/// Atomically hand a temporary directory to its final owner. Keep cleanup on
427/// rename failure, and disarm TempDir only after the handoff succeeds.
428pub(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            // A compiler holding this key's lock is publishing repaired metadata.
492            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        // Eviction reads access time, so an entry is dated without regard to which
519        // files it holds: "legacy" carries none of the current layout at all.
520        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        // A second sweep is a no-op rather than an error.
580        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    /// A sweep reads every entry's directory, which updates that directory's access time. If the
616    /// collector counted it, collecting often enough would keep an unused artifact forever.
617    #[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        // Nothing has read the artifact since; only the collector has looked at it.
648        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}