Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 115 additions & 7 deletions crates/config/src/config_yaml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,43 @@ pub enum FuseMode {
Auto,
}

/// Default mode when `enabled` is omitted (no `fuse:` block, or `fuse: {}`).
/// Platform-gated: Linux auto-enables FUSE (the engine decides per target),
/// while macOS and every other platform default off — the macOS backends
/// (kext / FSKit) each need a one-time, often admin- or MDM-gated approval
/// that can't be assumed present, so FUSE stays opt-in there.
#[cfg(target_os = "linux")]
const DEFAULT_OMITTED_MODE: FuseMode = FuseMode::Auto;
#[cfg(not(target_os = "linux"))]
const DEFAULT_OMITTED_MODE: FuseMode = FuseMode::Off;

/// Sandbox FUSE-overlay mode. `fuse: { enabled: true | false | auto }`
/// selects mode explicitly. Omit `enabled` (or the entire `fuse:` block) to
/// default to off; FUSE is opt-in.
/// take the platform default ([`DEFAULT_OMITTED_MODE`]): `auto` on Linux,
/// `off` on macOS and elsewhere.
///
/// `backend` (macOS only) selects the userspace FUSE backend: `kext` (the
/// classic kernel-extension path — fastest, but the macFUSE system extension
/// must be approved) or `fskit` (Apple's FSKit, macOS 15.4+ — unprivileged,
/// no kext, mounts under `/Volumes`). Omit to let the engine pick the backend
/// that is actually usable on the host. Ignored on Linux.
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct FuseConfig {
#[serde(default)]
pub enabled: Option<FuseEnabled>,
#[serde(default)]
pub backend: Option<FuseBackend>,
}

/// macOS FUSE backend selector. See [`FuseConfig`].
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FuseBackend {
/// Classic kernel-extension backend (macFUSE kext).
Kext,
/// Apple FSKit backend — unprivileged, macOS 15.4+.
Fskit,
}

/// Tri-state config value. Parses YAML `true`, `false`, or `"auto"`.
Expand Down Expand Up @@ -317,11 +346,13 @@ impl FuseConfig {
match self.enabled {
Some(FuseEnabled::On) => FuseMode::On,
Some(FuseEnabled::Auto) => FuseMode::Auto,
Some(FuseEnabled::Off) | None => FuseMode::Off,
Some(FuseEnabled::Off) => FuseMode::Off,
None => DEFAULT_OMITTED_MODE,
}
}

/// Convenience: FUSE off (explicit `enabled: false` or omitted).
/// Convenience: is FUSE off? (explicit `enabled: false`, or omitted on a
/// platform whose [`DEFAULT_OMITTED_MODE`] is `Off`, i.e. non-Linux).
pub fn is_off(&self) -> bool {
matches!(self.mode(), FuseMode::Off)
}
Expand All @@ -330,6 +361,28 @@ impl FuseConfig {
pub fn is_on(&self) -> bool {
matches!(self.mode(), FuseMode::On)
}

/// Build a config forced on (tests / programmatic callers).
pub fn on() -> Self {
Self {
enabled: Some(FuseEnabled::On),
backend: None,
}
}

/// Build a config in `auto` mode (tests / programmatic callers).
pub fn auto() -> Self {
Self {
enabled: Some(FuseEnabled::Auto),
backend: None,
}
}

/// Force a specific macOS backend, keeping the current mode.
pub fn with_backend(mut self, backend: FuseBackend) -> Self {
self.backend = Some(backend);
self
}
}

/// Execute-phase lock backend. `lock: { backend: fs | mem }`. Omit the block (or
Expand Down Expand Up @@ -778,9 +831,19 @@ caches:
let yaml = "fuse: {}\n";
let cfg: ConfigYaml = serde_yaml::from_str(yaml).expect("parse");
let f = cfg.fuse.expect("fuse present");
assert_eq!(f.mode(), FuseMode::Off);
assert!(f.is_off());
// Omitted `enabled` takes the platform default: auto on Linux, off
// on macOS/elsewhere.
assert!(!f.is_on());
#[cfg(target_os = "linux")]
{
assert_eq!(f.mode(), FuseMode::Auto);
assert!(!f.is_off());
}
#[cfg(not(target_os = "linux"))]
{
assert_eq!(f.mode(), FuseMode::Off);
assert!(f.is_off());
}
}

#[test]
Expand Down Expand Up @@ -808,10 +871,55 @@ caches:
}

#[test]
fn fuse_config_default_struct_is_off() {
fn fuse_config_default_struct_takes_platform_default() {
let f = FuseConfig::default();
assert_eq!(f.backend, None);
assert!(!f.is_on());
#[cfg(target_os = "linux")]
assert_eq!(f.mode(), FuseMode::Auto);
#[cfg(not(target_os = "linux"))]
assert_eq!(f.mode(), FuseMode::Off);
assert!(f.is_off());
}

#[test]
fn fuse_config_backend_kext() {
let yaml = "fuse:\n enabled: true\n backend: kext\n";
let cfg: ConfigYaml = serde_yaml::from_str(yaml).expect("parse");
let f = cfg.fuse.expect("fuse present");
assert_eq!(f.backend, Some(FuseBackend::Kext));
assert_eq!(f.mode(), FuseMode::On);
}

#[test]
fn fuse_config_backend_fskit() {
let yaml = "fuse:\n enabled: true\n backend: fskit\n";
let cfg: ConfigYaml = serde_yaml::from_str(yaml).expect("parse");
let f = cfg.fuse.expect("fuse present");
assert_eq!(f.backend, Some(FuseBackend::Fskit));
}

#[test]
fn fuse_config_backend_defaults_none_when_omitted() {
let yaml = "fuse:\n enabled: true\n";
let cfg: ConfigYaml = serde_yaml::from_str(yaml).expect("parse");
let f = cfg.fuse.expect("fuse present");
assert_eq!(f.backend, None);
}

#[test]
fn fuse_config_backend_rejects_unknown() {
let yaml = "fuse:\n enabled: true\n backend: bogus\n";
let err = serde_yaml::from_str::<ConfigYaml>(yaml).expect_err("must reject");
assert!(err.to_string().contains("bogus"), "{err}");
}

#[test]
fn fuse_config_helpers_set_mode_and_backend() {
assert_eq!(FuseConfig::on().mode(), FuseMode::On);
assert_eq!(FuseConfig::auto().mode(), FuseMode::Auto);
let f = FuseConfig::on().with_backend(FuseBackend::Fskit);
assert_eq!(f.backend, Some(FuseBackend::Fskit));
assert_eq!(f.mode(), FuseMode::On);
}

#[test]
Expand Down
15 changes: 14 additions & 1 deletion crates/e2e/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use heph::pluginexec;
use heph::pluginstatictarget;
use htestkit::WorkspaceBuilder;

pub use htestkit::{artifact_bytes, artifact_paths, artifact_string, root};
pub use htestkit::{artifact_bytes, artifact_paths, artifact_string, fuse_mount_works, root};

pub struct Workspace(htestkit::Workspace);

Expand Down Expand Up @@ -37,6 +37,19 @@ impl Workspace {
Self(builder.build().expect("build workspace"))
}

/// Workspace with the FUSE sandbox overlay forced on. Callers must first
/// check [`fuse_mount_works`] and skip the test when it returns false —
/// a forced-on mount errors on hosts without usable FUSE.
pub fn with_fuse() -> Self {
let builder = WorkspaceBuilder::new()
.expect("workspace tempdir")
.with_fuse(heph::engine::FuseConfig::on())
.with_provider(|init| Box::new(pluginbuildfile::Provider::new(init.root.to_path_buf())))
.with_managed_driver(Box::new(pluginexec::Driver::new_exec()))
.with_managed_driver(Box::new(pluginexec::Driver::new_bash()));
Self(builder.build().expect("build fuse workspace"))
}

pub fn with_static(targets: Vec<pluginstatictarget::Target>) -> Result<Self> {
let provider = pluginstatictarget::Provider::new(targets)?;
let ws = WorkspaceBuilder::new()?
Expand Down
133 changes: 133 additions & 0 deletions crates/e2e/tests/fuse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! End-to-end tests for the FUSE sandbox overlay.
//!
//! These force `fuse: { enabled: true }` and exercise the real mount path:
//! read-only dep outputs are served straight from the seekable tar bytes via
//! `TarIndex`, while a target's own writes land in the on-disk upper layer.
//!
//! Every test gates on [`common::fuse_mount_works`] and skips (rather than
//! fails) when the host has no usable FUSE — macFUSE present but its system
//! extension unapproved on macOS, or `/dev/fuse` missing in a container. On
//! Linux CI the mount succeeds and the bodies run for real.
//!
//! Note: a dep's output is materialized in the consumer's sandbox at
//! `<dep_package>/<out_name>`, so tests keep dep and consumer in distinct
//! packages with distinct output filenames to avoid path collisions.

mod common;

/// Skip the enclosing test when a real FUSE mount can't be brought up here.
macro_rules! require_fuse {
() => {
if !common::fuse_mount_works() {
eprintln!("skipping: no usable FUSE mount on this host");
return Ok(());
}
};
}

// A consumer reads its dependency's output. With FUSE on, the dep bytes are
// served from the tar layer through the mount — this is the exact path that
// used to abort with "RefCell already mutably borrowed" because the pooled
// sqlite connection backing the seekable reader was moved out from under the
// blob that borrowed it.
#[tokio::test]
async fn test_fuse_dep_output_read_through_layer() -> anyhow::Result<()> {
require_fuse!();
let ws = common::Workspace::with_fuse();
ws.write_build_file(
"libpkg",
r#"target(name = "lib", driver = "bash", run = "printf LIBDATA-9999 > $OUT", out = "lib.txt")"#,
);
ws.write_build_file(
"apppkg",
r#"target(name = "app", driver = "bash", run = "cat $SRC_LIB > $OUT", out = "app.txt", deps = {"lib": ["//libpkg:lib"]})"#,
);

let result = ws.run("//apppkg:app").await?;
let content = common::artifact_string(&result);
assert_eq!(
content, "LIBDATA-9999",
"dep bytes served via FUSE layer, got: {content:?}"
);
Ok(())
}

// A large dep output forces multi-block reads (>64 KiB copy chunks and many
// FUSE read ops). Verifies byte-exact streaming from the layer, not just that
// the mount comes up.
#[tokio::test]
async fn test_fuse_large_dep_read_is_byte_exact() -> anyhow::Result<()> {
require_fuse!();
let ws = common::Workspace::with_fuse();
// head reads directly from the /dev/zero file (no upstream pipe), so no
// SIGPIPE; tr maps NULs to 'A'. 500000 bytes >> one FUSE read.
ws.write_build_file(
"biglib",
r#"target(name = "lib", driver = "bash", run = "head -c 500000 /dev/zero | tr '\\0' 'A' > $OUT", out = "big.txt")"#,
);
ws.write_build_file(
"bigapp",
r#"target(name = "app", driver = "bash", run = "wc -c < $SRC_LIB | tr -d ' \n' > $OUT; printf : >> $OUT; cksum $SRC_LIB | cut -d' ' -f1 | tr -d '\n' >> $OUT", out = "app.txt", deps = {"lib": ["//biglib:lib"]})"#,
);

let result = ws.run("//bigapp:app").await?;
let content = common::artifact_string(&result);
let (count, sum) = content.split_once(':').unwrap_or(("", ""));
assert_eq!(count, "500000", "byte count via FUSE, got: {content:?}");
// cksum over 500000 'A' bytes is deterministic on coreutils.
assert!(!sum.is_empty(), "expected a checksum, got: {content:?}");
Ok(())
}

// A target writes a file, then reads it back within the same run. The write
// lands in the FUSE upper layer (copy-on-write) and the read must see it —
// exercises the passthrough write + upper-wins read path.
#[tokio::test]
async fn test_fuse_write_then_read_back_from_upper() -> anyhow::Result<()> {
require_fuse!();
let ws = common::Workspace::with_fuse();
ws.write_build_file(
"w",
r#"target(name = "t", driver = "bash", run = "echo scratch_payload > scratch.txt; cat scratch.txt > $OUT", out = "out.txt")"#,
);

let result = ws.run("//w:t").await?;
let content = common::artifact_string(&result);
assert!(
content.contains("scratch_payload"),
"upper-layer write should read back, got: {content:?}"
);
Ok(())
}

// Two consumers read the same dep concurrently. Each FUSE read opens its own
// pooled sqlite connection; this guards the multi-threaded read path (Linux
// runs the session multi-threaded) against connection-sharing regressions.
#[tokio::test]
async fn test_fuse_shared_dep_read_by_two_consumers() -> anyhow::Result<()> {
require_fuse!();
let ws = common::Workspace::with_fuse();
ws.write_build_file(
"leafpkg",
r#"target(name = "leaf", driver = "bash", run = "printf LEAFBYTES > $OUT", out = "leaf.txt")"#,
);
ws.write_build_file(
"fan",
r#"
target(name = "a", driver = "bash", run = "cat $SRC_LEAF > $OUT", out = "a.txt", deps = {"leaf": ["//leafpkg:leaf"]})
target(name = "b", driver = "bash", run = "cat $SRC_LEAF > $OUT", out = "b.txt", deps = {"leaf": ["//leafpkg:leaf"]})
target(
name = "root",
driver = "bash",
run = "printf '%s|%s' \"$(cat $SRC_A)\" \"$(cat $SRC_B)\" > $OUT",
out = "root.txt",
deps = {"a": ["//fan:a"], "b": ["//fan:b"]},
)
"#,
);

let result = ws.run("//fan:root").await?;
let content = common::artifact_string(&result);
assert_eq!(content, "LEAFBYTES|LEAFBYTES", "got: {content:?}");
Ok(())
}
Loading
Loading