use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::size::Mebibytes;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiskImageFormat {
Qcow2,
Raw,
Vmdk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RootfsSource {
Bind(PathBuf),
Oci(String),
DiskImage {
path: PathBuf,
format: DiskImageFormat,
fstype: Option<String>,
},
}
pub enum ImageSource {
Text(String),
Path(PathBuf),
}
#[derive(Default)]
pub struct ImageBuilder {
source: Option<RootfsSource>,
error: Option<crate::MicrosandboxError>,
}
pub trait IntoImage {
fn into_rootfs_source(self) -> crate::MicrosandboxResult<RootfsSource>;
}
pub enum VolumeMount {
Bind {
host: PathBuf,
guest: String,
readonly: bool,
},
Named {
name: String,
guest: String,
readonly: bool,
},
Tmpfs {
guest: String,
size_mib: Option<u32>,
},
}
pub struct MountBuilder {
guest: String,
mount: MountKind,
readonly: bool,
size_mib: Option<u32>,
}
enum MountKind {
Bind(PathBuf),
Named(String),
Tmpfs,
Unset,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Patch {
Text {
path: String,
content: String,
mode: Option<u32>,
replace: bool,
},
File {
path: String,
content: Vec<u8>,
mode: Option<u32>,
replace: bool,
},
CopyFile {
src: PathBuf,
dst: String,
mode: Option<u32>,
replace: bool,
},
CopyDir {
src: PathBuf,
dst: String,
replace: bool,
},
Symlink {
target: String,
link: String,
replace: bool,
},
Mkdir {
path: String,
mode: Option<u32>,
},
Remove {
path: String,
},
Append {
path: String,
content: String,
},
}
pub struct PatchBuilder {
patches: Vec<Patch>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsConfig {}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SshConfig {}
impl MountBuilder {
pub fn new(guest: impl Into<String>) -> Self {
Self {
guest: guest.into(),
mount: MountKind::Unset,
readonly: false,
size_mib: None,
}
}
pub fn bind(mut self, host: impl Into<PathBuf>) -> Self {
self.mount = MountKind::Bind(host.into());
self
}
pub fn named(mut self, name: impl Into<String>) -> Self {
self.mount = MountKind::Named(name.into());
self
}
pub fn tmpfs(mut self) -> Self {
self.mount = MountKind::Tmpfs;
self
}
pub fn readonly(mut self) -> Self {
self.readonly = true;
self
}
pub fn size(mut self, size: impl Into<Mebibytes>) -> Self {
self.size_mib = Some(size.into().as_u32());
self
}
pub(crate) fn build(self) -> crate::MicrosandboxResult<VolumeMount> {
if !self.guest.starts_with('/') {
return Err(crate::MicrosandboxError::InvalidConfig(format!(
"guest mount path must be absolute: {}",
self.guest
)));
}
if self.guest == "/" {
return Err(crate::MicrosandboxError::InvalidConfig(
"cannot mount a volume at guest root /".into(),
));
}
if self.guest.contains(':') || self.guest.contains(';') {
return Err(crate::MicrosandboxError::InvalidConfig(format!(
"guest mount path must not contain ':' or ';': {}",
self.guest
)));
}
match self.mount {
MountKind::Bind(host) => Ok(VolumeMount::Bind {
host,
guest: self.guest,
readonly: self.readonly,
}),
MountKind::Named(name) => Ok(VolumeMount::Named {
name,
guest: self.guest,
readonly: self.readonly,
}),
MountKind::Tmpfs => Ok(VolumeMount::Tmpfs {
guest: self.guest,
size_mib: self.size_mib,
}),
MountKind::Unset => Err(crate::MicrosandboxError::InvalidConfig(
"MountBuilder: no mount type set (call .bind(), .named(), or .tmpfs())".into(),
)),
}
}
}
impl Default for PatchBuilder {
fn default() -> Self {
Self::new()
}
}
impl PatchBuilder {
pub fn new() -> Self {
Self {
patches: Vec::new(),
}
}
pub fn text(
mut self,
path: impl Into<String>,
content: impl Into<String>,
mode: Option<u32>,
replace: bool,
) -> Self {
self.patches.push(Patch::Text {
path: path.into(),
content: content.into(),
mode,
replace,
});
self
}
pub fn file(
mut self,
path: impl Into<String>,
content: impl Into<Vec<u8>>,
mode: Option<u32>,
replace: bool,
) -> Self {
self.patches.push(Patch::File {
path: path.into(),
content: content.into(),
mode,
replace,
});
self
}
pub fn copy_file(
mut self,
src: impl Into<PathBuf>,
dst: impl Into<String>,
mode: Option<u32>,
replace: bool,
) -> Self {
self.patches.push(Patch::CopyFile {
src: src.into(),
dst: dst.into(),
mode,
replace,
});
self
}
pub fn copy_dir(
mut self,
src: impl Into<PathBuf>,
dst: impl Into<String>,
replace: bool,
) -> Self {
self.patches.push(Patch::CopyDir {
src: src.into(),
dst: dst.into(),
replace,
});
self
}
pub fn symlink(
mut self,
target: impl Into<String>,
link: impl Into<String>,
replace: bool,
) -> Self {
self.patches.push(Patch::Symlink {
target: target.into(),
link: link.into(),
replace,
});
self
}
pub fn mkdir(mut self, path: impl Into<String>, mode: Option<u32>) -> Self {
self.patches.push(Patch::Mkdir {
path: path.into(),
mode,
});
self
}
pub fn remove(mut self, path: impl Into<String>) -> Self {
self.patches.push(Patch::Remove { path: path.into() });
self
}
pub fn append(mut self, path: impl Into<String>, content: impl Into<String>) -> Self {
self.patches.push(Patch::Append {
path: path.into(),
content: content.into(),
});
self
}
pub fn build(self) -> Vec<Patch> {
self.patches
}
}
impl VolumeMount {
pub fn guest(&self) -> &str {
match self {
Self::Bind { guest, .. } | Self::Named { guest, .. } | Self::Tmpfs { guest, .. } => {
guest
}
}
}
}
impl ImageSource {
pub fn into_rootfs_source(self) -> crate::MicrosandboxResult<RootfsSource> {
match self {
Self::Path(path) => Self::resolve_path(path),
Self::Text(s) => {
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
Self::resolve_path(PathBuf::from(s))
} else {
Ok(RootfsSource::Oci(s))
}
}
}
}
fn resolve_path(path: PathBuf) -> crate::MicrosandboxResult<RootfsSource> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if let Some(format) = DiskImageFormat::from_extension(ext) {
Ok(RootfsSource::DiskImage {
path,
format,
fstype: None,
})
} else {
Ok(RootfsSource::Bind(path))
}
}
}
impl DiskImageFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Qcow2 => "qcow2",
Self::Raw => "raw",
Self::Vmdk => "vmdk",
}
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext {
"qcow2" => Some(Self::Qcow2),
"raw" => Some(Self::Raw),
"vmdk" => Some(Self::Vmdk),
_ => None,
}
}
}
impl ImageBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn disk(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let format = match DiskImageFormat::from_extension(ext) {
Some(f) => f,
None => {
self.error = Some(crate::MicrosandboxError::InvalidConfig(format!(
"unrecognized disk image extension: {ext:?} (expected .qcow2, .raw, or .vmdk)"
)));
return self;
}
};
self.source = Some(RootfsSource::DiskImage {
path,
format,
fstype: None,
});
self
}
pub fn fstype(mut self, fstype: impl Into<String>) -> Self {
let fstype = fstype.into();
if fstype.contains(',') || fstype.contains('=') {
self.error = Some(crate::MicrosandboxError::InvalidConfig(format!(
"fstype must not contain ',' or '=': {fstype}"
)));
return self;
}
match &mut self.source {
Some(RootfsSource::DiskImage { fstype: ft, .. }) => {
*ft = Some(fstype);
}
_ => {
if self.error.is_none() {
self.error = Some(crate::MicrosandboxError::InvalidConfig(
"fstype() requires disk() to be called first".into(),
));
}
}
}
self
}
pub(crate) fn build(self) -> crate::MicrosandboxResult<RootfsSource> {
if let Some(e) = self.error {
return Err(e);
}
self.source.ok_or_else(|| {
crate::MicrosandboxError::InvalidConfig(
"ImageBuilder: no image source set (call .disk())".into(),
)
})
}
}
impl IntoImage for &str {
fn into_rootfs_source(self) -> crate::MicrosandboxResult<RootfsSource> {
ImageSource::from(self).into_rootfs_source()
}
}
impl IntoImage for String {
fn into_rootfs_source(self) -> crate::MicrosandboxResult<RootfsSource> {
ImageSource::from(self).into_rootfs_source()
}
}
impl IntoImage for PathBuf {
fn into_rootfs_source(self) -> crate::MicrosandboxResult<RootfsSource> {
ImageSource::from(self).into_rootfs_source()
}
}
impl std::fmt::Display for DiskImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for DiskImageFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"qcow2" => Ok(Self::Qcow2),
"raw" => Ok(Self::Raw),
"vmdk" => Ok(Self::Vmdk),
_ => Err(format!("unknown disk image format: {s}")),
}
}
}
impl Default for RootfsSource {
fn default() -> Self {
Self::Oci(String::new())
}
}
impl From<&str> for ImageSource {
fn from(s: &str) -> Self {
Self::Text(s.to_string())
}
}
impl From<String> for ImageSource {
fn from(s: String) -> Self {
Self::Text(s)
}
}
impl From<PathBuf> for ImageSource {
fn from(p: PathBuf) -> Self {
Self::Path(p)
}
}
impl Serialize for VolumeMount {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Self::Bind {
host,
guest,
readonly,
} => {
let mut map = serializer.serialize_map(Some(4))?;
map.serialize_entry("type", "Bind")?;
map.serialize_entry("host", host)?;
map.serialize_entry("guest", guest)?;
map.serialize_entry("readonly", readonly)?;
map.end()
}
Self::Named {
name,
guest,
readonly,
} => {
let mut map = serializer.serialize_map(Some(4))?;
map.serialize_entry("type", "Named")?;
map.serialize_entry("name", name)?;
map.serialize_entry("guest", guest)?;
map.serialize_entry("readonly", readonly)?;
map.end()
}
Self::Tmpfs { guest, size_mib } => {
let mut map = serializer.serialize_map(Some(3))?;
map.serialize_entry("type", "Tmpfs")?;
map.serialize_entry("guest", guest)?;
map.serialize_entry("size_mib", size_mib)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for VolumeMount {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(tag = "type")]
enum VolumeMountHelper {
Bind {
host: PathBuf,
guest: String,
#[serde(default)]
readonly: bool,
},
Named {
name: String,
guest: String,
#[serde(default)]
readonly: bool,
},
Tmpfs {
guest: String,
#[serde(default)]
size_mib: Option<u32>,
},
}
let helper = VolumeMountHelper::deserialize(deserializer)?;
Ok(match helper {
VolumeMountHelper::Bind {
host,
guest,
readonly,
} => Self::Bind {
host,
guest,
readonly,
},
VolumeMountHelper::Named {
name,
guest,
readonly,
} => Self::Named {
name,
guest,
readonly,
},
VolumeMountHelper::Tmpfs { guest, size_mib } => Self::Tmpfs { guest, size_mib },
})
}
}
impl std::fmt::Debug for VolumeMount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bind {
host,
guest,
readonly,
} => f
.debug_struct("Bind")
.field("host", host)
.field("guest", guest)
.field("readonly", readonly)
.finish(),
Self::Named {
name,
guest,
readonly,
} => f
.debug_struct("Named")
.field("name", name)
.field("guest", guest)
.field("readonly", readonly)
.finish(),
Self::Tmpfs { guest, size_mib } => f
.debug_struct("Tmpfs")
.field("guest", guest)
.field("size_mib", size_mib)
.finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_disk_image_format_from_extension() {
assert_eq!(
DiskImageFormat::from_extension("qcow2"),
Some(DiskImageFormat::Qcow2)
);
assert_eq!(
DiskImageFormat::from_extension("raw"),
Some(DiskImageFormat::Raw)
);
assert_eq!(
DiskImageFormat::from_extension("vmdk"),
Some(DiskImageFormat::Vmdk)
);
assert_eq!(DiskImageFormat::from_extension("ext4"), None);
assert_eq!(DiskImageFormat::from_extension(""), None);
}
#[test]
fn test_disk_image_format_display_roundtrip() {
for fmt in [
DiskImageFormat::Qcow2,
DiskImageFormat::Raw,
DiskImageFormat::Vmdk,
] {
let s = fmt.to_string();
let parsed: DiskImageFormat = s.parse().unwrap();
assert_eq!(parsed, fmt);
}
}
#[test]
fn test_disk_image_format_from_str_unknown() {
assert!("ext4".parse::<DiskImageFormat>().is_err());
}
#[test]
fn test_image_source_resolves_qcow2() {
let source = ImageSource::from("./disk.qcow2");
let rootfs = source.into_rootfs_source().unwrap();
match rootfs {
RootfsSource::DiskImage { format, .. } => assert_eq!(format, DiskImageFormat::Qcow2),
_ => panic!("expected DiskImage"),
}
}
#[test]
fn test_image_source_resolves_raw() {
let source = ImageSource::from("/images/test.raw");
let rootfs = source.into_rootfs_source().unwrap();
match rootfs {
RootfsSource::DiskImage { format, .. } => assert_eq!(format, DiskImageFormat::Raw),
_ => panic!("expected DiskImage"),
}
}
#[test]
fn test_image_source_resolves_directory_as_bind() {
let source = ImageSource::from("./rootfs");
let rootfs = source.into_rootfs_source().unwrap();
assert!(matches!(rootfs, RootfsSource::Bind(_)));
}
#[test]
fn test_image_source_resolves_oci_reference() {
let source = ImageSource::from("python");
let rootfs = source.into_rootfs_source().unwrap();
assert!(matches!(rootfs, RootfsSource::Oci(_)));
}
#[test]
fn test_image_builder_disk_with_fstype() {
let rootfs = ImageBuilder::new()
.disk("./test.qcow2")
.fstype("ext4")
.build()
.unwrap();
match rootfs {
RootfsSource::DiskImage { format, fstype, .. } => {
assert_eq!(format, DiskImageFormat::Qcow2);
assert_eq!(fstype.as_deref(), Some("ext4"));
}
_ => panic!("expected DiskImage"),
}
}
#[test]
fn test_image_builder_disk_without_fstype() {
let rootfs = ImageBuilder::new().disk("./test.raw").build().unwrap();
match rootfs {
RootfsSource::DiskImage { format, fstype, .. } => {
assert_eq!(format, DiskImageFormat::Raw);
assert_eq!(fstype, None);
}
_ => panic!("expected DiskImage"),
}
}
#[test]
fn test_image_builder_bad_extension_errors() {
let result = ImageBuilder::new().disk("./test.txt").build();
assert!(result.is_err());
}
#[test]
fn test_image_builder_fstype_without_disk_errors() {
let result = ImageBuilder::new().fstype("ext4").build();
assert!(result.is_err());
}
#[test]
fn test_image_builder_fstype_rejects_comma() {
let result = ImageBuilder::new()
.disk("./test.qcow2")
.fstype("ext4,size=100")
.build();
assert!(result.is_err());
}
#[test]
fn test_image_builder_fstype_rejects_equals() {
let result = ImageBuilder::new()
.disk("./test.qcow2")
.fstype("key=value")
.build();
assert!(result.is_err());
}
}