Skip to content

Commit 02ccc6d

Browse files
committed
fuse: Add FUSE serving via CLI and varlink RPC
Wire the composefs-fuse crate into cfsctl behind a new `fuse` cargo feature (on by default). The FUSE implementation supports readdirplus, multithreaded serving via FUSE_DEV_IOC_CLONE, FOPEN_KEEP_CACHE, and optional passthrough (Linux 6.9+, root only) for kernel-bypass reads on external object files. CLI surface: - `cfsctl mount --fuse[=passthrough] [--raw-image]` serves an image over FUSE instead of doing a kernel composefs mount. `--raw-image` reads a bare EROFS file from disk rather than looking up a repo image. This replaces the old `fuse-serve` subcommand; all mount paths now live under `mount`. - `cfsctl oci mount --fuse[=passthrough]` likewise for OCI images. Varlink surface — the single `Mount`/`OciMount` methods now always return a detached mount fd via SCM_RIGHTS, for both kernel and FUSE backends. A new `MountPath`/`OciMountPath` pair handles the attach-to- a-path case server-side (blocking until the FUSE session ends unless `wait=false`). This makes FUSE mounts a proper first-class path: the caller gets the fd and can move_mount it wherever it likes, with the FUSE server running in the background for as long as the fd is held. Also adds `erofs_fd_to_filesystem` in composefs-fuse to replace the open-coded open_image → read_to_end → erofs_to_filesystem pattern that appeared at every call site. The privileged_fuse_dumpfile_roundtrip integration test spawns `cfsctl mount --raw-image --fuse`, polls for mount readiness via st_dev change, and compares the dumpfile produced over the FUSE mount against the expected output from write_dumpfile. Assisted-by: OpenCode (claude-sonnet-4-6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent e39e0af commit 02ccc6d

6 files changed

Lines changed: 1954 additions & 231 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", version = "0.4.0", 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: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,35 @@ impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
318318
}
319319
}
320320

321+
/// Options accepted by `--fuse[=<opts>]` on `mount` and `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+
#[cfg(feature = "fuse")]
329+
#[derive(Debug, Default, Clone)]
330+
struct FuseOptions {
331+
passthrough: bool,
332+
}
333+
334+
#[cfg(feature = "fuse")]
335+
impl std::str::FromStr for FuseOptions {
336+
type Err = anyhow::Error;
337+
338+
fn from_str(s: &str) -> Result<Self, Self::Err> {
339+
let mut opts = FuseOptions::default();
340+
for token in s.split(',').map(str::trim).filter(|t| !t.is_empty()) {
341+
match token {
342+
"passthrough" => opts.passthrough = true,
343+
other => anyhow::bail!("unknown fuse option: {other:?} (known: passthrough)"),
344+
}
345+
}
346+
Ok(opts)
347+
}
348+
}
349+
321350
/// Common options for operations using OCI config manifest streams that may transform the image rootfs
322351
#[cfg(feature = "oci")]
323352
#[derive(Debug, Parser)]
@@ -447,6 +476,17 @@ enum OciCommand {
447476
/// Mount read-write (requires --upperdir)
448477
#[arg(long, requires = "upperdir")]
449478
read_write: bool,
479+
/// Serve the EROFS image over FUSE instead of using a kernel composefs mount.
480+
/// Requires /dev/fuse and blocks until the mount is detached or the process
481+
/// is killed. Does not require fs-verity on the backing store.
482+
///
483+
/// Accepts an optional comma-separated list of options:
484+
/// --fuse basic FUSE mount
485+
/// --fuse=passthrough also enable kernel-bypass reads (Linux 6.9+, root, non-tmpfs)
486+
#[cfg(feature = "fuse")]
487+
#[arg(long, num_args = 0..=1, require_equals = false, value_name = "OPTS",
488+
default_missing_value = "")]
489+
fuse: Option<FuseOptions>,
450490
},
451491
/// Compute the composefs image ID of a stored OCI image's rootfs
452492
///
@@ -568,13 +608,23 @@ enum Command {
568608
#[clap(subcommand)]
569609
cmd: OciCommand,
570610
},
571-
/// Mounts a composefs image, possibly enforcing fsverity of the image
611+
/// Mounts a composefs image, possibly enforcing fsverity of the image.
612+
///
613+
/// By default the image is identified by its repo name (an fs-verity hash
614+
/// or a `ref/` tag). Pass `--raw-image` to supply a path to a bare EROFS
615+
/// file instead, which skips the repository image store lookup and is
616+
/// required when using `--fuse` without a full repository setup.
572617
Mount {
573-
/// the name of the image to mount, either an fs-verity hash or prefixed with 'ref/'
618+
/// The image to mount: a repo name (fs-verity hash or `ref/<tag>`) by
619+
/// default, or a filesystem path when `--raw-image` is given.
574620
name: String,
575621
/// the mountpoint
576622
mountpoint: String,
577-
/// Writable upper layer directory for overlayfs
623+
/// Treat <name> as a path to a raw EROFS image file instead of a repo
624+
/// image name.
625+
#[arg(long)]
626+
raw_image: bool,
627+
/// Writable upper layer directory for overlayfs (kernel mount only)
578628
#[arg(long, requires = "workdir")]
579629
upperdir: Option<PathBuf>,
580630
/// Work directory for overlayfs (required with --upperdir)
@@ -583,6 +633,15 @@ enum Command {
583633
/// Mount read-write (requires --upperdir)
584634
#[arg(long, requires = "upperdir")]
585635
read_write: bool,
636+
/// Serve the image over FUSE instead of a kernel composefs mount.
637+
///
638+
/// Accepts an optional comma-separated list of options:
639+
/// --fuse basic FUSE mount
640+
/// --fuse=passthrough also enable kernel-bypass reads (Linux 6.9+, root, non-tmpfs)
641+
#[cfg(feature = "fuse")]
642+
#[arg(long, num_args = 0..=1, require_equals = false, value_name = "OPTS",
643+
default_missing_value = "")]
644+
fuse: Option<FuseOptions>,
586645
},
587646
/// Read rootfs located at a path, add all files to the repo, then create the composefs image of the rootfs,
588647
/// commit it to the repo, and print its image object ID
@@ -1264,9 +1323,9 @@ where
12641323
ref upperdir,
12651324
ref workdir,
12661325
read_write,
1326+
#[cfg(feature = "fuse")]
1327+
fuse,
12671328
} => {
1268-
let mount_options =
1269-
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
12701329
let img = if image.starts_with("sha256:") {
12711330
let digest: composefs_oci::OciDigest =
12721331
image.parse().context("Parsing manifest digest")?;
@@ -1289,7 +1348,45 @@ where
12891348
),
12901349
}
12911350
};
1292-
repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1351+
#[cfg(feature = "fuse")]
1352+
if let Some(fuse_opts) = fuse {
1353+
use composefs_fuse::{
1354+
FuseConfig, erofs_fd_to_filesystem, mount_fuse, open_fuse,
1355+
serve_tree_fuse_fd,
1356+
};
1357+
1358+
let (image_fd, _verified) = repo.open_image(&erofs_id.to_hex())?;
1359+
let filesystem = erofs_fd_to_filesystem::<ObjectID>(image_fd)?;
1360+
1361+
let dev_fuse = open_fuse()?;
1362+
let mnt_fd = mount_fuse(&dev_fuse, &Default::default())?;
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_fd(
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+
} else {
1380+
let mount_options =
1381+
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1382+
repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?;
1383+
}
1384+
#[cfg(not(feature = "fuse"))]
1385+
{
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)?;
@@ -1513,13 +1610,58 @@ where
15131610
Command::Mount {
15141611
name,
15151612
mountpoint,
1613+
raw_image,
15161614
ref upperdir,
15171615
ref workdir,
15181616
read_write,
1617+
#[cfg(feature = "fuse")]
1618+
fuse,
15191619
} => {
1520-
let mount_options =
1521-
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1522-
repo.mount_at(&name, &mountpoint, &mount_options)?;
1620+
#[cfg(feature = "fuse")]
1621+
if let Some(fuse_opts) = fuse {
1622+
use composefs_fuse::{
1623+
FuseConfig, erofs_fd_to_filesystem, mount_fuse, open_fuse, serve_tree_fuse_fd,
1624+
};
1625+
1626+
let filesystem = if raw_image {
1627+
let bytes = std::fs::read(&name).with_context(|| format!("reading {name}"))?;
1628+
erofs_to_filesystem::<ObjectID>(&bytes).context("parsing EROFS image")?
1629+
} else {
1630+
let (image_fd, _verified) = repo.open_image(&name)?;
1631+
erofs_fd_to_filesystem::<ObjectID>(image_fd)?
1632+
};
1633+
1634+
let dev_fuse = open_fuse()?;
1635+
let mnt_fd = mount_fuse(&dev_fuse, &Default::default())?;
1636+
composefs::mount::mount_at(&mnt_fd, CWD, mountpoint.as_str())
1637+
.with_context(|| format!("attaching FUSE mount at {mountpoint}"))?;
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_fd(
1644+
dev_fuse,
1645+
Arc::new(filesystem),
1646+
Arc::clone(&repo),
1647+
FuseConfig {
1648+
passthrough: fuse_opts.passthrough,
1649+
},
1650+
)
1651+
.context("FUSE session error")?;
1652+
} else {
1653+
anyhow::ensure!(!raw_image, "--raw-image requires --fuse");
1654+
let mount_options =
1655+
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1656+
repo.mount_at(&name, &mountpoint, &mount_options)?;
1657+
}
1658+
#[cfg(not(feature = "fuse"))]
1659+
{
1660+
anyhow::ensure!(!raw_image, "--raw-image requires --fuse");
1661+
let mount_options =
1662+
get_mount_options(upperdir.as_deref(), workdir.as_deref(), read_write)?;
1663+
repo.mount_at(&name, &mountpoint, &mount_options)?;
1664+
}
15231665
}
15241666
Command::ImageObjects { name } => {
15251667
let objects = repo.objects_for_image(&name)?;

0 commit comments

Comments
 (0)