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();