use std::{
ffi::{CStr, CString, OsStr, c_char, c_int, c_void},
os::unix::ffi::OsStrExt,
sync::Arc,
};
use tracing::warn;
use crate::{
core::ContentAddressedMount,
error::{MountError, Result},
shell::{Entry, NodeId, PlatformShell},
};
pub mod c_abi;
pub mod readiness;
use c_abi::{
HeddleEnumerateEmit, HeddleFSKitSessionHandle, heddle_fskit_is_available,
heddle_fskit_session_free, heddle_fskit_session_new,
};
pub struct FSKitShell {
handle: HeddleFSKitSessionHandle,
}
unsafe impl Send for FSKitShell {}
unsafe impl Sync for FSKitShell {}
impl FSKitShell {
pub fn new(mount: ContentAddressedMount) -> Result<Self> {
Self::from_shell(Arc::new(mount))
}
pub fn from_shell(shell: Arc<dyn PlatformShell + Send + Sync>) -> Result<Self> {
let boxed: Box<Arc<dyn PlatformShell + Send + Sync>> = Box::new(shell);
let user_data = Box::into_raw(boxed) as *mut c_void;
let handle = unsafe {
heddle_fskit_session_new(
user_data,
Some(trampoline_lookup),
Some(trampoline_getattr),
Some(trampoline_read),
Some(trampoline_write),
Some(trampoline_enumerate),
Some(trampoline_flush),
Some(trampoline_drop),
)
};
if handle.is_null() {
unsafe {
drop(Box::from_raw(
user_data as *mut Arc<dyn PlatformShell + Send + Sync>,
))
};
return Err(MountError::SessionInit(
"heddle_fskit_session_new returned null (Swift FSKit shim \
failed to allocate a session)"
.to_string(),
));
}
Ok(Self { handle })
}
pub fn is_runtime_available() -> bool {
unsafe { heddle_fskit_is_available() == 1 }
}
pub fn into_handle(self) -> HeddleFSKitSessionHandle {
let handle = self.handle;
std::mem::forget(self);
handle
}
}
impl Drop for FSKitShell {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe { heddle_fskit_session_free(self.handle) };
}
}
}
fn fskit_log(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/heddle-fskit.log")
{
let _ = writeln!(
f,
"[{}] {}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
msg
);
}
}
pub(super) fn open_thread(repo_path: &str, thread_id: &str) -> c_abi::HeddleFSKitSessionHandle {
fskit_log(&format!("open_thread: repo={repo_path} thread={thread_id}"));
let repo = match repo::Repository::open(repo_path) {
Ok(r) => {
fskit_log("Repository::open succeeded");
r
}
Err(e) => {
fskit_log(&format!("Repository::open FAILED: {e:?}"));
return std::ptr::null_mut();
}
};
let mount = match ContentAddressedMount::new(repo, thread_id) {
Ok(m) => {
fskit_log("ContentAddressedMount::new succeeded");
m
}
Err(e) => {
fskit_log(&format!("ContentAddressedMount::new FAILED: {e:?}"));
return std::ptr::null_mut();
}
};
let shell = match FSKitShell::new(mount) {
Ok(s) => s,
Err(e) => {
fskit_log(&format!("FSKitShell::new FAILED: {e:?}"));
return std::ptr::null_mut();
}
};
fskit_log("open_thread returning handle");
shell.into_handle()
}
#[inline]
unsafe fn shell_ref<'a>(
user_data: *mut c_void,
) -> Option<&'a Arc<dyn PlatformShell + Send + Sync>> {
if user_data.is_null() {
return None;
}
Some(unsafe { &*(user_data as *const Arc<dyn PlatformShell + Send + Sync>) })
}
fn errno_from(err: MountError) -> c_int {
err.to_errno()
}
#[inline]
fn guarded_c_int<F: FnOnce() -> c_int>(label: &'static str, f: F) -> c_int {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(rc) => rc,
Err(payload) => {
let msg = crate::error::panic_payload_str(&payload);
tracing::error!(trampoline = label, %msg, "FSKit trampoline panicked; returning EIO");
libc::EIO
}
}
}
#[inline]
fn guarded_drop<F: FnOnce()>(label: &'static str, f: F) {
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
let msg = crate::error::panic_payload_str(&payload);
tracing::error!(trampoline = label, %msg, "FSKit trampoline panicked during drop");
}
}
unsafe extern "C" fn trampoline_lookup(
user_data: *mut c_void,
parent_inode: u64,
name_utf8: *const c_char,
out_child_inode: *mut u64,
out_unix_mode: *mut u32,
out_size: *mut u64,
) -> c_int {
guarded_c_int("lookup", || {
unsafe {
let Some(shell) = shell_ref(user_data) else {
return libc::EINVAL;
};
if name_utf8.is_null() {
return libc::EINVAL;
}
let cstr = CStr::from_ptr(name_utf8);
let name: &OsStr = OsStr::from_bytes(cstr.to_bytes());
match shell.lookup(NodeId(parent_inode), name) {
Ok(Some(entry)) => {
write_out(out_child_inode, entry.node.0);
write_out(out_unix_mode, entry.unix_mode);
write_out(out_size, entry.size);
0
}
Ok(None) => libc::ENOENT,
Err(err) => errno_from(err),
}
}
})
}
unsafe extern "C" fn trampoline_getattr(
user_data: *mut c_void,
inode: u64,
out_unix_mode: *mut u32,
out_size: *mut u64,
out_nlink: *mut u32,
out_mtime_sec: *mut i64,
) -> c_int {
guarded_c_int("getattr", || {
unsafe {
let Some(shell) = shell_ref(user_data) else {
return libc::EINVAL;
};
match shell.attrs(NodeId(inode)) {
Ok(attrs) => {
write_out(out_unix_mode, attrs.unix_mode);
write_out(out_size, attrs.size);
write_out(out_nlink, attrs.nlink);
write_out(out_mtime_sec, mtime_to_secs(attrs.mtime));
0
}
Err(err) => errno_from(err),
}
}
})
}
fn mtime_to_secs(t: std::time::SystemTime) -> i64 {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
unsafe extern "C" fn trampoline_read(
user_data: *mut c_void,
inode: u64,
offset: u64,
buffer: *mut u8,
buffer_capacity: u64,
out_bytes_read: *mut u64,
) -> c_int {
guarded_c_int("read", || {
unsafe {
let Some(shell) = shell_ref(user_data) else {
return libc::EINVAL;
};
if buffer.is_null() {
return libc::EINVAL;
}
let cap = buffer_capacity as usize;
let buf = std::slice::from_raw_parts_mut(buffer, cap);
match shell.read(NodeId(inode), offset, buf) {
Ok(n) => {
write_out(out_bytes_read, n as u64);
0
}
Err(err) => errno_from(err),
}
}
})
}
unsafe extern "C" fn trampoline_write(
user_data: *mut c_void,
inode: u64,
offset: u64,
data: *const u8,
data_len: u64,
out_bytes_written: *mut u64,
) -> c_int {
guarded_c_int("write", || {
unsafe {
let Some(shell) = shell_ref(user_data) else {
return libc::EINVAL;
};
if data.is_null() && data_len > 0 {
return libc::EINVAL;
}
let slice = if data_len == 0 {
&[][..]
} else {
std::slice::from_raw_parts(data, data_len as usize)
};
match shell.write(NodeId(inode), offset, slice) {
Ok(n) => {
write_out(out_bytes_written, n as u64);
0
}
Err(err) => errno_from(err),
}
}
})
}
unsafe extern "C" fn trampoline_enumerate(
user_data: *mut c_void,
dir_inode: u64,
emit_user_data: *mut c_void,
emit: HeddleEnumerateEmit,
) -> c_int {
guarded_c_int("enumerate", || {
unsafe {
let Some(shell) = shell_ref(user_data) else {
return libc::EINVAL;
};
let Some(emit) = emit else {
return libc::EINVAL;
};
let entries: Vec<Entry> = match shell.enumerate(NodeId(dir_inode)) {
Ok(e) => e,
Err(err) => return errno_from(err),
};
let dir_mtime_sec = shell
.attrs(NodeId(dir_inode))
.ok()
.map(|a| mtime_to_secs(a.mtime))
.unwrap_or(0);
for entry in entries {
let bytes = entry.name.as_os_str().as_bytes();
let Ok(c_name) = CString::new(bytes) else {
warn!(?entry.name, "fskit enumerate: skipping entry with embedded NUL");
continue;
};
let rc = emit(
emit_user_data,
entry.node.0,
c_name.as_ptr(),
entry.unix_mode,
entry.size,
dir_mtime_sec,
);
if rc != 0 {
break;
}
}
0
}
})
}
unsafe extern "C" fn trampoline_flush(user_data: *mut c_void, inode: u64) -> c_int {
guarded_c_int("flush", || {
unsafe {
let Some(shell) = shell_ref(user_data) else {
return libc::EINVAL;
};
match shell.flush(NodeId(inode)) {
Ok(()) => 0,
Err(err) => errno_from(err),
}
}
})
}
unsafe extern "C" fn trampoline_drop(user_data: *mut c_void) {
if user_data.is_null() {
return;
}
guarded_drop("drop", || {
unsafe {
drop(Box::from_raw(
user_data as *mut Arc<dyn PlatformShell + Send + Sync>,
));
}
});
}
#[inline]
fn write_out<T: Copy>(ptr: *mut T, value: T) {
if !ptr.is_null() {
unsafe { ptr.write(value) };
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
use crate::tests::mocks::CountingShell;
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires the fskit feature; opt-in via --features fskit"]
fn fskit_session_lifecycle_drops_inner_shell() {
let (counting, drops) = CountingShell::new();
let shell = Arc::new(counting);
let fskit = FSKitShell::from_shell(shell).expect("construct FSKit session");
assert_eq!(drops.load(Ordering::SeqCst), 0);
drop(fskit);
assert_eq!(drops.load(Ordering::SeqCst), 1);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires the fskit feature; opt-in via --features fskit"]
fn fskit_runtime_availability_is_callable() {
let _ = FSKitShell::is_runtime_available();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires the fskit feature; opt-in via --features fskit"]
fn fskit_bridging_header_declares_every_c_abi_symbol() {
const EXPECTED_SYMBOLS: &[&str] = &[
"HeddleFSKitSessionHandle",
"HeddleLookupCallback",
"HeddleGetattrCallback",
"HeddleReadCallback",
"HeddleWriteCallback",
"HeddleEnumerateEmit",
"HeddleEnumerateCallback",
"HeddleFlushCallback",
"HeddleDropCallback",
"heddle_fskit_session_new",
"heddle_fskit_session_free",
"heddle_fskit_is_available",
"heddle_fskit_open_thread",
];
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("swift")
.join("HeddleFSKit")
.join("HeddleFSKit-Bridging.h");
let header = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
assert!(
header.contains("GENERATED FROM `crates/mount/src/fskit/c_abi.rs`"),
"bridging header missing the cbindgen 'GENERATED' banner; \
rerun `cargo build -p mount --features fskit`"
);
for sym in EXPECTED_SYMBOLS {
assert!(
header.contains(sym),
"bridging header missing `{sym}` — has the C ABI in \
src/fskit/c_abi.rs drifted? Re-run `cargo build -p \
mount --features fskit` to regenerate."
);
}
}
#[test]
fn trampoline_lookup_recovers_eio_on_panic() {
use crate::tests::mocks::PanicShell;
let shell: Arc<dyn PlatformShell + Send + Sync> = Arc::new(PanicShell);
let boxed: Box<Arc<dyn PlatformShell + Send + Sync>> = Box::new(shell);
let user_data = Box::into_raw(boxed) as *mut c_void;
let mut child_inode: u64 = 0;
let mut unix_mode: u32 = 0;
let mut size: u64 = 0;
let name = std::ffi::CString::new("anything").unwrap();
let rc = unsafe {
trampoline_lookup(
user_data,
1,
name.as_ptr(),
&mut child_inode as *mut u64,
&mut unix_mode as *mut u32,
&mut size as *mut u64,
)
};
assert_eq!(
rc,
libc::EIO,
"panic in PlatformShell::lookup must surface as EIO, \
not propagate across the C ABI (got rc={rc})"
);
assert_eq!(child_inode, 0, "child_inode must not be torn on error");
assert_eq!(unix_mode, 0, "unix_mode must not be torn on error");
assert_eq!(size, 0, "size must not be torn on error");
unsafe { trampoline_drop(user_data) };
}
}