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:49:48 UTC

(opendal-yinyang) branch xuanwo/linux-fuse-acceptance updated: test: cover Linux read-only directory traversal

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch xuanwo/linux-fuse-acceptance
in repository https://gitbox.apache.org/repos/asf/opendal-yinyang.git


The following commit(s) were added to refs/heads/xuanwo/linux-fuse-acceptance by this push:
     new cc0111b  test: cover Linux read-only directory traversal
cc0111b is described below

commit cc0111b5fab632632067343f1533aec0ba9fbbc1
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 26 15:49:42 2026 +0800

    test: cover Linux read-only directory traversal
    
    Seed a nested tree and a 520-entry directory through one Planner transaction, then exercise cached readdir after lookup, stable inodes, page cursors, and rename identity.
    
    Co-authored-by: Chojan Shang <ps...@apache.org>
---
 docs/linux-fuse-acceptance.md  |   6 +-
 tests/linux_fuse/acceptance.rs | 122 ++++++++++++++++++++++++++++++++++++++++-
 tests/linux_fuse/harness.rs    |  91 +++++++++++++++++++++++++++++-
 3 files changed, 216 insertions(+), 3 deletions(-)

diff --git a/docs/linux-fuse-acceptance.md b/docs/linux-fuse-acceptance.md
index 8971122..12c1cd5 100644
--- a/docs/linux-fuse-acceptance.md
+++ b/docs/linux-fuse-acceptance.md
@@ -42,10 +42,14 @@ cargo x test-linux-fuse --read-only
 
 - Read-only basic: mounted reads, and explicit rejection (`EROFS`/`EACCES`) of
   writable open, create, mkdir and rename.
+- Read-only directories: nested `read_dir` enumeration and stable inode numbers
+  across repeated stat calls, cached `readdir` after a file lookup/read, and a
+  520-entry bulk directory that crosses the adapter's 512-entry readdir page.
 - Basic writable I/O: create, write, remote `fsync` verified through an
   independent authority, append, truncate, and multiple descriptors on one file.
 - Safe-save rename replacement: `rename` replaces an observed destination
-  atomically and the independent authority observes the result.
+  atomically and the independent authority observes the result; plain rename
+  preserves the moved node's inode identity.
 - Retained unlink reads: bytes stay readable through an already-open descriptor
   after `unlink`.
 - Unsupported operations: symlink and hard link return an explicit OS error
diff --git a/tests/linux_fuse/acceptance.rs b/tests/linux_fuse/acceptance.rs
index d8fc17c..ebb63a4 100644
--- a/tests/linux_fuse/acceptance.rs
+++ b/tests/linux_fuse/acceptance.rs
@@ -22,7 +22,8 @@ use crate::harness::{
 };
 use std::fs::{self, File, OpenOptions};
 use std::io::{Read, Seek, SeekFrom, Write};
-use std::os::unix::fs::FileExt;
+use std::os::unix::fs::{FileExt, MetadataExt};
+use std::path::Path;
 use std::sync::{Arc, Barrier, mpsc};
 use std::thread;
 use std::time::Duration;
@@ -41,6 +42,105 @@ fn fuse_read_only_both_publications() {
     }
 }
 
+fn names(path: &Path) -> std::collections::BTreeSet<String> {
+    fs::read_dir(path)
+        .unwrap_or_else(|error| panic!("read directory {}: {error}", path.display()))
+        .map(|entry| {
+            entry
+                .expect("read directory entry")
+                .file_name()
+                .into_string()
+                .expect("UTF-8 directory entry")
+        })
+        .collect()
+}
+
+fn inode(path: &Path) -> u64 {
+    fs::metadata(path)
+        .unwrap_or_else(|error| panic!("stat {}: {error}", path.display()))
+        .ino()
+}
+
+#[test]
+#[ignore = "requires /dev/fuse and isolated S3; run with cargo x test-linux-fuse"]
+fn fuse_read_only_cached_readdir_and_nested_inodes_both_publications() {
+    for publication in [Publication::Object, Publication::Service] {
+        let fixture = Fixture::new(publication);
+        fixture.seed_read_only_tree();
+        let config = fixture.volume_config(
+            &format!("stage-readdir-{}", fixture.publication().as_str()),
+            Frontend::Fuse,
+            true,
+        );
+        let mountpoint = fixture
+            .temp
+            .path()
+            .join(format!("mnt-readdir-{}", fixture.publication().as_str()));
+        fs::create_dir(&mountpoint).expect("create mountpoint");
+        let mut mounted = fixture.mount(&config, &mountpoint);
+        let root = mounted.mountpoint();
+
+        let nested = root.join("nested");
+        let inner = nested.join("inner");
+        let file = inner.join("hello.txt");
+        assert_eq!(
+            fs::read(&file).expect("read nested file"),
+            b"hello from nested"
+        );
+        let nested_ino = inode(&nested);
+        let inner_ino = inode(&inner);
+        let file_ino = inode(&file);
+        assert_eq!(
+            inode(&nested),
+            nested_ino,
+            "nested inode changed across stat"
+        );
+        assert_eq!(
+            inode(&inner),
+            inner_ino,
+            "inner directory inode changed across stat"
+        );
+        assert_eq!(inode(&file), file_ino, "file inode changed across stat");
+
+        let root_first = names(root);
+        let root_second = names(root);
+        assert_eq!(root_first, root_second, "cached root readdir changed");
+        assert!(root_first.contains("nested"));
+        assert!(root_first.contains("bulk"));
+        assert!(root_first.contains("readonly.txt"));
+        let nested_first = names(&nested);
+        assert_eq!(
+            names(&nested),
+            nested_first,
+            "cached nested readdir changed"
+        );
+        let inner_first = names(&inner);
+        assert_eq!(
+            inner_first,
+            ["hello.txt"].into_iter().map(str::to_owned).collect()
+        );
+        assert_eq!(names(&inner), inner_first, "cached inner readdir changed");
+
+        let bulk = root.join("bulk");
+        assert_eq!(fs::read(bulk.join("entry-0000")).unwrap(), b"");
+        assert_eq!(
+            inode(&bulk.join("entry-0000")),
+            inode(&bulk.join("entry-0000"))
+        );
+        let bulk_first = names(&bulk);
+        let bulk_second = names(&bulk);
+        assert_eq!(bulk_first.len(), 520, "bulk directory entry count");
+        assert_eq!(bulk_first, bulk_second, "multi-page cached readdir changed");
+        for index in 0..520 {
+            assert!(
+                bulk_first.contains(&format!("entry-{index:04}")),
+                "missing bulk entry {index}"
+            );
+        }
+        mounted.unmount();
+    }
+}
+
 fn read_only(fixture: &Fixture) {
     let library = fixture.volume_config(
         &format!("lib-readonly-{}", fixture.publication().as_str()),
@@ -213,8 +313,28 @@ fn rename_and_unlink(fixture: &Fixture) {
     temp.write_all(b"new").expect("write rename source");
     temp.sync_all().expect("fsync rename source");
     drop(temp);
+    let source_inode = inode(&root.join("saved.txt.new"));
+    fs::rename(root.join("saved.txt.new"), root.join("renamed.txt"))
+        .expect("plain rename must preserve inode identity");
+    assert_eq!(
+        inode(&root.join("renamed.txt")),
+        source_inode,
+        "rename must not change the inode identity of the moved node"
+    );
+    fs::rename(root.join("renamed.txt"), root.join("saved.txt.new"))
+        .expect("rename back before safe-save");
+    let target_inode = inode(&root.join("saved.txt"));
     fs::rename(root.join("saved.txt.new"), root.join("saved.txt"))
         .expect("safe-save rename must atomically replace the target");
+    assert_eq!(
+        inode(&root.join("saved.txt")),
+        source_inode,
+        "safe-save replacement must keep the source identity"
+    );
+    assert_ne!(
+        target_inode, source_inode,
+        "safe-save replacement must replace the observed target node"
+    );
     assert!(!root.join("saved.txt.new").exists());
     assert_eq!(
         fs::read(root.join("saved.txt")).expect("read replaced target"),
diff --git a/tests/linux_fuse/harness.rs b/tests/linux_fuse/harness.rs
index fb0d3a8..4ffafc7 100644
--- a/tests/linux_fuse/harness.rs
+++ b/tests/linux_fuse/harness.rs
@@ -22,9 +22,12 @@ use std::io::{BufRead, BufReader, Read};
 use std::net::SocketAddr;
 use std::path::{Path, PathBuf};
 use std::process::{Child, Command, ExitStatus, Stdio};
-use std::sync::mpsc::{self, RecvTimeoutError};
+use std::sync::mpsc::RecvTimeoutError;
+use std::sync::{Arc, mpsc};
 use std::thread::{self, JoinHandle};
 use std::time::{Duration, Instant};
+use yinyang::core::service::ServiceClient;
+use yinyang::core::{Authority, BackendProfile, CommitId, CommitOutcome, Fs, Planner};
 
 /// The kernel filesystem type advertised by the YinYang FUSE adapter.
 pub const MOUNT_TYPE: &str = "fuse.yinyang";
@@ -391,6 +394,92 @@ impl Fixture {
         path
     }
 
+    /// Seed a directory tree in one core transaction. The 520 entries in
+    /// `bulk` cross the adapter's 512-entry readdir page without launching one
+    /// CLI process per entry.
+    pub fn seed_read_only_tree(&self) {
+        let operator = self.operator();
+        let publication = self.publication();
+        let address = self.service.as_ref().map(|service| service.address);
+        let token = self.token.clone();
+        let runtime = tokio::runtime::Builder::new_multi_thread()
+            .enable_all()
+            .build()
+            .expect("build seed runtime");
+        runtime.block_on(async move {
+            let authority: Arc<dyn Authority> = match publication {
+                Publication::Object => Arc::new(
+                    Fs::open(operator, BackendProfile::Minio)
+                        .await
+                        .expect("open object authority for batch seed"),
+                ),
+                Publication::Service => Arc::new(
+                    ServiceClient::connect(address.expect("service address"), token, operator)
+                        .await
+                        .expect("connect service authority for batch seed"),
+                ),
+            };
+            let snapshot = authority.observe_latest().await.expect("observe seed base");
+            let mut empty_source: &[u8] = b"";
+            let empty = authority
+                .prepare(&mut empty_source)
+                .await
+                .expect("prepare empty file content");
+            let mut hello_source: &[u8] = b"hello from nested";
+            let hello = authority
+                .prepare(&mut hello_source)
+                .await
+                .expect("prepare nested file content");
+            let mut root_source: &[u8] = b"read-only bytes";
+            let root_file = authority
+                .prepare(&mut root_source)
+                .await
+                .expect("prepare root file content");
+            let mut plan = Planner::new(&snapshot, CommitId::generate());
+            let nested = plan
+                .create_directory(authority.root(), "nested")
+                .await
+                .expect("plan nested directory");
+            let inner = plan
+                .create_directory(nested, "inner")
+                .await
+                .expect("plan inner directory");
+            plan.create_file(inner, "hello.txt", hello, false)
+                .await
+                .expect("plan nested file");
+            plan.create_file(authority.root(), "readonly.txt", root_file, false)
+                .await
+                .expect("plan root file");
+            let bulk = plan
+                .create_directory(authority.root(), "bulk")
+                .await
+                .expect("plan bulk directory");
+            for index in 0..520 {
+                plan.create_file(bulk, &format!("entry-{index:04}"), empty.clone(), false)
+                    .await
+                    .expect("plan bulk entry");
+            }
+            let request = plan.finish().expect("finish batch seed");
+            let outcome = authority.commit(&request).await.expect("commit batch seed");
+            assert!(
+                matches!(&outcome, CommitOutcome::Committed(_)),
+                "batch seed did not commit: {outcome:?}"
+            );
+        });
+    }
+
+    fn operator(&self) -> opendal::Operator {
+        let config = self
+            .storage_env()
+            .into_iter()
+            .filter_map(|(key, value)| {
+                key.strip_prefix("YINYANG_S3_")
+                    .map(|key| (key.to_ascii_lowercase(), value))
+            })
+            .collect::<Vec<_>>();
+        opendal::Operator::via_iter("s3", config).expect("construct S3 operator")
+    }
+
     pub fn library_seed(&self, config: &Path, path: &str, bytes: &[u8]) {
         self.yy_ok(&[
             "--volume",