use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::Result;
use crate::error::Error;
use crate::state::{StateDb, VirtioFs};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum VolumeSource {
Bind {
host_path: PathBuf,
},
Named {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct VolumeMount {
pub source: VolumeSource,
pub guest_path: String,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub allow_sensitive: bool,
}
impl VolumeMount {
#[must_use]
pub fn bind(host_path: impl Into<PathBuf>, guest_path: impl Into<String>) -> Self {
Self {
source: VolumeSource::Bind {
host_path: host_path.into(),
},
guest_path: guest_path.into(),
read_only: false,
allow_sensitive: false,
}
}
#[must_use]
pub fn named(name: impl Into<String>, guest_path: impl Into<String>) -> Self {
Self {
source: VolumeSource::Named { name: name.into() },
guest_path: guest_path.into(),
read_only: false,
allow_sensitive: false,
}
}
#[must_use]
pub const fn read_only(mut self, yes: bool) -> Self {
self.read_only = yes;
self
}
#[must_use]
pub const fn allow_sensitive(mut self, yes: bool) -> Self {
self.allow_sensitive = yes;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ResolvedVolume {
pub tag: String,
pub host_path: PathBuf,
pub guest_path: String,
pub read_only: bool,
pub volume_id: Option<String>,
pub volume_name: Option<String>,
}
impl ResolvedVolume {
#[must_use]
pub(crate) fn to_virtiofs(&self) -> VirtioFs {
VirtioFs {
tag: self.tag.clone(),
path: self.host_path.to_string_lossy().into_owned(),
guest_path: self.guest_path.clone(),
read_only: self.read_only,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct VolumeInfo {
pub id: String,
pub name: String,
pub path: PathBuf,
pub created_at: SystemTime,
}
#[derive(Debug, Clone)]
pub struct VolumeManager {
root: PathBuf,
db: Arc<StateDb>,
}
impl VolumeManager {
pub(crate) fn open(data_dir: impl AsRef<Path>, db: Arc<StateDb>) -> Result<Self> {
let root = data_dir.as_ref().join("volumes");
fs::create_dir_all(&root)?;
Ok(Self { root, db })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn create(&self, name: &str) -> Result<VolumeInfo> {
validate_volume_name(name)?;
if let Some(existing) = self.db.get_volume_by_name(name)? {
return Ok(existing);
}
let path = self.root.join(name);
fs::create_dir_all(&path)?;
let info = VolumeInfo {
id: name.to_owned(),
name: name.to_owned(),
path: path.canonicalize().unwrap_or(path),
created_at: SystemTime::now(),
};
self.db.insert_volume(&info)?;
Ok(info)
}
pub fn list(&self) -> Result<Vec<VolumeInfo>> {
self.db.list_volumes()
}
pub fn get(&self, name: &str) -> Result<VolumeInfo> {
self.db
.get_volume_by_name(name)?
.ok_or_else(|| Error::NotFound(format!("volume '{name}' not found")))
}
pub fn remove(&self, name: &str) -> Result<()> {
let info = self.get(name)?;
let n = self.db.count_volume_attachments(&info.id)?;
if n > 0 {
return Err(Error::Busy(format!(
"volume '{name}' is attached to {n} VM(s); remove the VM or detach first"
)));
}
self.db.delete_volume(&info.id)?;
if info.path.exists() {
fs::remove_dir_all(&info.path)?;
}
Ok(())
}
pub fn resolve_mounts(&self, mounts: &[VolumeMount]) -> Result<Vec<ResolvedVolume>> {
let mut out = Vec::with_capacity(mounts.len());
for (idx, m) in mounts.iter().enumerate() {
out.push(self.resolve_one(idx, m)?);
}
Ok(out)
}
pub fn link_vm(&self, vm_id: &str, resolved: &[ResolvedVolume]) -> Result<()> {
for r in resolved {
if let Some(ref vol_id) = r.volume_id {
self.db.insert_vm_volume(vm_id, vol_id, &r.guest_path)?;
}
}
Ok(())
}
pub fn unlink_vm(&self, vm_id: &str) -> Result<()> {
self.db.delete_vm_volumes(vm_id)
}
fn resolve_one(&self, idx: usize, m: &VolumeMount) -> Result<ResolvedVolume> {
validate_guest_path(&m.guest_path)?;
let (host_path, volume_id, volume_name, tag) = match &m.source {
VolumeSource::Bind { host_path } => {
let path = validate_bind_path(host_path, m.allow_sensitive)?;
let tag = format!("vol{idx}");
(path, None, None, tag)
}
VolumeSource::Named { name } => {
let info = self.create(name)?;
let tag = format!("vol_{}", sanitize_tag(name));
let path = validate_resolved_path(&info.path, m.allow_sensitive)?;
(path, Some(info.id), Some(info.name), tag)
}
};
Ok(ResolvedVolume {
tag,
host_path,
guest_path: m.guest_path.clone(),
read_only: m.read_only,
volume_id,
volume_name,
})
}
}
pub fn validate_volume_name(name: &str) -> Result<()> {
if name.is_empty() || name.len() > 128 {
return Err(Error::InvalidConfig(
"volume name must be 1..=128 characters".into(),
));
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(Error::InvalidConfig(format!(
"invalid volume name {name:?}: path separators and '..' are not allowed"
)));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(Error::InvalidConfig(format!(
"invalid volume name {name:?}: use [A-Za-z0-9._-]"
)));
}
Ok(())
}
fn validate_guest_path(guest: &str) -> Result<()> {
bux_proto::validate_guest_mount_path(guest).map_err(Error::InvalidConfig)
}
fn validate_bind_path(path: &Path, allow_sensitive: bool) -> Result<PathBuf> {
if path.as_os_str().is_empty() {
return Err(Error::InvalidConfig("host volume path is empty".into()));
}
if path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(Error::InvalidConfig(format!(
"host volume path must not contain '..': {}",
path.display()
)));
}
if !path.is_absolute() {
return Err(Error::InvalidConfig(format!(
"host volume path must be absolute: {}",
path.display()
)));
}
if !path.is_dir() {
return Err(Error::InvalidConfig(format!(
"host volume path is not a directory: {}",
path.display()
)));
}
let canon = path.canonicalize().map_err(|e| {
Error::InvalidConfig(format!(
"cannot canonicalize host volume {}: {e}",
path.display()
))
})?;
validate_resolved_path(&canon, allow_sensitive)
}
fn validate_resolved_path(path: &Path, allow_sensitive: bool) -> Result<PathBuf> {
if path == Path::new("/") {
return Err(Error::InvalidConfig(
"refusing to expose host root filesystem as a volume".into(),
));
}
if !allow_sensitive {
for denied in sensitive_prefixes() {
if path_is_or_under(path, &denied) {
return Err(Error::InvalidConfig(format!(
"host path {} is under default-denied prefix {} \
(set allow_sensitive on the mount to override)",
path.display(),
denied.display()
)));
}
}
}
Ok(path.to_path_buf())
}
fn path_is_or_under(path: &Path, prefix: &Path) -> bool {
if path == prefix {
return true;
}
path.starts_with(prefix)
}
fn sensitive_prefixes() -> Vec<PathBuf> {
let mut out = vec![
PathBuf::from("/etc/shadow"),
PathBuf::from("/etc/gshadow"),
PathBuf::from("/etc/sudoers"),
PathBuf::from("/etc/ssh"),
PathBuf::from("/root/.ssh"),
PathBuf::from("/root/.gnupg"),
PathBuf::from("/root/.aws"),
];
if let Some(home) = dirs::home_dir() {
out.push(home.join(".ssh"));
out.push(home.join(".gnupg"));
out.push(home.join(".aws"));
out.push(home.join(".config/gcloud"));
out.push(home.join(".azure"));
out.push(home.join(".kube"));
out.push(home.join(".docker/config.json"));
}
out
}
fn sanitize_tag(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(32)
.collect()
}
pub fn parse_bind_spec(spec: &str) -> Result<VolumeMount> {
let parts: Vec<&str> = spec.splitn(3, ':').collect();
match parts.as_slice() {
[host, guest] => Ok(VolumeMount::bind(*host, *guest)),
[host, guest, opts] => {
let ro = opts.split(',').any(|o| o.eq_ignore_ascii_case("ro"));
Ok(VolumeMount::bind(*host, *guest).read_only(ro))
}
_ => Err(Error::InvalidConfig(format!(
"invalid volume spec {spec:?}; use hostPath:guestPath[:ro]"
))),
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
reason = "tests"
)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn reject_parent_dir_in_guest() {
assert!(validate_guest_path("/app/../etc").is_err());
assert!(validate_guest_path("relative").is_err());
assert!(validate_guest_path("/").is_err());
assert!(validate_guest_path("/./").is_err());
assert!(validate_guest_path("/data").is_ok());
}
#[test]
fn reject_volume_name_with_slash() {
assert!(validate_volume_name("a/b").is_err());
assert!(validate_volume_name("..").is_err());
assert!(validate_volume_name("good_vol-1").is_ok());
}
#[test]
fn deny_ssh_prefix() {
let Some(home) = dirs::home_dir() else {
return;
};
let ssh = home.join(".ssh");
if ssh.is_dir() {
let err = validate_bind_path(&ssh, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("denied") || msg.contains("default-denied"));
assert!(validate_bind_path(&ssh, true).is_ok());
}
}
#[test]
fn parse_bind_spec_ro() {
let m = parse_bind_spec("/tmp/data:/data:ro").unwrap();
assert!(m.read_only);
assert_eq!(m.guest_path, "/data");
let VolumeSource::Bind { host_path } = m.source else {
unreachable!("expected bind source");
};
assert_eq!(host_path, PathBuf::from("/tmp/data"));
}
#[test]
fn named_volume_roundtrip() {
let dir = tempdir().unwrap();
let db = Arc::new(StateDb::open(dir.path().join("bux.db")).unwrap());
let vm = VolumeManager::open(dir.path(), Arc::clone(&db)).unwrap();
let info = vm.create("cache").unwrap();
assert!(info.path.is_dir());
assert_eq!(vm.list().unwrap().len(), 1);
let mounts = vec![VolumeMount::named("cache", "/var/cache")];
let resolved = vm.resolve_mounts(&mounts).unwrap();
let first = resolved.first().expect("one resolved mount");
assert_eq!(first.guest_path, "/var/cache");
let root_canon = vm
.root()
.canonicalize()
.unwrap_or_else(|_| vm.root().to_path_buf());
assert!(first.host_path.starts_with(&root_canon) || first.host_path.starts_with(vm.root()));
let vm_state = crate::state::VmState {
id: "vm1".into(),
name: None,
pid: 1,
image: None,
socket: dir.path().join("vm1.sock"),
status: crate::state::Status::Running,
config: crate::state::VmConfig::default(),
created_at: SystemTime::now(),
};
db.insert(&vm_state).unwrap();
vm.link_vm("vm1", &resolved).unwrap();
assert!(vm.remove("cache").is_err());
vm.unlink_vm("vm1").unwrap();
vm.remove("cache").unwrap();
assert!(vm.list().unwrap().is_empty());
}
#[test]
fn bind_resolve_tmp() {
let dir = tempdir().unwrap();
let host = dir.path().join("bind");
fs::create_dir_all(&host).unwrap();
let db = Arc::new(StateDb::open(dir.path().join("bux.db")).unwrap());
let vm = VolumeManager::open(dir.path(), db).unwrap();
let mounts = vec![VolumeMount::bind(&host, "/mnt/data").read_only(true)];
let resolved = vm.resolve_mounts(&mounts).unwrap();
let first = resolved.first().expect("one resolved mount");
assert_eq!(first.tag, "vol0");
assert!(first.volume_id.is_none());
assert!(first.read_only);
assert!(first.to_virtiofs().read_only);
}
}