#![cfg(feature = "fuse")]
mod common;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use common::htree_bin;
use hashtree_cli::HashtreeStore;
use hashtree_core::{from_hex, nhash_encode};
use serde_json::Value;
use tempfile::TempDir;
const EMPTY_MOUNTS_OUTPUT: &str = "No active hashtree mounts found.";
struct ChildGuard(Option<Child>);
impl ChildGuard {
fn spawn(command: &mut Command) -> Self {
let child = command.spawn().expect("spawn mount command");
Self(Some(child))
}
fn child_mut(&mut self) -> &mut Child {
self.0.as_mut().expect("child process available")
}
fn kill(&mut self) {
if let Some(child) = self.0.as_mut() {
let _ = child.kill();
}
}
fn wait_with_output(&mut self) -> Output {
self.0
.take()
.expect("child process available")
.wait_with_output()
.expect("wait for child output")
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(child) = self.0.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn mounts_command_reports_empty_state() {
let tmp = TempDir::new().expect("temp dir");
let config_dir = tmp.path().join("config");
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&config_dir).expect("create config dir");
std::fs::create_dir_all(&data_dir).expect("create data dir");
let output = run_htree_command(&config_dir, &data_dir, ["mounts"]);
assert_success(&output, "htree mounts");
let stdout = String::from_utf8(output.stdout).expect("stdout utf-8");
assert!(
stdout.contains(EMPTY_MOUNTS_OUTPUT),
"expected empty mounts output.\nstdout:\n{stdout}"
);
}
#[test]
fn mount_smoke_lists_active_mount_and_unmounts_cleanly() {
if let Some(reason) = fuse_smoke_skip_reason() {
eprintln!("skipping fuse smoke test: {reason}");
return;
}
let tmp = TempDir::new().expect("temp dir");
let config_dir = tmp.path().join("config");
let data_dir = tmp.path().join("data");
let source_dir = tmp.path().join("source");
let mountpoint = tmp.path().join("mount");
std::fs::create_dir_all(&config_dir).expect("create config dir");
std::fs::create_dir_all(&data_dir).expect("create data dir");
std::fs::create_dir_all(source_dir.join("nested")).expect("create source dir");
std::fs::create_dir_all(&mountpoint).expect("create mountpoint");
std::fs::write(
source_dir.join("nested").join("hello.txt"),
"hello through fuse",
)
.expect("write source file");
let store = HashtreeStore::new(&data_dir).expect("create store");
let root_hex = store.upload_dir(&source_dir).expect("upload source dir");
let nhash =
nhash_encode(&from_hex(&root_hex).expect("decode root hash")).expect("encode nhash");
let mut mount_command = Command::new(htree_bin());
mount_command
.arg("--data-dir")
.arg(&data_dir)
.arg("mount")
.arg(&nhash)
.arg(&mountpoint);
#[cfg(target_os = "macos")]
mount_command.arg("--allow-other");
mount_command
.env("HTREE_CONFIG_DIR", &config_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = ChildGuard::spawn(&mut mount_command);
wait_for_mounted_file(
&mut child,
&config_dir,
&data_dir,
&mountpoint,
mountpoint.join("nested").join("hello.txt"),
"hello through fuse",
);
let mounts_output = run_htree_command(&config_dir, &data_dir, ["mounts"]);
assert_success(&mounts_output, "htree mounts while mounted");
let mounts_stdout = String::from_utf8(mounts_output.stdout).expect("stdout utf-8");
assert!(
mounts_stdout.contains(&mountpoint.display().to_string()),
"expected mounts output to include mountpoint.\nstdout:\n{mounts_stdout}"
);
assert!(
mounts_stdout.contains(&nhash),
"expected mounts output to include target/root.\nstdout:\n{mounts_stdout}"
);
let mounts_json_output = run_htree_command(&config_dir, &data_dir, ["mounts", "--json"]);
assert_success(&mounts_json_output, "htree mounts --json while mounted");
let mounts_json: Value =
serde_json::from_slice(&mounts_json_output.stdout).expect("parse mounts json");
let mounts = mounts_json.as_array().expect("mounts array");
assert_eq!(mounts.len(), 1, "expected one active mount in json");
let mount = mounts[0].as_object().expect("mount entry object");
assert_eq!(
mount.get("mountpoint").and_then(Value::as_str),
Some(mountpoint.to_string_lossy().as_ref())
);
assert_eq!(
mount.get("target").and_then(Value::as_str),
Some(nhash.as_str())
);
assert_eq!(
mount.get("mounted_cid").and_then(Value::as_str),
Some(nhash.as_str())
);
assert_eq!(
mount.get("visibility").and_then(Value::as_str),
Some("public")
);
assert_eq!(
mount.get("pid").and_then(Value::as_u64),
Some(child.child_mut().id().into())
);
assert_eq!(mount.get("published_key"), Some(&Value::Null));
assert_eq!(
mount.get("allow_other").and_then(Value::as_bool),
Some(cfg!(target_os = "macos"))
);
unmount(&mountpoint);
let mount_exit = child.wait_with_output();
assert_success(&mount_exit, "mounted htree process");
let post_unmount = run_htree_command(&config_dir, &data_dir, ["mounts"]);
assert_success(&post_unmount, "htree mounts after unmount");
let post_unmount_stdout = String::from_utf8(post_unmount.stdout).expect("stdout utf-8");
assert!(
post_unmount_stdout.contains(EMPTY_MOUNTS_OUTPUT),
"expected mounts output to be empty after unmount.\nstdout:\n{post_unmount_stdout}"
);
}
fn run_htree_command<const N: usize>(
config_dir: &Path,
data_dir: &Path,
args: [&str; N],
) -> Output {
Command::new(htree_bin())
.arg("--data-dir")
.arg(data_dir)
.args(args)
.env("HTREE_CONFIG_DIR", config_dir)
.output()
.expect("run htree command")
}
fn assert_success(output: &Output, description: &str) {
assert!(
output.status.success(),
"{description} failed.\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn wait_for_mounted_file(
child: &mut ChildGuard,
config_dir: &Path,
data_dir: &Path,
mountpoint: &Path,
path: PathBuf,
expected: &str,
) {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = child.child_mut().try_wait().expect("poll child") {
panic!("mount process exited early: {status}");
}
if !mountpoint_is_active(mountpoint) {
if Instant::now() >= deadline {
let registry_output = run_htree_command(config_dir, data_dir, ["mounts", "--json"]);
child.kill();
let mount_output = child.wait_with_output();
panic!(
"timed out waiting for active mount {}\nmountinfo:\n{}\nroot snapshot:\n{}\nregistry stdout:\n{}\nregistry stderr:\n{}\nmount stdout:\n{}\nmount stderr:\n{}",
mountpoint.display(),
current_mountinfo(),
snapshot_dir(mountpoint),
String::from_utf8_lossy(®istry_output.stdout),
String::from_utf8_lossy(®istry_output.stderr),
String::from_utf8_lossy(&mount_output.stdout),
String::from_utf8_lossy(&mount_output.stderr)
);
}
std::thread::sleep(Duration::from_millis(100));
continue;
}
if let Ok(contents) = std::fs::read_to_string(&path) {
if contents == expected {
return;
}
}
if Instant::now() >= deadline {
let registry_output = run_htree_command(config_dir, data_dir, ["mounts", "--json"]);
child.kill();
let mount_output = child.wait_with_output();
panic!(
"timed out waiting for mounted file {}\nmountinfo:\n{}\nroot snapshot:\n{}\nparent snapshot:\n{}\nregistry stdout:\n{}\nregistry stderr:\n{}\nmount stdout:\n{}\nmount stderr:\n{}",
path.display(),
current_mountinfo(),
snapshot_dir(mountpoint),
snapshot_dir(path.parent().unwrap_or(mountpoint)),
String::from_utf8_lossy(®istry_output.stdout),
String::from_utf8_lossy(®istry_output.stderr),
String::from_utf8_lossy(&mount_output.stdout),
String::from_utf8_lossy(&mount_output.stderr)
);
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn unmount(mountpoint: &Path) {
#[cfg(target_os = "linux")]
let commands: &[&str] = &["fusermount3", "fusermount", "umount"];
#[cfg(not(target_os = "linux"))]
let commands: &[&str] = &["umount"];
for command in commands {
let status = Command::new(command).arg(mountpoint).status();
match status {
Ok(status) if status.success() => return,
Ok(_) | Err(_) => continue,
}
}
panic!("failed to unmount {}", mountpoint.display());
}
fn fuse_smoke_skip_reason() -> Option<String> {
if std::env::var_os("HTREE_REQUIRE_FUSE_SMOKE").is_some() {
return None;
}
#[cfg(not(target_os = "linux"))]
{
return Some(
"automatic FUSE smoke coverage only runs on Linux; set HTREE_REQUIRE_FUSE_SMOKE=1 to force a local run"
.to_string(),
);
}
#[cfg(target_os = "linux")]
{
if !Path::new("/dev/fuse").exists() {
return Some("/dev/fuse is not available".to_string());
}
return None;
}
}
fn snapshot_dir(path: &Path) -> String {
match std::fs::read_dir(path) {
Ok(entries) => {
let mut names = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
names.sort();
if names.is_empty() {
"(empty)".to_string()
} else {
names.join("\n")
}
}
Err(error) => format!("read_dir failed: {error}"),
}
}
#[cfg(target_os = "linux")]
fn mountpoint_is_active(mountpoint: &Path) -> bool {
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
return false;
};
mountinfo_lists_mount(&mountinfo, mountpoint)
}
#[cfg(not(target_os = "linux"))]
fn mountpoint_is_active(_mountpoint: &Path) -> bool {
true
}
#[cfg(target_os = "linux")]
fn current_mountinfo() -> String {
std::fs::read_to_string("/proc/self/mountinfo")
.unwrap_or_else(|error| format!("failed to read /proc/self/mountinfo: {error}"))
}
#[cfg(not(target_os = "linux"))]
fn current_mountinfo() -> String {
"mountinfo unavailable on this platform".to_string()
}
fn mountinfo_lists_mount(mountinfo: &str, mountpoint: &Path) -> bool {
let expected = mountpoint.to_string_lossy();
mountinfo.lines().any(|line| {
let Some(fields) = line.split(" - ").next() else {
return false;
};
let mut parts = fields.split(' ');
let _mount_id = parts.next();
let _parent_id = parts.next();
let _major_minor = parts.next();
let _root = parts.next();
let Some(mount_point) = parts.next() else {
return false;
};
decode_mountinfo_path(mount_point) == expected
})
}
fn decode_mountinfo_path(value: &str) -> String {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\\' && index + 3 < bytes.len() {
let maybe_octal = &value[index + 1..index + 4];
if maybe_octal
.bytes()
.all(|byte| (b'0'..=b'7').contains(&byte))
{
let octal = u8::from_str_radix(maybe_octal, 8).expect("valid octal escape");
decoded.push(octal);
index += 4;
continue;
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8(decoded).expect("mountinfo path is valid utf-8")
}
#[test]
fn mountinfo_parser_matches_escaped_mountpoint_paths() {
let mountinfo = "\
35 26 0:31 / /tmp/hashtree\\040mount rw,nosuid,nodev - fuse.hashtree hashtree rw,user_id=1000,group_id=1000\n\
";
assert!(mountinfo_lists_mount(
mountinfo,
Path::new("/tmp/hashtree mount")
));
}
#[test]
fn mountinfo_parser_ignores_other_mountpoints() {
let mountinfo = "\
35 26 0:31 / /tmp/other rw,nosuid,nodev - fuse.hashtree hashtree rw,user_id=1000,group_id=1000\n\
";
assert!(!mountinfo_lists_mount(
mountinfo,
Path::new("/tmp/hashtree mount")
));
}