//! 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::cache::RootfsCache; use ecrou::rootfs::PrepareRequest; use ecrou::vm::RunOptions; use ecrou::{LogLevel, NoProgress, Progress}; use cli::{parse_env, parse_volume, 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, cli.verbose), Command::Pull { image, no_cache } => cmd_pull(&image, no_cache, cli.verbose), Command::Probe => cmd_probe(), } } fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { // A tty drives the payload's output through the terminal while the // capture share points the same streams at files: one of them has // to lose, so the run refuses instead of picking. if args.tty && args.capture.is_some() { return fail(&anyhow::anyhow!( "--capture and --tty cannot be combined: the payload's output \ cannot land in the capture files and on the terminal" )); } let mut options = RunOptions { image: args.image.clone(), command: args.command.clone(), memory: args.memory.clone(), workdir: args.workdir.clone(), log_level: if args.quiet { LogLevel::Quiet } else if cli_verbose { LogLevel::Verbose } else { LogLevel::Normal }, interactive: args.interactive, tty: args.tty, no_cache: args.no_cache, capture: args.capture.clone(), ..RunOptions::default() }; 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.volumes { match parse_volume(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)), } } /// Pull an image into the cache without booting it. fn cmd_pull(image: &str, no_cache: bool, _cli_verbose: bool) -> std::process::ExitCode { let progress: &dyn Progress = &TermProgress::new(); let dir = match RootfsCache::default_dir() { Ok(dir) => dir, Err(err) => return fail(&anyhow::Error::new(err)), }; let cache = RootfsCache::new(dir); let mut request = PrepareRequest::new(image.to_owned()); request.no_cache = no_cache; match cache.pull(&request, progress) { Ok(pulled) => { if pulled.downloaded { println!("pulled {image}"); } else { println!("{image} is up to date"); } println!(" {}", pulled.cache_path.display()); std::process::ExitCode::SUCCESS } 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!("e2fsprogs: "); let mut tools_ok = true; for tool in ["mkfs.ext4", "debugfs"] { match std::process::Command::new(tool).arg("-V").output() { Ok(_) => {} Err(err) => { ok = false; tools_ok = false; println!("missing {tool} ({err}); the rootfs image cannot be built"); break; } } } if tools_ok { println!("ok"); } print!("cache: "); match ecrou::cache::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] fn traced_syscall_parses_strace_line_shapes() { assert_eq!( traced_syscall("1234 mount(\"/x\", \"/y\", 0) = 0"), Some("mount") ); assert_eq!( traced_syscall("[pid 1234] umount2(\"/x\", 0) = 0"), Some("umount2") ); assert_eq!( traced_syscall("1234 mount(\"/x\" "), Some("mount") ); // A resumed split half carries no call of its own: its '(' // was on the unfinished line above, which already named the // syscall for the audit. assert_eq!( traced_syscall("[pid 1234] <... mount resumed>: \"/x\") = 0"), None ); assert_eq!(traced_syscall("strace: Process 1234 attached"), None); assert_eq!(traced_syscall("++++ exited with 0 ++++"), None); } #[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); // The SPEC's bar is zero hits, not zero failures: a mount that // succeeded is as much a violation as one the snap's seccomp // allowlist would have denied. The trace contains only the // traced syscalls, so an exact name at the head of a line is a // hit; exact comparison also keeps `mount` from matching // `umount2`. Resumed-split lines (`<... mount resumed>`) carry // the name behind a `<... ` prefix. let denied: Vec<&str> = denied.split(',').collect(); for line in trace.lines() { let head = traced_syscall(line); if let Some(head) = head { if denied.contains(&head) { panic!("denied syscall in the trace: {line}"); } } } } /// The syscall name a strace output line traces, or None for /// chatter. Line shapes covered: `1234 mount(…)`, /// `[pid 1234] umount2(…)`, unfinished split halves, timestamps, /// and lines without a call (`++++ exited with 0 ++++`). The call /// is the last identifier-like token before the parenthesis; a /// pid or a timestamp never qualifies (digits alone or /// punctuation). A resumed split half has no parenthesis — the /// unfinished half that names the call always precedes it, so /// None loses the audit nothing. fn traced_syscall(line: &str) -> Option<&str> { let open = line.find('(')?; let tokens: Vec<&str> = line[..open].split_whitespace().collect(); let is_call = |t: &str| { !t.is_empty() && t.chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') }; tokens.iter().rev().find(|t| is_call(t)).copied() } }