Skip to content

Commit fce1834

Browse files
committed
fuse: Expose FUSE serving via CLI and varlink RPC
Wire the composefs-fuse crate into cfsctl behind a new `fuse` cargo feature (on by default) and expose it through both the command line and the varlink RPC API, with an integration test exercising the FUSE mount end to end. CLI surface: - `cfsctl fuse-serve <image> <mountpoint>` serves an EROFS composefs image over FUSE from a file on disk. - `cfsctl oci mount --fuse[=<opts>]` FUSE-serves an OCI image's EROFS instead of doing a kernel composefs mount, so it works without fs-verity on the backing store. `--fuse=passthrough` opts into kernel-bypass reads (Linux 6.9+). Options are parsed via a small FuseOptions FromStr so the surface can grow without new flags. Varlink surface: - `org.composefs.Repository.FuseServe` and `org.composefs.Oci.OciFuseMount` let a client drive FUSE mounts over the RPC socket. Both take a `wait` parameter: with `wait=true` the call blocks for the session; with `wait=false` the FUSE session is detached into a background task and the call returns once the mount is registered, so a caller can mount and then go on to use the filesystem. The privileged_fuse_dumpfile_roundtrip integration test spawns `cfsctl fuse-serve` as a subprocess, polls for mount readiness via st_dev change, reads external files directly, and compares the dumpfile produced by `cfsctl create-dumpfile` over the FUSE mount against the expected output from write_dumpfile, asserting the FUSE implementation reports every piece of metadata the dumpfile format captures. Uses similar_asserts for readable diffs on mismatch. Assisted-by: OpenCode (claude-sonnet-4-6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 0262e1e commit fce1834

5 files changed

Lines changed: 1419 additions & 96 deletions

File tree

crates/composefs-ctl/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ name = "cfsctl"
1717
path = "src/main.rs"
1818

1919
[features]
20-
default = ['pre-6.15', 'oci', 'containers-storage']
20+
default = ['pre-6.15', 'oci', 'containers-storage', 'fuse']
21+
fuse = ['dep:composefs-fuse']
2122
http = ['composefs-http']
2223
oci = ['composefs-oci', 'composefs-oci/varlink']
2324
containers-storage = ['composefs-oci/containers-storage', 'cstorage']
@@ -31,6 +32,7 @@ clap = { version = "4.5.0", default-features = false, features = ["std", "help",
3132
comfy-table = { version = "7.1", default-features = false }
3233
composefs = { workspace = true, features = ["varlink"] }
3334
composefs-boot = { workspace = true }
35+
composefs-fuse = { path = "../composefs-fuse", optional = true }
3436
composefs-oci = { workspace = true, optional = true, features = ["boot"] }
3537
composefs-http = { workspace = true, optional = true }
3638
cstorage = { package = "composefs-storage", path = "../composefs-storage", version = "0.4.0", features = ["userns-helper"], optional = true }

crates/composefs-ctl/src/lib.rs

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,33 @@ impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
318318
}
319319
}
320320

321+
/// Options accepted by `--fuse[=<opts>]` on `oci mount`.
322+
///
323+
/// Pass bare `--fuse` to FUSE-mount with defaults, or `--fuse=passthrough`
324+
/// to also enable kernel-bypass reads for external files.
325+
///
326+
/// Multiple options are comma-separated: `--fuse=passthrough,option2`
327+
/// (only `passthrough` is defined today).
328+
#[derive(Debug, Default, Clone)]
329+
struct FuseOptions {
330+
passthrough: bool,
331+
}
332+
333+
impl std::str::FromStr for FuseOptions {
334+
type Err = anyhow::Error;
335+
336+
fn from_str(s: &str) -> Result<Self, Self::Err> {
337+
let mut opts = FuseOptions::default();
338+
for token in s.split(',').map(str::trim).filter(|t| !t.is_empty()) {
339+
match token {
340+
"passthrough" => opts.passthrough = true,
341+
other => anyhow::bail!("unknown fuse option: {other:?} (known: passthrough)"),
342+
}
343+
}
344+
Ok(opts)
345+
}
346+
}
347+
321348
/// Common options for operations using OCI config manifest streams that may transform the image rootfs
322349
#[cfg(feature = "oci")]
323350
#[derive(Debug, Parser)]
@@ -447,6 +474,17 @@ enum OciCommand {
447474
/// Mount read-write (requires --upperdir)
448475
#[arg(long, requires = "upperdir")]
449476
read_write: bool,
477+
/// Serve the EROFS image over FUSE instead of using a kernel composefs mount.
478+
/// Requires /dev/fuse and blocks until the mount is detached or the process
479+
/// is killed. Does not require fs-verity on the backing store.
480+
///
481+
/// Accepts an optional comma-separated list of options:
482+
/// --fuse basic FUSE mount
483+
/// --fuse=passthrough also enable kernel-bypass reads (Linux 6.9+, root, non-tmpfs)
484+
#[cfg_attr(not(feature = "fuse"), arg(hide = true))]
485+
#[arg(long, num_args = 0..=1, require_equals = false, value_name = "OPTS",
486+
default_missing_value = "")]
487+
fuse: Option<FuseOptions>,
450488
},
451489
/// Compute the composefs image ID of a stored OCI image's rootfs
452490
///
@@ -584,6 +622,23 @@ enum Command {
584622
#[arg(long, requires = "upperdir")]
585623
read_write: bool,
586624
},
625+
/// Serve an EROFS composefs image over FUSE at the given mountpoint.
626+
///
627+
/// Reads the EROFS image, opens /dev/fuse, mounts and attaches the
628+
/// FUSE filesystem at `<mountpoint>`, then blocks serving requests
629+
/// until killed or unmounted. External file objects are resolved
630+
/// via the repository given by `--repo`.
631+
#[cfg(feature = "fuse")]
632+
FuseServe {
633+
/// Path to the EROFS composefs image file.
634+
image: PathBuf,
635+
/// Directory to attach the FUSE mount at (must already exist).
636+
mountpoint: PathBuf,
637+
/// Enable FUSE passthrough for external files (Linux 6.9+;
638+
/// requires root and a non-tmpfs backing filesystem).
639+
#[clap(long)]
640+
passthrough: bool,
641+
},
587642
/// Read rootfs located at a path, add all files to the repo, then create the composefs image of the rootfs,
588643
/// commit it to the repo, and print its image object ID
589644
CreateImage {
@@ -1264,9 +1319,8 @@ where
12641319
ref upperdir,
12651320
ref workdir,
12661321
read_write,
1322+
fuse,
12671323
} => {
1268-
let mount_options =
1269-
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
12701324
let img = if image.starts_with("sha256:") {
12711325
let digest: composefs_oci::OciDigest =
12721326
image.parse().context("Parsing manifest digest")?;
@@ -1289,7 +1343,50 @@ where
12891343
),
12901344
}
12911345
};
1292-
repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1346+
if let Some(fuse_opts) = fuse {
1347+
#[cfg(feature = "fuse")]
1348+
{
1349+
use composefs_fuse::{FuseConfig, mount_fuse, open_fuse, serve_tree_fuse};
1350+
1351+
// Read the EROFS image from the repository's images/ directory.
1352+
let (image_fd, _verified) = repo.open_image(&erofs_id.to_hex())?;
1353+
let erofs_bytes = {
1354+
let mut buf = Vec::new();
1355+
std::fs::File::from(image_fd).read_to_end(&mut buf)?;
1356+
buf
1357+
};
1358+
let filesystem = erofs_to_filesystem::<ObjectID>(&erofs_bytes)
1359+
.context("parsing EROFS image")?;
1360+
1361+
let dev_fuse = open_fuse()?;
1362+
let mnt_fd = mount_fuse(&dev_fuse)?;
1363+
composefs::mount::mount_at(&mnt_fd, CWD, mountpoint.as_str())
1364+
.with_context(|| format!("attaching FUSE mount at {mountpoint}"))?;
1365+
1366+
// Hold mnt_fd alive for the session duration — it pins the FUSE
1367+
// superblock so the connection stays alive while we serve.
1368+
let _mnt_fd = mnt_fd;
1369+
1370+
serve_tree_fuse(
1371+
dev_fuse,
1372+
Arc::new(filesystem),
1373+
Arc::clone(&repo),
1374+
FuseConfig {
1375+
passthrough: fuse_opts.passthrough,
1376+
},
1377+
)
1378+
.context("FUSE session error")?;
1379+
}
1380+
#[cfg(not(feature = "fuse"))]
1381+
{
1382+
let _ = fuse_opts;
1383+
anyhow::bail!("cfsctl was built without FUSE support");
1384+
}
1385+
} else {
1386+
let mount_options =
1387+
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1388+
repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1389+
}
12931390
}
12941391
OciCommand::ComputeId { config_opts } => {
12951392
let mut fs = load_filesystem_from_oci_image(&repo, config_opts)?;
@@ -1521,6 +1618,36 @@ where
15211618
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
15221619
repo.mount_at(&name, &mountpoint, &mount_options)?;
15231620
}
1621+
#[cfg(feature = "fuse")]
1622+
Command::FuseServe {
1623+
ref image,
1624+
ref mountpoint,
1625+
passthrough,
1626+
} => {
1627+
use composefs_fuse::{FuseConfig, mount_fuse, open_fuse, serve_tree_fuse};
1628+
1629+
let erofs_bytes = std::fs::read(image)
1630+
.with_context(|| format!("reading EROFS image {}", image.display()))?;
1631+
let filesystem =
1632+
erofs_to_filesystem::<ObjectID>(&erofs_bytes).context("parsing EROFS image")?;
1633+
1634+
let dev_fuse = open_fuse()?;
1635+
let mnt_fd = mount_fuse(&dev_fuse)?;
1636+
composefs::mount::mount_at(&mnt_fd, CWD, mountpoint)
1637+
.with_context(|| format!("attaching FUSE mount at {}", mountpoint.display()))?;
1638+
1639+
// Hold mnt_fd alive for the session duration — it pins the FUSE
1640+
// superblock so the connection stays alive while we serve.
1641+
let _mnt_fd = mnt_fd;
1642+
1643+
serve_tree_fuse(
1644+
dev_fuse,
1645+
Arc::new(filesystem),
1646+
Arc::clone(&repo),
1647+
FuseConfig { passthrough },
1648+
)
1649+
.context("FUSE session error")?;
1650+
}
15241651
Command::ImageObjects { name } => {
15251652
let objects = repo.objects_for_image(&name)?;
15261653
for object in objects {

0 commit comments

Comments
 (0)