use std::fs;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy)]
pub struct EmbeddedFile {
pub path: &'static str,
pub text: &'static str,
}
macro_rules! embedded {
($($path:literal),+ $(,)?) => {
&[$(EmbeddedFile {
path: $path,
text: include_str!(concat!("companion/", $path)),
}),+]
};
}
pub const COMPANION_FILES: &[EmbeddedFile] = embedded!(
"adapters/bun-test.mjs",
"adapters/child-process.mjs",
"adapters/common.mjs",
"adapters/node-test.mjs",
"adapters/vitest.mjs",
"companion.mjs",
"generated-protocol.mjs",
"protocol.mjs",
"runtime/bun-test-preload.mjs",
"runtime/child-hooks.mjs",
"runtime/child.mjs",
"runtime/global.mjs",
"runtime/node-subprocess-preload.mjs",
"runtime/node-test-preload.mjs",
"runtime/overlay-hook.mjs",
"runtime/vitest-setup.mjs",
);
pub const COMPANION_ENTRY: &str = "companion.mjs";
const RUNTIME_PREFIX: &str = "runtime/";
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
hex
}
pub fn bundle_digest(files: &[EmbeddedFile]) -> String {
let mut hasher = Sha256::new();
for file in files {
hasher.update((file.path.len() as u64).to_le_bytes());
hasher.update(file.path.as_bytes());
hasher.update((file.text.len() as u64).to_le_bytes());
hasher.update(file.text.as_bytes());
}
let mut hex = String::with_capacity(64);
for byte in hasher.finalize() {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[derive(Debug, Clone)]
pub struct InstalledCompanion {
dir: PathBuf,
files: Vec<(PathBuf, String)>,
}
impl InstalledCompanion {
#[cfg(test)]
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn entry(&self) -> PathBuf {
self.dir.join(COMPANION_ENTRY)
}
pub fn verify(&self) -> Result<(), String> {
for (path, expected) in &self.files {
let bytes = fs::read(path).map_err(|error| {
format!(
"the mutation companion file {} cannot be read: {error}",
path.display()
)
})?;
let actual = sha256_hex(&bytes);
if &actual != expected {
return Err(format!(
"the mutation companion file {} changed after install (sha256 {actual}, expected {expected}). Fallow does not run a changed companion. Delete {} to restore it from the binary.",
path.display(),
self.dir.display()
));
}
}
Ok(())
}
pub fn runtime_files(&self) -> impl Iterator<Item = (&str, &Path)> {
self.files.iter().filter_map(|(path, _)| {
let relative = path.strip_prefix(&self.dir).ok()?.to_str()?;
relative
.replace('\\', "/")
.starts_with(RUNTIME_PREFIX)
.then_some((relative, path.as_path()))
})
}
}
pub fn install(
mutation_cache: &Path,
files: &[EmbeddedFile],
) -> Result<InstalledCompanion, String> {
let parent = mutation_cache.join("companion");
let dir = parent.join(bundle_digest(files));
let installed = InstalledCompanion {
files: files
.iter()
.map(|file| (dir.join(file.path), sha256_hex(file.text.as_bytes())))
.collect(),
dir: dir.clone(),
};
if !dir.is_dir() {
write_atomically(&parent, &dir, files)?;
}
installed.verify()?;
Ok(installed)
}
fn write_atomically(parent: &Path, dir: &Path, files: &[EmbeddedFile]) -> Result<(), String> {
let io = |error: std::io::Error| {
format!(
"cannot write the mutation companion to {}: {error}",
dir.display()
)
};
fs::create_dir_all(parent).map_err(io)?;
let staging = parent.join(format!(
".staging-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos())
));
let result = (|| {
for file in files {
let path = staging.join(file.path);
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
}
fs::write(&path, file.text)?;
}
fs::rename(&staging, dir)
})();
if let Err(error) = result {
let _ = fs::remove_dir_all(&staging);
if dir.is_dir() {
return Ok(());
}
return Err(io(error));
}
Ok(())
}
#[expect(clippy::unwrap_used, reason = "tests unwrap to keep assertions short")]
#[cfg(test)]
mod tests {
use super::*;
const FAKE: &[EmbeddedFile] = &[
EmbeddedFile {
path: "companion.mjs",
text: "console.log('entry');\n",
},
EmbeddedFile {
path: "runtime/global.mjs",
text: "export const g = 1;\n",
},
];
fn listed_directory(dir: &Path, prefix: &str, out: &mut Vec<String>) {
for entry in fs::read_dir(dir).expect("readable directory") {
let entry = entry.expect("entry");
let name = entry.file_name().to_string_lossy().into_owned();
let path = format!("{prefix}{name}");
if entry.file_type().expect("type").is_dir() {
listed_directory(&entry.path(), &format!("{path}/"), out);
} else {
out.push(path);
}
}
}
#[test]
fn the_embedded_list_is_every_file_of_the_companion_directory() {
let dir = Path::new(
&std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
)
.join("src/mutation/companion");
let mut on_disk = Vec::new();
listed_directory(&dir, "", &mut on_disk);
on_disk.sort();
let embedded: Vec<String> = COMPANION_FILES
.iter()
.map(|file| file.path.to_owned())
.collect();
assert_eq!(embedded, on_disk);
assert!(embedded.iter().any(|path| path == COMPANION_ENTRY));
}
#[test]
fn install_writes_each_file_under_the_bundle_digest_and_verifies_it() {
let temp = tempfile::tempdir().expect("temp dir");
let installed = install(temp.path(), FAKE).expect("install");
assert_eq!(
installed.dir(),
temp.path().join("companion").join(bundle_digest(FAKE))
);
assert_eq!(
fs::read_to_string(installed.entry()).expect("entry"),
"console.log('entry');\n"
);
let runtime: Vec<&str> = installed.runtime_files().map(|(path, _)| path).collect();
assert_eq!(runtime, vec!["runtime/global.mjs"]);
assert!(installed.verify().is_ok());
}
#[test]
fn a_changed_file_fails_the_check_and_a_new_install_does_not_repair_it() {
let temp = tempfile::tempdir().expect("temp dir");
let installed = install(temp.path(), FAKE).expect("install");
fs::write(
installed.dir().join("runtime/global.mjs"),
"export const g = 2;\n",
)
.expect("tamper");
let error = installed.verify().unwrap_err();
assert!(error.contains("runtime/global.mjs"), "{error}");
assert!(error.contains("changed after install"), "{error}");
assert!(
!error.contains("run again"),
"no step runs the tests: {error}"
);
assert!(install(temp.path(), FAKE).is_err());
}
#[test]
fn a_missing_file_fails_the_check() {
let temp = tempfile::tempdir().expect("temp dir");
let installed = install(temp.path(), FAKE).expect("install");
fs::remove_file(installed.entry()).expect("remove");
assert!(installed.verify().unwrap_err().contains("cannot be read"));
}
#[test]
fn a_different_bundle_gets_a_different_directory() {
let changed = [
FAKE[0],
EmbeddedFile {
path: "runtime/global.mjs",
text: "export const g = 3;\n",
},
];
assert_ne!(bundle_digest(FAKE), bundle_digest(&changed));
let moved = [
EmbeddedFile {
path: "companion.mjs",
text: "console.log('entry');\nexport const g = 1;\n",
},
EmbeddedFile {
path: "runtime/global.mjs",
text: "",
},
];
assert_ne!(
bundle_digest(FAKE),
bundle_digest(&moved),
"text moved between files changes the digest"
);
}
}