use mlua::{Lua, Table};
const DEFAULT_TIMEOUT_SECS: f64 = 1800.0;
fn validate_name(kind: &str, name: &str) -> mlua::Result<()> {
if name.is_empty() {
return Err(mlua::Error::runtime(format!(
"machinectl.{kind}: name must be non-empty"
)));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(mlua::Error::runtime(format!(
"machinectl.{kind}: name {name:?} must match [A-Za-z0-9._-]+"
)));
}
if name.starts_with('-') {
return Err(mlua::Error::runtime(format!(
"machinectl.{kind}: name {name:?} must not start with '-' (would be parsed as a flag)"
)));
}
Ok(())
}
fn validate_url(kind: &str, url: &str) -> mlua::Result<()> {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err(mlua::Error::runtime(format!(
"machinectl.{kind}: url must be http(s)://"
)));
}
if url.chars().any(|c| c.is_control()) {
return Err(mlua::Error::runtime(format!(
"machinectl.{kind}: url contains control characters"
)));
}
Ok(())
}
fn parse_timeout(opts: Option<&Table>, kind: &str) -> mlua::Result<std::time::Duration> {
let secs = match opts {
Some(t) => t
.get::<Option<f64>>("timeout")?
.unwrap_or(DEFAULT_TIMEOUT_SECS),
None => DEFAULT_TIMEOUT_SECS,
};
if !secs.is_finite() || secs <= 0.0 {
return Err(mlua::Error::runtime(format!(
"machinectl.{kind}: timeout must be a positive finite number"
)));
}
Ok(std::time::Duration::from_secs_f64(secs))
}
fn is_root() -> bool {
static IS_ROOT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*IS_ROOT.get_or_init(|| {
unsafe { libc::getuid() == 0 }
})
}
async fn run_machinectl(
args: Vec<&str>,
timeout: std::time::Duration,
op_label: &str,
) -> mlua::Result<(i32, Vec<u8>, Vec<u8>)> {
let mut cmd = if is_root() {
let mut c = tokio::process::Command::new("machinectl");
c.args(&args);
c
} else {
let mut c = tokio::process::Command::new("sudo");
c.arg("-n").arg("machinectl");
c.args(&args);
c
};
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| mlua::Error::runtime(format!("machinectl.{op_label}: spawn: {e}")))?;
let stdout_h = child.stdout.take();
let stderr_h = child.stderr.take();
let stdout_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut s) = stdout_h {
use tokio::io::AsyncReadExt;
let _ = s.read_to_end(&mut buf).await;
}
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut s) = stderr_h {
use tokio::io::AsyncReadExt;
let _ = s.read_to_end(&mut buf).await;
}
buf
});
match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => Ok((
status.code().unwrap_or(-1),
stdout_task.await.unwrap_or_default(),
stderr_task.await.unwrap_or_default(),
)),
Ok(Err(e)) => Err(mlua::Error::runtime(format!(
"machinectl.{op_label}: wait: {e}"
))),
Err(_elapsed) => {
let _ = child.start_kill();
let _ = child.wait().await;
stdout_task.abort();
stderr_task.abort();
Err(mlua::Error::runtime(format!(
"machinectl.{op_label}: timed out after {}s",
timeout.as_secs()
)))
}
}
}
fn build_result(lua: &Lua, status: i32, stdout: Vec<u8>, stderr: Vec<u8>) -> mlua::Result<Table> {
let r = lua.create_table()?;
r.set("status", status as i64)?;
r.set("stdout", String::from_utf8_lossy(&stdout).to_string())?;
r.set("stderr", String::from_utf8_lossy(&stderr).to_string())?;
Ok(r)
}
pub fn register_machinectl(lua: &Lua) -> mlua::Result<()> {
let t = lua.create_table()?;
t.set(
"pull_tar",
lua.create_async_function(|lua, args: mlua::MultiValue| async move {
let mut iter = args.into_iter();
let url: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.pull_tar: url required"))
.and_then(|v| lua.unpack(v))?;
let name: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.pull_tar: name required"))
.and_then(|v| lua.unpack(v))?;
let opts: Option<Table> = match iter.next() {
Some(mlua::Value::Table(t)) => Some(t),
_ => None,
};
validate_url("pull_tar", &url)?;
validate_name("pull_tar", &name)?;
let timeout = parse_timeout(opts.as_ref(), "pull_tar")?;
let verify: bool = opts
.as_ref()
.and_then(|t| t.get::<Option<bool>>("verify").ok().flatten())
.unwrap_or(false);
let mut argv: Vec<&str> = vec!["pull-tar"];
if !verify {
argv.push("--verify=no");
}
argv.push("--");
argv.push(&url);
argv.push(&name);
let (status, stdout, stderr) = run_machinectl(argv, timeout, "pull_tar").await?;
build_result(&lua, status, stdout, stderr)
})?,
)?;
t.set(
"pull_raw",
lua.create_async_function(|lua, args: mlua::MultiValue| async move {
let mut iter = args.into_iter();
let url: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.pull_raw: url required"))
.and_then(|v| lua.unpack(v))?;
let name: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.pull_raw: name required"))
.and_then(|v| lua.unpack(v))?;
let opts: Option<Table> = match iter.next() {
Some(mlua::Value::Table(t)) => Some(t),
_ => None,
};
validate_url("pull_raw", &url)?;
validate_name("pull_raw", &name)?;
let timeout = parse_timeout(opts.as_ref(), "pull_raw")?;
let verify: bool = opts
.as_ref()
.and_then(|t| t.get::<Option<bool>>("verify").ok().flatten())
.unwrap_or(false);
let mut argv: Vec<&str> = vec!["pull-raw"];
if !verify {
argv.push("--verify=no");
}
argv.push("--");
argv.push(&url);
argv.push(&name);
let (status, stdout, stderr) = run_machinectl(argv, timeout, "pull_raw").await?;
build_result(&lua, status, stdout, stderr)
})?,
)?;
t.set(
"remove",
lua.create_async_function(|lua, args: mlua::MultiValue| async move {
let mut iter = args.into_iter();
let name: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.remove: name required"))
.and_then(|v| lua.unpack(v))?;
let opts: Option<Table> = match iter.next() {
Some(mlua::Value::Table(t)) => Some(t),
_ => None,
};
validate_name("remove", &name)?;
let timeout = parse_timeout(opts.as_ref(), "remove")?;
let argv: Vec<&str> = vec!["remove", "--", &name];
let (status, stdout, stderr) = run_machinectl(argv, timeout, "remove").await?;
build_result(&lua, status, stdout, stderr)
})?,
)?;
t.set(
"clone",
lua.create_async_function(|lua, args: mlua::MultiValue| async move {
let mut iter = args.into_iter();
let src: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.clone: src required"))
.and_then(|v| lua.unpack(v))?;
let dst: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("machinectl.clone: dst required"))
.and_then(|v| lua.unpack(v))?;
let opts: Option<Table> = match iter.next() {
Some(mlua::Value::Table(t)) => Some(t),
_ => None,
};
validate_name("clone(src)", &src)?;
validate_name("clone(dst)", &dst)?;
let timeout = parse_timeout(opts.as_ref(), "clone")?;
let argv: Vec<&str> = vec!["clone", "--", &src, &dst];
let (status, stdout, stderr) = run_machinectl(argv, timeout, "clone").await?;
build_result(&lua, status, stdout, stderr)
})?,
)?;
lua.globals().set("machinectl", t)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_name_accepts_normal_names() {
validate_name("test", "apex").unwrap();
validate_name("test", "agent-x").unwrap();
validate_name("test", "node_01").unwrap();
validate_name("test", "host.local").unwrap();
}
#[test]
fn validate_name_rejects_empty() {
assert!(validate_name("test", "").is_err());
}
#[test]
fn validate_name_rejects_path_traversal() {
assert!(validate_name("test", "../../../etc/passwd").is_err());
assert!(validate_name("test", "name/with/slash").is_err());
}
#[test]
fn validate_name_rejects_shell_meta() {
assert!(validate_name("test", "name;rm -rf /").is_err());
assert!(validate_name("test", "$(whoami)").is_err());
assert!(validate_name("test", "name with space").is_err());
}
#[test]
fn validate_name_rejects_leading_dash() {
assert!(validate_name("test", "-rf").is_err());
}
#[test]
fn validate_url_accepts_http_and_https() {
validate_url("test", "https://example.com/image.tar.xz").unwrap();
validate_url("test", "http://example.com/image.raw").unwrap();
}
#[test]
fn validate_url_rejects_other_schemes() {
assert!(validate_url("test", "ftp://example.com/file").is_err());
assert!(validate_url("test", "file:///etc/passwd").is_err());
}
#[test]
fn validate_url_rejects_control_chars() {
assert!(validate_url("test", "https://example.com/\nfoo").is_err());
assert!(validate_url("test", "https://example.com/\rfoo").is_err());
}
}