use std::path::{Path, PathBuf};
use boxlite_shared::errors::{BoxliteError, BoxliteResult};
pub fn stage_single_file(
staging_dir: &Path,
source: &Path,
file_name: &str,
read_only: bool,
) -> BoxliteResult<PathBuf> {
std::fs::create_dir_all(staging_dir).map_err(|e| {
BoxliteError::Config(format!(
"Failed to create volume staging dir '{}': {}",
staging_dir.display(),
e
))
})?;
let staged = staging_dir.join(file_name);
if read_only {
tracing::warn!(
source = %source.display(),
"read-only single-file volume is staged as a boot-time snapshot; \
host edits after box start won't reach the guest"
);
return copy_into_staging(source, &staged);
}
if staged.exists() {
std::fs::remove_file(&staged).map_err(|e| {
BoxliteError::Config(format!(
"Failed to clear staged volume file '{}': {}",
staged.display(),
e
))
})?;
}
match std::fs::hard_link(source, &staged) {
Ok(()) => Ok(staged),
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => Err(BoxliteError::Config(format!(
"Cannot mount read-write single file '{}': it is on a different filesystem than \
the box storage, so it cannot be hard-linked and a copy would silently drop the \
guest's writes. Mount its parent directory instead, or place the file on the same \
filesystem as the box home.",
source.display()
))),
Err(e) => Err(BoxliteError::Config(format!(
"Failed to stage volume file '{}': {}",
source.display(),
e
))),
}
}
fn copy_into_staging(source: &Path, staged: &Path) -> BoxliteResult<PathBuf> {
let tmp = staged.with_file_name(format!(
"{}.staging-tmp",
staged.file_name().and_then(|n| n.to_str()).unwrap_or("vol")
));
std::fs::copy(source, &tmp).map_err(|e| {
BoxliteError::Config(format!(
"Failed to copy volume file '{}' into staging: {}",
source.display(),
e
))
})?;
std::fs::rename(&tmp, staged).map_err(|e| {
BoxliteError::Config(format!(
"Failed to publish staged volume file '{}': {}",
staged.display(),
e
))
})?;
Ok(staged.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::MetadataExt;
#[test]
fn read_write_file_is_hardlinked_into_staging_dir() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("app.conf");
std::fs::write(&source, "key=value\n").unwrap();
let staging = tmp.path().join("staging").join("uservol0");
let staged = stage_single_file(&staging, &source, "app.conf", false).unwrap();
assert_eq!(staged, staging.join("app.conf"));
assert_eq!(std::fs::read_to_string(&staged).unwrap(), "key=value\n");
assert_eq!(
std::fs::metadata(&staged).unwrap().ino(),
std::fs::metadata(&source).unwrap().ino()
);
}
#[test]
fn read_only_file_is_copied_so_writes_never_reach_source() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("secret.conf");
std::fs::write(&source, "sensitive=true\n").unwrap();
let staging = tmp.path().join("staging");
let staged = stage_single_file(&staging, &source, "app.conf", true).unwrap();
assert_eq!(
std::fs::read_to_string(&staged).unwrap(),
"sensitive=true\n"
);
assert_ne!(
std::fs::metadata(&staged).unwrap().ino(),
std::fs::metadata(&source).unwrap().ino()
);
std::fs::write(&staged, "TAMPERED\n").unwrap();
assert_eq!(
std::fs::read_to_string(&source).unwrap(),
"sensitive=true\n"
);
}
#[test]
fn re_staging_replaces_existing_entry() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let first = tmp.path().join("first.conf");
std::fs::write(&first, "one").unwrap();
stage_single_file(&staging, &first, "app.conf", false).unwrap();
let second = tmp.path().join("second.conf");
std::fs::write(&second, "two").unwrap();
let staged = stage_single_file(&staging, &second, "app.conf", false).unwrap();
assert_eq!(std::fs::read_to_string(&staged).unwrap(), "two");
}
}