//! Cache entries are immutable once published. The lock covers validation,
//! repair, publication, and dlopen/LoadLibrary, but never addon registration.
//! A digest is checked on every cache hit; a familiar filename is not proof.
use std::{
env,
ffi::OsString,
fs::{self, File},
io,
path::{Path, PathBuf},
time::SystemTime,
};
use crate::{format::Payload, platform, Error, ErrorKind, Result};
const MAX_PERSISTENT_IMAGES: usize = 3;
const PRUNE_LOCK: &str = ".swc-native-prune.lock";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CacheMode {
Default,
Temporary,
Custom(PathBuf),
}
impl CacheMode {
pub fn from_env() -> Result<Self> {
Self::parse(env::var_os("SWC_NATIVE_BINDING_CACHE"))
}
pub fn parse(value: Option<OsString>) -> Result<Self> {
match value {
None => Ok(Self::Default),
Some(value) if value.is_empty() => Ok(Self::Default),
Some(value) if value == "0" => Ok(Self::Temporary),
Some(value) => {
let path = PathBuf::from(value);
if !path.is_absolute() {
return Err(Error::new(
ErrorKind::Configuration,
"SWC_NATIVE_BINDING_CACHE must be 0 or an absolute directory; unset it to \
use the default user cache",
));
}
Ok(Self::Custom(path))
}
}
}
}
/// Owns staging cleanup and cache coordination until loading has succeeded.
/// On Windows a cleanup worker is armed before loading when execution is
/// allowed.
pub struct Materialized {
path: PathBuf,
temporary: bool,
lock: Option<File>,
cache_directory: Option<PathBuf>,
#[cfg(windows)]
_cleanup: Option<crate::cleanup::Cleanup>,
}
impl Materialized {
pub fn path(&self) -> &Path {
&self.path
}
/// Call immediately after successful native loading, before registration.
pub fn loaded(&mut self) -> Result<()> {
#[cfg(unix)]
if self.temporary {
fs::remove_file(&self.path)
.map_err(|e| Error::io(ErrorKind::Cache, "unlink loaded temporary addon", e))?;
self.temporary = false;
}
self.lock = None;
if let Some(directory) = &self.cache_directory {
match namespace_lock(directory) {
Ok(namespace) => {
if let Err(error) = prune(directory, &self.path) {
tracing::debug!(path = %directory.display(), %error, "native cache pruning failed");
}
drop(namespace);
}
Err(error) => {
tracing::debug!(path = %directory.display(), %error, "native cache pruning lock unavailable");
}
}
}
Ok(())
}
}
impl Drop for Materialized {
fn drop(&mut self) {
if self.temporary {
// The Windows worker retries after the pipe closes if an image
// section still prevents this immediate, best-effort deletion.
let _ = fs::remove_file(&self.path);
}
}
}
fn io_error(operation: &str, path: &Path, error: io::Error) -> Error {
Error::io(
ErrorKind::Cache,
&format!("{operation} {}", path.display()),
error,
)
}
/// The user namespace is used for custom roots too, so explicitly shared parent
/// directories cannot accidentally turn the cache into a cross-user code
/// source.
pub fn cache_directory(root: &Path) -> Result<PathBuf> {
platform::executable_cache_root(root)?;
platform::create_cache_root(root).map_err(|e| io_error("create cache root", root, e))?;
platform::secure_cache_root(root).map_err(|e| io_error("validate cache root", root, e))?;
let user = root.join(platform::user_namespace()?);
platform::private_directory(&user).map_err(|e| io_error("secure cache directory", &user, e))?;
let version = user.join("v1");
platform::private_directory(&version)
.map_err(|e| io_error("secure cache directory", &version, e))?;
Ok(version)
}
/// Try the usual user cache first, then the platform fallback. Shared ancestors
/// can make Unix home directories unsafe, as can ordinary Windows LocalAppData
/// ACLs. Retry only cache failures, never payload corruption, and validate both
/// roots using the same security checks. Primary success does no extra work.
fn with_user_cache_root<T>(materialize: impl Fn(&Path) -> Result<T>) -> Result<T> {
let primary = platform::user_cache_root().and_then(|root| materialize(&root));
if let Err(primary) = primary {
if primary.kind != ErrorKind::Cache {
return Err(primary);
}
tracing::debug!(error = %primary, "native user cache unavailable; trying fallback cache");
return platform::fallback_cache_root()
.and_then(|root| materialize(&root))
.map_err(|fallback| {
if fallback.kind != ErrorKind::Cache {
return fallback;
}
Error::new(
ErrorKind::Cache,
format!(
"user cache failed: {primary}; fallback cache failed: {fallback}; set \
SWC_NATIVE_BINDING_CACHE to a safe absolute directory owned by your user \
whose ancestors do not grant other users replacement rights"
),
)
});
}
primary
}
pub fn materialize(payload: &Payload<'_>, mode: &CacheMode) -> Result<Materialized> {
match mode {
CacheMode::Temporary => temporary(payload),
CacheMode::Default => with_user_cache_root(|root| cached_at(payload, root)),
CacheMode::Custom(root) => match cached_at(payload, root) {
Ok(file) => Ok(file),
Err(custom) => {
tracing::debug!(path = %root.display(), error = %custom, "custom native cache unavailable; using user cache");
with_user_cache_root(|root| cached_at(payload, root)).map_err(|default| {
Error::new(
ErrorKind::Cache,
format!(
"custom cache failed: {custom}; default cache failed: {default}; set \
SWC_NATIVE_BINDING_CACHE to a safe writable absolute directory or \
unset it to use the default user cache"
),
)
})
}
},
}
}
/// Materialize without consulting or creating any persistent digest entry.
pub fn temporary(payload: &Payload<'_>) -> Result<Materialized> {
with_user_cache_root(|root| temporary_at(payload, root))
}
fn temporary_at(payload: &Payload<'_>, root: &Path) -> Result<Materialized> {
let dir = cache_directory(root)?;
let mut file =
platform::temporary_file(&dir, &format!("process-{}-", std::process::id()), ".node")
.map_err(|e| io_error("create temporary addon", &dir, e))?;
platform::prepare_cache_image(file.as_file())
.map_err(|e| io_error("prepare temporary addon", file.path(), e))?;
payload.decode_into(file.as_file_mut())?;
// File writes are already visible to the image loader. This process-local
// image does not need to survive power loss, so no durability flush is needed.
// Close the writable file before mapping an image on Windows. Retaining a
// write handle can conflict with the OS image loader's sharing requirements.
let path = file.into_temp_path();
// Arm cleanup before LoadLibrary. The pipe writer is not inherited by
// other children and closes even on forced termination of this process.
#[cfg(windows)]
let cleanup = crate::cleanup::arm(&path).map_err(|error| {
// Worker verification reports corrupt bytes as InvalidData. This is
// an integrity failure, not an unusable root: do not hide it by trying
// a different cache, just as we never retry a corrupt addon payload.
let kind = if error.kind() == io::ErrorKind::InvalidData {
ErrorKind::Integrity
} else {
ErrorKind::Cache
};
Error::io(
kind,
&format!("arm temporary addon cleanup {}", path.display()),
error,
)
})?;
let path = path
.keep()
.map_err(|e| io_error("retain temporary addon", &dir, e.error))?;
Ok(Materialized {
path,
temporary: true,
lock: None,
cache_directory: None,
#[cfg(windows)]
_cleanup: cleanup,
})
}
fn namespace_lock(directory: &Path) -> Result<File> {
let path = directory.join(PRUNE_LOCK);
let lock = platform::open_regular(&path, true, true)
.map_err(|e| io_error("open cache pruning lock", &path, e))?;
platform::lock_exclusive(&lock).map_err(|e| io_error("lock cache pruning", &path, e))?;
Ok(lock)
}
fn is_cache_key(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension == "node")
&& path.file_stem().is_some_and(|stem| {
stem.to_str().is_some_and(|stem| {
stem.len() == 128 && stem.bytes().all(|byte| byte.is_ascii_hexdigit())
})
})
}
fn prune(directory: &Path, current: &Path) -> Result<()> {
let mut entries = Vec::new();
for entry in
fs::read_dir(directory).map_err(|e| io_error("enumerate cache entries", directory, e))?
{
let entry = entry.map_err(|e| io_error("read cache entry", directory, e))?;
let path = entry.path();
if path != current && is_cache_key(&path) {
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
entries.push((modified, path));
}
}
// Include the current image in the budget. Older images are evicted first.
let mut remaining = entries.len() + 1;
if remaining <= MAX_PERSISTENT_IMAGES {
return Ok(());
}
entries.sort_unstable_by_key(|(modified, _)| *modified);
for (_, image) in entries {
if remaining <= MAX_PERSISTENT_IMAGES {
break;
}
let Some(key) = image.file_stem() else {
continue;
};
let lock_path = directory.join(key).with_extension("lock");
let lock = match platform::open_regular(&lock_path, true, false) {
Ok(lock) => lock,
Err(error) => {
tracing::debug!(path = %lock_path.display(), %error, "skip cache entry without a usable pruning lock");
continue;
}
};
match platform::try_lock_exclusive(&lock) {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
tracing::debug!(path = %lock_path.display(), %error, "skip cache entry whose pruning lock cannot be acquired");
continue;
}
}
match fs::remove_file(&image) {
Ok(()) => {
remaining -= 1;
if let Err(error) = fs::remove_file(&lock_path) {
tracing::debug!(path = %lock_path.display(), %error, "remove stale native cache lock");
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
remaining -= 1;
}
Err(error) => {
tracing::debug!(path = %image.display(), %error, "skip native cache entry that cannot be pruned");
}
}
}
Ok(())
}
/// Public within the private crate API to allow isolated-root concurrency
/// tests.
pub fn cached_at(payload: &Payload<'_>, root: &Path) -> Result<Materialized> {
let directory = cache_directory(root)?;
let key = payload.header.cache_key();
let path = directory.join(format!("{key}.node"));
let lock_path = directory.join(format!("{key}.lock"));
// Coordinate lock-file creation with pruning. The per-entry lock remains
// held after this short critical section, through native loading.
let namespace = namespace_lock(&directory)?;
let lock = platform::open_regular(&lock_path, true, true)
.map_err(|e| io_error("open cache lock", &lock_path, e))?;
platform::lock_exclusive(&lock).map_err(|e| io_error("lock cache entry", &lock_path, e))?;
drop(namespace);
let valid = match platform::open_regular(&path, false, false) {
Ok(mut file) => match payload.verify_image(&mut file) {
Ok(()) => true,
Err(error) => {
tracing::warn!(path = %path.display(), %error, "rejecting corrupt native cache entry; rebuilding from payload");
false
}
},
Err(e) if e.kind() == io::ErrorKind::NotFound => false,
// Never follow or repair an attacker-controlled special file. A custom
// root can still fall back to the private default root.
Err(e) => return Err(io_error("open cache entry", &path, e)),
};
if !valid {
let mut staged = platform::temporary_file(&directory, "publish-", ".tmp")
.map_err(|e| io_error("stage cache entry", &directory, e))?;
platform::prepare_cache_image(staged.as_file())
.map_err(|e| io_error("prepare staged cache image", staged.path(), e))?;
payload.decode_into(staged.as_file_mut())?;
payload.verify_image(staged.as_file_mut())?;
// All cooperating readers hold the digest lock, including during repair.
// No writer ever truncates the canonical filename in place.
let staged = staged.into_temp_path();
platform::replace_file(&staged, &path)
.map_err(|e| io_error("atomically publish cache entry", &path, e))?;
// A cache is recoverable, unlike the installed carrier. Atomic rename
// protects concurrent readers; full-byte verification repairs missing,
// truncated, or corrupt entries after power loss. Avoid forcing either
// the file or directory to stable storage on the startup path.
}
Ok(Materialized {
path,
temporary: false,
lock: Some(lock),
cache_directory: Some(directory),
#[cfg(windows)]
_cleanup: None,
})
}