You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/10/26 07:37:45 UTC
(opendal-yinyang) 02/06: feat: admit read-only Linux FUSE volumes
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/linux-fuse-foundation
in repository https://gitbox.apache.org/repos/asf/opendal-yinyang.git
commit 20ab7ea2959493639a12ac2a4604632a74d8ebc4
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 26 15:18:10 2026 +0800
feat: admit read-only Linux FUSE volumes
Negotiate the fuse frontend from its own implemented capability set
instead of rejecting every non-library frontend. The combination baseline
is now the intersection of the volume, access and frontend contracts, so
a read-only FUSE mount still requires read, list, stable identity, pinned
reads and retained-unlink reads without claiming recoverable staging or
any write-side operation. Writable and non-Linux FUSE configurations are
rejected before an authority connection or staging lease.
Co-authored-by: Chojan Shang <ps...@apache.org>
---
src/volume.rs | 95 +++++++++++++++++++++++++++++++++++++++++----------------
tests/volume.rs | 42 +++++++++++++++++++++++++
2 files changed, 111 insertions(+), 26 deletions(-)
diff --git a/src/volume.rs b/src/volume.rs
index 7ae8ded..b93a254 100644
--- a/src/volume.rs
+++ b/src/volume.rs
@@ -73,6 +73,21 @@ fn read_only() -> CapabilitySet {
.into_iter()
.collect()
}
+/// Read-only Linux FUSE frontend capabilities. The frontend serves immutable
+/// pinned file versions and pinned directory scans, so it does not provide
+/// recoverable staging or any write-side operation.
+fn fuse() -> CapabilitySet {
+ use Capability::*;
+ [
+ Read,
+ List,
+ StableIdentity,
+ PinnedReads,
+ RetainedUnlinkRead,
+ ]
+ .into_iter()
+ .collect()
+}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Model {
@@ -168,11 +183,7 @@ impl VolumeConfig {
/// Negotiation is read-only. Storage capabilities are semantic prerequisites
/// for Managed operations, not claims that raw S3 implements atomic rename.
pub fn capabilities(&self, operator: &Operator) -> Capabilities {
- let volume = if self.model == Model::Managed {
- managed()
- } else {
- CapabilitySet::new()
- };
+ let volume = self.volume_capabilities();
let cap = operator.info().capability();
let content = operator.info().scheme() == "s3"
&& cap.read
@@ -185,21 +196,43 @@ impl VolumeConfig {
} else {
CapabilitySet::new()
};
- let access = if self.access == Access::Mount {
- if self.read_only {
- read_only()
- } else {
- managed()
- }
- } else {
- CapabilitySet::new()
- };
- let frontend = if self.frontend == Frontend::Library {
+ let access = self.access_capabilities();
+ let frontend = self.frontend_capabilities();
+ Capabilities::intersect(volume, storage, access, frontend)
+ }
+ fn volume_capabilities(&self) -> CapabilitySet {
+ if self.model == Model::Managed {
managed()
} else {
CapabilitySet::new()
- };
- Capabilities::intersect(volume, storage, access, frontend)
+ }
+ }
+ fn access_capabilities(&self) -> CapabilitySet {
+ match self.access {
+ Access::Mount if self.read_only => read_only(),
+ Access::Mount => managed(),
+ Access::Sync => CapabilitySet::new(),
+ }
+ }
+ fn frontend_capabilities(&self) -> CapabilitySet {
+ match self.frontend {
+ Frontend::Library => managed(),
+ Frontend::Fuse => fuse(),
+ Frontend::Macos | Frontend::Windows => CapabilitySet::new(),
+ }
+ }
+ /// Capabilities the selected combination promises independent of storage
+ /// support. Startup requires this intersection to survive storage and
+ /// frontend negotiation, so a frontend cannot silently weaken the model.
+ fn baseline(&self) -> CapabilitySet {
+ let volume = self.volume_capabilities();
+ let access = self.access_capabilities();
+ let frontend = self.frontend_capabilities();
+ volume
+ .intersection(&access)
+ .filter(|capability| frontend.contains(capability))
+ .copied()
+ .collect()
}
pub fn validate(&self, operator: &Operator) -> Result<Capabilities> {
if self.name.trim().is_empty() || self.staging.as_os_str().is_empty() {
@@ -211,10 +244,24 @@ impl VolumeConfig {
if self.access != Access::Mount {
return Err(Error::Invalid("Sync reconciliation is not implemented"));
}
- if self.frontend != Frontend::Library {
- return Err(Error::Invalid(
- "OS frontends are not implemented; select the library frontend",
- ));
+ match self.frontend {
+ Frontend::Library => {}
+ Frontend::Fuse if !cfg!(target_os = "linux") => {
+ return Err(Error::Invalid(
+ "the fuse frontend is implemented only on Linux",
+ ));
+ }
+ Frontend::Fuse if !self.read_only => {
+ return Err(Error::Invalid(
+ "the Linux FUSE frontend is read-only in this release",
+ ));
+ }
+ Frontend::Fuse => {}
+ Frontend::Macos | Frontend::Windows => {
+ return Err(Error::Invalid(
+ "macOS and Windows frontends are not implemented; select library or fuse",
+ ));
+ }
}
match (self.publication, self.service_address) {
(Publication::Service, Some(addr)) if addr.ip().is_loopback() => {}
@@ -226,11 +273,7 @@ impl VolumeConfig {
}
}
let caps = self.capabilities(operator);
- let baseline = if self.read_only {
- read_only()
- } else {
- managed()
- };
+ let baseline = self.baseline();
if !baseline.is_subset(&caps.effective) || !self.require.is_subset(&caps.effective) {
return Err(Error::Invalid(
"effective capability intersection is missing baseline or required operations",
diff --git a/tests/volume.rs b/tests/volume.rs
index e512c78..15e3fe8 100644
--- a/tests/volume.rs
+++ b/tests/volume.rs
@@ -95,6 +95,48 @@ async fn capability_intersection_and_startup_rejection_precede_visibility() {
assert!(VolumeConfig::from_json(&serde_json::to_vec(&json).unwrap()).is_err());
}
#[tokio::test]
+async fn fuse_frontend_admits_only_read_only_managed_mounts() {
+ let temp = tempfile::tempdir().unwrap();
+ let backend = TestBackend::default();
+ let cfg = VolumeConfig {
+ frontend: Frontend::Fuse,
+ read_only: true,
+ require: BTreeSet::new(),
+ ..config(temp.path().join("state"))
+ };
+ let caps = cfg.capabilities(&backend.operator());
+ let expected: CapabilitySet = [
+ Capability::Read,
+ Capability::List,
+ Capability::StableIdentity,
+ Capability::PinnedReads,
+ Capability::RetainedUnlinkRead,
+ ]
+ .into_iter()
+ .collect();
+ assert_eq!(caps.frontend, expected);
+ assert_eq!(caps.effective, expected);
+ assert!(!caps.effective.contains(&Capability::RecoverableStaging));
+ if cfg!(target_os = "linux") {
+ assert!(cfg.validate(&backend.operator()).is_ok());
+ } else {
+ assert!(cfg.validate(&backend.operator()).is_err());
+ }
+ for invalid in [
+ VolumeConfig {
+ read_only: false,
+ ..cfg.clone()
+ },
+ VolumeConfig {
+ service_address: Some("0.0.0.0:7447".parse().unwrap()),
+ publication: Publication::Service,
+ ..cfg.clone()
+ },
+ ] {
+ assert!(invalid.validate(&backend.operator()).is_err());
+ }
+}
+#[tokio::test]
async fn read_only_volume_enforces_policy_on_new_and_recovered_handles() {
let temp = tempfile::tempdir().unwrap();
let backend = TestBackend::default();