mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
feat(cli): run and probe commands
run maps the flags onto RunOptions, promotes the first read-write share to the workspace, inherits stdio, and exits with the guest's code; probe reports what a run would find (KVM, libkrun loadability with the remediation, nested virt, cache writability) and fails when a run would. T6, the no-mount audit, lives here where the binary is built, ignored behind ECROU_BIN for CI.
This commit is contained in:
1 parent
348c4dd93c
commit
6a222d474d
5 files changed
+476
-7
No files matched your search
@@ -0,0 +1,126 @@
|
|||||||
|
//! Flag definitions and the small parsing helpers they need.
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "ecrou",
|
||||||
|
version,
|
||||||
|
about = "One-shot microVM environments on libkrun",
|
||||||
|
after_help = "Run 'ecrou run IMAGE -- COMMAND' to boot a machine for one command."
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Command,
|
||||||
|
/// Print diagnostic messages
|
||||||
|
#[arg(short, long, global = true)]
|
||||||
|
pub verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Command {
|
||||||
|
/// Run a command in a fresh microVM from an image
|
||||||
|
Run(RunArgs),
|
||||||
|
/// Report what a run would find on this host
|
||||||
|
Probe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct RunArgs {
|
||||||
|
/// Distribution name or OCI image reference
|
||||||
|
pub image: String,
|
||||||
|
/// Command to run in the machine; the image's default shell when omitted
|
||||||
|
#[arg(last = true)]
|
||||||
|
pub command: Vec<String>,
|
||||||
|
/// Directory to share into the machine as HOST:GUEST[:ro] (repeatable)
|
||||||
|
#[arg(long = "share")]
|
||||||
|
pub shares: Vec<String>,
|
||||||
|
/// Guest memory size
|
||||||
|
#[arg(short = 'm', long, default_value = "1G")]
|
||||||
|
pub memory: String,
|
||||||
|
/// Guest working directory
|
||||||
|
#[arg(long, default_value = "/")]
|
||||||
|
pub workdir: String,
|
||||||
|
/// Guest environment KEY=VALUE (repeatable)
|
||||||
|
#[arg(short = 'e', long = "env")]
|
||||||
|
pub env: Vec<String>,
|
||||||
|
/// Target architecture; the host architecture when omitted
|
||||||
|
#[arg(short = 'a', long)]
|
||||||
|
pub arch: Option<String>,
|
||||||
|
/// Suppress console noise
|
||||||
|
#[arg(long)]
|
||||||
|
pub quiet: bool,
|
||||||
|
/// Ignore the cache and download a fresh image
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_cache: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `HOST:GUEST[:ro]` share specification. The guest path must
|
||||||
|
/// be absolute; the host side is checked for existence at run time.
|
||||||
|
pub fn parse_share(spec: &str) -> anyhow::Result<ecrou::Share> {
|
||||||
|
let parts: Vec<&str> = spec.split(':').collect();
|
||||||
|
let (host, guest, ro) = match parts.as_slice() {
|
||||||
|
[h, g] => (*h, *g, false),
|
||||||
|
[h, g, "ro"] => (*h, *g, true),
|
||||||
|
[_, _, other] => {
|
||||||
|
anyhow::bail!("share suffix must be 'ro', got ':{}' in {:?}", other, spec)
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("share must be HOST:GUEST[:ro], got {:?}", spec),
|
||||||
|
};
|
||||||
|
if host.is_empty() || guest.is_empty() {
|
||||||
|
anyhow::bail!("share must be HOST:GUEST[:ro], got {:?}", spec);
|
||||||
|
}
|
||||||
|
if !guest.starts_with('/') {
|
||||||
|
anyhow::bail!("share guest path must be absolute, got {:?}", guest);
|
||||||
|
}
|
||||||
|
Ok(ecrou::Share {
|
||||||
|
host: host.into(),
|
||||||
|
guest: guest.to_string(),
|
||||||
|
read_only: ro,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `KEY=VALUE` environment pair.
|
||||||
|
pub fn parse_env(spec: &str) -> anyhow::Result<(String, String)> {
|
||||||
|
let (k, v) = spec
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("environment must be KEY=VALUE, got {:?}", spec))?;
|
||||||
|
if k.is_empty() {
|
||||||
|
anyhow::bail!("environment key is empty: {:?}", spec);
|
||||||
|
}
|
||||||
|
Ok((k.to_string(), v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shares_parse_rw_and_ro() {
|
||||||
|
let s = parse_share("/home/me/work:/workspace").unwrap();
|
||||||
|
assert_eq!(s.host, std::path::Path::new("/home/me/work"));
|
||||||
|
assert_eq!(s.guest, "/workspace");
|
||||||
|
assert!(!s.read_only);
|
||||||
|
|
||||||
|
let s = parse_share("/skills:/skills:ro").unwrap();
|
||||||
|
assert!(s.read_only);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shares_reject_malformed_specs() {
|
||||||
|
assert!(parse_share("/only-host").is_err());
|
||||||
|
assert!(parse_share(":/guest").is_err());
|
||||||
|
assert!(parse_share("/host:relative").is_err());
|
||||||
|
assert!(parse_share("/h:/g:rw").is_err());
|
||||||
|
assert!(parse_share("/h:/g:ro:extra").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_pairs_split_on_the_first_equals() {
|
||||||
|
let (k, v) = parse_env("KEY=value=with=equals").unwrap();
|
||||||
|
assert_eq!(k, "KEY");
|
||||||
|
assert_eq!(v, "value=with=equals");
|
||||||
|
assert!(parse_env("NOEQUALS").is_err());
|
||||||
|
assert!(parse_env("=novalue").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! stderr logging sink: `Warn`/`Error` records always, `Debug`
|
||||||
|
//! diagnostics only with -v.
|
||||||
|
|
||||||
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
|
|
||||||
|
struct CliLogger {
|
||||||
|
max: LevelFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Log for CliLogger {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
if metadata.level() > self.max {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match metadata.level() {
|
||||||
|
Level::Warn | Level::Error => true,
|
||||||
|
// Dependencies (hyper, rustls, …) share the log facade and are
|
||||||
|
// far noisier than we are; -v follows only our own diagnostics.
|
||||||
|
_ => metadata.target().starts_with("ecrou"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
if !self.enabled(record.metadata()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match record.level() {
|
||||||
|
Level::Error => eprintln!("Error: {}", record.args()),
|
||||||
|
Level::Warn => eprintln!("Warning: {}", record.args()),
|
||||||
|
_ => eprintln!("{}", record.args()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the CLI logger: warnings and errors always, `ecrou` debug
|
||||||
|
/// diagnostics only when `verbose` (-v).
|
||||||
|
pub fn init(verbose: bool) {
|
||||||
|
let max = if verbose {
|
||||||
|
LevelFilter::Debug
|
||||||
|
} else {
|
||||||
|
LevelFilter::Warn
|
||||||
|
};
|
||||||
|
log::set_boxed_logger(Box::new(CliLogger { max })).ok();
|
||||||
|
log::set_max_level(max);
|
||||||
|
}
|
||||||
@@ -1 +1,184 @@
|
|||||||
fn main() {}
|
//! The ecrou CLI: flags onto the library API, exit codes out. All
|
||||||
|
//! terminal output lives here; the library stays silent behind `log`
|
||||||
|
//! and the progress sink.
|
||||||
|
|
||||||
|
mod cli;
|
||||||
|
mod logger;
|
||||||
|
mod progress;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use ecrou::vm::RunOptions;
|
||||||
|
use ecrou::{NoProgress, Progress};
|
||||||
|
|
||||||
|
use cli::{parse_env, parse_share, Cli, Command, RunArgs};
|
||||||
|
use progress::TermProgress;
|
||||||
|
|
||||||
|
fn main() -> std::process::ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
logger::init(cli.verbose);
|
||||||
|
match cli.command {
|
||||||
|
Command::Run(args) => cmd_run(&args),
|
||||||
|
Command::Probe => cmd_probe(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_run(args: &RunArgs) -> std::process::ExitCode {
|
||||||
|
let mut options = RunOptions {
|
||||||
|
image: args.image.clone(),
|
||||||
|
memory: args.memory.clone(),
|
||||||
|
workdir: args.workdir.clone(),
|
||||||
|
quiet: args.quiet,
|
||||||
|
no_cache: args.no_cache,
|
||||||
|
..RunOptions::default()
|
||||||
|
};
|
||||||
|
if let Some(arch) = &args.arch {
|
||||||
|
options.arch = arch.clone();
|
||||||
|
}
|
||||||
|
for spec in &args.env {
|
||||||
|
match parse_env(spec) {
|
||||||
|
Ok((k, v)) => options.env.push((k, v)),
|
||||||
|
Err(err) => return fail(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut shares = Vec::new();
|
||||||
|
for spec in &args.shares {
|
||||||
|
match parse_share(spec) {
|
||||||
|
Ok(share) => shares.push(share),
|
||||||
|
Err(err) => return fail(&err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The first read-write share is the workspace (its guest path rides
|
||||||
|
// into the guest as ECROU_WORKSPACE); everything else is a plain
|
||||||
|
// share.
|
||||||
|
if let Some(pos) = shares.iter().position(|s| !s.read_only) {
|
||||||
|
options.workspace = Some(shares.remove(pos));
|
||||||
|
}
|
||||||
|
options.shares = shares;
|
||||||
|
|
||||||
|
let progress: &dyn Progress = if args.quiet {
|
||||||
|
&NoProgress
|
||||||
|
} else {
|
||||||
|
&TermProgress::new()
|
||||||
|
};
|
||||||
|
match ecrou::vm::run(&options, progress) {
|
||||||
|
Ok(code) => std::process::ExitCode::from(code.clamp(0, 255) as u8),
|
||||||
|
Err(err) => fail(&anyhow::Error::new(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_probe() -> std::process::ExitCode {
|
||||||
|
use ecrou::krun;
|
||||||
|
|
||||||
|
let mut ok = true;
|
||||||
|
|
||||||
|
print!("kvm: ");
|
||||||
|
match krun::kvm_probe() {
|
||||||
|
Ok(()) => println!("ok"),
|
||||||
|
Err(err) => {
|
||||||
|
ok = false;
|
||||||
|
println!("unavailable\n {:#}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("libkrun: ");
|
||||||
|
match krun::Krun::load() {
|
||||||
|
Ok(krun) => {
|
||||||
|
println!("ok");
|
||||||
|
print!("nested virt: ");
|
||||||
|
match krun.check_nested_virt() {
|
||||||
|
Ok(true) => println!("available"),
|
||||||
|
Ok(false) => println!("not available (only matters for nested runs)"),
|
||||||
|
Err(err) => println!("unknown ({err:#})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
ok = false;
|
||||||
|
println!("not loadable\n {:#}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("cache: ");
|
||||||
|
match ecrou::rootfs::RootfsCache::default_dir() {
|
||||||
|
Ok(dir) => {
|
||||||
|
// The run flow creates the cache on demand; probe mirrors it.
|
||||||
|
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||||
|
ok = false;
|
||||||
|
println!("{} not creatable ({})", dir.display(), err);
|
||||||
|
return probe_done(ok);
|
||||||
|
}
|
||||||
|
let probe = dir.join(".ecrou-probe");
|
||||||
|
match std::fs::write(&probe, b"") {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = std::fs::remove_file(&probe);
|
||||||
|
println!("{} (writable)", dir.display());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
ok = false;
|
||||||
|
println!("{} not writable ({})", dir.display(), err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
ok = false;
|
||||||
|
println!("unavailable ({err:#})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_done(ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_done(ok: bool) -> std::process::ExitCode {
|
||||||
|
if ok {
|
||||||
|
std::process::ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(err: &anyhow::Error) -> std::process::ExitCode {
|
||||||
|
eprintln!("Error: {err:#}");
|
||||||
|
std::process::ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T6, the founding rule as a test: strace a full run and fail on any
|
||||||
|
/// denied syscall. Needs the built binary, libkrun, and strace, so it
|
||||||
|
/// runs in CI (`cargo test -- --ignored`).
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs the ecrou binary, libkrun, and strace; runs in CI"]
|
||||||
|
fn t6_no_mount_or_namespace_syscalls() {
|
||||||
|
let denied = "mount,umount2,unshare,setns,pivot_root,open_tree,move_mount,fsopen,fsmount";
|
||||||
|
// CARGO_BIN_EXE_* exists only for integration tests, which the
|
||||||
|
// conventions forbid; CI points ECROU_BIN at the built binary.
|
||||||
|
let exe = std::env::var("ECROU_BIN")
|
||||||
|
.expect("T6 needs ECROU_BIN pointing at the built ecrou binary");
|
||||||
|
let trace_path = std::env::temp_dir().join("ecrou-t6.strace");
|
||||||
|
let out = std::process::Command::new("strace")
|
||||||
|
.args([
|
||||||
|
"-f",
|
||||||
|
"-e",
|
||||||
|
&format!("trace={denied}"),
|
||||||
|
"-o",
|
||||||
|
trace_path.to_str().unwrap(),
|
||||||
|
&exe,
|
||||||
|
"run",
|
||||||
|
"alpine:3.23",
|
||||||
|
"--",
|
||||||
|
"true",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.expect("strace run");
|
||||||
|
assert!(out.success(), "the traced run failed; see the strace log");
|
||||||
|
let trace = std::fs::read_to_string(&trace_path).unwrap();
|
||||||
|
let _ = std::fs::remove_file(&trace_path);
|
||||||
|
for line in trace.lines() {
|
||||||
|
let denied_call = denied.split(',').any(|syscall| line.contains(syscall));
|
||||||
|
let failed = line.contains("= -1 EPERM") || line.contains("= -1 ENOSYS");
|
||||||
|
assert!(
|
||||||
|
!(denied_call && failed),
|
||||||
|
"denied syscall in the trace: {line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! Terminal rendering of library progress events (indicatif-backed).
|
||||||
|
//!
|
||||||
|
//! This is CLI-owned UI: the library only emits [`Progress`] events,
|
||||||
|
//! and everything drawn on the terminal lives here. indicatif hides
|
||||||
|
//! all output when stderr is not a terminal.
|
||||||
|
|
||||||
|
use ecrou::progress::{Progress, Unit};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Renders sequential library operations as bars and spinners on stderr.
|
||||||
|
pub struct TermProgress {
|
||||||
|
bar: Mutex<Option<ProgressBar>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TermProgress {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
TermProgress {
|
||||||
|
bar: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TermProgress {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TermProgress {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Progress for TermProgress {
|
||||||
|
fn begin(&self, label: &str, unit: Unit, total: Option<u64>) {
|
||||||
|
let mut slot = self.bar.lock().unwrap();
|
||||||
|
// The library's `begin` implicitly ends the previous operation.
|
||||||
|
if let Some(prev) = slot.take() {
|
||||||
|
prev.finish_and_clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let bar = match (unit, total) {
|
||||||
|
(Unit::Bytes, Some(total)) => {
|
||||||
|
let pb = ProgressBar::new(total);
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {msg} {bytes}/{total_bytes} ({eta})")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("#>-"),
|
||||||
|
);
|
||||||
|
pb.set_message(label.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
(Unit::Bytes, None) => {
|
||||||
|
let pb = ProgressBar::new_spinner();
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_spinner()
|
||||||
|
.template("{spinner:.green} {msg} ({bytes})")
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
pb.set_message(label.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
(Unit::Files, _) => {
|
||||||
|
let pb = ProgressBar::new_spinner();
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_spinner()
|
||||||
|
.template("{spinner:.green} {msg} ({pos} files)")
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
pb.set_message(label.to_string());
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*slot = Some(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&self, n: u64) {
|
||||||
|
if let Some(bar) = &*self.bar.lock().unwrap() {
|
||||||
|
bar.inc(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self) {
|
||||||
|
if let Some(bar) = self.bar.lock().unwrap().take() {
|
||||||
|
bar.finish_and_clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,12 @@ pub fn sh_quote(s: &str) -> String {
|
|||||||
/// Render the guest init. The payload runs as a child (never `exec`):
|
/// Render the guest init. The payload runs as a child (never `exec`):
|
||||||
/// the init must outlive it to write the exit code and power the
|
/// the init must outlive it to write the exit code and power the
|
||||||
/// machine off.
|
/// machine off.
|
||||||
pub fn init_script(shares: &[GuestShare], payload: &[String], workdir: &str) -> String {
|
pub fn init_script(
|
||||||
|
shares: &[GuestShare],
|
||||||
|
payload: &[String],
|
||||||
|
workdir: &str,
|
||||||
|
hostname: &str,
|
||||||
|
) -> String {
|
||||||
let mut script = String::from(
|
let mut script = String::from(
|
||||||
"#!/bin/sh\n\
|
"#!/bin/sh\n\
|
||||||
# ecrou guest init: mount the shares, run the payload, report its\n\
|
# ecrou guest init: mount the shares, run the payload, report its\n\
|
||||||
@@ -54,8 +59,18 @@ pub fn init_script(shares: &[GuestShare], payload: &[String], workdir: &str) ->
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best effort: some rootfs trees ship their own /etc/hostname.
|
||||||
script.push_str(&format!(
|
script.push_str(&format!(
|
||||||
"\ncd {} || exit 126\n\n",
|
"echo {} > /proc/sys/kernel/hostname 2>/dev/null || true\n",
|
||||||
|
sh_quote(hostname)
|
||||||
|
));
|
||||||
|
script.push_str(&format!(
|
||||||
|
"echo {} > /etc/hostname 2>/dev/null || true\n\n",
|
||||||
|
sh_quote(hostname)
|
||||||
|
));
|
||||||
|
|
||||||
|
script.push_str(&format!(
|
||||||
|
"cd {} || exit 126\n\n",
|
||||||
sh_quote(if workdir.is_empty() { "/" } else { workdir })
|
sh_quote(if workdir.is_empty() { "/" } else { workdir })
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -98,7 +113,7 @@ mod tests {
|
|||||||
read_only: true,
|
read_only: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let script = init_script(&shares, &["true".to_string()], "/");
|
let script = init_script(&shares, &["true".to_string()], "/", "ecrou-vm-x");
|
||||||
assert!(script.contains("mount_share 'ws0' '/workspace' 'rw'"));
|
assert!(script.contains("mount_share 'ws0' '/workspace' 'rw'"));
|
||||||
assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'"));
|
assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'"));
|
||||||
}
|
}
|
||||||
@@ -110,7 +125,7 @@ mod tests {
|
|||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
"echo $HOME; rm -rf '/'".to_string(),
|
"echo $HOME; rm -rf '/'".to_string(),
|
||||||
];
|
];
|
||||||
let script = init_script(&[], &payload, "/");
|
let script = init_script(&[], &payload, "/", "ecrou-vm-x");
|
||||||
assert!(script.contains(r"'/bin/bash' '-c' 'echo $HOME; rm -rf '\''/'\''' "));
|
assert!(script.contains(r"'/bin/bash' '-c' 'echo $HOME; rm -rf '\''/'\''' "));
|
||||||
// The payload line must appear after the cd and before the code
|
// The payload line must appear after the cd and before the code
|
||||||
// capture, so its exit code is what gets written.
|
// capture, so its exit code is what gets written.
|
||||||
@@ -122,7 +137,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn writes_exit_code_and_powers_off() {
|
fn writes_exit_code_and_powers_off() {
|
||||||
let script = init_script(&[], &["true".to_string()], "/work");
|
let script = init_script(&[], &["true".to_string()], "/work", "ecrou-vm-x");
|
||||||
assert!(script.contains(&format!("echo \"$code\" > '{}'", EXIT_FILE)));
|
assert!(script.contains(&format!("echo \"$code\" > '{}'", EXIT_FILE)));
|
||||||
assert!(script.contains("echo o > /proc/sysrq-trigger"));
|
assert!(script.contains("echo o > /proc/sysrq-trigger"));
|
||||||
assert!(script.contains("exit \"$code\""));
|
assert!(script.contains("exit \"$code\""));
|
||||||
@@ -130,9 +145,16 @@ mod tests {
|
|||||||
assert!(!script.contains("exec "));
|
assert!(!script.contains("exec "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_lands_in_proc_and_etc() {
|
||||||
|
let script = init_script(&[], &["true".to_string()], "/", "ecrou-vm-x");
|
||||||
|
assert!(script.contains("echo 'ecrou-vm-x' > /proc/sys/kernel/hostname"));
|
||||||
|
assert!(script.contains("echo 'ecrou-vm-x' > /etc/hostname"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_workdir_falls_back_to_root() {
|
fn empty_workdir_falls_back_to_root() {
|
||||||
let script = init_script(&[], &["true".to_string()], "");
|
let script = init_script(&[], &["true".to_string()], "", "ecrou-vm-x");
|
||||||
assert!(script.contains("cd '/'"));
|
assert!(script.contains("cd '/'"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user