Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 87 additions & 48 deletions src/backends/lxc/common/src/filesystem_mounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
//! mount entries:
//! - `readwritePaths` → `bind,rw` mount
//! - `readonlyPaths` → `bind,ro` mount
//! - `deniedPaths` → not mounted (inaccessible inside container)
//! - `deniedPaths` → masked (inaccessible inside container)

use wxc_common::filesystem_resolve::{resolve_mount_order, FsIntent};
use wxc_common::logger::Logger;
use wxc_common::models::ContainerPolicy;

Expand All @@ -31,6 +32,21 @@ fn validate_path(path: &str) -> Result<(), String> {
Ok(())
}

/// Classify a denied **host** path as a regular file, to pick the LXC mask type.
///
/// Denied paths are host paths, so the mask is decided from host reality rather
/// than from the container rootfs (which does not exist until mounts are
/// applied): a regular file is masked with a read-only `/dev/null` bind
/// (`create=file`) and everything else — a directory, a symlink, or a path that
/// does not exist on the host — with an empty read-only tmpfs (`create=dir`).
/// Uses `symlink_metadata` so a symlinked deny is never followed to an
/// unintended target when choosing the mask.
fn denied_path_is_file(host_path: &str) -> bool {
std::fs::symlink_metadata(host_path)
.map(|meta| meta.file_type().is_file())
.unwrap_or(false)
}

/// Configure filesystem bind mounts on the container based on the policy.
///
/// Adds `lxc.mount.entry` config items for each path in the policy.
Expand All @@ -39,56 +55,55 @@ pub fn configure_filesystem_mounts(
policy: &ContainerPolicy,
logger: &mut Logger,
) -> Result<(), String> {
// Read-write bind mounts
for host_path in &policy.readwrite_paths {
for mount in resolve_mount_order(policy) {
let host_path = &mount.path;
validate_path(host_path)?;
let container_path = host_path.trim_start_matches('/');
let mount_entry = format!("{} {} none bind,create=dir 0 0", host_path, container_path);
logger.log_line(&format!(
"Adding rw bind mount: {} -> /{}",
host_path, container_path
));
container.set_config_item("lxc.mount.entry", &mount_entry)?;
}

// Read-only bind mounts
for host_path in &policy.readonly_paths {
validate_path(host_path)?;
let container_path = host_path.trim_start_matches('/');
let mount_entry = format!(
"{} {} none bind,ro,create=dir 0 0",
host_path, container_path
);
logger.log_line(&format!(
"Adding ro bind mount: {} -> /{}",
host_path, container_path
));
container.set_config_item("lxc.mount.entry", &mount_entry)?;
}

// Denied paths: mount tmpfs over them to mask contents
for host_path in &policy.denied_paths {
validate_path(host_path)?;
let container_path = host_path.trim_start_matches('/');

// Determine if the path is a file or directory inside the container rootfs
// so we use the correct LXC `create=` type for the mount entry.
let rootfs_base = format!("{}/{}/rootfs", container.lxc_path(), container.name());
let full_path = format!("{}/{}", rootfs_base, container_path);

// Use /dev/null bind mount for files, tmpfs for directories
let is_file = std::path::Path::new(&full_path).is_file();
let mount_entry = if is_file {
format!("/dev/null {} none bind,ro,create=file 0 0", container_path)
} else {
format!("tmpfs {} tmpfs ro,size=0,create=dir 0 0", container_path)
};
let create_type = if is_file { "file" } else { "dir" };
logger.log_line(&format!(
"Masking denied path: /{} ({})",
container_path, create_type
));
container.set_config_item("lxc.mount.entry", &mount_entry)?;
match mount.intent {
FsIntent::ReadWrite => {
let mount_entry =
format!("{} {} none bind,create=dir 0 0", host_path, container_path);
logger.log_line(&format!(
"Adding rw bind mount: {} -> /{}",
host_path, container_path
));
container.set_config_item("lxc.mount.entry", &mount_entry)?;
}
FsIntent::ReadOnly => {
let mount_entry = format!(
"{} {} none bind,ro,create=dir 0 0",
host_path, container_path
);
logger.log_line(&format!(
"Adding ro bind mount: {} -> /{}",
host_path, container_path
));
container.set_config_item("lxc.mount.entry", &mount_entry)?;
}
FsIntent::Denied => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Denied file/dir masking gap unaddressed? still rootfs is_file()?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bae58f5. The denied branch no longer inspects the rootfs path (<lxc_path>/<name>/rootfs/<container_path>), which doesn't exist until mounts are applied and therefore always classified as a directory. It now classifies from the host path via a new denied_path_is_file helper that uses symlink_metadata (never follows symlinks): a regular host file is masked with /dev/null (create=file), and a directory / symlink / missing host path with an empty read-only tmpfs (create=dir). Added a unit test covering the file / dir / missing host-path cases.

// Classify the denied path from the HOST path, not the container
// rootfs path. Denied paths are host paths, and the rootfs path
// (`<lxc_path>/<name>/rootfs/<container_path>`) does not exist
// until mounts are applied, so inspecting it would always look
// "missing" and mask every deny as a directory. Host reality
// decides the `create=` type instead.
let is_file = denied_path_is_file(host_path);

// Use /dev/null bind mount for files, tmpfs for directories.
let mount_entry = if is_file {
format!("/dev/null {} none bind,ro,create=file 0 0", container_path)
} else {
format!("tmpfs {} tmpfs ro,size=0,create=dir 0 0", container_path)
};
let create_type = if is_file { "file" } else { "dir" };
logger.log_line(&format!(
"Masking denied path: /{} ({})",
container_path, create_type
));
container.set_config_item("lxc.mount.entry", &mount_entry)?;
}
}
}

Ok(())
Expand Down Expand Up @@ -141,4 +156,28 @@ mod tests {
fn test_validate_path_accepts_normal() {
assert!(validate_path("/mnt/shared").is_ok());
}

#[test]
fn denied_path_is_file_reflects_host_reality() {
use std::io::Write;
let base = std::env::temp_dir();
let unique = format!("mxc_lxc_denied_{}", std::process::id());
let file_path = base.join(format!("{unique}.file"));
let dir_path = base.join(format!("{unique}.dir"));
let missing = base.join(format!("{unique}.missing"));

let mut file = std::fs::File::create(&file_path).expect("create temp file");
writeln!(file, "x").expect("write temp file");
std::fs::create_dir_all(&dir_path).expect("create temp dir");

// Regular host file → masked with /dev/null (create=file).
assert!(denied_path_is_file(&file_path.to_string_lossy()));
// Host directory → masked with an empty tmpfs (create=dir).
assert!(!denied_path_is_file(&dir_path.to_string_lossy()));
// Missing host path → not a file, so masked as an empty directory.
assert!(!denied_path_is_file(&missing.to_string_lossy()));

let _ = std::fs::remove_file(&file_path);
let _ = std::fs::remove_dir_all(&dir_path);
}
}
Loading
Loading