#![cfg(all(target_os = "linux", feature = "fuse"))]
use std::{
fs,
io::Write,
path::{Path, PathBuf},
sync::Arc,
thread,
time::{Duration, Instant},
};
use mount::{BackgroundSession, ContentAddressedMount, FuseShell};
use repo::Repository;
use tempfile::TempDir;
fn build_fixture() -> (TempDir, Repository) {
let repo_dir = TempDir::new().expect("tempdir for repo");
let repo = Repository::init_default(repo_dir.path()).expect("init_default");
fs::write(repo_dir.path().join("hello.txt"), b"world").expect("write hello.txt");
repo.snapshot(Some("fixture".into()), None)
.expect("snapshot fixture");
(repo_dir, repo)
}
fn mount_fixture(repo: Repository) -> (BackgroundSession, TempDir) {
let mount = ContentAddressedMount::new(repo, "main").expect("open mount");
let mountpoint = TempDir::new().expect("tempdir for mountpoint");
let session = FuseShell::new(mount)
.mount_background(mountpoint.path())
.expect("mount session");
let target = mountpoint.path().join("hello.txt");
wait_for(&target, true, Duration::from_secs(5));
(session, mountpoint)
}
fn mount_fixture_with_read_counter(
repo: Repository,
) -> (
BackgroundSession,
TempDir,
std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
let mount = ContentAddressedMount::new(repo, "main").expect("open mount");
let mountpoint = TempDir::new().expect("tempdir for mountpoint");
let shell = FuseShell::new(mount);
let read_calls = shell.read_calls_handle();
let session = shell
.mount_background(mountpoint.path())
.expect("mount session");
let target = mountpoint.path().join("hello.txt");
wait_for(&target, true, Duration::from_secs(5));
(session, mountpoint, read_calls)
}
fn wait_for(target: &Path, expect_present: bool, dur: Duration) {
let deadline = Instant::now() + dur;
while target.exists() != expect_present && Instant::now() < deadline {
thread::sleep(Duration::from_millis(20));
}
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_serves_blob_content() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let target = mountpoint.path().join("hello.txt");
let read = fs::read_to_string(&target).expect("read mounted file");
assert_eq!(read, "world");
drop(session); }
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_round_trips_writes_to_existing_file() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let target = mountpoint.path().join("hello.txt");
assert_eq!(fs::read_to_string(&target).expect("read captured"), "world");
{
let mut f = fs::OpenOptions::new()
.write(true)
.open(&target)
.expect("open for write");
f.write_all(b"WORLD").expect("write through mount");
}
let after = fs::read_to_string(&target).expect("read after write");
assert_eq!(
after, "WORLD",
"expected write-through-mount to be visible on re-read"
);
drop(session);
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_cache_stays_coherent_after_write() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let target = mountpoint.path().join("hello.txt");
assert_eq!(
fs::read_to_string(&target).expect("prime read"),
"world",
"captured content visible before write"
);
{
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&target)
.expect("open for truncate+write");
f.write_all(b"FRESH-AND-LONGER-CONTENT")
.expect("write new content");
}
let deadline = Instant::now() + Duration::from_millis(700);
let mut after = fs::read_to_string(&target).expect("read after rewrite");
while after != "FRESH-AND-LONGER-CONTENT" && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
after = fs::read_to_string(&target).expect("re-read after rewrite");
}
assert_eq!(
after, "FRESH-AND-LONGER-CONTENT",
"active invalidation must drop the stale page cache so the \
reopened fd sees fresh content (heddle#87 coherence)"
);
drop(session);
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_serves_repeat_reads_from_cache() {
use std::sync::atomic::Ordering;
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint, read_calls) = mount_fixture_with_read_counter(repo);
let target = mountpoint.path().join("hello.txt");
assert_eq!(fs::read_to_string(&target).expect("first read"), "world");
let after_first = read_calls.load(Ordering::Relaxed);
assert!(
after_first >= 1,
"the cold first read must reach the FUSE read callback at least once \
(got {after_first})"
);
assert_eq!(fs::read_to_string(&target).expect("second read"), "world");
let after_second = read_calls.load(Ordering::Relaxed);
assert_eq!(
after_second, after_first,
"cached mode must serve the second read from the kernel page cache \
without a FUSE round-trip — counter went {after_first} → {after_second}; \
a nonzero delta means the mount is still bypassing the cache \
(FOPEN_DIRECT_IO regression, heddle#87)"
);
drop(session);
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_serves_concurrent_readers() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let target = Arc::new(mountpoint.path().join("hello.txt"));
const THREADS: usize = 4;
const READS_PER_THREAD: usize = 20;
let handles: Vec<_> = (0..THREADS)
.map(|_| {
let target = Arc::clone(&target);
thread::spawn(move || {
for _ in 0..READS_PER_THREAD {
let read = fs::read_to_string(&*target).expect("concurrent read");
assert_eq!(read, "world");
}
})
})
.collect();
for h in handles {
h.join().expect("reader thread panicked");
}
drop(session);
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_serves_mmap_readers() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let target = mountpoint.path().join("hello.txt");
let file = fs::File::open(&target).expect("open mounted file");
let mapping = unsafe { memmap2::Mmap::map(&file) }
.expect("mmap(MAP_SHARED) on mounted file (cached mode must be in effect)");
assert_eq!(
&mapping[..],
b"world",
"mmap'd bytes must match captured content"
);
drop(mapping);
drop(file);
drop(session);
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_round_trips_write_side_ops() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let root = mountpoint.path();
let lock_path = root.join("Cargo.lock");
{
let mut f = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&lock_path)
.expect("create + open Cargo.lock");
f.write_all(b"[package]\nname=\"x\"\n").expect("write");
}
let read_back = fs::read_to_string(&lock_path).expect("read created file");
assert_eq!(read_back, "[package]\nname=\"x\"\n");
let excl_err = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&lock_path)
.expect_err("O_CREAT|O_EXCL on existing must fail");
assert_eq!(
excl_err.kind(),
std::io::ErrorKind::AlreadyExists,
"exclusive create surfaced wrong errno: {excl_err:?}"
);
let target_dir = root.join("target");
fs::create_dir(&target_dir).expect("mkdir target/");
assert!(target_dir.is_dir(), "mkdir didn't make a directory");
let nested_file = target_dir.join("output.bin");
fs::write(&nested_file, b"build artifact").expect("write under new dir");
assert_eq!(fs::read(&nested_file).unwrap(), b"build artifact");
let tmp = root.join("hello.txt.tmp");
fs::write(&tmp, b"NEW\n").expect("write tmp");
fs::rename(&tmp, root.join("hello.txt")).expect("rename over existing");
assert!(!tmp.exists(), "tmp source still visible after rename");
let after_rename = fs::read_to_string(root.join("hello.txt")).expect("read renamed");
assert_eq!(after_rename, "NEW\n");
let trunc_path = root.join("hello.txt");
{
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&trunc_path)
.expect("open O_TRUNC");
f.write_all(b"TRUNCATED").expect("write after truncate");
}
assert_eq!(
fs::read_to_string(&trunc_path).unwrap(),
"TRUNCATED",
"O_TRUNC didn't clear the prior content"
);
let link_path = root.join("hello.lnk");
std::os::unix::fs::symlink("hello.txt", &link_path).expect("symlink");
let resolved = fs::read_link(&link_path).expect("readlink");
assert_eq!(resolved.as_os_str(), "hello.txt");
fs::remove_file(root.join("Cargo.lock")).expect("unlink");
assert!(
!root.join("Cargo.lock").exists(),
"unlinked file still visible to the mount"
);
fs::remove_file(&nested_file).expect("unlink before rmdir");
fs::remove_dir(&target_dir).expect("rmdir of empty pending dir");
assert!(!target_dir.exists(), "rmdir didn't remove the directory");
drop(session);
}
#[test]
#[ignore = "requires FUSE on host; opt-in via --ignored"]
fn fuse_mount_unmounts_cleanly_on_session_drop() {
let (_repo_dir, repo) = build_fixture();
let (session, mountpoint) = mount_fixture(repo);
let target = mountpoint.path().join("hello.txt");
assert!(target.exists(), "fixture file must be visible before drop");
let mp: PathBuf = mountpoint.path().to_path_buf();
drop(session);
wait_for(&target, false, Duration::from_secs(5));
assert!(
!target.exists(),
"hello.txt must disappear from {} after unmount",
mp.display()
);
let listing: Vec<_> = fs::read_dir(&mp)
.expect("read mountpoint dir")
.filter_map(|e| e.ok())
.map(|e| e.file_name())
.collect();
assert!(
listing.is_empty(),
"mountpoint not empty after unmount: {listing:?}"
);
}