use boxlite_shared::errors::{BoxliteError, BoxliteResult};
use boxlite_shared::layout::{SharedGuestLayout, dirs as shared_dirs};
use std::path::{Path, PathBuf};
pub mod dirs {
pub const BOXLITE_DIR: &str = ".boxlite";
pub const DB_DIR: &str = "db";
pub const IMAGES_DIR: &str = "images";
pub const LAYERS_DIR: &str = "layers";
pub const MANIFESTS_DIR: &str = "manifests";
pub const BOXES_DIR: &str = "boxes";
pub const SOCKETS_DIR: &str = "sockets";
pub const UPPER_DIR: &str = "upper";
pub const WORK_DIR: &str = "work";
pub const OVERLAYFS_DIR: &str = "overlayfs";
pub const LOGS_DIR: &str = "logs";
pub const DISKS_DIR: &str = "disks";
pub const LOCKS_DIR: &str = "locks";
}
#[derive(Clone, Debug, Default)]
pub struct FsLayoutConfig {
bind_mount_supported: bool,
}
impl FsLayoutConfig {
pub fn with_bind_mount() -> Self {
Self {
bind_mount_supported: true,
}
}
pub fn without_bind_mount() -> Self {
Self {
bind_mount_supported: false,
}
}
pub fn is_bind_mount_supported(&self) -> bool {
self.bind_mount_supported
}
}
#[derive(Clone, Debug)]
pub struct FilesystemLayout {
home_dir: PathBuf,
config: FsLayoutConfig,
}
impl FilesystemLayout {
pub fn new(home_dir: PathBuf, config: FsLayoutConfig) -> Self {
Self { home_dir, config }
}
pub fn home_dir(&self) -> &Path {
&self.home_dir
}
pub fn db_dir(&self) -> PathBuf {
self.home_dir.join(dirs::DB_DIR)
}
pub fn images_dir(&self) -> PathBuf {
self.home_dir.join(dirs::IMAGES_DIR)
}
pub fn logs_dir(&self) -> PathBuf {
self.home_dir.join(dirs::LOGS_DIR)
}
pub fn image_layers_dir(&self) -> PathBuf {
self.images_dir().join(dirs::LAYERS_DIR)
}
pub fn image_manifests_dir(&self) -> PathBuf {
self.images_dir().join(dirs::MANIFESTS_DIR)
}
pub fn boxes_dir(&self) -> PathBuf {
self.home_dir.join(dirs::BOXES_DIR)
}
pub fn bases_dir(&self) -> PathBuf {
self.home_dir.join("bases")
}
pub fn locks_dir(&self) -> PathBuf {
self.home_dir.join(dirs::LOCKS_DIR)
}
pub fn temp_dir(&self) -> PathBuf {
self.home_dir.join("tmp")
}
pub fn prepare(&self) -> BoxliteResult<()> {
std::fs::create_dir_all(&self.home_dir)
.map_err(|e| BoxliteError::Storage(format!("failed to create home: {e}")))?;
std::fs::create_dir_all(self.boxes_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create boxes dir: {e}")))?;
std::fs::create_dir_all(self.bases_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create bases dir: {e}")))?;
std::fs::create_dir_all(self.temp_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create temp dir: {e}")))?;
std::fs::create_dir_all(self.image_layers_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create layers dir: {e}")))?;
std::fs::create_dir_all(self.image_manifests_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create manifests dir: {e}")))?;
std::fs::create_dir_all(self.image_layout().disk_images_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create disk-images dir: {e}")))?;
self.validate_same_filesystem()?;
Ok(())
}
fn validate_same_filesystem(&self) -> BoxliteResult<()> {
use std::os::unix::fs::MetadataExt;
let temp_dev = std::fs::metadata(self.temp_dir())
.map_err(|e| {
BoxliteError::Storage(format!(
"Failed to stat temp dir {}: {}",
self.temp_dir().display(),
e
))
})?
.dev();
let bases_dev = std::fs::metadata(self.bases_dir())
.map_err(|e| {
BoxliteError::Storage(format!(
"Failed to stat bases dir {}: {}",
self.bases_dir().display(),
e
))
})?
.dev();
let images_dev = std::fs::metadata(self.image_layout().disk_images_dir())
.map_err(|e| {
BoxliteError::Storage(format!(
"Failed to stat disk-images dir {}: {}",
self.image_layout().disk_images_dir().display(),
e
))
})?
.dev();
if temp_dev != bases_dev || temp_dev != images_dev {
return Err(BoxliteError::Storage(format!(
"tmp, bases, and disk-images directories must be on the same filesystem \
for atomic rename. Found devices: tmp={}, bases={}, disk-images={}. \
Check your BOXLITE_HOME configuration.",
temp_dev, bases_dev, images_dev
)));
}
Ok(())
}
pub fn box_layout(
&self,
box_id: &str,
isolate_mounts: bool,
) -> BoxliteResult<BoxFilesystemLayout> {
let effective_isolate = isolate_mounts && self.config.is_bind_mount_supported();
if isolate_mounts && !effective_isolate {
tracing::warn!(
"Mount isolation requested but bind mounts are not supported on this system. \
Falling back to shared directory without isolation."
);
}
Ok(BoxFilesystemLayout::new(
self.boxes_dir().join(box_id),
self.config.clone(),
effective_isolate,
))
}
pub fn image_layout(&self) -> ImageFilesystemLayout {
ImageFilesystemLayout::new(self.images_dir())
}
}
#[derive(Clone, Debug)]
pub struct BoxFilesystemLayout {
box_dir: PathBuf,
shared_layout: SharedGuestLayout,
config: FsLayoutConfig,
isolate_mounts: bool,
}
impl BoxFilesystemLayout {
pub fn new(box_dir: PathBuf, config: FsLayoutConfig, isolate_mounts: bool) -> Self {
let shared_layout = SharedGuestLayout::new(box_dir.join(shared_dirs::MOUNTS));
Self {
box_dir,
shared_layout,
config,
isolate_mounts,
}
}
pub fn root(&self) -> &Path {
&self.box_dir
}
pub fn sockets_dir(&self) -> PathBuf {
self.box_dir.join(dirs::SOCKETS_DIR)
}
pub fn socket_path(&self) -> PathBuf {
self.sockets_dir().join("box.sock")
}
pub fn ready_socket_path(&self) -> PathBuf {
self.sockets_dir().join("ready.sock")
}
pub fn net_backend_socket_path(&self) -> PathBuf {
self.sockets_dir().join("net.sock")
}
pub fn shared_layout(&self) -> &SharedGuestLayout {
&self.shared_layout
}
pub fn mounts_dir(&self) -> PathBuf {
if self.config.is_bind_mount_supported() && self.isolate_mounts {
self.shared_layout.base().to_path_buf()
} else {
self.shared_dir()
}
}
pub fn shared_dir(&self) -> PathBuf {
self.box_dir.join(shared_dirs::SHARED)
}
pub fn bin_dir(&self) -> PathBuf {
self.box_dir.join("bin")
}
pub fn ca_dir(&self) -> PathBuf {
self.box_dir.join("ca")
}
pub fn logs_dir(&self) -> PathBuf {
self.box_dir.join("logs")
}
pub fn tmp_dir(&self) -> PathBuf {
self.box_dir.join("tmp")
}
pub fn disks_dir(&self) -> PathBuf {
self.box_dir.join(dirs::DISKS_DIR)
}
pub fn disk_path(&self) -> PathBuf {
self.disks_dir()
.join(crate::disk::constants::filenames::CONTAINER_DISK)
}
pub fn guest_rootfs_disk_path(&self) -> PathBuf {
self.disks_dir()
.join(crate::disk::constants::filenames::GUEST_ROOTFS_DISK)
}
pub fn console_output_path(&self) -> PathBuf {
self.logs_dir().join("console.log")
}
pub fn pid_file_path(&self) -> PathBuf {
self.box_dir.join("shim.pid")
}
pub fn exit_file_path(&self) -> PathBuf {
self.box_dir.join("exit")
}
pub fn stderr_file_path(&self) -> PathBuf {
self.box_dir.join("shim.stderr")
}
pub fn prepare(&self) -> BoxliteResult<()> {
std::fs::create_dir_all(&self.box_dir)
.map_err(|e| BoxliteError::Storage(format!("failed to create box dir: {e}")))?;
std::fs::create_dir_all(self.sockets_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create sockets dir: {e}")))?;
std::fs::create_dir_all(self.tmp_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create tmp dir: {e}")))?;
std::fs::create_dir_all(self.disks_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create disks dir: {e}")))?;
std::fs::create_dir_all(self.mounts_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create mounts dir: {e}")))?;
std::fs::create_dir_all(self.logs_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create logs dir: {e}")))?;
Ok(())
}
pub fn cleanup(&self) -> BoxliteResult<()> {
if self.box_dir.exists() {
std::fs::remove_dir_all(&self.box_dir)
.map_err(|e| BoxliteError::Storage(format!("failed to cleanup box dir: {e}")))?;
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct ImageFilesystemLayout {
images_dir: PathBuf,
}
impl ImageFilesystemLayout {
pub fn new(images_dir: PathBuf) -> Self {
Self { images_dir }
}
pub fn local_bundle_cache_dir(&self, bundle_path: &Path, manifest_digest: &str) -> PathBuf {
use sha2::{Digest, Sha256};
let path_str = bundle_path.to_string_lossy();
let path_hash = Sha256::digest(path_str.as_bytes());
let path_short = format!("{:x}", path_hash)
.chars()
.take(8)
.collect::<String>();
let manifest_short = manifest_digest
.strip_prefix("sha256:")
.unwrap_or(manifest_digest);
let manifest_short = &manifest_short[..8.min(manifest_short.len())];
self.images_dir
.join("local")
.join(format!("{}-{}", path_short, manifest_short))
}
pub fn root(&self) -> &Path {
&self.images_dir
}
pub fn layers_dir(&self) -> PathBuf {
self.images_dir.join(dirs::LAYERS_DIR)
}
pub fn extracted_dir(&self) -> PathBuf {
self.images_dir.join("extracted")
}
pub fn disk_images_dir(&self) -> PathBuf {
self.images_dir.join("disk-images")
}
pub fn manifests_dir(&self) -> PathBuf {
self.images_dir.join(dirs::MANIFESTS_DIR)
}
pub fn configs_dir(&self) -> PathBuf {
self.images_dir.join("configs")
}
pub fn prepare(&self) -> BoxliteResult<()> {
std::fs::create_dir_all(self.layers_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create layers dir: {e}")))?;
std::fs::create_dir_all(self.extracted_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create extracted dir: {e}")))?;
std::fs::create_dir_all(self.disk_images_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create disk-images dir: {e}")))?;
std::fs::create_dir_all(self.manifests_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create manifests dir: {e}")))?;
std::fs::create_dir_all(self.configs_dir())
.map_err(|e| BoxliteError::Storage(format!("failed to create configs dir: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_local_bundle_cache_dir_format() {
let layout = ImageFilesystemLayout::new(PathBuf::from("/images"));
let cache_dir =
layout.local_bundle_cache_dir(Path::new("/my/bundle"), "sha256:abc123def456789");
assert!(cache_dir.starts_with("/images/local/"));
let dir_name = cache_dir.file_name().unwrap().to_str().unwrap();
assert!(
dir_name.contains('-'),
"should have format path_hash-manifest_short"
);
let parts: Vec<&str> = dir_name.split('-').collect();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].len(), 8, "path hash should be 8 chars");
assert_eq!(parts[1].len(), 8, "manifest short should be 8 chars");
}
#[test]
fn test_local_bundle_cache_invalidation_on_content_change() {
let layout = ImageFilesystemLayout::new(PathBuf::from("/images"));
let bundle_path = Path::new("/my/bundle");
let cache_v1 = layout.local_bundle_cache_dir(
bundle_path,
"sha256:a1b2c3d4e5f6789012345678901234567890abcd",
);
let cache_v2 = layout.local_bundle_cache_dir(
bundle_path,
"sha256:f9e8d7c6b5a4321098765432109876543210fedc",
);
assert_ne!(
cache_v1, cache_v2,
"Same path but different manifest should use DIFFERENT cache dirs"
);
assert_eq!(cache_v1.parent(), cache_v2.parent());
let name_v1 = cache_v1.file_name().unwrap().to_str().unwrap();
let name_v2 = cache_v2.file_name().unwrap().to_str().unwrap();
let parts_v1: Vec<&str> = name_v1.split('-').collect();
let parts_v2: Vec<&str> = name_v2.split('-').collect();
assert_eq!(
parts_v1[0], parts_v2[0],
"Same path should have same path hash"
);
assert_ne!(
parts_v1[1], parts_v2[1],
"Different manifest should have different hash"
);
}
#[test]
fn test_local_bundle_cache_same_content_same_cache() {
let layout = ImageFilesystemLayout::new(PathBuf::from("/images"));
let cache1 = layout.local_bundle_cache_dir(Path::new("/my/bundle"), "sha256:abc123");
let cache2 = layout.local_bundle_cache_dir(Path::new("/my/bundle"), "sha256:abc123");
assert_eq!(
cache1, cache2,
"Same path + manifest should give same cache dir"
);
}
#[test]
fn test_local_bundle_different_paths_different_caches() {
let layout = ImageFilesystemLayout::new(PathBuf::from("/images"));
let manifest = "sha256:same_manifest";
let cache1 = layout.local_bundle_cache_dir(Path::new("/bundle1"), manifest);
let cache2 = layout.local_bundle_cache_dir(Path::new("/bundle2"), manifest);
assert_ne!(
cache1, cache2,
"Different paths should have different cache dirs"
);
}
#[test]
fn test_different_boxes_get_different_net_backend_socket_paths() {
let config = FsLayoutConfig::without_bind_mount();
let box_a = BoxFilesystemLayout::new(
PathBuf::from("/home/user/.boxlite/boxes/box-aaa"),
config.clone(),
false,
);
let box_b = BoxFilesystemLayout::new(
PathBuf::from("/home/user/.boxlite/boxes/box-bbb"),
config,
false,
);
let path_a = box_a.net_backend_socket_path();
let path_b = box_b.net_backend_socket_path();
assert_ne!(
path_a, path_b,
"Two different boxes must have different net backend socket paths"
);
assert!(path_a.starts_with("/home/user/.boxlite/boxes/box-aaa/sockets/"));
assert!(path_b.starts_with("/home/user/.boxlite/boxes/box-bbb/sockets/"));
assert_eq!(path_a.file_name().unwrap(), "net.sock");
assert_eq!(path_b.file_name().unwrap(), "net.sock");
}
#[test]
fn test_bases_dir() {
let layout = FilesystemLayout::new(
PathBuf::from("/home/user/.boxlite"),
FsLayoutConfig::without_bind_mount(),
);
assert_eq!(
layout.bases_dir(),
PathBuf::from("/home/user/.boxlite/bases")
);
}
#[test]
fn test_prepare_creates_bases_dir() {
let dir = tempfile::TempDir::new().unwrap();
let layout = FilesystemLayout::new(
dir.path().to_path_buf(),
FsLayoutConfig::without_bind_mount(),
);
layout.prepare().unwrap();
assert!(layout.bases_dir().exists());
assert!(layout.temp_dir().exists());
assert!(layout.boxes_dir().exists());
}
#[test]
fn test_prepare_validates_same_filesystem() {
let dir = tempfile::TempDir::new().unwrap();
let layout = FilesystemLayout::new(
dir.path().to_path_buf(),
FsLayoutConfig::without_bind_mount(),
);
layout.prepare().unwrap();
layout.validate_same_filesystem().unwrap();
}
fn test_box_layout(box_dir: &str) -> BoxFilesystemLayout {
BoxFilesystemLayout::new(
PathBuf::from(box_dir),
FsLayoutConfig::without_bind_mount(),
false,
)
}
#[test]
fn test_box_layout_logs_dir() {
let layout = test_box_layout("/home/.boxlite/boxes/mybox");
assert_eq!(
layout.logs_dir(),
PathBuf::from("/home/.boxlite/boxes/mybox/logs")
);
}
#[test]
fn test_box_layout_bin_dir() {
let layout = test_box_layout("/home/.boxlite/boxes/mybox");
assert_eq!(
layout.bin_dir(),
PathBuf::from("/home/.boxlite/boxes/mybox/bin")
);
}
#[test]
fn test_box_layout_tmp_dir() {
let layout = test_box_layout("/home/.boxlite/boxes/mybox");
assert_eq!(
layout.tmp_dir(),
PathBuf::from("/home/.boxlite/boxes/mybox/tmp")
);
}
#[test]
fn test_box_layout_guest_rootfs_disk_path() {
let layout = test_box_layout("/home/.boxlite/boxes/mybox");
assert_eq!(
layout.guest_rootfs_disk_path(),
PathBuf::from("/home/.boxlite/boxes/mybox/disks/guest-rootfs.qcow2")
);
}
#[test]
fn test_box_layout_disks_dir() {
let layout = test_box_layout("/home/.boxlite/boxes/mybox");
assert_eq!(
layout.disks_dir(),
PathBuf::from("/home/.boxlite/boxes/mybox/disks")
);
}
#[test]
fn test_box_layout_disk_path() {
let layout = test_box_layout("/home/.boxlite/boxes/mybox");
assert_eq!(
layout.disk_path(),
PathBuf::from("/home/.boxlite/boxes/mybox/disks/disk.qcow2")
);
}
#[test]
fn test_box_layout_all_jailer_paths_inside_box_dir() {
let box_dir = "/home/.boxlite/boxes/test";
let layout = test_box_layout(box_dir);
let paths = [
layout.logs_dir(),
layout.bin_dir(),
layout.sockets_dir(),
layout.tmp_dir(),
layout.guest_rootfs_disk_path(),
layout.exit_file_path(),
layout.console_output_path(),
layout.disk_path(),
];
for path in &paths {
assert!(
path.starts_with(box_dir),
"Path {} should be inside box_dir {}",
path.display(),
box_dir
);
}
}
#[test]
fn test_box_layout_prepare_creates_tmp_dir() {
let dir = tempfile::TempDir::new().unwrap();
let box_dir = dir.path().join("box-tmp-test");
let layout = BoxFilesystemLayout::new(box_dir, FsLayoutConfig::without_bind_mount(), false);
layout.prepare().unwrap();
assert!(layout.tmp_dir().exists());
}
}