From ebf39c01244e75b86322fa59507470c60ed9ee5e Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:09:25 -0700 Subject: [PATCH 1/2] Add scaffold subcommand Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + justfile | 9 ++- src/main.rs | 7 ++ src/scaffold/_gitignore | 1 + src/scaffold/guest/_Cargo.toml | 9 +++ src/scaffold/guest/_main.rs | 58 +++++++++++++++ src/scaffold/host/_Cargo.toml | 7 ++ src/scaffold/host/_main.rs | 76 ++++++++++++++++++++ src/scaffold/mod.rs | 98 ++++++++++++++++++++++++++ tests/scaffold.rs | 125 +++++++++++++++++++++++++++++++++ 10 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 src/scaffold/_gitignore create mode 100644 src/scaffold/guest/_Cargo.toml create mode 100644 src/scaffold/guest/_main.rs create mode 100644 src/scaffold/host/_Cargo.toml create mode 100644 src/scaffold/host/_main.rs create mode 100644 src/scaffold/mod.rs create mode 100644 tests/scaffold.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35a0870..37b1d0f 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 scaffold + shell: bash + run: just test-scaffold spelling: name: Spell check with typos diff --git a/justfile b/justfile index efbc9f5..7512baf 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/scaffold/guest/_main.rs ./src/scaffold/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/scaffold/guest/_main.rs ./src/scaffold/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-scaffold: + cargo test --test scaffold diff --git a/src/main.rs b/src/main.rs index 0fdff8a..4e4d831 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::env; use cargo_hyperlight::cargo; mod perf; +mod scaffold; const VERSION: &str = env!("CARGO_PKG_VERSION"); const GIT_HASH: &str = env!("GIT_HASH"); @@ -26,6 +27,12 @@ fn main() { std::process::exit(1); } } + Some(a) if a == "scaffold" => { + if let Err(e) = scaffold::run(args) { + eprintln!("{e:?}"); + std::process::exit(1); + } + } _ => { cargo() .expect("Failed to create cargo command") diff --git a/src/scaffold/_gitignore b/src/scaffold/_gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/src/scaffold/_gitignore @@ -0,0 +1 @@ +target diff --git a/src/scaffold/guest/_Cargo.toml b/src/scaffold/guest/_Cargo.toml new file mode 100644 index 0000000..e9ab2dd --- /dev/null +++ b/src/scaffold/guest/_Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "{name}" +version = "0.1.0" +edition = "2024" + +[dependencies] +hyperlight-guest = "{version}" +hyperlight-guest-bin = "{version}" +hyperlight-common = { version = "{version}", default-features = false } diff --git a/src/scaffold/guest/_main.rs b/src/scaffold/guest/_main.rs new file mode 100644 index 0000000..ec661f2 --- /dev/null +++ b/src/scaffold/guest/_main.rs @@ -0,0 +1,58 @@ +#![no_std] +#![no_main] +extern crate alloc; +extern crate hyperlight_guest_bin; + +use alloc::string::String; +use alloc::vec::Vec; +use core::sync::atomic::{AtomicI32, Ordering}; + +use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; +use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; +use hyperlight_guest::bail; +use hyperlight_guest::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 { + COUNTER.fetch_add(1, Ordering::Relaxed); + Ok(COUNTER.load(Ordering::Relaxed)) +} + +// Called once when the guest binary is loaded, during evolve(). +// Use this for initialization. +#[unsafe(no_mangle)] +pub extern "C" fn hyperlight_main() {} + +// Called when the host calls a guest function not handled by #[guest_function]. +// You usually don't need to modify this. +#[unsafe(no_mangle)] +pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { + let function_name = function_call.function_name; + bail!(ErrorCode::GuestFunctionNotFound => "{function_name}"); +} diff --git a/src/scaffold/host/_Cargo.toml b/src/scaffold/host/_Cargo.toml new file mode 100644 index 0000000..3b7fe25 --- /dev/null +++ b/src/scaffold/host/_Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "{name}" +version = "0.1.0" +edition = "2024" + +[dependencies] +hyperlight-host = "{version}" diff --git a/src/scaffold/host/_main.rs b/src/scaffold/host/_main.rs new file mode 100644 index 0000000..3ac3350 --- /dev/null +++ b/src/scaffold/host/_main.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use hyperlight_host::{GuestBinary, MultiUseSandbox, UninitializedSandbox}; + +fn main() -> hyperlight_host::Result<()> { + // TODO: support aarch64-hyperlight-none when aarch64 guests are supported. + let base = PathBuf::from("../guest/target/x86_64-hyperlight-none"); + let guest_path = ["debug", "release"] + .iter() + .map(|p| base.join(p).join("{guest_name}")) + .find(|p| p.exists()) + .expect( + "guest binary not found - build it first with: cd ../guest && cargo hyperlight build", + ); + + // 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_path.display().to_string()), + 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(()) +} + +// Returns the current day of the week as a String. +fn weekday() -> hyperlight_host::Result { + let days = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_secs(); + // January 1, 1970 was a Thursday (day index 3 when Monday = 0). + Ok(days[((secs / (60 * 60 * 24) + 3) % 7) as usize].to_string()) +} diff --git a/src/scaffold/mod.rs b/src/scaffold/mod.rs new file mode 100644 index 0000000..c1a3404 --- /dev/null +++ b/src/scaffold/mod.rs @@ -0,0 +1,98 @@ +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.13"; + +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"); + +/// Scaffold a new Hyperlight project. +#[derive(Parser, Debug)] +#[command(name = "scaffold")] +struct ScaffoldCli { + /// Path to create the project at. The directory name is used as the crate + /// name (like `cargo new`). + path: PathBuf, + + /// Generate only a guest project instead of both host and guest. + #[arg(long, default_value_t = false)] + guest_only: bool, +} + +pub fn run(args: impl Iterator) -> Result<()> { + let cli = ScaffoldCli::parse_from(args); + + let name = cli + .path + .file_name() + .context("Invalid project path")? + .to_str() + .context("Project name must be valid UTF-8")?; + + ensure!(!name.is_empty(), "Project name must not be empty"); + ensure!( + !cli.path.exists(), + "Directory '{}' already exists", + cli.path.display() + ); + + if cli.guest_only { + write_guest(&cli.path, name)?; + } else { + 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)?; + } + write_file(cli.path.join(".gitignore"), GITIGNORE)?; + + let dir = cli.path.display(); + println!("Created project at '{dir}'\n"); + if cli.guest_only { + println!("Build:"); + println!(" cd {dir} && cargo hyperlight build"); + } else { + println!("Build and run:"); + println!(" cd {dir}/guest && cargo hyperlight build"); + println!(" cd {dir}/host && cargo run"); + } + + 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); + 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(()) +} diff --git a/tests/scaffold.rs b/tests/scaffold.rs new file mode 100644 index 0000000..467075f --- /dev/null +++ b/tests/scaffold.rs @@ -0,0 +1,125 @@ +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 scaffolded 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 scaffold_host_and_guest() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("myproject"); + + run(cargo_hyperlight().arg("scaffold").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!(lines[0].starts_with("Hello, World! Today is")); + 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 scaffold_guest_only() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("myguest"); + + run(cargo_hyperlight() + .arg("scaffold") + .arg("--guest-only") + .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 scaffold_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("scaffold") + .arg(&project) + .output() + .unwrap(); + assert!(!output.status.success()); +} From f396daf4e7d57302ab78b8c2f900c8a4f2b92317 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:09:30 -0700 Subject: [PATCH 2/2] Validate name more carefully Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/scaffold/mod.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/scaffold/mod.rs b/src/scaffold/mod.rs index c1a3404..ddbe768 100644 --- a/src/scaffold/mod.rs +++ b/src/scaffold/mod.rs @@ -36,7 +36,7 @@ pub fn run(args: impl Iterator) -> Result<()> { .to_str() .context("Project name must be valid UTF-8")?; - ensure!(!name.is_empty(), "Project name must not be empty"); + validate_name(name)?; ensure!( !cli.path.exists(), "Directory '{}' already exists", @@ -96,3 +96,37 @@ fn write_file(path: impl AsRef, content: &str) -> Result<()> { 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(()) +}