Skip to content

Commit 6a51e4f

Browse files
authored
Add scaffold subcommand (#36)
* Add 'new' subcommand to scaffold Hyperlight projects Generates a getting-started project demonstrating host/guest calls, multi-arg tuple syntax, and state persistence with snapshot/restore. By default creates both host and guest crates; --no-host or --no-guest generates just one. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
1 parent 399f2e8 commit 6a51e4f

10 files changed

Lines changed: 441 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jobs:
4141
- name: Run example
4242
shell: bash
4343
run: just run-guest
44+
- name: Test `new` subcommand
45+
shell: bash
46+
run: just test-new
4447

4548
spelling:
4649
name: Spell check with typos

justfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ fmt:
77
cargo +nightly fmt --all -- --check
88
cargo +nightly fmt --all --manifest-path ./examples/host/Cargo.toml -- --check
99
cargo +nightly fmt --all --manifest-path ./examples/guest/Cargo.toml -- --check
10+
# These are standalone template files not part of any crate, so cargo fmt wont find them.
11+
rustfmt +nightly --check ./src/new/guest/_main.rs ./src/new/host/_main.rs
1012

1113
fmt-apply:
1214
cargo +nightly fmt --all
1315
cargo +nightly fmt --all --manifest-path ./examples/host/Cargo.toml
1416
cargo +nightly fmt --all --manifest-path ./examples/guest/Cargo.toml
17+
# These are standalone template files not part of any crate, so cargo fmt wont find them.
18+
rustfmt +nightly ./src/new/guest/_main.rs ./src/new/host/_main.rs
1519

1620
clippy:
1721
cargo clippy --all -- -D warnings
@@ -22,4 +26,7 @@ build-guest:
2226
cargo hyperlight build --manifest-path ./examples/guest/Cargo.toml
2327

2428
run-guest: build-guest
25-
cargo run --manifest-path ./examples/host/Cargo.toml -- ./target/x86_64-hyperlight-none/debug/guest
29+
cargo run --manifest-path ./examples/host/Cargo.toml -- ./target/x86_64-hyperlight-none/debug/guest
30+
31+
test-new:
32+
cargo test --test new

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::env;
22

33
use cargo_hyperlight::cargo;
44

5+
mod new;
56
mod perf;
67

78
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -26,6 +27,12 @@ fn main() {
2627
std::process::exit(1);
2728
}
2829
}
30+
Some(a) if a == "new" => {
31+
if let Err(e) = new::run(args) {
32+
eprintln!("{e:?}");
33+
std::process::exit(1);
34+
}
35+
}
2936
_ => {
3037
cargo()
3138
.expect("Failed to create cargo command")

src/new/_gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
target

src/new/guest/_Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "{name}"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
hyperlight-guest-bin = "{version}"

src/new/guest/_main.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#![no_std]
2+
#![no_main]
3+
extern crate alloc;
4+
5+
use alloc::string::String;
6+
use core::sync::atomic::{AtomicI32, Ordering};
7+
8+
use hyperlight_guest_bin::error::Result;
9+
use hyperlight_guest_bin::{guest_function, host_function};
10+
11+
static COUNTER: AtomicI32 = AtomicI32::new(0);
12+
13+
// Declare a host function that the guest can call. The string is the
14+
// registration name (must match what the host passes to register()).
15+
// If omitted, the Rust function name is used.
16+
// The host must register this before the sandbox is initialized.
17+
#[host_function("GetWeekday")]
18+
fn get_weekday() -> Result<String>;
19+
20+
// Register a guest function that can be called by the host.
21+
#[guest_function("SayHello")]
22+
fn say_hello(name: String) -> Result<String> {
23+
let weekday = get_weekday()?;
24+
Ok(alloc::format!("Hello, {name}! Today is {weekday}."))
25+
}
26+
27+
// Guest functions can take multiple arguments of different types.
28+
#[guest_function("Add")]
29+
fn add(a: i32, b: i32) -> Result<i32> {
30+
Ok(a + b)
31+
}
32+
33+
// Increments a counter and returns the new value. State persists across
34+
// calls until the host restores a snapshot, which resets all VM memory
35+
// back to the state it was in when the snapshot was taken.
36+
#[guest_function("Increment")]
37+
fn increment() -> Result<i32> {
38+
let old = COUNTER.fetch_add(1, Ordering::Relaxed);
39+
Ok(old + 1)
40+
}

src/new/host/_Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "{name}"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
hyperlight-host = "{version}"

src/new/host/_main.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use hyperlight_host::{GuestBinary, MultiUseSandbox, UninitializedSandbox};
2+
3+
fn main() -> hyperlight_host::Result<()> {
4+
// Create a sandbox from the guest binary. It starts uninitialized so you
5+
// can register host functions before the guest begins executing.
6+
let mut sandbox = UninitializedSandbox::new(
7+
GuestBinary::FilePath("../guest/target/{arch}-hyperlight-none/debug/{guest_name}".into()),
8+
None,
9+
)?;
10+
11+
// Register a host function that the guest can call.
12+
sandbox.register("GetWeekday", weekday)?;
13+
14+
// Evolve into a MultiUseSandbox, which lets you call guest functions
15+
// multiple times.
16+
let mut sandbox: MultiUseSandbox = sandbox.evolve()?;
17+
18+
// Call a guest function with a single argument.
19+
let result: String = sandbox.call("SayHello", "World".to_string())?;
20+
println!("{result}");
21+
22+
// Multiple arguments are passed as a tuple.
23+
let sum: i32 = sandbox.call("Add", (2_i32, 3_i32))?;
24+
println!("2 + 3 = {sum}");
25+
26+
// Guest state persists between calls. Take a snapshot so we can
27+
// restore back to this point later.
28+
let snapshot = sandbox.snapshot()?;
29+
30+
let count: i32 = sandbox.call("Increment", ())?;
31+
println!("count = {count}"); // 1
32+
let count: i32 = sandbox.call("Increment", ())?;
33+
println!("count = {count}"); // 2
34+
let count: i32 = sandbox.call("Increment", ())?;
35+
println!("count = {count}"); // 3
36+
37+
// Restore resets all guest memory back to the snapshot.
38+
sandbox.restore(snapshot)?;
39+
40+
let count: i32 = sandbox.call("Increment", ())?;
41+
println!("count after restore = {count}"); // 1 again
42+
43+
Ok(())
44+
}
45+
46+
fn weekday() -> hyperlight_host::Result<String> {
47+
// It's always Monday here, sorry Garfield!
48+
Ok("Monday".to_string())
49+
}

src/new/mod.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use std::ffi::OsString;
2+
use std::fs;
3+
use std::path::{Path, PathBuf};
4+
5+
use anyhow::{Context, Result, ensure};
6+
use clap::Parser;
7+
8+
const HYPERLIGHT_VERSION: &str = "0.15";
9+
// TODO: support aarch64-hyperlight-none when aarch64 guests are supported.
10+
const GUEST_ARCH: &str = "x86_64";
11+
12+
const GUEST_CARGO_TOML: &str = include_str!("guest/_Cargo.toml");
13+
const GUEST_MAIN_RS: &str = include_str!("guest/_main.rs");
14+
const HOST_CARGO_TOML: &str = include_str!("host/_Cargo.toml");
15+
const HOST_MAIN_RS: &str = include_str!("host/_main.rs");
16+
const GITIGNORE: &str = include_str!("_gitignore");
17+
18+
/// Create a new Hyperlight project.
19+
#[derive(Parser, Debug)]
20+
#[command(name = "new")]
21+
struct NewCli {
22+
/// Path to create the project at. The directory name is used as the crate
23+
/// name (like `cargo new`).
24+
path: PathBuf,
25+
26+
/// Skip generating the host crate.
27+
#[arg(long, default_value_t = false)]
28+
no_host: bool,
29+
30+
/// Skip generating the guest crate.
31+
#[arg(long, default_value_t = false, conflicts_with = "no_host")]
32+
no_guest: bool,
33+
}
34+
35+
pub fn run(args: impl Iterator<Item = OsString>) -> Result<()> {
36+
let cli = NewCli::parse_from(args);
37+
38+
let name = cli
39+
.path
40+
.file_name()
41+
.context("Invalid project path")?
42+
.to_str()
43+
.context("Project name must be valid UTF-8")?;
44+
45+
validate_name(name)?;
46+
ensure!(
47+
!cli.path.exists(),
48+
"Directory '{}' already exists",
49+
cli.path.display()
50+
);
51+
52+
match (cli.no_host, cli.no_guest) {
53+
(true, false) => {
54+
write_guest(&cli.path, name)?;
55+
}
56+
(false, true) => {
57+
write_host(&cli.path, name, &format!("{name}-guest"))?;
58+
}
59+
(false, false) => {
60+
let guest_name = format!("{name}-guest");
61+
write_guest(&cli.path.join("guest"), &guest_name)?;
62+
write_host(&cli.path.join("host"), &format!("{name}-host"), &guest_name)?;
63+
}
64+
(true, true) => unreachable!("clap rejects --no-host and --no-guest together"),
65+
}
66+
write_file(cli.path.join(".gitignore"), GITIGNORE)?;
67+
68+
let dir = cli.path.display();
69+
println!("Created project at '{dir}'\n");
70+
match (cli.no_host, cli.no_guest) {
71+
(true, false) => {
72+
println!("Build:");
73+
println!(" cd {dir} && cargo hyperlight build");
74+
}
75+
(false, true) => {
76+
println!("Build:");
77+
println!(" cd {dir} && cargo build");
78+
}
79+
(false, false) => {
80+
println!("Build and run:");
81+
println!(" cd {dir}/guest && cargo hyperlight build");
82+
println!(" cd {dir}/host && cargo run");
83+
}
84+
(true, true) => unreachable!(),
85+
}
86+
87+
Ok(())
88+
}
89+
90+
fn write_guest(dir: &Path, name: &str) -> Result<()> {
91+
let cargo_toml = GUEST_CARGO_TOML
92+
.replace("{name}", name)
93+
.replace("{version}", HYPERLIGHT_VERSION);
94+
write_file(dir.join("Cargo.toml"), &cargo_toml)?;
95+
write_file(dir.join("src/main.rs"), GUEST_MAIN_RS)?;
96+
Ok(())
97+
}
98+
99+
fn write_host(dir: &Path, name: &str, guest_name: &str) -> Result<()> {
100+
let cargo_toml = HOST_CARGO_TOML
101+
.replace("{name}", name)
102+
.replace("{version}", HYPERLIGHT_VERSION);
103+
let main_rs = HOST_MAIN_RS
104+
.replace("{name}", name)
105+
.replace("{guest_name}", guest_name)
106+
.replace("{arch}", GUEST_ARCH);
107+
write_file(dir.join("Cargo.toml"), &cargo_toml)?;
108+
write_file(dir.join("src/main.rs"), &main_rs)?;
109+
Ok(())
110+
}
111+
112+
fn write_file(path: impl AsRef<Path>, content: &str) -> Result<()> {
113+
let path = path.as_ref();
114+
if let Some(parent) = path.parent() {
115+
fs::create_dir_all(parent)
116+
.with_context(|| format!("Failed to create directory '{}'", parent.display()))?;
117+
}
118+
fs::write(path, content).with_context(|| format!("Failed to write '{}'", path.display()))?;
119+
Ok(())
120+
}
121+
122+
/// Validate that the name is usable as a Cargo package name.
123+
/// Mirrors the essential checks from `cargo new`.
124+
fn validate_name(name: &str) -> Result<()> {
125+
ensure!(!name.is_empty(), "project name must not be empty");
126+
ensure!(
127+
name.chars()
128+
.all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
129+
"invalid project name `{name}`: must contain only letters, numbers, `-`, or `_`"
130+
);
131+
ensure!(
132+
name.chars()
133+
.next()
134+
.is_some_and(|c| c.is_alphabetic() || c == '_'),
135+
"invalid project name `{name}`: must start with a letter or `_`"
136+
);
137+
let reserved = [
138+
"test",
139+
"core",
140+
"std",
141+
"alloc",
142+
"proc_macro",
143+
"proc-macro",
144+
"self",
145+
"Self",
146+
"crate",
147+
"super",
148+
];
149+
ensure!(
150+
!reserved.contains(&name),
151+
"invalid project name `{name}`: it conflicts with a Rust built-in name"
152+
);
153+
Ok(())
154+
}

0 commit comments

Comments
 (0)