Skip to content

Commit 316855d

Browse files
committed
test: add gather tests and fix fd inheritance
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent e34d1bb commit 316855d

4 files changed

Lines changed: 222 additions & 7 deletions

File tree

crates/sandlock-core/src/context.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ fn write_id_maps_overflow() {
647647
///
648648
/// This function **never returns**: it calls `execvp` on success or
649649
/// `_exit(127)` on any error.
650-
pub(crate) fn confine_child(policy: &Policy, cmd: &[CString], pipes: &PipePair, cow_config: Option<&ChildMountConfig>, nested: bool) -> ! {
650+
pub(crate) fn confine_child(policy: &Policy, cmd: &[CString], pipes: &PipePair, cow_config: Option<&ChildMountConfig>, nested: bool, keep_fds: &[RawFd]) -> ! {
651651
// Helper: abort child on error. Includes the OS error automatically.
652652
macro_rules! fail {
653653
($msg:expr) => {{
@@ -884,11 +884,11 @@ pub(crate) fn confine_child(policy: &Policy, cmd: &[CString], pipes: &PipePair,
884884
}
885885

886886
// 12. Close all fds above stderr (always on for isolation)
887+
let mut fds_to_keep: Vec<RawFd> = keep_fds.to_vec();
887888
if keep_fd >= 0 {
888-
close_fds_above(2, &[keep_fd]);
889-
} else {
890-
close_fds_above(2, &[]);
889+
fds_to_keep.push(keep_fd);
891890
}
891+
close_fds_above(2, &fds_to_keep);
892892

893893
// 13. Apply environment
894894
if policy.clean_env {

crates/sandlock-core/src/sandbox.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,8 +825,11 @@ impl Sandbox {
825825
drop(stdout_r);
826826
drop(stderr_r);
827827

828+
// Collect target fds from gather that must survive close_fds_above
829+
let gather_keep_fds: Vec<i32> = self.extra_fds.iter().map(|&(target, _)| target).collect();
830+
828831
// This never returns.
829-
context::confine_child(&self.policy, &c_cmd, &pipes, cow_config.as_ref(), nested);
832+
context::confine_child(&self.policy, &c_cmd, &pipes, cow_config.as_ref(), nested, &gather_keep_fds);
830833
}
831834

832835
// ===== PARENT PROCESS =====

crates/sandlock-core/tests/integration/test_pipeline.rs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use sandlock_core::policy::Policy;
2-
use sandlock_core::pipeline::{Stage, Pipeline};
2+
use sandlock_core::pipeline::{Stage, Pipeline, Gather};
33
use std::time::Duration;
44

55
fn base_policy() -> Policy {
@@ -194,3 +194,106 @@ async fn test_xoa_data_flow() {
194194

195195
let _ = std::fs::remove_dir_all(&tmp);
196196
}
197+
198+
// ============================================================
199+
// Gather tests
200+
// ============================================================
201+
202+
#[tokio::test]
203+
async fn test_gather_two_sources() {
204+
let policy = base_policy();
205+
// greeting → fd 3, name → stdin (last source)
206+
// Consumer: cat fd 3 first, then cat stdin
207+
let result = Gather::new()
208+
.source("greeting", Stage::new(&policy, &["echo", "hello"]))
209+
.source("name", Stage::new(&policy, &["echo", "world"]))
210+
.consumer(Stage::new(&policy, &["sh", "-c",
211+
"cat <&3; cat"
212+
]))
213+
.run(None).await.unwrap();
214+
215+
assert!(result.success(), "exit={:?} stderr={}", result.code(),
216+
result.stderr_str().unwrap_or(""));
217+
let stdout = result.stdout_str().unwrap_or("");
218+
assert!(stdout.contains("hello"), "missing greeting, got: {}", stdout);
219+
assert!(stdout.contains("world"), "missing name, got: {}", stdout);
220+
}
221+
222+
#[tokio::test]
223+
async fn test_gather_env_var() {
224+
let policy = base_policy();
225+
let result = Gather::new()
226+
.source("alpha", Stage::new(&policy, &["echo", "aaa"]))
227+
.source("beta", Stage::new(&policy, &["echo", "bbb"]))
228+
.consumer(Stage::new(&policy, &["sh", "-c",
229+
"echo $_SANDLOCK_GATHER"
230+
]))
231+
.run(None).await.unwrap();
232+
233+
assert!(result.success());
234+
let stdout = result.stdout_str().unwrap_or("");
235+
assert!(stdout.contains("alpha:"), "got: {}", stdout);
236+
assert!(stdout.contains("beta:"), "got: {}", stdout);
237+
}
238+
239+
#[tokio::test]
240+
async fn test_gather_disjoint_policies() {
241+
let tmp = std::env::temp_dir().join(format!("sandlock-test-gather-{}", std::process::id()));
242+
let _ = std::fs::create_dir_all(&tmp);
243+
let secret = tmp.join("secret.txt");
244+
std::fs::write(&secret, "secret data").unwrap();
245+
246+
// Data source: can read the file
247+
let data_policy = Policy::builder()
248+
.fs_read("/usr").fs_read("/lib").fs_read("/lib64").fs_read("/bin")
249+
.fs_read("/etc").fs_read("/proc").fs_read("/dev")
250+
.fs_read(&tmp)
251+
.build()
252+
.unwrap();
253+
254+
// Code source: no access to tmp (like a planner)
255+
let code_policy = base_policy();
256+
257+
// Consumer: no access to tmp either (gets data via pipe)
258+
let consumer_policy = base_policy();
259+
260+
let result = Gather::new()
261+
.source("data", Stage::new(&data_policy, &[
262+
"cat", secret.to_str().unwrap()
263+
]))
264+
.source("code", Stage::new(&code_policy, &[
265+
"echo", "tr a-z A-Z <&3"
266+
]))
267+
.consumer(Stage::new(&consumer_policy, &[
268+
// code is on stdin, data is on fd 3
269+
"sh", "-c", "code=$(cat); eval \"$code\""
270+
]))
271+
.run(None).await.unwrap();
272+
273+
assert!(result.success(), "exit={:?} stderr={}", result.code(),
274+
result.stderr_str().unwrap_or(""));
275+
let stdout = result.stdout_str().unwrap_or("");
276+
assert!(stdout.contains("SECRET DATA"), "got: {}", stdout);
277+
278+
let _ = std::fs::remove_dir_all(&tmp);
279+
}
280+
281+
#[tokio::test]
282+
async fn test_gather_requires_consumer() {
283+
let policy = base_policy();
284+
let result = Gather::new()
285+
.source("a", Stage::new(&policy, &["echo", "hello"]))
286+
.run(None).await;
287+
288+
assert!(result.is_err());
289+
}
290+
291+
#[tokio::test]
292+
async fn test_gather_requires_sources() {
293+
let policy = base_policy();
294+
let result = Gather::new()
295+
.consumer(Stage::new(&policy, &["cat"]))
296+
.run(None).await;
297+
298+
assert!(result.is_err());
299+
}

python/tests/test_pipeline.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import pytest
99

10-
from sandlock import Sandbox, Policy, Stage, Pipeline
10+
from sandlock import Sandbox, Policy, Stage, Pipeline, NamedStage, Gather, GatherPipeline
1111

1212

1313
# --- Helpers ---
@@ -231,3 +231,112 @@ def test_xoa_executor_no_network(self):
231231

232232
# Executor tried to connect but was blocked
233233
assert not result.success
234+
235+
236+
# --- Gather ---
237+
238+
class TestGather:
239+
def test_as_returns_named_stage(self):
240+
stage = Sandbox(_policy()).cmd(["echo", "hello"])
241+
named = stage.as_("greeting")
242+
assert isinstance(named, NamedStage)
243+
assert named.name == "greeting"
244+
245+
def test_add_returns_gather(self):
246+
a = Sandbox(_policy()).cmd(["echo", "a"]).as_("a")
247+
b = Sandbox(_policy()).cmd(["echo", "b"]).as_("b")
248+
g = a + b
249+
assert isinstance(g, Gather)
250+
assert len(g.sources) == 2
251+
252+
def test_gather_or_stage_returns_pipeline(self):
253+
g = (
254+
Sandbox(_policy()).cmd(["echo", "a"]).as_("a")
255+
+ Sandbox(_policy()).cmd(["echo", "b"]).as_("b")
256+
)
257+
gp = g | Sandbox(_policy()).cmd(["cat"])
258+
assert isinstance(gp, GatherPipeline)
259+
260+
def test_gather_two_sources(self):
261+
"""Two producers pipe into one consumer via gather."""
262+
result = (
263+
Sandbox(_policy()).cmd(["echo", "hello"]).as_("greeting")
264+
+ Sandbox(_policy()).cmd(["echo", "world"]).as_("name")
265+
| Sandbox(_policy()).cmd(
266+
["sh", "-c",
267+
'read name; greeting=$(cat <&3); echo "$greeting $name"']
268+
)
269+
).run()
270+
assert result.success, f"stderr={result.stderr}"
271+
assert b"hello" in result.stdout
272+
assert b"world" in result.stdout
273+
274+
def test_gather_with_python_inputs(self):
275+
"""Consumer reads gather inputs via sandlock.inputs."""
276+
python_paths = [p for p in sys.path if p and os.path.isdir(p)]
277+
policy = _policy(fs_readable=list(dict.fromkeys([
278+
"/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin",
279+
"/home", _PYTHON_PREFIX,
280+
] + python_paths)))
281+
282+
result = (
283+
Sandbox(policy).cmd(
284+
[sys.executable, "-c", "print('DATA_CONTENT')"]
285+
).as_("data")
286+
+ Sandbox(policy).cmd(
287+
[sys.executable, "-c", "print('CODE_CONTENT')"]
288+
).as_("code")
289+
| Sandbox(policy).cmd(
290+
[sys.executable, "-c",
291+
"from sandlock import inputs; "
292+
"print(f'code={inputs[\"code\"].strip()}'); "
293+
"print(f'data={inputs[\"data\"].strip()}')"]
294+
)
295+
).run()
296+
assert result.success, f"stderr={result.stderr}"
297+
assert b"code=CODE_CONTENT" in result.stdout
298+
assert b"data=DATA_CONTENT" in result.stdout
299+
300+
def test_gather_disjoint_policies(self):
301+
"""Sources have independent policies — data source can read
302+
a file the code source cannot."""
303+
with tempfile.TemporaryDirectory() as tmp:
304+
secret = os.path.join(tmp, "secret.txt")
305+
with open(secret, "w") as f:
306+
f.write("sensitive data")
307+
308+
data_policy = _policy(fs_readable=list(dict.fromkeys([
309+
tmp, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin",
310+
_PYTHON_PREFIX,
311+
])))
312+
code_policy = _policy()
313+
consumer_policy = _policy()
314+
315+
result = (
316+
Sandbox(data_policy).cmd(["cat", secret]).as_("data")
317+
+ Sandbox(code_policy).cmd(
318+
["echo", "tr a-z A-Z <&3"]
319+
).as_("code")
320+
| Sandbox(consumer_policy).cmd(
321+
["sh", "-c", 'eval "$(cat)"']
322+
)
323+
).run()
324+
assert result.success, f"stderr={result.stderr}"
325+
assert b"SENSITIVE DATA" in result.stdout
326+
327+
def test_gather_three_sources(self):
328+
"""Three producers fan into one consumer."""
329+
result = (
330+
Sandbox(_policy()).cmd(["echo", "aaa"]).as_("a")
331+
+ Sandbox(_policy()).cmd(["echo", "bbb"]).as_("b")
332+
+ Sandbox(_policy()).cmd(["echo", "ccc"]).as_("c")
333+
| Sandbox(_policy()).cmd(
334+
["sh", "-c",
335+
# c on stdin, a on fd 3, b on fd 4
336+
'read c; a=$(cat <&3); b=$(cat <&4); echo "$a $b $c"']
337+
)
338+
).run()
339+
assert result.success, f"stderr={result.stderr}"
340+
assert b"aaa" in result.stdout
341+
assert b"bbb" in result.stdout
342+
assert b"ccc" in result.stdout

0 commit comments

Comments
 (0)