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",