Skip to main content

nova_driver/
runtime_cache.rs

1//! The runtime library a `nova` carries, and where `nova build` and
2//! `nova test` find the one they link (spec
3//! `docs/superpowers/specs/2026-10-07-phase-3-0-foundations-design.md` §4.2;
4//! `docs/adr/0027-runtime-and-std-in-an-installed-nova.md`).
5//!
6//! nova-cli's build script embeds the library gzip-compressed. This module
7//! unpacks it once per version and build into
8//! `$NOVA_HOME/runtime/<version>-<crc32>/`, and every later link reuses that
9//! file after checking its size and CRC-32.
10
11use std::fs;
12use std::io::{self, Read, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::OnceLock;
16
17use anyhow::{bail, Context, Result};
18
19/// The runtime library a `nova` carries, as its build script embedded it.
20#[derive(Clone, Copy, Debug)]
21pub struct EmbeddedRuntime {
22    /// The library, gzip-compressed. Empty when this `nova` carries none.
23    pub gz: &'static [u8],
24    /// The uncompressed library's CRC-32.
25    pub crc32: u32,
26    /// The uncompressed library's size in bytes.
27    pub size: u64,
28    /// The library's file name for the target `nova` was built for.
29    pub file_name: &'static str,
30    /// nova-cli's version: the first half of the cache directory's name.
31    pub version: &'static str,
32}
33
34static EMBEDDED: OnceLock<EmbeddedRuntime> = OnceLock::new();
35
36/// Record the runtime this `nova` carries. nova-cli calls this once, at
37/// startup. An empty payload records nothing, so the lookup goes on to the
38/// executable's neighbours.
39pub fn set_embedded_runtime(runtime: EmbeddedRuntime) {
40    if !runtime.gz.is_empty() {
41        let _ = EMBEDDED.set(runtime);
42    }
43}
44
45/// The runtime [`set_embedded_runtime`] recorded, if any.
46pub(crate) fn embedded_runtime() -> Option<&'static EmbeddedRuntime> {
47    EMBEDDED.get()
48}
49
50/// The cache's root, `$NOVA_HOME/runtime`. `NOVA_HOME` defaults to `.nova`
51/// in the home directory, which is `USERPROFILE` on Windows and `HOME`
52/// elsewhere. It takes the variables' values rather than reading them, so
53/// tests need not change the process environment. An empty value counts as
54/// unset. The rule is `nova_pm::nova_home`'s (spec 3.3b §5.1).
55pub(crate) fn cache_root(
56    nova_home: Option<PathBuf>,
57    userprofile: Option<PathBuf>,
58    home: Option<PathBuf>,
59    windows: bool,
60) -> Result<PathBuf> {
61    match nova_pm::nova_home(nova_home, userprofile, home, windows) {
62        Some(home) => Ok(home.join("runtime")),
63        None => {
64            let name = if windows { "USERPROFILE" } else { "HOME" };
65            bail!(
66                "cannot place the runtime library's cache: neither NOVA_HOME nor {name} is \
67                 set; set NOVA_HOME to a writable directory, or NOVA_RUNTIME_LIB to a runtime \
68                 library"
69            )
70        }
71    }
72}
73
74/// Make sure `<root>/<version>-<crc32>/<file name>` holds `runtime`'s
75/// library, unpacking it when it is missing or damaged, and return its path.
76pub(crate) fn unpack(runtime: &EmbeddedRuntime, root: &Path) -> Result<PathBuf> {
77    let dir = root.join(format!("{}-{:08x}", runtime.version, runtime.crc32));
78    let path = dir.join(runtime.file_name);
79    if verified(&path, runtime) {
80        return Ok(path);
81    }
82    fs::create_dir_all(&dir).with_context(|| {
83        format!(
84            "creating the runtime library's cache {}; set NOVA_HOME to a writable \
85             directory, or NOVA_RUNTIME_LIB to a runtime library",
86            dir.display()
87        )
88    })?;
89    let (file, temp) = new_temp_file(&dir, runtime.file_name)?;
90    if let Err(error) = decompress(runtime, file) {
91        let _ = fs::remove_file(&temp);
92        return Err(error.context(format!(
93            "unpacking the runtime library into {}",
94            temp.display()
95        )));
96    }
97    match fs::rename(&temp, &path) {
98        Ok(()) => Ok(path),
99        Err(error) => {
100            let _ = fs::remove_file(&temp);
101            // Another process may have put a good copy there first.
102            if verified(&path, runtime) {
103                Ok(path)
104            } else {
105                Err(error)
106                    .with_context(|| format!("moving the runtime library to {}", path.display()))
107            }
108        }
109    }
110}
111
112/// Whether `path` holds `runtime`'s library: the right size and CRC-32. A
113/// file that cannot be read does not; unpacking it again reports why.
114fn verified(path: &Path, runtime: &EmbeddedRuntime) -> bool {
115    let Ok(mut file) = fs::File::open(path) else {
116        return false;
117    };
118    match file.metadata() {
119        Ok(metadata) if metadata.len() == runtime.size => {}
120        _ => return false,
121    }
122    let mut hasher = crc32fast::Hasher::new();
123    let mut buffer = vec![0u8; 1 << 16];
124    loop {
125        match file.read(&mut buffer) {
126            Ok(0) => break,
127            Ok(read) => hasher.update(&buffer[..read]),
128            Err(_) => return false,
129        }
130    }
131    hasher.finalize() == runtime.crc32
132}
133
134static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
135
136/// A new temporary file beside the library. The process id and a
137/// per-process counter make its name unique, and `create_new` refuses a
138/// name a crashed run left behind, in which case the next number is tried.
139fn new_temp_file(dir: &Path, file_name: &str) -> Result<(fs::File, PathBuf)> {
140    loop {
141        let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
142        let temp = dir.join(format!("{file_name}.{}-{n}.tmp", std::process::id()));
143        match fs::OpenOptions::new()
144            .write(true)
145            .create_new(true)
146            .open(&temp)
147        {
148            Ok(file) => return Ok((file, temp)),
149            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
150            Err(error) => {
151                return Err(error).with_context(|| format!("creating {}", temp.display()))
152            }
153        }
154    }
155}
156
157/// Decompress `runtime` into `file`, checking the result's size and CRC-32.
158fn decompress(runtime: &EmbeddedRuntime, mut file: fs::File) -> Result<()> {
159    let mut decoder = flate2::read::GzDecoder::new(runtime.gz);
160    let mut hasher = crc32fast::Hasher::new();
161    let mut size = 0u64;
162    let mut buffer = vec![0u8; 1 << 16];
163    loop {
164        let read = decoder.read(&mut buffer).context("decompressing")?;
165        if read == 0 {
166            break;
167        }
168        hasher.update(&buffer[..read]);
169        file.write_all(&buffer[..read]).context("writing")?;
170        size += read as u64;
171    }
172    file.flush().context("writing")?;
173    if size != runtime.size || hasher.finalize() != runtime.crc32 {
174        bail!(
175            "the runtime library this nova carries is damaged: it unpacked to {size} bytes \
176             with a different CRC-32, not the {} bytes it was built with",
177            runtime.size
178        );
179    }
180    Ok(())
181}
182
183/// `find_runtime_lib`'s order, over explicit inputs (spec §4.2):
184/// `NOVA_RUNTIME_LIB`, then the embedded runtime, then the executable's
185/// neighbours. When a runtime is embedded, failing to unpack it is an error;
186/// the neighbours are tried only when nothing is embedded.
187pub(crate) fn locate(
188    override_path: Option<PathBuf>,
189    embedded: Option<&EmbeddedRuntime>,
190    cache_root: impl FnOnce() -> Result<PathBuf>,
191    beside_exe: impl FnOnce() -> Result<PathBuf>,
192) -> Result<PathBuf> {
193    if let Some(path) = override_path {
194        if path.exists() {
195            return Ok(path);
196        }
197        bail!(
198            "NOVA_RUNTIME_LIB points to {}, which does not exist",
199            path.display()
200        );
201    }
202    match embedded {
203        Some(runtime) => unpack(runtime, &cache_root()?),
204        None => beside_exe(),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    /// A fake library and its embedded form. The bytes are the same in
213    /// every process, so a child process unpacks exactly what its parent
214    /// does.
215    fn fake_runtime() -> EmbeddedRuntime {
216        let library: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
217        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
218        encoder.write_all(&library).unwrap();
219        let gz = encoder.finish().unwrap();
220        EmbeddedRuntime {
221            gz: Box::leak(gz.into_boxed_slice()),
222            crc32: crc32fast::hash(&library),
223            size: library.len() as u64,
224            file_name: "fake_runtime.lib",
225            version: "0.0.0-test",
226        }
227    }
228
229    /// A fresh, empty directory under the system temp dir, unique to this
230    /// test.
231    fn fresh_dir(name: &str) -> PathBuf {
232        // A fixed name, so each run replaces the last run's directory: a name with
233        // the process id in it left one more behind on every run.
234        let dir = std::env::temp_dir().join(format!("nova-runtime-cache-{name}"));
235        let _ = fs::remove_dir_all(&dir);
236        fs::create_dir_all(&dir).unwrap();
237        dir
238    }
239
240    fn library_path(root: &Path, runtime: &EmbeddedRuntime) -> PathBuf {
241        root.join(format!("0.0.0-test-{:08x}", runtime.crc32))
242            .join("fake_runtime.lib")
243    }
244
245    fn never() -> Result<PathBuf> {
246        panic!("this step of the lookup must not run")
247    }
248
249    #[test]
250    fn the_first_use_writes_the_library() {
251        let runtime = fake_runtime();
252        let root = fresh_dir("first");
253        let path = unpack(&runtime, &root).unwrap();
254        assert_eq!(path, library_path(&root, &runtime));
255        assert!(verified(&path, &runtime));
256    }
257
258    #[test]
259    fn a_second_use_reuses_the_file_untouched() {
260        let runtime = fake_runtime();
261        let root = fresh_dir("second");
262        let path = unpack(&runtime, &root).unwrap();
263        let written = fs::metadata(&path).unwrap().modified().unwrap();
264        std::thread::sleep(std::time::Duration::from_millis(50));
265        assert_eq!(unpack(&runtime, &root).unwrap(), path);
266        assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), written);
267    }
268
269    #[test]
270    fn a_damaged_copy_is_replaced() {
271        let runtime = fake_runtime();
272        let root = fresh_dir("damaged");
273        let path = unpack(&runtime, &root).unwrap();
274        // The right size, the wrong bytes.
275        fs::write(&path, vec![0u8; runtime.size as usize]).unwrap();
276        assert!(!verified(&path, &runtime));
277        unpack(&runtime, &root).unwrap();
278        assert!(verified(&path, &runtime));
279        // The wrong size.
280        fs::write(&path, b"short").unwrap();
281        unpack(&runtime, &root).unwrap();
282        assert!(verified(&path, &runtime));
283    }
284
285    #[test]
286    fn threads_unpacking_at_once_all_get_a_verified_file() {
287        let runtime = fake_runtime();
288        let root = fresh_dir("threads");
289        let barrier = std::sync::Barrier::new(8);
290        std::thread::scope(|scope| {
291            let handles: Vec<_> = (0..8)
292                .map(|_| {
293                    scope.spawn(|| {
294                        barrier.wait();
295                        unpack(&runtime, &root)
296                    })
297                })
298                .collect();
299            for handle in handles {
300                let path = handle.join().unwrap().unwrap();
301                assert!(verified(&path, &runtime));
302            }
303        });
304        let dir = library_path(&root, &runtime)
305            .parent()
306            .unwrap()
307            .to_path_buf();
308        let leftovers: Vec<_> = fs::read_dir(dir)
309            .unwrap()
310            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
311            .filter(|name| name.ends_with(".tmp"))
312            .collect();
313        assert!(leftovers.is_empty(), "{leftovers:?}");
314    }
315
316    /// Two processes unpacking at once both get a verified file (spec §9,
317    /// as Section 3 of the design asked). The test runs its own binary
318    /// twice, and each child runs only `unpack_as_a_child_process`. Their
319    /// output is piped, not inherited: an inherited stdout would put each
320    /// child's own `test result:` line into the parent's output, and so
321    /// into every count of a full run.
322    #[test]
323    fn processes_unpacking_at_once_both_get_a_verified_file() {
324        let root = fresh_dir("processes");
325        let me = std::env::current_exe().unwrap();
326        let children: Vec<_> = (0..2)
327            .map(|_| {
328                std::process::Command::new(&me)
329                    .args([
330                        "--exact",
331                        "runtime_cache::tests::unpack_as_a_child_process",
332                        "--nocapture",
333                    ])
334                    .env("NOVA_TEST_UNPACK_ROOT", &root)
335                    .stdout(std::process::Stdio::piped())
336                    .stderr(std::process::Stdio::piped())
337                    .spawn()
338                    .unwrap()
339            })
340            .collect();
341        for child in children {
342            let output = child.wait_with_output().unwrap();
343            assert!(
344                output.status.success(),
345                "a child failed:\n{}\n{}",
346                String::from_utf8_lossy(&output.stdout),
347                String::from_utf8_lossy(&output.stderr)
348            );
349        }
350        let runtime = fake_runtime();
351        assert!(verified(&library_path(&root, &runtime), &runtime));
352    }
353
354    /// Does nothing unless `NOVA_TEST_UNPACK_ROOT` names a root to unpack
355    /// into, which only the test above sets, for its child processes.
356    #[test]
357    fn unpack_as_a_child_process() {
358        let Some(root) = std::env::var_os("NOVA_TEST_UNPACK_ROOT") else {
359            return;
360        };
361        let runtime = fake_runtime();
362        let path = unpack(&runtime, Path::new(&root)).unwrap();
363        assert!(verified(&path, &runtime));
364    }
365
366    #[test]
367    fn a_nova_home_that_is_a_file_gives_an_error_naming_it() {
368        let runtime = fake_runtime();
369        let file = fresh_dir("home-is-a-file").join("not-a-directory");
370        fs::write(&file, b"").unwrap();
371        let error = format!("{:#}", unpack(&runtime, &file.join("runtime")).unwrap_err());
372        assert!(error.contains("not-a-directory"), "{error}");
373        assert!(error.contains("NOVA_HOME"), "{error}");
374    }
375
376    /// Review Focus 4: a Windows profile such as `C:\Users\สมชาย ใจดี`.
377    #[test]
378    fn a_cache_path_with_spaces_and_thai_letters_works() {
379        let runtime = fake_runtime();
380        let root = fresh_dir("path").join("โฟลเดอร์ มี ช่องว่าง");
381        let path = unpack(&runtime, &root).unwrap();
382        assert!(verified(&path, &runtime));
383    }
384
385    #[test]
386    fn nova_home_decides_the_root() {
387        let root = cache_root(
388            Some("D:/n".into()),
389            Some("C:/Users/u".into()),
390            Some("/home/u".into()),
391            true,
392        )
393        .unwrap();
394        assert_eq!(root, PathBuf::from("D:/n").join("runtime"));
395    }
396
397    #[test]
398    fn without_nova_home_the_root_is_under_the_home_directory() {
399        let windows = cache_root(
400            None,
401            Some("C:/Users/u".into()),
402            Some("/home/u".into()),
403            true,
404        );
405        assert_eq!(
406            windows.unwrap(),
407            PathBuf::from("C:/Users/u").join(".nova").join("runtime")
408        );
409        let unix = cache_root(
410            None,
411            Some("C:/Users/u".into()),
412            Some("/home/u".into()),
413            false,
414        );
415        assert_eq!(
416            unix.unwrap(),
417            PathBuf::from("/home/u").join(".nova").join("runtime")
418        );
419    }
420
421    #[test]
422    fn an_empty_variable_counts_as_unset() {
423        let root = cache_root(Some("".into()), Some("C:/Users/u".into()), None, true);
424        assert_eq!(
425            root.unwrap(),
426            PathBuf::from("C:/Users/u").join(".nova").join("runtime")
427        );
428    }
429
430    #[test]
431    fn with_no_home_at_all_the_error_names_the_variables() {
432        for windows in [true, false] {
433            let error = cache_root(None, None, None, windows)
434                .unwrap_err()
435                .to_string();
436            assert!(error.contains("NOVA_HOME"), "{error}");
437            let home = if windows { "USERPROFILE" } else { "HOME" };
438            assert!(error.contains(home), "{error}");
439        }
440    }
441
442    #[test]
443    fn nova_runtime_lib_comes_first() {
444        let runtime = fake_runtime();
445        let lib = fresh_dir("override").join("my_runtime.lib");
446        fs::write(&lib, b"").unwrap();
447        assert_eq!(
448            locate(Some(lib.clone()), Some(&runtime), never, never).unwrap(),
449            lib
450        );
451    }
452
453    #[test]
454    fn a_missing_nova_runtime_lib_is_an_error() {
455        let missing = PathBuf::from("no/such/runtime.lib");
456        let error = locate(Some(missing), None, never, never)
457            .unwrap_err()
458            .to_string();
459        assert!(error.contains("NOVA_RUNTIME_LIB"), "{error}");
460    }
461
462    #[test]
463    fn the_embedded_runtime_comes_before_the_executables_neighbours() {
464        let runtime = fake_runtime();
465        let root = fresh_dir("embedded-first");
466        let path = locate(None, Some(&runtime), || Ok(root.clone()), never).unwrap();
467        assert_eq!(path, library_path(&root, &runtime));
468    }
469
470    #[test]
471    fn an_embedded_runtime_that_fails_to_unpack_is_an_error_not_a_fall_back() {
472        let runtime = fake_runtime();
473        let error = locate(None, Some(&runtime), || bail!("no home here"), never)
474            .unwrap_err()
475            .to_string();
476        assert!(error.contains("no home here"), "{error}");
477    }
478
479    #[test]
480    fn with_nothing_embedded_the_neighbours_are_tried() {
481        let lib = PathBuf::from("beside/nova_runtime.lib");
482        assert_eq!(locate(None, None, never, || Ok(lib.clone())).unwrap(), lib);
483    }
484}