use crate::error::Result;
use crate::platform::common::MOUNT_POINT_PERMISSIONS;
use std::path::Path;
use tokio::fs;
use tracing::debug;
use tracing::warn;
pub async fn ensure_mount_point(path: &Path) -> Result<()> {
if !path.exists() {
debug!("Creating mount point directory: {}", path.display());
fs::create_dir_all(path).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(MOUNT_POINT_PERMISSIONS);
fs::set_permissions(path, permissions).await?;
}
} else if !path.is_dir() {
return Err(crate::error::ThoughtsError::MountOperationFailed {
message: format!("{} exists but is not a directory", path.display()),
});
}
Ok(())
}
pub async fn cleanup_mount_point(path: &Path) -> Result<()> {
if path.exists() && path.is_dir() {
let mut entries = fs::read_dir(path).await?;
if entries.next_entry().await?.is_none() {
debug!("Removing empty mount point: {}", path.display());
match fs::remove_dir(path).await {
Ok(()) => {}
Err(e) => {
warn!("Failed to remove mount point {}: {}", path.display(), e);
}
}
}
}
Ok(())
}
#[expect(
clippy::unused_async,
reason = "async for API consistency with ensure_mount_point/cleanup_mount_point siblings"
)]
pub async fn validate_mount_point(path: &Path) -> Result<()> {
let path_str = path.to_str().unwrap_or("");
if let Ok(home) = std::env::var("HOME")
&& path_str.starts_with(&home)
{
return Ok(());
}
if path_str.starts_with("/tmp") || path_str.starts_with("/private/tmp") {
return Ok(());
}
let forbidden_paths = [
"/",
"/bin",
"/boot",
"/dev",
"/etc",
"/lib",
"/lib64",
"/opt",
"/proc",
"/root",
"/sbin",
"/sys",
"/usr",
"/var",
"/System",
"/Library",
"/Applications",
"/Users/Shared",
];
for forbidden in &forbidden_paths {
if path_str == *forbidden || path_str.starts_with(&format!("{forbidden}/")) {
return Err(crate::error::ThoughtsError::MountOperationFailed {
message: format!("Cannot mount on system directory: {}", path.display()),
});
}
}
Ok(())
}
pub fn normalize_mount_path(path: &Path) -> Result<std::path::PathBuf> {
use crate::utils::paths::expand_path;
let expanded = expand_path(path).map_err(crate::error::ThoughtsError::Other)?;
if expanded.exists() {
Ok(expanded.canonicalize()?)
} else {
Ok(expanded)
}
}
pub async fn verify_with_polling<F, Fut>(
mut check: F,
timeout: std::time::Duration,
interval: std::time::Duration,
) -> Result<bool>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<bool>>,
{
use std::time::Instant;
use tokio::time::sleep;
let deadline = Instant::now() + timeout;
loop {
match check().await {
Ok(true) => return Ok(true),
Ok(false) => {
if Instant::now() >= deadline {
return Ok(false);
}
sleep(interval).await;
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_ensure_mount_point() {
let temp_dir = TempDir::new().unwrap();
let mount_point = temp_dir.path().join("test_mount");
assert!(!mount_point.exists());
ensure_mount_point(&mount_point).await.unwrap();
assert!(mount_point.exists());
assert!(mount_point.is_dir());
ensure_mount_point(&mount_point).await.unwrap();
assert!(mount_point.exists());
}
#[tokio::test]
async fn test_cleanup_mount_point() {
let temp_dir = TempDir::new().unwrap();
let mount_point = temp_dir.path().join("test_mount");
fs::create_dir(&mount_point).await.unwrap();
cleanup_mount_point(&mount_point).await.unwrap();
assert!(!mount_point.exists());
cleanup_mount_point(&mount_point).await.unwrap();
fs::create_dir(&mount_point).await.unwrap();
fs::write(mount_point.join("file.txt"), "test")
.await
.unwrap();
cleanup_mount_point(&mount_point).await.unwrap();
assert!(mount_point.exists());
}
#[tokio::test]
async fn test_validate_mount_point() {
assert!(
validate_mount_point(Path::new("/etc/thoughts"))
.await
.is_err()
);
assert!(
validate_mount_point(Path::new("/usr/local/thoughts"))
.await
.is_err()
);
if let Ok(home) = std::env::var("HOME") {
let user_path = Path::new(&home).join("thoughts");
assert!(validate_mount_point(&user_path).await.is_ok());
}
assert!(
validate_mount_point(Path::new("/tmp/thoughts"))
.await
.is_ok()
);
}
#[tokio::test]
async fn test_verify_with_polling_eventually_true() {
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
let counter = Arc::new(AtomicUsize::new(0));
let counter_closure = {
let counter = Arc::clone(&counter);
move || {
let counter = Arc::clone(&counter);
async move {
let n = counter.fetch_add(1, Ordering::SeqCst);
Ok(n >= 3) }
}
};
let ok = super::verify_with_polling(
counter_closure,
Duration::from_millis(300),
Duration::from_millis(5),
)
.await
.unwrap();
assert!(ok, "should become true before timeout");
}
#[tokio::test]
async fn test_verify_with_polling_times_out() {
use std::time::Duration;
let ok = super::verify_with_polling(
|| async { Ok(false) },
Duration::from_millis(50),
Duration::from_millis(10),
)
.await
.unwrap();
assert!(!ok, "should time out and return Ok(false)");
}
#[tokio::test]
async fn test_verify_with_polling_error_propagates() {
use crate::error::ThoughtsError;
use std::time::Duration;
let err = super::verify_with_polling(
|| async {
Err(ThoughtsError::MountOperationFailed {
message: "boom".into(),
})
},
Duration::from_millis(50),
Duration::from_millis(10),
)
.await
.expect_err("expected error");
assert!(format!("{err}").contains("boom"));
}
}