1use 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#[derive(Clone, Copy, Debug)]
21pub struct EmbeddedRuntime {
22 pub gz: &'static [u8],
24 pub crc32: u32,
26 pub size: u64,
28 pub file_name: &'static str,
30 pub version: &'static str,
32}
33
34static EMBEDDED: OnceLock<EmbeddedRuntime> = OnceLock::new();
35
36pub fn set_embedded_runtime(runtime: EmbeddedRuntime) {
40 if !runtime.gz.is_empty() {
41 let _ = EMBEDDED.set(runtime);
42 }
43}
44
45pub(crate) fn embedded_runtime() -> Option<&'static EmbeddedRuntime> {
47 EMBEDDED.get()
48}
49
50pub(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
74pub(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 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
112fn 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
136fn 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
157fn 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
183pub(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 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 fn fresh_dir(name: &str) -> PathBuf {
232 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 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 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 #[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 #[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 #[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}