use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockFs {
Squashfs,
}
impl BlockFs {
pub fn as_str(&self) -> &'static str {
match self {
BlockFs::Squashfs => "squashfs",
}
}
}
impl std::fmt::Display for BlockFs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RootfsArtifact {
Directory {
path: PathBuf,
read_only: bool,
},
BlockImage {
path: PathBuf,
fstype: BlockFs,
},
}
#[derive(Debug, Error)]
pub enum ProviderError {
#[error("invalid spec: {0}")]
InvalidSpec(String),
#[error("offline mode: {0} not in cache")]
OfflineCacheMiss(String),
#[error("network: {0}")]
Network(String),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Other(String),
}
#[derive(Debug, Clone)]
pub struct Context {
cache_root: PathBuf,
offline: bool,
guest_helpers_dir: Option<PathBuf>,
}
impl Context {
pub fn new(cache_root: impl Into<PathBuf>) -> Self {
Self {
cache_root: cache_root.into(),
offline: false,
guest_helpers_dir: None,
}
}
pub fn offline(mut self, offline: bool) -> Self {
self.offline = offline;
self
}
pub fn guest_helpers_dir(mut self, dir: Option<PathBuf>) -> Self {
self.guest_helpers_dir = dir;
self
}
pub fn provider_cache(&self, provider_name: &str) -> Result<PathBuf, ProviderError> {
let dir = self.cache_root.join(provider_name);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn is_offline(&self) -> bool {
self.offline
}
pub fn guest_helpers(&self) -> Option<&Path> {
self.guest_helpers_dir.as_deref()
}
pub fn cache_root(&self) -> &Path {
&self.cache_root
}
}
pub trait RootfsProvider: Send + Sync {
fn name(&self) -> &'static str;
fn matches(&self, spec: &str) -> bool;
fn provide(&self, spec: &str, ctx: &Context) -> Result<RootfsArtifact, ProviderError>;
}
#[derive(Default)]
pub struct Registry {
providers: Vec<Box<dyn RootfsProvider>>,
}
impl Registry {
pub fn new() -> Self {
Self {
providers: Vec::new(),
}
}
pub fn with<P: RootfsProvider + 'static>(mut self, p: P) -> Self {
self.providers.push(Box::new(p));
self
}
pub fn resolve(&self, spec: &str, ctx: &Context) -> Result<RootfsArtifact, ProviderError> {
for p in &self.providers {
if p.matches(spec) {
return p.provide(spec, ctx);
}
}
let names: Vec<&str> = self.providers.iter().map(|p| p.name()).collect();
Err(ProviderError::InvalidSpec(format!(
"no provider claims {:?}; registered: [{}]",
spec,
names.join(", ")
)))
}
pub fn names(&self) -> Vec<&'static str> {
self.providers.iter().map(|p| p.name()).collect()
}
pub fn len(&self) -> usize {
self.providers.len()
}
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
}
pub fn inject_guest_helpers(rootfs: &Path, src_bin_dir: &Path) -> std::io::Result<()> {
let target_dir = rootfs.join("usr/local/bin");
std::fs::create_dir_all(&target_dir)?;
for name in &["vsock-send", "vsock-runner"] {
let src = src_bin_dir.join(name);
if !src.exists() {
tracing::warn!(name = name, "guest helper not found in source; skipping");
continue;
}
let dst = target_dir.join(name);
if let (Ok(s_meta), Ok(d_meta)) = (std::fs::metadata(&src), std::fs::metadata(&dst)) {
if s_meta.len() == d_meta.len() {
if let (Ok(s_mtime), Ok(d_mtime)) = (s_meta.modified(), d_meta.modified()) {
if d_mtime >= s_mtime {
continue;
}
}
}
}
std::fs::copy(&src, &dst)?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
#[derive(Debug, Default, Clone, Copy)]
pub struct DirProvider;
impl DirProvider {
pub fn new() -> Self {
Self
}
}
impl RootfsProvider for DirProvider {
fn name(&self) -> &'static str {
"dir"
}
fn matches(&self, spec: &str) -> bool {
if spec.starts_with('/')
|| spec.starts_with("./")
|| spec.starts_with("../")
|| spec.starts_with("~/")
|| spec == "."
|| spec == ".."
{
return true;
}
std::path::Path::new(spec).is_dir()
}
fn provide(&self, spec: &str, _ctx: &Context) -> Result<RootfsArtifact, ProviderError> {
let path = if let Some(rest) = spec.strip_prefix("~/") {
let home = std::env::var_os("HOME").ok_or_else(|| {
ProviderError::InvalidSpec("$HOME not set; cannot expand '~/'".into())
})?;
PathBuf::from(home).join(rest)
} else {
PathBuf::from(spec)
};
let meta = std::fs::metadata(&path)
.map_err(|e| ProviderError::InvalidSpec(format!("{}: {}", path.display(), e)))?;
if !meta.is_dir() {
return Err(ProviderError::InvalidSpec(format!(
"{} is not a directory",
path.display()
)));
}
Ok(RootfsArtifact::Directory {
path,
read_only: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dir_matches_path_like_specs() {
let p = DirProvider;
assert!(p.matches("/abs/path"));
assert!(p.matches("./rel"));
assert!(p.matches("../up"));
assert!(p.matches("~/home"));
assert!(p.matches("."));
assert!(p.matches(".."));
assert!(!p.matches("alpine:3.20"));
assert!(!p.matches("oci://alpine"));
assert!(!p.matches("tar+https://example.com/a.tgz"));
assert!(!p.matches(""));
}
#[test]
fn dir_matches_bare_relative_existing_dir() {
let p = DirProvider;
assert!(p.matches("src"));
assert!(!p.matches("definitely-not-a-dir-xyz"));
assert!(!p.matches("Cargo.toml"));
}
#[test]
fn dir_rejects_missing_paths() {
let ctx = Context::new("/tmp/vmette-test-cache");
let p = DirProvider;
let err = p
.provide("/definitely/does/not/exist/vmette", &ctx)
.unwrap_err();
assert!(matches!(err, ProviderError::InvalidSpec(_)));
}
#[test]
fn dir_rejects_files() {
let ctx = Context::new("/tmp/vmette-test-cache");
let p = DirProvider;
let err = p.provide("/etc/hosts", &ctx).unwrap_err();
match err {
ProviderError::InvalidSpec(m) => assert!(m.contains("not a directory")),
other => panic!("expected InvalidSpec, got {other:?}"),
}
}
#[test]
fn dir_accepts_real_directory() {
let ctx = Context::new("/tmp/vmette-test-cache");
let p = DirProvider;
let resolved = p.provide("/tmp", &ctx).expect("resolve /tmp");
match resolved {
RootfsArtifact::Directory { path, read_only } => {
assert_eq!(path, std::path::PathBuf::from("/tmp"));
assert!(path.is_dir());
assert!(!read_only);
}
other => panic!("expected Directory, got {other:?}"),
}
}
#[test]
fn registry_dispatches_first_match() {
fn dir_path(a: RootfsArtifact) -> PathBuf {
match a {
RootfsArtifact::Directory { path, .. } => path,
other => panic!("expected Directory, got {other:?}"),
}
}
struct A;
impl RootfsProvider for A {
fn name(&self) -> &'static str {
"a"
}
fn matches(&self, s: &str) -> bool {
s.starts_with("a:")
}
fn provide(&self, _: &str, _: &Context) -> Result<RootfsArtifact, ProviderError> {
Ok(RootfsArtifact::Directory {
path: PathBuf::from("/a"),
read_only: false,
})
}
}
struct B;
impl RootfsProvider for B {
fn name(&self) -> &'static str {
"b"
}
fn matches(&self, _: &str) -> bool {
true
}
fn provide(&self, _: &str, _: &Context) -> Result<RootfsArtifact, ProviderError> {
Ok(RootfsArtifact::Directory {
path: PathBuf::from("/b"),
read_only: false,
})
}
}
let reg = Registry::new().with(A).with(B);
let ctx = Context::new("/tmp");
assert_eq!(
dir_path(reg.resolve("a:x", &ctx).unwrap()),
PathBuf::from("/a")
);
assert_eq!(
dir_path(reg.resolve("z", &ctx).unwrap()),
PathBuf::from("/b")
);
assert_eq!(reg.names(), vec!["a", "b"]);
}
#[test]
fn registry_reports_missing_provider() {
let reg = Registry::new().with(DirProvider);
let ctx = Context::new("/tmp");
let err = reg.resolve("alpine:3.20", &ctx).unwrap_err();
match err {
ProviderError::InvalidSpec(m) => {
assert!(m.contains("alpine:3.20"));
assert!(m.contains("dir"));
}
other => panic!("expected InvalidSpec, got {other:?}"),
}
}
#[test]
fn context_provider_cache_creates_subdir() {
let tmp = std::env::temp_dir().join(format!("vmette-prov-test-{}", std::process::id()));
let ctx = Context::new(&tmp);
let sub = ctx.provider_cache("foo").unwrap();
assert!(sub.is_dir());
assert_eq!(sub, tmp.join("foo"));
let _ = std::fs::remove_dir_all(&tmp);
}
}