diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 93b0446..e90b29e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -8,6 +8,12 @@ repository = "https://github.com/tursodatabase/agentfs" name = "agentfs" path = "src/main.rs" +[features] +default = [] +# Force FUSE even on macOS 26+ (for testing/compatibility) +# On macOS without this feature, FSKit is used instead +force-fuse = ["dep:fuser"] + [dependencies] agentfs-sdk = { path = "../sdk/rust" } tokio = { version = "1", features = ["full"] } @@ -23,17 +29,15 @@ dirs = "6" [target.'cfg(unix)'.dependencies] libc = "0.2" -# Linux-only dependencies for FUSE functionality +# Linux-only FUSE dependency (always included on Linux) [target.'cfg(target_os = "linux")'.dependencies] fuser = { version = "0.15", default-features = false, features = ["abi-7-23"] } uuid = { version = "1", features = ["v4"] } -# macOS dependencies for FUSE functionality (requires macFUSE) +# macOS FUSE dependency (only included with force-fuse feature) [target.'cfg(target_os = "macos")'.dependencies] -fuser = { version = "0.15", default-features = false, features = [ - "abi-7-23", - "libfuse", -] } +fuser = { version = "0.15", default-features = false, features = ["abi-7-23", "libfuse"], optional = true } +uuid = { version = "1", features = ["v4"] } # Sandbox dependencies - Linux x86_64 only (requires libunwind-ptrace) [target.'cfg(all(target_os = "linux", target_arch = "x86_64"))'.dependencies] diff --git a/cli/src/cmd/mod.rs b/cli/src/cmd/mod.rs index 86fdc4c..d93a93b 100644 --- a/cli/src/cmd/mod.rs +++ b/cli/src/cmd/mod.rs @@ -2,15 +2,43 @@ pub mod completions; pub mod fs; pub mod init; -#[cfg(any(target_os = "linux", target_os = "macos"))] +// Mount module selection: +// - Linux: always use FUSE (mount.rs) +// - macOS with force-fuse: use FUSE (mount.rs) +// - macOS without force-fuse: use FSKit (mount_fskit.rs) +// - Other platforms: use stub (mount_stub.rs) + +#[cfg(target_os = "linux")] +mod mount; + +#[cfg(all(target_os = "macos", feature = "force-fuse"))] +mod mount; + +#[cfg(all(target_os = "macos", not(feature = "force-fuse")))] +#[path = "mount_fskit.rs"] mod mount; + #[cfg(not(any(target_os = "linux", target_os = "macos")))] #[path = "mount_stub.rs"] mod mount; +// Run module selection: +// - Linux x86_64: use overlay sandbox (run.rs) +// - macOS with force-fuse: use stub (not yet supported with FUSE) +// - macOS without force-fuse: use FSKit (run_fskit.rs) +// - Other platforms: use stub (run_stub.rs) + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] mod run; -#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] + +#[cfg(all(target_os = "macos", not(feature = "force-fuse")))] +#[path = "run_fskit.rs"] +mod run; + +#[cfg(not(any( + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "macos", not(feature = "force-fuse")) +)))] #[path = "run_stub.rs"] mod run; diff --git a/cli/src/cmd/mount.rs b/cli/src/cmd/mount.rs index 048978e..03052f5 100644 --- a/cli/src/cmd/mount.rs +++ b/cli/src/cmd/mount.rs @@ -1,3 +1,10 @@ +//! FUSE-based mount implementation. +//! +//! This module is only compiled on Linux, or on macOS when the `force-fuse` feature is enabled. +//! On macOS without `force-fuse`, the mount_fskit module is used instead. + +#![cfg(any(target_os = "linux", all(target_os = "macos", feature = "force-fuse")))] + use agentfs_sdk::{AgentFS, AgentFSOptions, FileSystem, HostFS, OverlayFS}; use anyhow::Result; use std::{os::unix::fs::MetadataExt, path::PathBuf, sync::Arc}; diff --git a/cli/src/cmd/mount_fskit.rs b/cli/src/cmd/mount_fskit.rs new file mode 100644 index 0000000..a1a5c94 --- /dev/null +++ b/cli/src/cmd/mount_fskit.rs @@ -0,0 +1,178 @@ +//! FSKit-based mount implementation for macOS 26+. +//! +//! This module is only compiled on macOS when the `force-fuse` feature is NOT enabled. +//! It uses Apple's FSKit framework for user-space filesystem mounting without kernel extensions. + +#![cfg(all(target_os = "macos", not(feature = "force-fuse")))] + +use agentfs_sdk::AgentFSOptions; +use anyhow::Result; +use std::path::PathBuf; +use std::process::Command; + +/// Arguments for the mount command. +#[derive(Debug, Clone)] +pub struct MountArgs { + /// The agent filesystem ID or path. + pub id_or_path: String, + /// The mountpoint path. + pub mountpoint: PathBuf, + /// Automatically unmount when the process exits. + pub auto_unmount: bool, + /// Allow root to access the mount. + pub allow_root: bool, + /// Run in foreground (don't daemonize). + pub foreground: bool, + /// User ID to report for all files (defaults to current user). + pub uid: Option, + /// Group ID to report for all files (defaults to current group). + pub gid: Option, +} + +/// Mount the agent filesystem using FSKit. +/// +/// This requires: +/// - macOS 26 or later +/// - The AgentFS FSKit extension to be installed and enabled +pub fn mount(args: MountArgs) -> Result<()> { + // Check macOS version + if !supports_fskit()? { + anyhow::bail!( + "FSKit requires macOS 26 or later.\n\ + You can use the `--features force-fuse` flag to use macFUSE instead." + ); + } + + // Check if extension is installed + if !is_extension_installed() { + anyhow::bail!( + "AgentFS FSKit extension is not installed.\n\ + \n\ + To install:\n\ + 1. Build the extension: cd fskit-ffi && make build-extension\n\ + 2. Install the extension app bundle\n\ + 3. Enable it via: System Settings > General > Login Items & Extensions\n\ + > File System Extensions > AgentFS\n\ + \n\ + Alternatively, use macFUSE with: cargo build --features force-fuse" + ); + } + + // Resolve the database path + let db_path = resolve_db_path(&args.id_or_path)?; + + // Validate mountpoint exists + if !args.mountpoint.exists() { + anyhow::bail!("Mountpoint does not exist: {}", args.mountpoint.display()); + } + + // Mount using FSKit's mount command + // FSKit filesystems are mounted via: mount -t + // The resource is a URL for FSGenericURLResource + let resource_url = format!("file://{}", db_path); + + eprintln!("Mounting {} at {}", db_path, args.mountpoint.display()); + + let mut cmd = Command::new("/sbin/mount"); + cmd.arg("-t").arg("agentfs") + .arg(&resource_url) + .arg(&args.mountpoint); + + let output = cmd.output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "Mount failed: {}\n\ + \n\ + Make sure the AgentFS FSKit extension is installed and enabled.", + stderr.trim() + ); + } + + eprintln!("Mounted successfully!"); + + if args.foreground { + // Wait for unmount signal + wait_for_unmount(&args.mountpoint)?; + } + + Ok(()) +} + +/// Resolve the database path from an ID or path. +fn resolve_db_path(id_or_path: &str) -> Result { + let opts = AgentFSOptions::resolve(id_or_path)?; + + if let Some(path) = opts.path { + Ok(std::fs::canonicalize(&path)?.to_string_lossy().to_string()) + } else { + anyhow::bail!("Cannot mount ephemeral filesystem") + } +} + +/// Check if macOS version supports FSKit (26+). +fn supports_fskit() -> Result { + let output = Command::new("sw_vers") + .arg("-productVersion") + .output()?; + + if !output.status.success() { + return Ok(false); + } + + let version = String::from_utf8_lossy(&output.stdout); + let major: u32 = version + .trim() + .split('.') + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + // FSKit with FSGenericURLResource requires macOS 26+ + Ok(major >= 26) +} + +/// Check if the AgentFS FSKit extension is installed. +fn is_extension_installed() -> bool { + let output = Command::new("systemextensionsctl") + .arg("list") + .output() + .ok(); + + if let Some(output) = output { + let stdout = String::from_utf8_lossy(&output.stdout); + // Check for our bundle identifier + stdout.contains("io.turso.agentfs") || stdout.contains("AgentFS") + } else { + false + } +} + +/// Wait for the filesystem to be unmounted. +fn wait_for_unmount(mountpoint: &PathBuf) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + eprintln!("Running in foreground. Press Ctrl+C to unmount."); + + // Get the device ID of the mounted filesystem + let mounted_dev = std::fs::metadata(mountpoint)?.dev(); + + // Poll for unmount + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + + match std::fs::metadata(mountpoint) { + Ok(meta) => { + // Check if device ID changed (unmounted) + if meta.dev() != mounted_dev { + break; + } + } + Err(_) => break, // Mountpoint gone or inaccessible + } + } + + eprintln!("Unmounted."); + Ok(()) +} diff --git a/cli/src/fuse.rs b/cli/src/fuse.rs index 35748cc..5b595d8 100644 --- a/cli/src/fuse.rs +++ b/cli/src/fuse.rs @@ -1,8 +1,16 @@ +//! FUSE filesystem implementation for AgentFS. +//! +//! This module is only compiled on Linux, or on macOS when the `force-fuse` feature is enabled. +//! On macOS without `force-fuse`, FSKit is used instead (see mount_fskit.rs). + +#![cfg(any(target_os = "linux", all(target_os = "macos", feature = "force-fuse")))] + use agentfs_sdk::{FileSystem, FsError, Stats}; + use fuser::{ consts::FUSE_WRITEBACK_CACHE, FileAttr, FileType, Filesystem, KernelConfig, MountOption, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, - ReplyStatfs, ReplyWrite, Request, + ReplyStatfs, ReplyWrite, Request, TimeOrNow, }; use parking_lot::Mutex; use std::{ @@ -126,8 +134,8 @@ impl Filesystem for AgentFSFuse { _uid: Option, _gid: Option, size: Option, - _atime: Option, - _mtime: Option, + _atime: Option, + _mtime: Option, _ctime: Option, fh: Option, _crtime: Option, diff --git a/cli/src/main.rs b/cli/src/main.rs index b87fae3..02e9f80 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,10 +2,13 @@ mod cmd; mod parser; mod sandbox; -#[cfg(any(target_os = "linux", target_os = "macos"))] +// Daemon module for background mounting (FUSE only) +#[cfg(any(target_os = "linux", all(target_os = "macos", feature = "force-fuse")))] mod daemon; -#[cfg(any(target_os = "linux", target_os = "macos"))] +// FUSE module - only on Linux or macOS with force-fuse feature +// On macOS without force-fuse, FSKit is used instead (no kernel extension needed) +#[cfg(any(target_os = "linux", all(target_os = "macos", feature = "force-fuse")))] mod fuse; use clap::{CommandFactory, Parser}; diff --git a/fskit-extension/.gitignore b/fskit-extension/.gitignore new file mode 100644 index 0000000..21b2b2a --- /dev/null +++ b/fskit-extension/.gitignore @@ -0,0 +1,5 @@ +DerivedData/ +build/ +.DS_Store +*.xcuserstate +xcuserdata/ diff --git a/fskit-extension/AgentFS.xcodeproj/project.pbxproj b/fskit-extension/AgentFS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6e0b378 --- /dev/null +++ b/fskit-extension/AgentFS.xcodeproj/project.pbxproj @@ -0,0 +1,469 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + /* Host App */ + A1000001 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000001 /* main.swift */; }; + + /* Extension */ + A1000010 /* AgentFSExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000010 /* AgentFSExtension.swift */; }; + A1000011 /* AgentFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000011 /* AgentFileSystem.swift */; }; + A1000012 /* AgentVolume.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000012 /* AgentVolume.swift */; }; + A1000013 /* AgentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000013 /* AgentItem.swift */; }; + A1000014 /* libagentfs_ffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A2000020 /* libagentfs_ffi.a */; }; + A1000015 /* FSKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A2000021 /* FSKit.framework */; }; + + /* Embed Extension */ + A1000030 /* AgentFSExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A3000002 /* AgentFSExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A4000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A0000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A3000001; + remoteInfo = AgentFSExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + A5000001 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + A1000030 /* AgentFSExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + /* Host App */ + A2000001 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + A2000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A3000000 /* AgentFS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AgentFS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + + /* Extension */ + A2000010 /* AgentFSExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentFSExtension.swift; sourceTree = ""; }; + A2000011 /* AgentFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentFileSystem.swift; sourceTree = ""; }; + A2000012 /* AgentVolume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentVolume.swift; sourceTree = ""; }; + A2000013 /* AgentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentItem.swift; sourceTree = ""; }; + A2000014 /* agentfs_ffi.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = agentfs_ffi.h; sourceTree = ""; }; + A2000015 /* ExtensionInfo.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = ExtensionInfo.plist; sourceTree = ""; }; + A2000016 /* AgentFSExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AgentFSExtension.entitlements; sourceTree = ""; }; + A2000020 /* libagentfs_ffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libagentfs_ffi.a; path = "../fskit-ffi/target/release/libagentfs_ffi.a"; sourceTree = ""; }; + A2000021 /* FSKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FSKit.framework; path = System/Library/Frameworks/FSKit.framework; sourceTree = SDKROOT; }; + A3000002 /* AgentFSExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AgentFSExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A6000001 /* Frameworks (Host) */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A6000002 /* Frameworks (Extension) */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000014 /* libagentfs_ffi.a in Frameworks */, + A1000015 /* FSKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A7000000 /* Root */ = { + isa = PBXGroup; + children = ( + A7000001 /* AgentFS */, + A7000002 /* AgentFSExtension */, + A7000003 /* Frameworks */, + A7000004 /* Products */, + ); + sourceTree = ""; + }; + A7000001 /* AgentFS */ = { + isa = PBXGroup; + children = ( + A2000001 /* main.swift */, + A2000002 /* Info.plist */, + ); + path = AgentFS; + sourceTree = ""; + }; + A7000002 /* AgentFSExtension */ = { + isa = PBXGroup; + children = ( + A2000010 /* AgentFSExtension.swift */, + A2000011 /* AgentFileSystem.swift */, + A2000012 /* AgentVolume.swift */, + A2000013 /* AgentItem.swift */, + A2000014 /* agentfs_ffi.h */, + A2000015 /* ExtensionInfo.plist */, + A2000016 /* AgentFSExtension.entitlements */, + ); + path = AgentFSExtension; + sourceTree = ""; + }; + A7000003 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A2000020 /* libagentfs_ffi.a */, + A2000021 /* FSKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + A7000004 /* Products */ = { + isa = PBXGroup; + children = ( + A3000000 /* AgentFS.app */, + A3000002 /* AgentFSExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A3000001 /* AgentFSExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A9000002 /* Build configuration list for PBXNativeTarget "AgentFSExtension" */; + buildPhases = ( + A8000002 /* Sources */, + A6000002 /* Frameworks (Extension) */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AgentFSExtension; + productName = AgentFSExtension; + productReference = A3000002 /* AgentFSExtension.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; + A3000003 /* AgentFS */ = { + isa = PBXNativeTarget; + buildConfigurationList = A9000001 /* Build configuration list for PBXNativeTarget "AgentFS" */; + buildPhases = ( + A8000001 /* Sources */, + A6000001 /* Frameworks (Host) */, + A5000001 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + A4000002 /* PBXTargetDependency */, + ); + name = AgentFS; + productName = AgentFS; + productReference = A3000000 /* AgentFS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A0000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A3000001 = { + CreatedOnToolsVersion = 16.0; + }; + A3000003 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = A9000000 /* Build configuration list for PBXProject "AgentFS" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A7000000 /* Root */; + productRefGroup = A7000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A3000003 /* AgentFS */, + A3000001 /* AgentFSExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + A8000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000001 /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000010 /* AgentFSExtension.swift in Sources */, + A1000011 /* AgentFileSystem.swift in Sources */, + A1000012 /* AgentVolume.swift in Sources */, + A1000013 /* AgentItem.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A4000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A3000001 /* AgentFSExtension */; + targetProxy = A4000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + B0000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + B0000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + B1000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AgentFS/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.turso.agentfs.host; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + B1000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AgentFS/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.turso.agentfs.host; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + B2000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = AgentFSExtension/AgentFSExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/AgentFSExtension", + "$(SRCROOT)/../fskit-ffi", + ); + INFOPLIST_FILE = AgentFSExtension/ExtensionInfo.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../fskit-ffi/target/release", + "$(SRCROOT)/../fskit-ffi/target/debug", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-lagentfs_ffi", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.turso.agentfs.host.extension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "AgentFSExtension/agentfs_ffi.h"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + B2000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = AgentFSExtension/AgentFSExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/AgentFSExtension", + "$(SRCROOT)/../fskit-ffi", + ); + INFOPLIST_FILE = AgentFSExtension/ExtensionInfo.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../fskit-ffi/target/release", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-lagentfs_ffi", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.turso.agentfs.host.extension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "AgentFSExtension/agentfs_ffi.h"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A9000000 /* Build configuration list for PBXProject "AgentFS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B0000001 /* Debug */, + B0000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A9000001 /* Build configuration list for PBXNativeTarget "AgentFS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B1000001 /* Debug */, + B1000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A9000002 /* Build configuration list for PBXNativeTarget "AgentFSExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B2000001 /* Debug */, + B2000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A0000001 /* Project object */; +} diff --git a/fskit-extension/AgentFS/Info.plist b/fskit-extension/AgentFS/Info.plist new file mode 100644 index 0000000..36d6146 --- /dev/null +++ b/fskit-extension/AgentFS/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AgentFS + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright 2024 Turso. All rights reserved. + NSPrincipalClass + NSApplication + + diff --git a/fskit-extension/AgentFS/main.swift b/fskit-extension/AgentFS/main.swift new file mode 100644 index 0000000..ca73dea --- /dev/null +++ b/fskit-extension/AgentFS/main.swift @@ -0,0 +1,47 @@ +// AgentFS Host App +// This minimal app exists to install and manage the AgentFS FSKit extension. + +import Cocoa + +struct AgentFSApp { + static func main() { + let app = NSApplication.shared + app.setActivationPolicy(.regular) + + // Create a simple window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "AgentFS" + window.center() + + // Create content view with instructions + let textView = NSTextView(frame: NSRect(x: 20, y: 20, width: 360, height: 160)) + textView.isEditable = false + textView.backgroundColor = .clear + textView.string = """ + AgentFS Extension + + The AgentFS file system extension is now installed. + + To enable it: + 1. Open System Settings + 2. Go to General > Login Items & Extensions + 3. Enable "AgentFS" under File System Extensions + + Once enabled, you can mount AgentFS databases using: + agentfs mount + """ + + window.contentView = textView + window.makeKeyAndOrderFront(nil) + + app.run() + } +} + +// Entry point +AgentFSApp.main() diff --git a/fskit-extension/AgentFSExtension/AgentFSExtension.entitlements b/fskit-extension/AgentFSExtension/AgentFSExtension.entitlements new file mode 100644 index 0000000..1776cff --- /dev/null +++ b/fskit-extension/AgentFSExtension/AgentFSExtension.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.developer.fskit.extension + + + + com.apple.security.app-sandbox + + + + com.apple.security.files.user-selected.read-write + + + diff --git a/fskit-extension/AgentFSExtension/AgentFSExtension.swift b/fskit-extension/AgentFSExtension/AgentFSExtension.swift new file mode 100644 index 0000000..2e2e5ef --- /dev/null +++ b/fskit-extension/AgentFSExtension/AgentFSExtension.swift @@ -0,0 +1,24 @@ +// AgentFSExtension.swift +// Main entry point for the AgentFS FSKit extension. +// +// This is a user-space filesystem extension that exposes AgentFS databases +// as mountable filesystems on macOS 26+ without requiring kernel extensions. + +import FSKit +import Foundation +import os + +@main +class AgentFSExtensionMain { + static let logger = Logger(subsystem: "io.turso.agentfs", category: "Extension") + + static func main() { + logger.info("Starting AgentFS extension") + + // Create the file system and run the extension + let fileSystem = AgentFileSystem() + + // FSKit extensions run via XPC - the system manages the lifecycle + RunLoop.main.run() + } +} diff --git a/fskit-extension/AgentFSExtension/AgentFileSystem.swift b/fskit-extension/AgentFSExtension/AgentFileSystem.swift new file mode 100644 index 0000000..898eef8 --- /dev/null +++ b/fskit-extension/AgentFSExtension/AgentFileSystem.swift @@ -0,0 +1,90 @@ +// AgentFileSystem.swift +// FSUnaryFileSystem implementation for AgentFS. +// +// This class handles probing and loading AgentFS database resources. + +import FSKit +import os + +final class AgentFileSystem: FSUnaryFileSystem, FSUnaryFileSystemOperations { + + private let logger = Logger(subsystem: "io.turso.agentfs", category: "FileSystem") + + // MARK: - FSUnaryFileSystemOperations + + func probeResource( + resource: FSResource, + replyHandler: @escaping (FSProbeResult?, Error?) -> Void + ) { + logger.info("Probing resource: \(String(describing: resource))") + + // For FSGenericURLResource, extract the database path from URL + guard let urlResource = resource as? FSGenericURLResource else { + logger.error("Resource is not FSGenericURLResource") + replyHandler(nil, NSError(domain: NSPOSIXErrorDomain, code: Int(EINVAL))) + return + } + + let url = urlResource.url + let dbPath = url.path + + logger.info("Probing database at: \(dbPath)") + + // Validate the database exists + guard FileManager.default.fileExists(atPath: dbPath) else { + logger.error("Database file does not exist: \(dbPath)") + replyHandler(nil, NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT))) + return + } + + // Return probe result indicating this is a usable AgentFS database + let containerID = FSContainerIdentifier(uuid: UUID()) + let result = FSProbeResult.usable(name: "AgentFS", containerID: containerID) + + logger.info("Probe successful for: \(dbPath)") + replyHandler(result, nil) + } + + func loadResource( + resource: FSResource, + options: FSTaskOptions, + replyHandler: @escaping (FSVolume?, Error?) -> Void + ) { + logger.info("Loading resource") + + guard let urlResource = resource as? FSGenericURLResource else { + logger.error("Resource is not FSGenericURLResource") + replyHandler(nil, NSError(domain: NSPOSIXErrorDomain, code: Int(EINVAL))) + return + } + + let dbPath = urlResource.url.path + logger.info("Loading database: \(dbPath)") + + // Open the AgentFS database via FFI + guard let handle = dbPath.withCString({ agentfs_open($0) }) else { + logger.error("Failed to open AgentFS database at \(dbPath)") + replyHandler(nil, NSError(domain: NSPOSIXErrorDomain, code: Int(EIO))) + return + } + + logger.info("Database opened successfully") + + // Create and return the volume + let volume = AgentVolume(handle: handle, dbPath: dbPath) + replyHandler(volume, nil) + } + + func unloadResource( + resource: FSResource, + options: FSTaskOptions, + replyHandler: @escaping (Error?) -> Void + ) { + logger.info("Unloading resource") + replyHandler(nil) + } + + func didFinishLoading() { + logger.info("Extension finished loading") + } +} diff --git a/fskit-extension/AgentFSExtension/AgentItem.swift b/fskit-extension/AgentFSExtension/AgentItem.swift new file mode 100644 index 0000000..9fe75a8 --- /dev/null +++ b/fskit-extension/AgentFSExtension/AgentItem.swift @@ -0,0 +1,83 @@ +// AgentItem.swift +// FSItem implementation for AgentFS. +// +// Wraps a filesystem path and cached statistics. + +import FSKit +import Foundation + +final class AgentItem: FSItem { + + let volume: AgentVolume + let path: String + let isDirectory: Bool + private var cachedStats: FFIStats? + + init(volume: AgentVolume, path: String, isDirectory: Bool, stats: FFIStats? = nil) { + self.volume = volume + self.path = path + self.isDirectory = isDirectory + self.cachedStats = stats + super.init() + } + + var currentAttributes: FSItem.Attributes { + if let stats = cachedStats { + return makeAttributes(from: stats) + } + // Return minimal attributes if no stats cached + let attrs = FSItem.Attributes() + attrs.type = isDirectory ? .directory : .file + return attrs + } + + func makeAttributes(from stats: FFIStats) -> FSItem.Attributes { + let attrs = FSItem.Attributes() + + // Determine item type from mode + let fileType = stats.mode & 0o170000 + switch fileType { + case 0o040000: + attrs.type = .directory + case 0o120000: + attrs.type = .symlink + default: + attrs.type = .file + } + + // File permissions (lower 12 bits) + attrs.mode = stats.mode & 0o7777 + + // Link count + attrs.linkCount = UInt32(stats.nlink) + + // Ownership + attrs.uid = stats.uid + attrs.gid = stats.gid + + // Size + attrs.size = UInt64(stats.size) + + // Allocated size (round up to block size) + let blockSize: Int64 = 4096 + let blocks = (stats.size + blockSize - 1) / blockSize + attrs.allocSize = UInt64(blocks * blockSize) + + // Timestamps (using timespec) + attrs.accessTime = timespec(tv_sec: Int(stats.atime), tv_nsec: 0) + attrs.modifyTime = timespec(tv_sec: Int(stats.mtime), tv_nsec: 0) + attrs.changeTime = timespec(tv_sec: Int(stats.ctime), tv_nsec: 0) + attrs.birthTime = timespec(tv_sec: Int(stats.ctime), tv_nsec: 0) + + // Inode / file ID + if let fileID = FSItem.Identifier(rawValue: UInt64(bitPattern: stats.ino)) { + attrs.fileID = fileID + } + + return attrs + } + + func updateStats(_ stats: FFIStats) { + self.cachedStats = stats + } +} diff --git a/fskit-extension/AgentFSExtension/AgentVolume.swift b/fskit-extension/AgentFSExtension/AgentVolume.swift new file mode 100644 index 0000000..bb24496 --- /dev/null +++ b/fskit-extension/AgentFSExtension/AgentVolume.swift @@ -0,0 +1,530 @@ +// AgentVolume.swift +// FSVolume implementation for AgentFS. +// +// This class handles all filesystem operations by calling into the Rust FFI layer. + +import FSKit +import Foundation +import os + +final class AgentVolume: FSVolume { + + private let logger = Logger(subsystem: "io.turso.agentfs", category: "Volume") + private let handle: OpaquePointer + private let dbPath: String + private var _rootItem: AgentItem? + + init(handle: OpaquePointer, dbPath: String) { + self.handle = handle + self.dbPath = dbPath + + let volumeID = FSVolume.Identifier(uuid: UUID()) + let volumeName = FSFileName(string: "AgentFS") + + super.init(volumeID: volumeID, volumeName: volumeName) + + // Create root item + self._rootItem = AgentItem(volume: self, path: "/", isDirectory: true) + } + + deinit { + logger.info("Closing AgentFS handle") + // The handle is an OpaquePointer which can be passed directly to the FFI function + // that expects UnsafeMutablePointer? + agentfs_close(handle) + } + + // MARK: - Internal helpers + + func getHandle() -> OpaquePointer { + return handle + } +} + +// MARK: - FSVolume.Operations +extension AgentVolume: FSVolume.Operations { + + // MARK: Required properties + + var supportedVolumeCapabilities: FSVolume.SupportedCapabilities { + let caps = FSVolume.SupportedCapabilities() + caps.supportsHardLinks = false + caps.supportsSymbolicLinks = true + caps.supportsPersistentObjectIDs = true + caps.supports64BitObjectIDs = true + return caps + } + + var volumeStatistics: FSStatFSResult { + let result = FSStatFSResult(fileSystemTypeName: "agentfs") + + var ffiStats = FFIFilesystemStats() + let ok = agentfs_statfs(handle, &ffiStats) + if ok.success { + // Use sensible defaults for block-based stats + let blockSize: Int = 4096 + result.blockSize = blockSize + result.ioSize = blockSize + + // Calculate blocks from bytes + let totalBytes = ffiStats.bytes_used + (1024 * 1024 * 1024) // Assume 1GB capacity + result.totalBlocks = UInt64(totalBytes) / UInt64(blockSize) + result.freeBlocks = UInt64(1024 * 1024 * 1024 - Int64(ffiStats.bytes_used)) / UInt64(blockSize) + result.availableBlocks = result.freeBlocks + result.totalBytes = totalBytes + result.usedBytes = ffiStats.bytes_used + result.freeBytes = UInt64(1024 * 1024 * 1024) - ffiStats.bytes_used + result.availableBytes = result.freeBytes + + result.totalFiles = ffiStats.inodes + 1000 // Some headroom + result.freeFiles = 1000 + } else { + // Defaults if statfs fails + result.blockSize = 4096 + result.ioSize = 4096 + result.totalBlocks = 1024 * 1024 // 4GB + result.freeBlocks = 1024 * 1024 + result.availableBlocks = 1024 * 1024 + } + + return result + } + + // MARK: PathConfOperations properties (required by Operations) + + var maximumLinkCount: Int { 1 } + var maximumNameLength: Int { 255 } + var restrictsOwnershipChanges: Bool { true } + var truncatesLongNames: Bool { false } + + // MARK: Operations methods + + func activate(options: FSTaskOptions) async throws -> FSItem { + logger.info("Activating volume: \(self.dbPath)") + guard let root = _rootItem else { + throw posixError(EIO) + } + return root + } + + func deactivate(options: FSDeactivateOptions) async throws { + logger.info("Deactivating volume: \(self.dbPath)") + } + + func mount(options: FSTaskOptions) async throws { + logger.info("Mounting volume: \(self.dbPath)") + } + + func unmount() async { + logger.info("Unmounting volume: \(self.dbPath)") + } + + func synchronize(flags: FSSyncFlags) async throws { + logger.debug("Synchronizing volume") + let result = "/".withCString { agentfs_fsync(handle, $0) } + if !result.success { + throw posixError(EIO) + } + } + + func attributes(_ request: FSItem.GetAttributesRequest, of item: FSItem) async throws -> FSItem.Attributes { + guard let agentItem = item as? AgentItem else { + throw posixError(EINVAL) + } + + var stats = FFIStats() + let result = agentItem.path.withCString { agentfs_stat(handle, $0, &stats) } + + guard result.success else { + throw posixError(result.error_code) + } + + return agentItem.makeAttributes(from: stats) + } + + func setAttributes(_ request: FSItem.SetAttributesRequest, on item: FSItem) async throws -> FSItem.Attributes { + guard let agentItem = item as? AgentItem else { + throw posixError(EINVAL) + } + + // Handle truncate via setAttributes + if request.isValid(.size) { + let result = agentItem.path.withCString { agentfs_truncate(handle, $0, request.size) } + if !result.success { + throw posixError(result.error_code) + } + request.consumedAttributes.insert(.size) + } + + // Return updated attributes + var stats = FFIStats() + guard agentItem.path.withCString({ agentfs_stat(handle, $0, &stats) }).success else { + throw posixError(EIO) + } + return agentItem.makeAttributes(from: stats) + } + + func lookupItem(named name: FSFileName, inDirectory directory: FSItem) async throws -> (FSItem, FSFileName) { + guard let parentItem = directory as? AgentItem, + let nameString = name.string else { + throw posixError(EINVAL) + } + + let childPath: String + if parentItem.path == "/" { + childPath = "/\(nameString)" + } else { + childPath = "\(parentItem.path)/\(nameString)" + } + + var stats = FFIStats() + let result = childPath.withCString { agentfs_stat(handle, $0, &stats) } + + guard result.success else { + throw posixError(result.error_code) + } + + let isDir = (stats.mode & 0o170000) == 0o040000 + let item = AgentItem(volume: self, path: childPath, isDirectory: isDir, stats: stats) + return (item, name) + } + + func reclaimItem(_ item: FSItem) async throws { + // Nothing to do - items are stateless + } + + func readSymbolicLink(_ item: FSItem) async throws -> FSFileName { + guard let agentItem = item as? AgentItem else { + throw posixError(EINVAL) + } + + var targetPtr: UnsafeMutablePointer? + let result = agentItem.path.withCString { agentfs_readlink(handle, $0, &targetPtr) } + + guard result.success, let target = targetPtr else { + throw posixError(result.error_code) + } + + defer { agentfs_free_string(targetPtr) } + return FSFileName(string: String(cString: target)) + } + + func createItem( + named name: FSFileName, + type: FSItem.ItemType, + inDirectory directory: FSItem, + attributes: FSItem.SetAttributesRequest + ) async throws -> (FSItem, FSFileName) { + guard let parentItem = directory as? AgentItem, + let nameString = name.string else { + throw posixError(EINVAL) + } + + let newPath: String + if parentItem.path == "/" { + newPath = "/\(nameString)" + } else { + newPath = "\(parentItem.path)/\(nameString)" + } + + let result: FFIResult + switch type { + case .directory: + result = newPath.withCString { agentfs_mkdir(handle, $0) } + case .file: + // Create empty file via pwrite with 0 bytes + result = newPath.withCString { agentfs_pwrite(handle, $0, 0, nil, 0) } + default: + throw posixError(ENOTSUP) + } + + guard result.success else { + throw posixError(result.error_code) + } + + var stats = FFIStats() + guard newPath.withCString({ agentfs_stat(handle, $0, &stats) }).success else { + throw posixError(EIO) + } + + let item = AgentItem(volume: self, path: newPath, isDirectory: type == .directory, stats: stats) + return (item, name) + } + + func createSymbolicLink( + named name: FSFileName, + inDirectory directory: FSItem, + attributes: FSItem.SetAttributesRequest, + linkContents: FSFileName + ) async throws -> (FSItem, FSFileName) { + guard let parentItem = directory as? AgentItem, + let nameString = name.string, + let targetString = linkContents.string else { + throw posixError(EINVAL) + } + + let linkPath: String + if parentItem.path == "/" { + linkPath = "/\(nameString)" + } else { + linkPath = "\(parentItem.path)/\(nameString)" + } + + let result = targetString.withCString { targetCStr in + linkPath.withCString { linkCStr in + agentfs_symlink(handle, targetCStr, linkCStr) + } + } + + guard result.success else { + throw posixError(result.error_code) + } + + var stats = FFIStats() + guard linkPath.withCString({ agentfs_lstat(handle, $0, &stats) }).success else { + throw posixError(EIO) + } + + let item = AgentItem(volume: self, path: linkPath, isDirectory: false, stats: stats) + return (item, name) + } + + func createLink(to item: FSItem, named name: FSFileName, inDirectory directory: FSItem) async throws -> FSFileName { + // AgentFS doesn't support hard links + throw posixError(ENOTSUP) + } + + func removeItem(_ item: FSItem, named name: FSFileName, fromDirectory directory: FSItem) async throws { + guard let agentItem = item as? AgentItem else { + throw posixError(EINVAL) + } + + let result = agentItem.path.withCString { agentfs_remove(handle, $0) } + guard result.success else { + throw posixError(result.error_code) + } + } + + func renameItem( + _ item: FSItem, + inDirectory sourceDirectory: FSItem, + named sourceName: FSFileName, + to destinationName: FSFileName, + inDirectory destinationDirectory: FSItem, + overItem: FSItem? + ) async throws -> FSFileName { + guard let srcItem = item as? AgentItem, + let dstDir = destinationDirectory as? AgentItem, + let destNameString = destinationName.string else { + throw posixError(EINVAL) + } + + let newPath: String + if dstDir.path == "/" { + newPath = "/\(destNameString)" + } else { + newPath = "\(dstDir.path)/\(destNameString)" + } + + let result = srcItem.path.withCString { fromCStr in + newPath.withCString { toCStr in + agentfs_rename(handle, fromCStr, toCStr) + } + } + + guard result.success else { + throw posixError(result.error_code) + } + + return destinationName + } + + func enumerateDirectory( + _ directory: FSItem, + startingAt cookie: FSDirectoryCookie, + verifier: FSDirectoryVerifier, + attributes: FSItem.GetAttributesRequest?, + packer: FSDirectoryEntryPacker + ) async throws -> FSDirectoryVerifier { + guard let dirItem = directory as? AgentItem else { + throw posixError(EINVAL) + } + + var entriesPtr: UnsafeMutablePointer? + let result = dirItem.path.withCString { agentfs_readdir(handle, $0, &entriesPtr) } + + guard result.success, let json = entriesPtr else { + throw posixError(result.error_code) + } + + defer { agentfs_free_string(entriesPtr) } + + // Parse JSON array of entry names + let jsonString = String(cString: json) + guard let jsonData = jsonString.data(using: .utf8), + let entries = try? JSONDecoder().decode([String].self, from: jsonData) else { + throw posixError(EIO) + } + + // If no attributes requested, include . and .. entries + let cookieValue = cookie.rawValue + if attributes == nil { + if cookie == .initial { + // Pack "." entry + let dotName = FSFileName(string: ".") + let packed = packer.packEntry( + name: dotName, + itemType: .directory, + itemID: .rootDirectory, + nextCookie: FSDirectoryCookie(rawValue: 1), + attributes: nil + ) + if !packed { + return FSDirectoryVerifier(rawValue: 1) + } + } + if cookieValue <= 1 { + // Pack ".." entry + let dotDotName = FSFileName(string: "..") + let packed = packer.packEntry( + name: dotDotName, + itemType: .directory, + itemID: .parentOfRoot, + nextCookie: FSDirectoryCookie(rawValue: 2), + attributes: nil + ) + if !packed { + return FSDirectoryVerifier(rawValue: 1) + } + } + } + + let startIndex = attributes == nil ? Int(cookieValue) - 2 : Int(cookieValue) + + for (index, name) in entries.enumerated() { + guard index >= startIndex else { continue } + + let childPath: String + if dirItem.path == "/" { + childPath = "/\(name)" + } else { + childPath = "\(dirItem.path)/\(name)" + } + + var stats = FFIStats() + guard childPath.withCString({ agentfs_stat(handle, $0, &stats) }).success else { + continue + } + + let isDir = (stats.mode & 0o170000) == 0o040000 + let item = AgentItem(volume: self, path: childPath, isDirectory: isDir, stats: stats) + + let itemType: FSItem.ItemType = isDir ? .directory : .file + let nextCookieValue: UInt64 = attributes == nil ? UInt64(index + 3) : UInt64(index + 1) + let nextCookie = FSDirectoryCookie(rawValue: nextCookieValue) + + let fileName = FSFileName(string: name) + + let itemID = FSItem.Identifier(rawValue: UInt64(bitPattern: stats.ino))! + + let packed = packer.packEntry( + name: fileName, + itemType: itemType, + itemID: itemID, + nextCookie: nextCookie, + attributes: attributes != nil ? item.currentAttributes : nil + ) + + if !packed { break } + } + + return FSDirectoryVerifier(rawValue: 1) + } +} + +// MARK: - FSVolume.OpenCloseOperations +extension AgentVolume: FSVolume.OpenCloseOperations { + + func openItem(_ item: FSItem, modes: FSVolume.OpenModes) async throws { + // AgentFS handles files statelessly via path - nothing to do + logger.debug("Opening item: \((item as? AgentItem)?.path ?? "unknown")") + } + + func closeItem(_ item: FSItem, modes: FSVolume.OpenModes) async throws { + // Stateless - nothing to do + logger.debug("Closing item: \((item as? AgentItem)?.path ?? "unknown")") + } +} + +// MARK: - FSVolume.ReadWriteOperations +extension AgentVolume: FSVolume.ReadWriteOperations { + + func read( + from item: FSItem, + at offset: off_t, + length: Int, + into buffer: FSMutableFileDataBuffer + ) async throws -> Int { + guard let agentItem = item as? AgentItem else { + throw posixError(EINVAL) + } + + var ffiBuffer = FFIBuffer() + let result = agentItem.path.withCString { + agentfs_pread(handle, $0, UInt64(offset), UInt64(length), &ffiBuffer) + } + + guard result.success else { + throw posixError(result.error_code) + } + + defer { agentfs_free_buffer(ffiBuffer) } + + let bytesToCopy = min(Int(ffiBuffer.len), length, Int(buffer.length)) + if bytesToCopy > 0, let sourceData = ffiBuffer.data { + // Access mutableBytes via Obj-C selector since Swift API is unavailable + let selector = NSSelectorFromString("mutableBytes") + if buffer.responds(to: selector), + let destRawPtr = buffer.perform(selector)?.toOpaque() { + let destPtr = UnsafeMutableRawPointer(destRawPtr) + destPtr.copyMemory(from: sourceData, byteCount: bytesToCopy) + } + } + + return bytesToCopy + } + + func write( + contents: Data, + to item: FSItem, + at offset: off_t + ) async throws -> Int { + guard let agentItem = item as? AgentItem else { + throw posixError(EINVAL) + } + + let result = contents.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> FFIResult in + agentItem.path.withCString { pathCStr in + agentfs_pwrite( + handle, + pathCStr, + UInt64(offset), + bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + contents.count + ) + } + } + + guard result.success else { + throw posixError(result.error_code) + } + + return contents.count + } +} + +// MARK: - Helper Functions + +private func posixError(_ errno: Int32) -> NSError { + return NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) +} diff --git a/fskit-extension/AgentFSExtension/ExtensionInfo.plist b/fskit-extension/AgentFSExtension/ExtensionInfo.plist new file mode 100644 index 0000000..fde2130 --- /dev/null +++ b/fskit-extension/AgentFSExtension/ExtensionInfo.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + AgentFS + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AgentFSExtension + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.fskit.unaryfs + + FSKitExtensionName + agentfs + + diff --git a/fskit-extension/AgentFSExtension/agentfs_ffi.h b/fskit-extension/AgentFSExtension/agentfs_ffi.h new file mode 100644 index 0000000..3c46ed7 --- /dev/null +++ b/fskit-extension/AgentFSExtension/agentfs_ffi.h @@ -0,0 +1,239 @@ +// AgentFS FFI - C-compatible interface for FSKit Swift extension +// Auto-generated by cbindgen - do not edit manually + + +#ifndef AGENTFS_FFI_H +#define AGENTFS_FFI_H + +/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */ + +#include +#include +#include +#include +#include + +// Opaque handle to a mounted filesystem instance. +// +// This handle wraps the Rust FileSystem trait object and a Tokio runtime +// for executing async operations. +typedef struct AgentFSHandle AgentFSHandle; + +// Result type for FFI operations. +// +// - `success`: true if the operation succeeded +// - `error_code`: 0 on success, positive errno value on failure +typedef struct FFIResult { + bool success; + int32_t error_code; +} FFIResult; + +// File statistics returned to Swift. +// +// Mirrors the Rust `Stats` struct with C-compatible types. +typedef struct FFIStats { + int64_t ino; + uint32_t mode; + uint32_t nlink; + uint32_t uid; + uint32_t gid; + int64_t size; + int64_t atime; + int64_t mtime; + int64_t ctime; +} FFIStats; + +// Buffer for returning variable-length data. +// +// The caller is responsible for freeing this buffer using `agentfs_free_buffer`. +typedef struct FFIBuffer { + uint8_t *data; + size_t len; + size_t capacity; +} FFIBuffer; + +// Filesystem statistics for statfs. +typedef struct FFIFilesystemStats { + uint64_t inodes; + uint64_t bytes_used; +} FFIFilesystemStats; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// Open an AgentFS database and return a handle. +// +// # Arguments +// * `db_path` - Path to the SQLite database file (null-terminated C string) +// +// # Returns +// * Non-null handle on success +// * Null pointer on failure +// +// # Safety +// `db_path` must be a valid null-terminated C string. +struct AgentFSHandle *agentfs_open(const char *db_path); + +// Close and free an AgentFS handle. +// +// # Safety +// `handle` must be a valid handle returned by `agentfs_open`, or null. +// After calling this function, the handle must not be used again. +void agentfs_close(struct AgentFSHandle *handle); + +// Get file statistics, following symlinks. +// +// # Safety +// - `handle` must be a valid handle +// - `path` must be a valid null-terminated C string +// - `out_stats` must be a valid pointer to write stats +struct FFIResult agentfs_stat(const struct AgentFSHandle *handle, + const char *path, + struct FFIStats *out_stats); + +// Get file statistics without following symlinks. +// +// # Safety +// Same as `agentfs_stat`. +struct FFIResult agentfs_lstat(const struct AgentFSHandle *handle, + const char *path, + struct FFIStats *out_stats); + +// Read data from a file at offset. +// +// # Arguments +// * `handle` - AgentFS handle +// * `path` - File path +// * `offset` - Byte offset to start reading +// * `size` - Maximum bytes to read +// * `out_buffer` - Output buffer (caller must free with `agentfs_free_buffer`) +// +// # Safety +// - All pointers must be valid +// - `out_buffer` will be filled with allocated data that must be freed +struct FFIResult agentfs_pread(const struct AgentFSHandle *handle, + const char *path, + uint64_t offset, + uint64_t size, + struct FFIBuffer *out_buffer); + +// Write data to a file at offset. +// +// Creates the file if it doesn't exist. Extends the file if writing past end. +// +// # Safety +// - All pointers must be valid +// - `data` must point to at least `data_len` bytes +struct FFIResult agentfs_pwrite(const struct AgentFSHandle *handle, + const char *path, + uint64_t offset, + const uint8_t *data, + size_t data_len); + +// Read entire file contents. +// +// # Safety +// Same as `agentfs_pread`. +struct FFIResult agentfs_read_file(const struct AgentFSHandle *handle, + const char *path, + struct FFIBuffer *out_buffer); + +// Write entire file contents (creates or overwrites). +// +// # Safety +// Same as `agentfs_pwrite`. +struct FFIResult agentfs_write_file(const struct AgentFSHandle *handle, + const char *path, + const uint8_t *data, + size_t data_len); + +// Truncate a file to a specific size. +// +// # Safety +// - `handle` must be valid +// - `path` must be a valid null-terminated string +struct FFIResult agentfs_truncate(const struct AgentFSHandle *handle, + const char *path, + uint64_t size); + +// Read directory entries. +// +// Returns entries as a JSON array string: `["file1", "file2", "dir1"]` +// +// # Safety +// - `out_entries` will be set to a newly allocated string (free with `agentfs_free_string`) +struct FFIResult agentfs_readdir(const struct AgentFSHandle *handle, + const char *path, + char **out_entries); + +// Create a directory. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_mkdir(const struct AgentFSHandle *handle, const char *path); + +// Remove a file or empty directory. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_remove(const struct AgentFSHandle *handle, const char *path); + +// Rename/move a file or directory. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_rename(const struct AgentFSHandle *handle, + const char *from, + const char *to); + +// Create a symbolic link. +// +// # Arguments +// * `target` - What the symlink points to +// * `linkpath` - Path where the symlink will be created +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_symlink(const struct AgentFSHandle *handle, + const char *target, + const char *linkpath); + +// Read the target of a symbolic link. +// +// # Safety +// - `out_target` will be set to a newly allocated string (free with `agentfs_free_string`) +struct FFIResult agentfs_readlink(const struct AgentFSHandle *handle, + const char *path, + char **out_target); + +// Get filesystem statistics. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_statfs(const struct AgentFSHandle *handle, + struct FFIFilesystemStats *out_stats); + +// Synchronize file data to persistent storage. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_fsync(const struct AgentFSHandle *handle, const char *path); + +// Free a string allocated by Rust. +// +// # Safety +// `s` must be a string returned by an agentfs_* function, or null. +void agentfs_free_string(char *s); + +// Free a buffer allocated by Rust. +// +// # Safety +// `buf` must be a buffer returned by an agentfs_* function. +void agentfs_free_buffer(struct FFIBuffer buf); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* AGENTFS_FFI_H */ diff --git a/fskit-ffi/.gitignore b/fskit-ffi/.gitignore new file mode 100644 index 0000000..38746c5 --- /dev/null +++ b/fskit-ffi/.gitignore @@ -0,0 +1,6 @@ +target/ +*.a +*.dylib +DerivedData/ +build/ +.DS_Store diff --git a/fskit-ffi/Cargo.toml b/fskit-ffi/Cargo.toml new file mode 100644 index 0000000..81a8e57 --- /dev/null +++ b/fskit-ffi/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agentfs-ffi" +version = "0.1.0" +edition = "2021" +description = "C-compatible FFI layer for AgentFS, used by FSKit Swift extension" + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +agentfs-sdk = { path = "../sdk/rust" } +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" +libc = "0.2" +turso = "0.4.0-pre.17" + +[build-dependencies] +cbindgen = "0.27" diff --git a/fskit-ffi/Makefile b/fskit-ffi/Makefile new file mode 100644 index 0000000..802650e --- /dev/null +++ b/fskit-ffi/Makefile @@ -0,0 +1,83 @@ +# Makefile for AgentFS FSKit build +# +# This coordinates building the Rust FFI library and Swift FSKit extension. + +.PHONY: all clean build-ffi build-extension install-extension help + +# Detect platform +UNAME := $(shell uname) +ARCH := $(shell uname -m) + +# Paths +FFI_DIR := . +EXTENSION_DIR := ../fskit-extension +FFI_HEADER := $(FFI_DIR)/agentfs_ffi.h +FFI_LIB := $(FFI_DIR)/target/release/libagentfs_ffi.a + +# Default target +all: build-ffi build-extension + +# Build the Rust FFI library +build-ffi: + @echo "Building Rust FFI library..." + cargo build --release + @echo "Copying header to Swift extension..." + cp $(FFI_HEADER) $(EXTENSION_DIR)/Sources/AgentFSExtension/ + +# Build the Swift FSKit extension (macOS only) +build-extension: build-ffi +ifeq ($(UNAME),Darwin) + @echo "Building Swift FSKit extension..." + cd $(EXTENSION_DIR) && swift build -c release \ + -Xswiftc -I$(shell pwd) \ + -Xlinker -L$(shell pwd)/target/release \ + -Xlinker -lagentfs_ffi +else + @echo "Skipping FSKit extension build (not on macOS)" +endif + +# Clean build artifacts +clean: + cargo clean +ifeq ($(UNAME),Darwin) + cd $(EXTENSION_DIR) && swift package clean || true +endif + +# Install the extension (requires code signing) +install-extension: build-extension +ifeq ($(UNAME),Darwin) + @echo "" + @echo "==============================================" + @echo "FSKit Extension Built Successfully!" + @echo "==============================================" + @echo "" + @echo "To install the extension:" + @echo "1. The extension needs to be code-signed and notarized" + @echo "2. Package it in an app bundle for distribution" + @echo "3. Users enable it via:" + @echo " System Settings > General > Login Items & Extensions" + @echo " > File System Extensions" + @echo "" + @echo "For development, you can run directly from Xcode with" + @echo "proper signing configuration." + @echo "" +else + @echo "FSKit extensions only work on macOS" +endif + +# Help +help: + @echo "AgentFS FSKit Build System" + @echo "" + @echo "Targets:" + @echo " all - Build FFI library and Swift extension" + @echo " build-ffi - Build Rust FFI library only" + @echo " build-extension - Build Swift FSKit extension" + @echo " clean - Clean all build artifacts" + @echo " install-extension - Show installation instructions" + @echo " help - Show this help" + @echo "" + @echo "Requirements:" + @echo " - Rust toolchain (cargo)" + @echo " - Swift 6.0+ (for FSKit extension)" + @echo " - macOS 15+ SDK (for FSKit framework)" diff --git a/fskit-ffi/agentfs_ffi.h b/fskit-ffi/agentfs_ffi.h new file mode 100644 index 0000000..3c46ed7 --- /dev/null +++ b/fskit-ffi/agentfs_ffi.h @@ -0,0 +1,239 @@ +// AgentFS FFI - C-compatible interface for FSKit Swift extension +// Auto-generated by cbindgen - do not edit manually + + +#ifndef AGENTFS_FFI_H +#define AGENTFS_FFI_H + +/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */ + +#include +#include +#include +#include +#include + +// Opaque handle to a mounted filesystem instance. +// +// This handle wraps the Rust FileSystem trait object and a Tokio runtime +// for executing async operations. +typedef struct AgentFSHandle AgentFSHandle; + +// Result type for FFI operations. +// +// - `success`: true if the operation succeeded +// - `error_code`: 0 on success, positive errno value on failure +typedef struct FFIResult { + bool success; + int32_t error_code; +} FFIResult; + +// File statistics returned to Swift. +// +// Mirrors the Rust `Stats` struct with C-compatible types. +typedef struct FFIStats { + int64_t ino; + uint32_t mode; + uint32_t nlink; + uint32_t uid; + uint32_t gid; + int64_t size; + int64_t atime; + int64_t mtime; + int64_t ctime; +} FFIStats; + +// Buffer for returning variable-length data. +// +// The caller is responsible for freeing this buffer using `agentfs_free_buffer`. +typedef struct FFIBuffer { + uint8_t *data; + size_t len; + size_t capacity; +} FFIBuffer; + +// Filesystem statistics for statfs. +typedef struct FFIFilesystemStats { + uint64_t inodes; + uint64_t bytes_used; +} FFIFilesystemStats; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// Open an AgentFS database and return a handle. +// +// # Arguments +// * `db_path` - Path to the SQLite database file (null-terminated C string) +// +// # Returns +// * Non-null handle on success +// * Null pointer on failure +// +// # Safety +// `db_path` must be a valid null-terminated C string. +struct AgentFSHandle *agentfs_open(const char *db_path); + +// Close and free an AgentFS handle. +// +// # Safety +// `handle` must be a valid handle returned by `agentfs_open`, or null. +// After calling this function, the handle must not be used again. +void agentfs_close(struct AgentFSHandle *handle); + +// Get file statistics, following symlinks. +// +// # Safety +// - `handle` must be a valid handle +// - `path` must be a valid null-terminated C string +// - `out_stats` must be a valid pointer to write stats +struct FFIResult agentfs_stat(const struct AgentFSHandle *handle, + const char *path, + struct FFIStats *out_stats); + +// Get file statistics without following symlinks. +// +// # Safety +// Same as `agentfs_stat`. +struct FFIResult agentfs_lstat(const struct AgentFSHandle *handle, + const char *path, + struct FFIStats *out_stats); + +// Read data from a file at offset. +// +// # Arguments +// * `handle` - AgentFS handle +// * `path` - File path +// * `offset` - Byte offset to start reading +// * `size` - Maximum bytes to read +// * `out_buffer` - Output buffer (caller must free with `agentfs_free_buffer`) +// +// # Safety +// - All pointers must be valid +// - `out_buffer` will be filled with allocated data that must be freed +struct FFIResult agentfs_pread(const struct AgentFSHandle *handle, + const char *path, + uint64_t offset, + uint64_t size, + struct FFIBuffer *out_buffer); + +// Write data to a file at offset. +// +// Creates the file if it doesn't exist. Extends the file if writing past end. +// +// # Safety +// - All pointers must be valid +// - `data` must point to at least `data_len` bytes +struct FFIResult agentfs_pwrite(const struct AgentFSHandle *handle, + const char *path, + uint64_t offset, + const uint8_t *data, + size_t data_len); + +// Read entire file contents. +// +// # Safety +// Same as `agentfs_pread`. +struct FFIResult agentfs_read_file(const struct AgentFSHandle *handle, + const char *path, + struct FFIBuffer *out_buffer); + +// Write entire file contents (creates or overwrites). +// +// # Safety +// Same as `agentfs_pwrite`. +struct FFIResult agentfs_write_file(const struct AgentFSHandle *handle, + const char *path, + const uint8_t *data, + size_t data_len); + +// Truncate a file to a specific size. +// +// # Safety +// - `handle` must be valid +// - `path` must be a valid null-terminated string +struct FFIResult agentfs_truncate(const struct AgentFSHandle *handle, + const char *path, + uint64_t size); + +// Read directory entries. +// +// Returns entries as a JSON array string: `["file1", "file2", "dir1"]` +// +// # Safety +// - `out_entries` will be set to a newly allocated string (free with `agentfs_free_string`) +struct FFIResult agentfs_readdir(const struct AgentFSHandle *handle, + const char *path, + char **out_entries); + +// Create a directory. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_mkdir(const struct AgentFSHandle *handle, const char *path); + +// Remove a file or empty directory. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_remove(const struct AgentFSHandle *handle, const char *path); + +// Rename/move a file or directory. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_rename(const struct AgentFSHandle *handle, + const char *from, + const char *to); + +// Create a symbolic link. +// +// # Arguments +// * `target` - What the symlink points to +// * `linkpath` - Path where the symlink will be created +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_symlink(const struct AgentFSHandle *handle, + const char *target, + const char *linkpath); + +// Read the target of a symbolic link. +// +// # Safety +// - `out_target` will be set to a newly allocated string (free with `agentfs_free_string`) +struct FFIResult agentfs_readlink(const struct AgentFSHandle *handle, + const char *path, + char **out_target); + +// Get filesystem statistics. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_statfs(const struct AgentFSHandle *handle, + struct FFIFilesystemStats *out_stats); + +// Synchronize file data to persistent storage. +// +// # Safety +// Standard pointer validity requirements. +struct FFIResult agentfs_fsync(const struct AgentFSHandle *handle, const char *path); + +// Free a string allocated by Rust. +// +// # Safety +// `s` must be a string returned by an agentfs_* function, or null. +void agentfs_free_string(char *s); + +// Free a buffer allocated by Rust. +// +// # Safety +// `buf` must be a buffer returned by an agentfs_* function. +void agentfs_free_buffer(struct FFIBuffer buf); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* AGENTFS_FFI_H */ diff --git a/fskit-ffi/build.rs b/fskit-ffi/build.rs new file mode 100644 index 0000000..3e626bc --- /dev/null +++ b/fskit-ffi/build.rs @@ -0,0 +1,21 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let output_dir = PathBuf::from(&crate_dir); + + // Generate C header using cbindgen + let config = cbindgen::Config::from_file("cbindgen.toml") + .unwrap_or_default(); + + cbindgen::Builder::new() + .with_crate(&crate_dir) + .with_config(config) + .generate() + .expect("Unable to generate C bindings") + .write_to_file(output_dir.join("agentfs_ffi.h")); + + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=cbindgen.toml"); +} diff --git a/fskit-ffi/cbindgen.toml b/fskit-ffi/cbindgen.toml new file mode 100644 index 0000000..38a910e --- /dev/null +++ b/fskit-ffi/cbindgen.toml @@ -0,0 +1,43 @@ +# cbindgen configuration for agentfs-ffi + +language = "C" + +# Header configuration +header = """ +// AgentFS FFI - C-compatible interface for FSKit Swift extension +// Auto-generated by cbindgen - do not edit manually +""" +include_guard = "AGENTFS_FFI_H" +autogen_warning = "/* Warning: this file was auto-generated by cbindgen. Don't modify this manually. */" + +# Style configuration +tab_width = 4 +style = "both" # Generate both typedef and struct tags +cpp_compat = true + +# Documentation +documentation = true +documentation_style = "c99" + +# Type generation +usize_is_size_t = true + +[parse] +parse_deps = false +clean = false + +[export] +include = [] +exclude = [] + +[fn] +# Rename functions to follow C conventions +rename_args = "SnakeCase" + +[struct] +# Use camelCase for struct fields +rename_fields = "None" + +[enum] +# Prefix enum variants +rename_variants = "ScreamingSnakeCase" diff --git a/fskit-ffi/src/lib.rs b/fskit-ffi/src/lib.rs new file mode 100644 index 0000000..d906948 --- /dev/null +++ b/fskit-ffi/src/lib.rs @@ -0,0 +1,839 @@ +//! C-compatible FFI layer exposing the FileSystem trait for FSKit Swift extension. +//! +//! This crate provides a stable C ABI for Swift to call into the Rust filesystem +//! implementation. All functions use C-compatible types and follow memory safety +//! conventions for FFI. + +use std::ffi::{c_char, CStr, CString}; +use std::ptr; +use std::sync::Arc; + +use agentfs_sdk::{AgentFS, AgentFSOptions, FileSystem, HostFS, OverlayFS}; +use tokio::runtime::Runtime; +use turso::Value; + +// ============================================================================ +// Types +// ============================================================================ + +/// Opaque handle to a mounted filesystem instance. +/// +/// This handle wraps the Rust FileSystem trait object and a Tokio runtime +/// for executing async operations. +pub struct AgentFSHandle { + fs: Arc, + runtime: Runtime, +} + +/// File statistics returned to Swift. +/// +/// Mirrors the Rust `Stats` struct with C-compatible types. +#[repr(C)] +pub struct FFIStats { + pub ino: i64, + pub mode: u32, + pub nlink: u32, + pub uid: u32, + pub gid: u32, + pub size: i64, + pub atime: i64, + pub mtime: i64, + pub ctime: i64, +} + +/// Filesystem statistics for statfs. +#[repr(C)] +pub struct FFIFilesystemStats { + pub inodes: u64, + pub bytes_used: u64, +} + +/// Result type for FFI operations. +/// +/// - `success`: true if the operation succeeded +/// - `error_code`: 0 on success, positive errno value on failure +#[repr(C)] +pub struct FFIResult { + pub success: bool, + pub error_code: i32, +} + +impl FFIResult { + fn ok() -> Self { + FFIResult { success: true, error_code: 0 } + } + + fn err(errno: i32) -> Self { + FFIResult { success: false, error_code: errno } + } + + fn not_found() -> Self { + Self::err(libc::ENOENT) + } + + fn io_error() -> Self { + Self::err(libc::EIO) + } + + fn invalid_arg() -> Self { + Self::err(libc::EINVAL) + } +} + +/// Buffer for returning variable-length data. +/// +/// The caller is responsible for freeing this buffer using `agentfs_free_buffer`. +#[repr(C)] +pub struct FFIBuffer { + pub data: *mut u8, + pub len: usize, + pub capacity: usize, +} + +impl FFIBuffer { + fn null() -> Self { + FFIBuffer { + data: ptr::null_mut(), + len: 0, + capacity: 0, + } + } + + fn from_vec(v: Vec) -> Self { + let mut v = v.into_boxed_slice(); + let len = v.len(); + let data = v.as_mut_ptr(); + std::mem::forget(v); + FFIBuffer { + data, + len, + capacity: len, + } + } +} + +// ============================================================================ +// Lifecycle Functions +// ============================================================================ + +/// Open an AgentFS database and return a handle. +/// +/// # Arguments +/// * `db_path` - Path to the SQLite database file (null-terminated C string) +/// +/// # Returns +/// * Non-null handle on success +/// * Null pointer on failure +/// +/// # Safety +/// `db_path` must be a valid null-terminated C string. +#[no_mangle] +pub unsafe extern "C" fn agentfs_open(db_path: *const c_char) -> *mut AgentFSHandle { + if db_path.is_null() { + return ptr::null_mut(); + } + + let path = match CStr::from_ptr(db_path).to_str() { + Ok(s) => s, + Err(_) => return ptr::null_mut(), + }; + + let runtime = match Runtime::new() { + Ok(rt) => rt, + Err(_) => return ptr::null_mut(), + }; + + let opts = match AgentFSOptions::resolve(path) { + Ok(o) => o, + Err(_) => return ptr::null_mut(), + }; + + let fs: Arc = match runtime.block_on(async { + let agentfs = AgentFS::open(opts).await?; + + // Check for overlay configuration + let conn = agentfs.get_connection(); + let query = "SELECT value FROM fs_overlay_config WHERE key = 'base_path'"; + let base_path: Option = match conn.query(query, ()).await { + Ok(mut rows) => { + if let Ok(Some(row)) = rows.next().await { + row.get_value(0).ok().and_then(|v| { + if let Value::Text(s) = v { + Some(s.clone()) + } else { + None + } + }) + } else { + None + } + } + Err(_) => None, + }; + + if let Some(base_path) = base_path { + let hostfs = HostFS::new(&base_path)?; + let overlay = OverlayFS::new(Arc::new(hostfs), agentfs.fs); + Ok::, anyhow::Error>(Arc::new(overlay)) + } else { + Ok(Arc::new(agentfs.fs) as Arc) + } + }) { + Ok(fs) => fs, + Err(_) => return ptr::null_mut(), + }; + + Box::into_raw(Box::new(AgentFSHandle { fs, runtime })) +} + +/// Close and free an AgentFS handle. +/// +/// # Safety +/// `handle` must be a valid handle returned by `agentfs_open`, or null. +/// After calling this function, the handle must not be used again. +#[no_mangle] +pub unsafe extern "C" fn agentfs_close(handle: *mut AgentFSHandle) { + if !handle.is_null() { + let _ = Box::from_raw(handle); + } +} + +// ============================================================================ +// File Metadata Operations +// ============================================================================ + +/// Get file statistics, following symlinks. +/// +/// # Safety +/// - `handle` must be a valid handle +/// - `path` must be a valid null-terminated C string +/// - `out_stats` must be a valid pointer to write stats +#[no_mangle] +pub unsafe extern "C" fn agentfs_stat( + handle: *const AgentFSHandle, + path: *const c_char, + out_stats: *mut FFIStats, +) -> FFIResult { + if handle.is_null() || path.is_null() || out_stats.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.stat(path)) { + Ok(Some(stats)) => { + *out_stats = FFIStats { + ino: stats.ino, + mode: stats.mode, + nlink: stats.nlink, + uid: stats.uid, + gid: stats.gid, + size: stats.size, + atime: stats.atime, + mtime: stats.mtime, + ctime: stats.ctime, + }; + FFIResult::ok() + } + Ok(None) => FFIResult::not_found(), + Err(_) => FFIResult::io_error(), + } +} + +/// Get file statistics without following symlinks. +/// +/// # Safety +/// Same as `agentfs_stat`. +#[no_mangle] +pub unsafe extern "C" fn agentfs_lstat( + handle: *const AgentFSHandle, + path: *const c_char, + out_stats: *mut FFIStats, +) -> FFIResult { + if handle.is_null() || path.is_null() || out_stats.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.lstat(path)) { + Ok(Some(stats)) => { + *out_stats = FFIStats { + ino: stats.ino, + mode: stats.mode, + nlink: stats.nlink, + uid: stats.uid, + gid: stats.gid, + size: stats.size, + atime: stats.atime, + mtime: stats.mtime, + ctime: stats.ctime, + }; + FFIResult::ok() + } + Ok(None) => FFIResult::not_found(), + Err(_) => FFIResult::io_error(), + } +} + +// ============================================================================ +// File I/O Operations +// ============================================================================ + +/// Read data from a file at offset. +/// +/// # Arguments +/// * `handle` - AgentFS handle +/// * `path` - File path +/// * `offset` - Byte offset to start reading +/// * `size` - Maximum bytes to read +/// * `out_buffer` - Output buffer (caller must free with `agentfs_free_buffer`) +/// +/// # Safety +/// - All pointers must be valid +/// - `out_buffer` will be filled with allocated data that must be freed +#[no_mangle] +pub unsafe extern "C" fn agentfs_pread( + handle: *const AgentFSHandle, + path: *const c_char, + offset: u64, + size: u64, + out_buffer: *mut FFIBuffer, +) -> FFIResult { + if handle.is_null() || path.is_null() || out_buffer.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.pread(path, offset, size)) { + Ok(Some(data)) => { + *out_buffer = FFIBuffer::from_vec(data); + FFIResult::ok() + } + Ok(None) => { + *out_buffer = FFIBuffer::null(); + FFIResult::not_found() + } + Err(_) => { + *out_buffer = FFIBuffer::null(); + FFIResult::io_error() + } + } +} + +/// Write data to a file at offset. +/// +/// Creates the file if it doesn't exist. Extends the file if writing past end. +/// +/// # Safety +/// - All pointers must be valid +/// - `data` must point to at least `data_len` bytes +#[no_mangle] +pub unsafe extern "C" fn agentfs_pwrite( + handle: *const AgentFSHandle, + path: *const c_char, + offset: u64, + data: *const u8, + data_len: usize, +) -> FFIResult { + if handle.is_null() || path.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + let data_slice = if data.is_null() || data_len == 0 { + &[] + } else { + std::slice::from_raw_parts(data, data_len) + }; + + match handle.runtime.block_on(handle.fs.pwrite(path, offset, data_slice)) { + Ok(()) => FFIResult::ok(), + Err(_) => FFIResult::io_error(), + } +} + +/// Read entire file contents. +/// +/// # Safety +/// Same as `agentfs_pread`. +#[no_mangle] +pub unsafe extern "C" fn agentfs_read_file( + handle: *const AgentFSHandle, + path: *const c_char, + out_buffer: *mut FFIBuffer, +) -> FFIResult { + if handle.is_null() || path.is_null() || out_buffer.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.read_file(path)) { + Ok(Some(data)) => { + *out_buffer = FFIBuffer::from_vec(data); + FFIResult::ok() + } + Ok(None) => { + *out_buffer = FFIBuffer::null(); + FFIResult::not_found() + } + Err(_) => { + *out_buffer = FFIBuffer::null(); + FFIResult::io_error() + } + } +} + +/// Write entire file contents (creates or overwrites). +/// +/// # Safety +/// Same as `agentfs_pwrite`. +#[no_mangle] +pub unsafe extern "C" fn agentfs_write_file( + handle: *const AgentFSHandle, + path: *const c_char, + data: *const u8, + data_len: usize, +) -> FFIResult { + if handle.is_null() || path.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + let data_slice = if data.is_null() || data_len == 0 { + &[] + } else { + std::slice::from_raw_parts(data, data_len) + }; + + match handle.runtime.block_on(handle.fs.write_file(path, data_slice)) { + Ok(()) => FFIResult::ok(), + Err(_) => FFIResult::io_error(), + } +} + +/// Truncate a file to a specific size. +/// +/// # Safety +/// - `handle` must be valid +/// - `path` must be a valid null-terminated string +#[no_mangle] +pub unsafe extern "C" fn agentfs_truncate( + handle: *const AgentFSHandle, + path: *const c_char, + size: u64, +) -> FFIResult { + if handle.is_null() || path.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.truncate(path, size)) { + Ok(()) => FFIResult::ok(), + Err(_) => FFIResult::io_error(), + } +} + +// ============================================================================ +// Directory Operations +// ============================================================================ + +/// Read directory entries. +/// +/// Returns entries as a JSON array string: `["file1", "file2", "dir1"]` +/// +/// # Safety +/// - `out_entries` will be set to a newly allocated string (free with `agentfs_free_string`) +#[no_mangle] +pub unsafe extern "C" fn agentfs_readdir( + handle: *const AgentFSHandle, + path: *const c_char, + out_entries: *mut *mut c_char, +) -> FFIResult { + if handle.is_null() || path.is_null() || out_entries.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.readdir(path)) { + Ok(Some(entries)) => { + // Format as JSON array + let json = format!( + "[{}]", + entries + .iter() + .map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))) + .collect::>() + .join(",") + ); + + match CString::new(json) { + Ok(cstr) => { + *out_entries = cstr.into_raw(); + FFIResult::ok() + } + Err(_) => { + *out_entries = ptr::null_mut(); + FFIResult::io_error() + } + } + } + Ok(None) => { + *out_entries = ptr::null_mut(); + FFIResult::not_found() + } + Err(_) => { + *out_entries = ptr::null_mut(); + FFIResult::io_error() + } + } +} + +/// Create a directory. +/// +/// # Safety +/// Standard pointer validity requirements. +#[no_mangle] +pub unsafe extern "C" fn agentfs_mkdir( + handle: *const AgentFSHandle, + path: *const c_char, +) -> FFIResult { + if handle.is_null() || path.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.mkdir(path)) { + Ok(()) => FFIResult::ok(), + Err(e) => { + // Check for specific error types + if let Some(fs_err) = e.downcast_ref::() { + FFIResult::err(fs_err.to_errno()) + } else { + FFIResult::io_error() + } + } + } +} + +/// Remove a file or empty directory. +/// +/// # Safety +/// Standard pointer validity requirements. +#[no_mangle] +pub unsafe extern "C" fn agentfs_remove( + handle: *const AgentFSHandle, + path: *const c_char, +) -> FFIResult { + if handle.is_null() || path.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.remove(path)) { + Ok(()) => FFIResult::ok(), + Err(e) => { + if let Some(fs_err) = e.downcast_ref::() { + FFIResult::err(fs_err.to_errno()) + } else { + FFIResult::io_error() + } + } + } +} + +/// Rename/move a file or directory. +/// +/// # Safety +/// Standard pointer validity requirements. +#[no_mangle] +pub unsafe extern "C" fn agentfs_rename( + handle: *const AgentFSHandle, + from: *const c_char, + to: *const c_char, +) -> FFIResult { + if handle.is_null() || from.is_null() || to.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let from_path = match CStr::from_ptr(from).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + let to_path = match CStr::from_ptr(to).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.rename(from_path, to_path)) { + Ok(()) => FFIResult::ok(), + Err(e) => { + if let Some(fs_err) = e.downcast_ref::() { + FFIResult::err(fs_err.to_errno()) + } else { + FFIResult::io_error() + } + } + } +} + +// ============================================================================ +// Symlink Operations +// ============================================================================ + +/// Create a symbolic link. +/// +/// # Arguments +/// * `target` - What the symlink points to +/// * `linkpath` - Path where the symlink will be created +/// +/// # Safety +/// Standard pointer validity requirements. +#[no_mangle] +pub unsafe extern "C" fn agentfs_symlink( + handle: *const AgentFSHandle, + target: *const c_char, + linkpath: *const c_char, +) -> FFIResult { + if handle.is_null() || target.is_null() || linkpath.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let target_str = match CStr::from_ptr(target).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + let linkpath_str = match CStr::from_ptr(linkpath).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.symlink(target_str, linkpath_str)) { + Ok(()) => FFIResult::ok(), + Err(e) => { + if let Some(fs_err) = e.downcast_ref::() { + FFIResult::err(fs_err.to_errno()) + } else { + FFIResult::io_error() + } + } + } +} + +/// Read the target of a symbolic link. +/// +/// # Safety +/// - `out_target` will be set to a newly allocated string (free with `agentfs_free_string`) +#[no_mangle] +pub unsafe extern "C" fn agentfs_readlink( + handle: *const AgentFSHandle, + path: *const c_char, + out_target: *mut *mut c_char, +) -> FFIResult { + if handle.is_null() || path.is_null() || out_target.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.readlink(path)) { + Ok(Some(target)) => match CString::new(target) { + Ok(cstr) => { + *out_target = cstr.into_raw(); + FFIResult::ok() + } + Err(_) => { + *out_target = ptr::null_mut(); + FFIResult::io_error() + } + }, + Ok(None) => { + *out_target = ptr::null_mut(); + FFIResult::not_found() + } + Err(_) => { + *out_target = ptr::null_mut(); + FFIResult::io_error() + } + } +} + +// ============================================================================ +// Filesystem Operations +// ============================================================================ + +/// Get filesystem statistics. +/// +/// # Safety +/// Standard pointer validity requirements. +#[no_mangle] +pub unsafe extern "C" fn agentfs_statfs( + handle: *const AgentFSHandle, + out_stats: *mut FFIFilesystemStats, +) -> FFIResult { + if handle.is_null() || out_stats.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + + match handle.runtime.block_on(handle.fs.statfs()) { + Ok(stats) => { + *out_stats = FFIFilesystemStats { + inodes: stats.inodes, + bytes_used: stats.bytes_used, + }; + FFIResult::ok() + } + Err(_) => FFIResult::io_error(), + } +} + +/// Synchronize file data to persistent storage. +/// +/// # Safety +/// Standard pointer validity requirements. +#[no_mangle] +pub unsafe extern "C" fn agentfs_fsync( + handle: *const AgentFSHandle, + path: *const c_char, +) -> FFIResult { + if handle.is_null() || path.is_null() { + return FFIResult::invalid_arg(); + } + + let handle = &*handle; + let path = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(_) => return FFIResult::invalid_arg(), + }; + + match handle.runtime.block_on(handle.fs.fsync(path)) { + Ok(()) => FFIResult::ok(), + Err(_) => FFIResult::io_error(), + } +} + +// ============================================================================ +// Memory Management +// ============================================================================ + +/// Free a string allocated by Rust. +/// +/// # Safety +/// `s` must be a string returned by an agentfs_* function, or null. +#[no_mangle] +pub unsafe extern "C" fn agentfs_free_string(s: *mut c_char) { + if !s.is_null() { + let _ = CString::from_raw(s); + } +} + +/// Free a buffer allocated by Rust. +/// +/// # Safety +/// `buf` must be a buffer returned by an agentfs_* function. +#[no_mangle] +pub unsafe extern "C" fn agentfs_free_buffer(buf: FFIBuffer) { + if !buf.data.is_null() && buf.capacity > 0 { + let _ = Vec::from_raw_parts(buf.data, buf.len, buf.capacity); + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ffi_result_values() { + let ok = FFIResult::ok(); + assert!(ok.success); + assert_eq!(ok.error_code, 0); + + let not_found = FFIResult::not_found(); + assert!(!not_found.success); + assert_eq!(not_found.error_code, libc::ENOENT); + } + + #[test] + fn test_ffi_buffer_from_vec() { + let data = vec![1u8, 2, 3, 4, 5]; + let buf = FFIBuffer::from_vec(data); + assert!(!buf.data.is_null()); + assert_eq!(buf.len, 5); + assert_eq!(buf.capacity, 5); + + // Free the buffer + unsafe { agentfs_free_buffer(buf) }; + } + + #[test] + fn test_null_handle_safety() { + unsafe { + // All functions should handle null handles gracefully + let result = agentfs_stat(ptr::null(), c"test".as_ptr(), ptr::null_mut()); + assert!(!result.success); + assert_eq!(result.error_code, libc::EINVAL); + + agentfs_close(ptr::null_mut()); // Should not crash + } + } +}