diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88ffc27..13aac31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Run example shell: bash run: just run-guest + - name: Test `new` subcommand + shell: bash + run: just test-new spelling: name: Spell check with typos diff --git a/justfile b/justfile index efbc9f5..c87396f 100644 --- a/justfile +++ b/justfile @@ -7,11 +7,15 @@ fmt: cargo +nightly fmt --all -- --check cargo +nightly fmt --all --manifest-path ./examples/host/Cargo.toml -- --check cargo +nightly fmt --all --manifest-path ./examples/guest/Cargo.toml -- --check + # These are standalone template files not part of any crate, so cargo fmt wont find them. + rustfmt +nightly --check ./src/new/guest/_main.rs ./src/new/host/_main.rs fmt-apply: cargo +nightly fmt --all cargo +nightly fmt --all --manifest-path ./examples/host/Cargo.toml cargo +nightly fmt --all --manifest-path ./examples/guest/Cargo.toml + # These are standalone template files not part of any crate, so cargo fmt wont find them. + rustfmt +nightly ./src/new/guest/_main.rs ./src/new/host/_main.rs clippy: cargo clippy --all -- -D warnings @@ -22,4 +26,7 @@ build-guest: cargo hyperlight build --manifest-path ./examples/guest/Cargo.toml run-guest: build-guest - cargo run --manifest-path ./examples/host/Cargo.toml -- ./target/x86_64-hyperlight-none/debug/guest \ No newline at end of file + cargo run --manifest-path ./examples/host/Cargo.toml -- ./target/x86_64-hyperlight-none/debug/guest + +test-new: + cargo test --test new diff --git a/src/main.rs b/src/main.rs index 0fdff8a..e89babc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::env; use cargo_hyperlight::cargo; +mod new; mod perf; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -26,6 +27,12 @@ fn main() { std::process::exit(1); } } + Some(a) if a == "new" => { + if let Err(e) = new::run(args) { + eprintln!("{e:?}"); + std::process::exit(1); + } + } _ => { cargo() .expect("Failed to create cargo command") diff --git a/src/new/_gitignore b/src/new/_gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/src/new/_gitignore @@ -0,0 +1 @@ +target diff --git a/src/new/guest/_Cargo.toml b/src/new/guest/_Cargo.toml new file mode 100644 index 0000000..da399b5 --- /dev/null +++ b/src/new/guest/_Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "{name}" +version = "0.1.0" +edition = "2024" + +[dependencies] +hyperlight-guest-bin = "{version}" diff --git a/src/new/guest/_main.rs b/src/new/guest/_main.rs new file mode 100644 index 0000000..3cf5784 --- /dev/null +++ b/src/new/guest/_main.rs @@ -0,0 +1,40 @@ +#![no_std] +#![no_main] +extern crate alloc; + +use alloc::string::String; +use core::sync::atomic::{AtomicI32, Ordering}; + +use hyperlight_guest_bin::error::Result; +use hyperlight_guest_bin::{guest_function, host_function}; + +static COUNTER: AtomicI32 = AtomicI32::new(0); + +// Declare a host function that the guest can call. The string is the +// registration name (must match what the host passes to register()). +// If omitted, the Rust function name is used. +// The host must register this before the sandbox is initialized. +#[host_function("GetWeekday")] +fn get_weekday() -> Result; + +// Register a guest function that can be called by the host. +#[guest_function("SayHello")] +fn say_hello(name: String) -> Result { + let weekday = get_weekday()?; + Ok(alloc::format!("Hello, {name}! Today is {weekday}.")) +} + +// Guest functions can take multiple arguments of different types. +#[guest_function("Add")] +fn add(a: i32, b: i32) -> Result { + Ok(a + b) +} + +// Increments a counter and returns the new value. State persists across +// calls until the host restores a snapshot, which resets all VM memory +// back to the state it was in when the snapshot was taken. +#[guest_function("Increment")] +fn increment() -> Result { + let old = COUNTER.fetch_add(1, Ordering::Relaxed); + Ok(old + 1) +} diff --git a/src/new/host/_Cargo.toml b/src/new/host/_Cargo.toml new file mode 100644 index 0000000..3b7fe25 --- /dev/null +++ b/src/new/host/_Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "{name}" +version = "0.1.0" +edition = "2024" + +[dependencies] +hyperlight-host = "{version}" diff --git a/src/new/host/_main.rs b/src/new/host/_main.rs new file mode 100644 index 0000000..7b3b79a --- /dev/null +++ b/src/new/host/_main.rs @@ -0,0 +1,49 @@ +use hyperlight_host::{GuestBinary, MultiUseSandbox, UninitializedSandbox}; + +fn main() -> hyperlight_host::Result<()> { + // Create a sandbox from the guest binary. It starts uninitialized so you + // can register host functions before the guest begins executing. + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath("../guest/target/{arch}-hyperlight-none/debug/{guest_name}".into()), + None, + )?; + + // Register a host function that the guest can call. + sandbox.register("GetWeekday", weekday)?; + + // Evolve into a MultiUseSandbox, which lets you call guest functions + // multiple times. + let mut sandbox: MultiUseSandbox = sandbox.evolve()?; + + // Call a guest function with a single argument. + let result: String = sandbox.call("SayHello", "World".to_string())?; + println!("{result}"); + + // Multiple arguments are passed as a tuple. + let sum: i32 = sandbox.call("Add", (2_i32, 3_i32))?; + println!("2 + 3 = {sum}"); + + // Guest state persists between calls. Take a snapshot so we can + // restore back to this point later. + let snapshot = sandbox.snapshot()?; + + let count: i32 = sandbox.call("Increment", ())?; + println!("count = {count}"); // 1 + let count: i32 = sandbox.call("Increment", ())?; + println!("count = {count}"); // 2 + let count: i32 = sandbox.call("Increment", ())?; + println!("count = {count}"); // 3 + + // Restore resets all guest memory back to the snapshot. + sandbox.restore(snapshot)?; + + let count: i32 = sandbox.call("Increment", ())?; + println!("count after restore = {count}"); // 1 again + + Ok(()) +} + +fn weekday() -> hyperlight_host::Result { + // It's always Monday here, sorry Garfield! + Ok("Monday".to_string()) +} diff --git a/src/new/mod.rs b/src/new/mod.rs new file mode 100644 index 0000000..f46d595 --- /dev/null +++ b/src/new/mod.rs @@ -0,0 +1,154 @@ +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use clap::Parser; + +const HYPERLIGHT_VERSION: &str = "0.15"; +// TODO: support aarch64-hyperlight-none when aarch64 guests are supported. +const GUEST_ARCH: &str = "x86_64"; + +const GUEST_CARGO_TOML: &str = include_str!("guest/_Cargo.toml"); +const GUEST_MAIN_RS: &str = include_str!("guest/_main.rs"); +const HOST_CARGO_TOML: &str = include_str!("host/_Cargo.toml"); +const HOST_MAIN_RS: &str = include_str!("host/_main.rs"); +const GITIGNORE: &str = include_str!("_gitignore"); + +/// Create a new Hyperlight project. +#[derive(Parser, Debug)] +#[command(name = "new")] +struct NewCli { + /// Path to create the project at. The directory name is used as the crate + /// name (like `cargo new`). + path: PathBuf, + + /// Skip generating the host crate. + #[arg(long, default_value_t = false)] + no_host: bool, + + /// Skip generating the guest crate. + #[arg(long, default_value_t = false, conflicts_with = "no_host")] + no_guest: bool, +} + +pub fn run(args: impl Iterator) -> Result<()> { + let cli = NewCli::parse_from(args); + + let name = cli + .path + .file_name() + .context("Invalid project path")? + .to_str() + .context("Project name must be valid UTF-8")?; + + validate_name(name)?; + ensure!( + !cli.path.exists(), + "Directory '{}' already exists", + cli.path.display() + ); + + match (cli.no_host, cli.no_guest) { + (true, false) => { + write_guest(&cli.path, name)?; + } + (false, true) => { + write_host(&cli.path, name, &format!("{name}-guest"))?; + } + (false, false) => { + let guest_name = format!("{name}-guest"); + write_guest(&cli.path.join("guest"), &guest_name)?; + write_host(&cli.path.join("host"), &format!("{name}-host"), &guest_name)?; + } + (true, true) => unreachable!("clap rejects --no-host and --no-guest together"), + } + write_file(cli.path.join(".gitignore"), GITIGNORE)?; + + let dir = cli.path.display(); + println!("Created project at '{dir}'\n"); + match (cli.no_host, cli.no_guest) { + (true, false) => { + println!("Build:"); + println!(" cd {dir} && cargo hyperlight build"); + } + (false, true) => { + println!("Build:"); + println!(" cd {dir} && cargo build"); + } + (false, false) => { + println!("Build and run:"); + println!(" cd {dir}/guest && cargo hyperlight build"); + println!(" cd {dir}/host && cargo run"); + } + (true, true) => unreachable!(), + } + + Ok(()) +} + +fn write_guest(dir: &Path, name: &str) -> Result<()> { + let cargo_toml = GUEST_CARGO_TOML + .replace("{name}", name) + .replace("{version}", HYPERLIGHT_VERSION); + write_file(dir.join("Cargo.toml"), &cargo_toml)?; + write_file(dir.join("src/main.rs"), GUEST_MAIN_RS)?; + Ok(()) +} + +fn write_host(dir: &Path, name: &str, guest_name: &str) -> Result<()> { + let cargo_toml = HOST_CARGO_TOML + .replace("{name}", name) + .replace("{version}", HYPERLIGHT_VERSION); + let main_rs = HOST_MAIN_RS + .replace("{name}", name) + .replace("{guest_name}", guest_name) + .replace("{arch}", GUEST_ARCH); + write_file(dir.join("Cargo.toml"), &cargo_toml)?; + write_file(dir.join("src/main.rs"), &main_rs)?; + Ok(()) +} + +fn write_file(path: impl AsRef, content: &str) -> Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory '{}'", parent.display()))?; + } + fs::write(path, content).with_context(|| format!("Failed to write '{}'", path.display()))?; + Ok(()) +} + +/// Validate that the name is usable as a Cargo package name. +/// Mirrors the essential checks from `cargo new`. +fn validate_name(name: &str) -> Result<()> { + ensure!(!name.is_empty(), "project name must not be empty"); + ensure!( + name.chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'), + "invalid project name `{name}`: must contain only letters, numbers, `-`, or `_`" + ); + ensure!( + name.chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_'), + "invalid project name `{name}`: must start with a letter or `_`" + ); + let reserved = [ + "test", + "core", + "std", + "alloc", + "proc_macro", + "proc-macro", + "self", + "Self", + "crate", + "super", + ]; + ensure!( + !reserved.contains(&name), + "invalid project name `{name}`: it conflicts with a Rust built-in name" + ); + Ok(()) +} diff --git a/tests/new.rs b/tests/new.rs new file mode 100644 index 0000000..e02870e --- /dev/null +++ b/tests/new.rs @@ -0,0 +1,165 @@ +use std::process::Command; + +/// Invokes cargo-hyperlight from the current workspace via `cargo run`. +fn cargo_hyperlight() -> Command { + let mut cmd = Command::new(env!("CARGO")); + cmd.args(["run", "--quiet", "--"]); + cmd +} + +/// Cargo command for generated projects. Removes CARGO_TARGET_DIR so each +/// project uses its own target/, matching how users actually run the commands. +fn cargo() -> Command { + let mut cmd = Command::new(env!("CARGO")); + cmd.env_remove("CARGO_TARGET_DIR"); + cmd +} + +fn run(cmd: &mut Command) -> String { + let output = cmd.output().expect("failed to execute command"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + panic!( + "command failed: {:?}\nstdout: {stdout}\nstderr: {stderr}", + cmd.get_program() + ); + } + String::from_utf8(output.stdout).expect("non-utf8 output") +} + +#[test] +fn new_host_and_guest() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("myproject"); + + run(cargo_hyperlight().arg("new").arg(&project)); + + assert!(project.join("guest/Cargo.toml").exists()); + assert!(project.join("guest/src/main.rs").exists()); + assert!(project.join("host/Cargo.toml").exists()); + assert!(project.join("host/src/main.rs").exists()); + assert!(project.join(".gitignore").exists()); + + let guest_toml = std::fs::read_to_string(project.join("guest/Cargo.toml")).unwrap(); + assert!(guest_toml.contains("name = \"myproject-guest\"")); + let host_toml = std::fs::read_to_string(project.join("host/Cargo.toml")).unwrap(); + assert!(host_toml.contains("name = \"myproject-host\"")); + + // Clippy + run(cargo() + .args(["hyperlight", "clippy", "--all", "--manifest-path"]) + .arg(project.join("guest/Cargo.toml")) + .args(["--", "-D", "warnings"])); + run(cargo() + .args(["clippy", "--all", "--manifest-path"]) + .arg(project.join("host/Cargo.toml")) + .args(["--", "-D", "warnings"])); + + // Build + run(cargo() + .args(["hyperlight", "build", "--manifest-path"]) + .arg(project.join("guest/Cargo.toml"))); + run(cargo() + .args(["build", "--manifest-path"]) + .arg(project.join("host/Cargo.toml"))); + + // Run and check output + let output = run(cargo() + .args(["run", "--quiet", "--manifest-path"]) + .arg(project.join("host/Cargo.toml")) + .current_dir(project.join("host"))); + + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "Hello, World! Today is Monday."); + assert_eq!(lines[1], "2 + 3 = 5"); + assert_eq!(lines[2], "count = 1"); + assert_eq!(lines[3], "count = 2"); + assert_eq!(lines[4], "count = 3"); + assert_eq!(lines[5], "count after restore = 1"); + assert_eq!(lines.len(), 6); +} + +#[test] +fn new_guest_only() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("myguest"); + + run(cargo_hyperlight().arg("new").arg("--no-host").arg(&project)); + + assert!(project.join("Cargo.toml").exists()); + assert!(project.join("src/main.rs").exists()); + assert!(project.join(".gitignore").exists()); + assert!(!project.join("host").exists()); + + let toml = std::fs::read_to_string(project.join("Cargo.toml")).unwrap(); + assert!(toml.contains("name = \"myguest\"")); + + // Clippy + run(cargo() + .args(["hyperlight", "clippy", "--all", "--manifest-path"]) + .arg(project.join("Cargo.toml")) + .args(["--", "-D", "warnings"])); + + // Build + run(cargo() + .args(["hyperlight", "build", "--manifest-path"]) + .arg(project.join("Cargo.toml"))); +} + +#[test] +fn new_host_only() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("myhost"); + + run(cargo_hyperlight() + .arg("new") + .arg("--no-guest") + .arg(&project)); + + assert!(project.join("Cargo.toml").exists()); + assert!(project.join("src/main.rs").exists()); + assert!(project.join(".gitignore").exists()); + assert!(!project.join("guest").exists()); + + let toml = std::fs::read_to_string(project.join("Cargo.toml")).unwrap(); + assert!(toml.contains("name = \"myhost\"")); + + // Clippy and build (running it would fail since there's no guest binary). + run(cargo() + .args(["clippy", "--all", "--manifest-path"]) + .arg(project.join("Cargo.toml")) + .args(["--", "-D", "warnings"])); + run(cargo() + .args(["build", "--manifest-path"]) + .arg(project.join("Cargo.toml"))); +} + +#[test] +fn new_refuses_existing_directory() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("exists"); + std::fs::create_dir(&project).unwrap(); + + let output = cargo_hyperlight() + .arg("new") + .arg(&project) + .output() + .unwrap(); + assert!(!output.status.success()); +} + +#[test] +fn new_rejects_no_host_and_no_guest_together() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("both"); + + let output = cargo_hyperlight() + .arg("new") + .arg("--no-host") + .arg("--no-guest") + .arg(&project) + .output() + .unwrap(); + assert!(!output.status.success()); +}