Skip to content

Commit 2c92788

Browse files
authored
test: add pyhl::Runtime integration tests (#61)
Signed-off-by: danbugs <danilochiarlone@gmail.com>
1 parent c9d37ee commit 2c92788

1 file changed

Lines changed: 360 additions & 0 deletions

File tree

host/tests/pyhl_runtime.rs

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
//! Integration tests for the `pyhl::Runtime` API.
2+
//!
3+
//! These tests exercise the programmatic interface that library consumers
4+
//! use: `Runtime::new()` → `run_code()` with various configurations
5+
//! (filesystem mounts, network policies, exit codes, hermetic rewinds).
6+
//!
7+
//! All tests self-skip if the pyhl image is not installed (no snapshot at
8+
//! `.pyhl/snapshot.hls`) or if no hypervisor is available. Run `pyhl setup`
9+
//! to populate the image before running these tests.
10+
11+
use hyperlight_unikraft::pyhl::Runtime;
12+
use hyperlight_unikraft::{AllowList, NetworkPolicy, Preopen};
13+
use std::path::{Path, PathBuf};
14+
15+
// ---------------------------------------------------------------------------
16+
// Environment probe
17+
// ---------------------------------------------------------------------------
18+
19+
fn hypervisor_available() -> bool {
20+
#[cfg(unix)]
21+
{
22+
std::fs::OpenOptions::new()
23+
.read(true)
24+
.write(true)
25+
.open("/dev/kvm")
26+
.is_ok()
27+
}
28+
#[cfg(windows)]
29+
{
30+
true
31+
}
32+
}
33+
34+
fn pyhl_home() -> Option<PathBuf> {
35+
// Check .pyhl/ in the workspace root (two levels up from host/)
36+
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
37+
.parent()
38+
.unwrap()
39+
.join(".pyhl");
40+
if workspace.join("snapshot.hls").is_file() {
41+
return Some(workspace);
42+
}
43+
// Check user-level install
44+
if let Some(home) = dirs_or_default() {
45+
if home.join("snapshot.hls").is_file() {
46+
return Some(home);
47+
}
48+
}
49+
None
50+
}
51+
52+
fn dirs_or_default() -> Option<PathBuf> {
53+
let base = if cfg!(windows) {
54+
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
55+
} else {
56+
Some(
57+
std::env::var_os("XDG_DATA_HOME")
58+
.map(PathBuf::from)
59+
.unwrap_or_else(|| {
60+
let home = std::env::var_os("HOME").unwrap_or_default();
61+
PathBuf::from(home).join(".local/share")
62+
}),
63+
)
64+
};
65+
base.map(|b| b.join("pyhl"))
66+
}
67+
68+
fn setup() -> Option<(PathBuf, Runtime)> {
69+
if !hypervisor_available() {
70+
eprintln!("SKIP: no hypervisor available");
71+
return None;
72+
}
73+
let home = pyhl_home()?;
74+
let rt = Runtime::new(&home, &[], None, None).ok()?;
75+
Some((home, rt))
76+
}
77+
78+
fn setup_with_net(policy: NetworkPolicy) -> Option<Runtime> {
79+
if !hypervisor_available() {
80+
eprintln!("SKIP: no hypervisor available");
81+
return None;
82+
}
83+
let home = pyhl_home()?;
84+
Runtime::new(&home, &[], Some(&policy), None).ok()
85+
}
86+
87+
fn setup_with_mount(preopen: Preopen) -> Option<Runtime> {
88+
if !hypervisor_available() {
89+
eprintln!("SKIP: no hypervisor available");
90+
return None;
91+
}
92+
let home = pyhl_home()?;
93+
Runtime::new(&home, &[preopen], None, None).ok()
94+
}
95+
96+
// ---------------------------------------------------------------------------
97+
// Basic execution
98+
// ---------------------------------------------------------------------------
99+
100+
#[test]
101+
fn runtime_hello_world() {
102+
let Some((_home, mut rt)) = setup() else {
103+
return;
104+
};
105+
let timing = rt.run_code("print('hello from runtime test')").unwrap();
106+
assert_eq!(timing.exit_code, 0);
107+
}
108+
109+
#[test]
110+
fn runtime_pandas_import() {
111+
let Some((_home, mut rt)) = setup() else {
112+
return;
113+
};
114+
let timing = rt
115+
.run_code("import pandas as pd; print(pd.DataFrame({'x':[1,2,3]}).sum().to_dict())")
116+
.unwrap();
117+
assert_eq!(timing.exit_code, 0);
118+
}
119+
120+
// ---------------------------------------------------------------------------
121+
// Exit code propagation
122+
// ---------------------------------------------------------------------------
123+
124+
#[test]
125+
fn runtime_exit_code_zero() {
126+
let Some((_home, mut rt)) = setup() else {
127+
return;
128+
};
129+
let timing = rt.run_code("import sys; sys.exit(0)").unwrap();
130+
assert_eq!(timing.exit_code, 0);
131+
}
132+
133+
#[test]
134+
fn runtime_exit_code_nonzero() {
135+
let Some((_home, mut rt)) = setup() else {
136+
return;
137+
};
138+
let timing = rt.run_code("import sys; sys.exit(42)").unwrap();
139+
assert_eq!(timing.exit_code, 42);
140+
}
141+
142+
#[test]
143+
fn runtime_exit_code_from_exception() {
144+
let Some((_home, mut rt)) = setup() else {
145+
return;
146+
};
147+
let timing = rt.run_code("raise ValueError('boom')").unwrap();
148+
assert_ne!(timing.exit_code, 0);
149+
}
150+
151+
// ---------------------------------------------------------------------------
152+
// Hermetic execution (state isolation between calls)
153+
// ---------------------------------------------------------------------------
154+
155+
#[test]
156+
fn runtime_hermetic_no_state_leak() {
157+
let Some((_home, mut rt)) = setup() else {
158+
return;
159+
};
160+
// First call sets a global
161+
let t1 = rt
162+
.run_code("MARKER = 'set_by_first_call'; print('first')")
163+
.unwrap();
164+
assert_eq!(t1.exit_code, 0);
165+
166+
// Second call should NOT see the global from the first
167+
let t2 = rt
168+
.run_code(
169+
"import sys\ntry:\n print(MARKER)\n sys.exit(1)\nexcept NameError:\n print('clean')\n sys.exit(0)",
170+
)
171+
.unwrap();
172+
assert_eq!(t2.exit_code, 0, "state leaked between hermetic calls");
173+
}
174+
175+
#[test]
176+
fn runtime_repeated_runs_stable() {
177+
let Some((_home, mut rt)) = setup() else {
178+
return;
179+
};
180+
for i in 0..5 {
181+
let timing = rt
182+
.run_code(&format!("import time; print('iter {}', time.time())", i))
183+
.unwrap();
184+
assert_eq!(timing.exit_code, 0, "iteration {i} failed");
185+
}
186+
}
187+
188+
// ---------------------------------------------------------------------------
189+
// Filesystem access via Preopen
190+
// ---------------------------------------------------------------------------
191+
192+
#[test]
193+
fn runtime_filesystem_write_and_read() {
194+
let tmp = tempdir("hl-rt-fs");
195+
let preopen = match Preopen::new(&tmp, "/host") {
196+
Ok(p) => p,
197+
Err(e) => {
198+
eprintln!("SKIP: cannot create preopen: {e}");
199+
cleanup(&tmp);
200+
return;
201+
}
202+
};
203+
let Some(mut rt) = setup_with_mount(preopen) else {
204+
cleanup(&tmp);
205+
return;
206+
};
207+
208+
// Write a file from guest
209+
let timing = rt
210+
.run_code("with open('/host/test.txt', 'w') as f: f.write('hello from guest\\n')")
211+
.unwrap();
212+
assert_eq!(timing.exit_code, 0);
213+
214+
// Verify on host side
215+
let content = std::fs::read_to_string(tmp.join("test.txt")).unwrap();
216+
assert_eq!(content, "hello from guest\n");
217+
218+
// Read it back from guest
219+
let timing = rt
220+
.run_code("with open('/host/test.txt') as f: print(f.read().strip())")
221+
.unwrap();
222+
assert_eq!(timing.exit_code, 0);
223+
224+
cleanup(&tmp);
225+
}
226+
227+
#[test]
228+
fn runtime_filesystem_listdir() {
229+
let tmp = tempdir("hl-rt-ls");
230+
std::fs::write(tmp.join("a.txt"), "aaa").unwrap();
231+
std::fs::write(tmp.join("b.txt"), "bbb").unwrap();
232+
let preopen = match Preopen::new(&tmp, "/host") {
233+
Ok(p) => p,
234+
Err(e) => {
235+
eprintln!("SKIP: cannot create preopen: {e}");
236+
cleanup(&tmp);
237+
return;
238+
}
239+
};
240+
let Some(mut rt) = setup_with_mount(preopen) else {
241+
cleanup(&tmp);
242+
return;
243+
};
244+
245+
let timing = rt
246+
.run_code("import os; files = sorted(os.listdir('/host')); print(files); assert 'a.txt' in files and 'b.txt' in files")
247+
.unwrap();
248+
assert_eq!(timing.exit_code, 0);
249+
250+
cleanup(&tmp);
251+
}
252+
253+
#[test]
254+
fn runtime_filesystem_mkdir_and_stat() {
255+
let tmp = tempdir("hl-rt-mkdir");
256+
let preopen = match Preopen::new(&tmp, "/host") {
257+
Ok(p) => p,
258+
Err(e) => {
259+
eprintln!("SKIP: cannot create preopen: {e}");
260+
cleanup(&tmp);
261+
return;
262+
}
263+
};
264+
let Some(mut rt) = setup_with_mount(preopen) else {
265+
cleanup(&tmp);
266+
return;
267+
};
268+
269+
let timing = rt
270+
.run_code("import os\nos.makedirs('/host/sub/dir', exist_ok=True)\nwith open('/host/sub/dir/file.txt', 'w') as f: f.write('nested')\nst = os.stat('/host/sub/dir/file.txt')\nprint(f'size={st.st_size}')")
271+
.unwrap();
272+
assert_eq!(timing.exit_code, 0);
273+
274+
assert!(tmp.join("sub/dir/file.txt").is_file());
275+
assert_eq!(
276+
std::fs::read_to_string(tmp.join("sub/dir/file.txt")).unwrap(),
277+
"nested"
278+
);
279+
280+
cleanup(&tmp);
281+
}
282+
283+
// ---------------------------------------------------------------------------
284+
// Network policy enforcement
285+
// ---------------------------------------------------------------------------
286+
287+
#[test]
288+
fn runtime_network_allowlist_permits_allowed_host() {
289+
let al = match AllowList::from_hosts(&["example.com"]) {
290+
Ok(al) => al,
291+
Err(e) => {
292+
eprintln!("SKIP: DNS resolution failed: {e}");
293+
return;
294+
}
295+
};
296+
let Some(mut rt) = setup_with_net(NetworkPolicy::AllowList(al)) else {
297+
return;
298+
};
299+
300+
let timing = rt
301+
.run_code("import urllib.request; r = urllib.request.urlopen('http://example.com/', timeout=10); print(r.status)")
302+
.unwrap();
303+
assert_eq!(timing.exit_code, 0);
304+
}
305+
306+
#[test]
307+
fn runtime_network_allowlist_blocks_unlisted_host() {
308+
let al = match AllowList::from_hosts(&["example.com"]) {
309+
Ok(al) => al,
310+
Err(e) => {
311+
eprintln!("SKIP: DNS resolution failed: {e}");
312+
return;
313+
}
314+
};
315+
let Some(mut rt) = setup_with_net(NetworkPolicy::AllowList(al)) else {
316+
return;
317+
};
318+
319+
// httpbin.org is NOT in the allowlist — should be blocked
320+
let timing = rt
321+
.run_code("import urllib.request, sys\ntry:\n urllib.request.urlopen('http://httpbin.org/', timeout=5)\n sys.exit(1)\nexcept Exception:\n sys.exit(0)")
322+
.unwrap();
323+
assert_eq!(timing.exit_code, 0, "unlisted host should be blocked");
324+
}
325+
326+
#[test]
327+
fn runtime_network_disabled_by_default() {
328+
let Some((_home, mut rt)) = setup() else {
329+
return;
330+
};
331+
332+
// Without network policy, socket operations should fail
333+
let timing = rt
334+
.run_code("import urllib.request, sys\ntry:\n urllib.request.urlopen('http://example.com/', timeout=5)\n sys.exit(1)\nexcept Exception:\n sys.exit(0)")
335+
.unwrap();
336+
assert_eq!(
337+
timing.exit_code, 0,
338+
"networking should be disabled by default"
339+
);
340+
}
341+
342+
// ---------------------------------------------------------------------------
343+
// Helpers
344+
// ---------------------------------------------------------------------------
345+
346+
fn tempdir(prefix: &str) -> PathBuf {
347+
let base = std::env::temp_dir();
348+
let pid = std::process::id();
349+
let uniq = std::time::SystemTime::now()
350+
.duration_since(std::time::UNIX_EPOCH)
351+
.map(|d| d.as_nanos())
352+
.unwrap_or(0);
353+
let dir = base.join(format!("{prefix}-{pid}-{uniq}"));
354+
std::fs::create_dir_all(&dir).expect("create tempdir");
355+
dir
356+
}
357+
358+
fn cleanup(path: &Path) {
359+
let _ = std::fs::remove_dir_all(path);
360+
}

0 commit comments

Comments
 (0)