aster_forge_cloud_files_macos_bridge/
ffi.rs

1//! Minimal C ABI for Swift-owned byte transfer and identifier conversion.
2
3use std::{panic::AssertUnwindSafe, ptr};
4
5use aster_forge_cloud_files_core::{
6    CloudItemId, CloudItemKey, CloudNamespaceId, CloudRootId, CloudScope,
7};
8
9use crate::identifier::{MAX_FILE_PROVIDER_IDENTIFIER_BYTES, MAX_IDENTITY_FIELD_BYTES};
10use crate::{
11    MacosBridgeError, MacosErrorCode, MacosExtensionRequestLease, MacosExtensionSession,
12    MacosFileProviderIdentifier, Result,
13};
14
15/// Owned byte allocation returned to Swift. Release exactly once with
16/// [`aster_forge_cloud_files_macos_buffer_release`].
17#[derive(Debug)]
18#[repr(C)]
19pub struct MacosFfiBuffer {
20    /// Allocation base, or null for an empty buffer.
21    pub ptr: *mut u8,
22    /// Logical initialized length.
23    pub len: usize,
24    /// Allocation capacity required for exact reconstruction during release.
25    pub capacity: usize,
26}
27
28impl MacosFfiBuffer {
29    const fn empty() -> Self {
30        Self {
31            ptr: ptr::null_mut(),
32            len: 0,
33            capacity: 0,
34        }
35    }
36
37    fn from_vec(mut bytes: Vec<u8>) -> Self {
38        if bytes.is_empty() {
39            return Self::empty();
40        }
41        let buffer = Self {
42            ptr: bytes.as_mut_ptr(),
43            len: bytes.len(),
44            capacity: bytes.capacity(),
45        };
46        std::mem::forget(bytes);
47        buffer
48    }
49}
50
51/// C ABI result containing either an owned byte buffer or a classified error.
52#[derive(Debug)]
53#[repr(C)]
54pub struct MacosFfiResult {
55    /// Native-facing result classification.
56    pub code: MacosErrorCode,
57    /// Owned payload on success; empty for errors.
58    pub buffer: MacosFfiBuffer,
59}
60
61/// Opaque extension-session owner held by Swift.
62#[derive(Debug, Clone, Copy)]
63#[repr(C)]
64pub struct MacosFfiSessionHandle {
65    /// Opaque Rust allocation pointer. Swift must not dereference or modify it.
66    pub raw: *const (),
67}
68
69/// Opaque accepted-request owner held until one completion/cancellation terminal path.
70#[derive(Debug, Clone, Copy)]
71#[repr(C)]
72pub struct MacosFfiRequestHandle {
73    /// Opaque Rust allocation pointer. Swift must not dereference or modify it.
74    pub raw: *mut (),
75}
76
77/// C ABI result for session creation.
78#[derive(Debug)]
79#[repr(C)]
80pub struct MacosFfiSessionResult {
81    /// Native-facing result classification.
82    pub code: MacosErrorCode,
83    /// Owned session handle on success.
84    pub handle: MacosFfiSessionHandle,
85}
86
87/// C ABI result for accepting one request lease.
88#[derive(Debug)]
89#[repr(C)]
90pub struct MacosFfiRequestResult {
91    /// Native-facing result classification.
92    pub code: MacosErrorCode,
93    /// Owned request handle on success.
94    pub handle: MacosFfiRequestHandle,
95}
96
97impl MacosFfiResult {
98    fn success(bytes: Vec<u8>) -> Self {
99        Self {
100            code: MacosErrorCode::Success,
101            buffer: MacosFfiBuffer::from_vec(bytes),
102        }
103    }
104
105    const fn failure(code: MacosErrorCode) -> Self {
106        Self {
107            code,
108            buffer: MacosFfiBuffer::empty(),
109        }
110    }
111}
112
113/// Creates one accepting extension session generation.
114#[unsafe(no_mangle)]
115pub extern "C" fn aster_forge_cloud_files_macos_session_create(
116    generation: u64,
117) -> MacosFfiSessionResult {
118    let result =
119        std::panic::catch_unwind(AssertUnwindSafe(|| {
120            let generation = aster_forge_cloud_files_core::SessionGeneration::new(generation)
121                .map_err(|_| MacosBridgeError::InvalidFfiInput {
122                    reason: "extension session generation must be non-zero",
123                })?;
124            Ok::<_, MacosBridgeError>(MacosExtensionSession::new(generation).into_ffi_handle())
125        }));
126    match result {
127        Ok(Ok(raw)) => MacosFfiSessionResult {
128            code: MacosErrorCode::Success,
129            handle: MacosFfiSessionHandle { raw },
130        },
131        Ok(Err(error)) => MacosFfiSessionResult {
132            code: error.error_code(),
133            handle: MacosFfiSessionHandle { raw: ptr::null() },
134        },
135        Err(_) => MacosFfiSessionResult {
136            code: MacosErrorCode::Internal,
137            handle: MacosFfiSessionHandle { raw: ptr::null() },
138        },
139    }
140}
141
142/// Accepts one request against the exact active session generation.
143///
144/// # Safety
145///
146/// `session` must be a live unreleased handle returned by this library.
147#[unsafe(no_mangle)]
148pub unsafe extern "C" fn aster_forge_cloud_files_macos_session_begin_request(
149    session: MacosFfiSessionHandle,
150    generation: u64,
151) -> MacosFfiRequestResult {
152    let result =
153        std::panic::catch_unwind(AssertUnwindSafe(|| {
154            // SAFETY: required by this exported function's handle contract.
155            let session = unsafe { MacosExtensionSession::clone_from_ffi_handle(session.raw) }?;
156            let generation = aster_forge_cloud_files_core::SessionGeneration::new(generation)
157                .map_err(|_| MacosBridgeError::InvalidFfiInput {
158                    reason: "request generation must be non-zero",
159                })?;
160            Ok::<_, MacosBridgeError>(session.begin_request(generation)?.into_ffi_handle())
161        }));
162    match result {
163        Ok(Ok(raw)) => MacosFfiRequestResult {
164            code: MacosErrorCode::Success,
165            handle: MacosFfiRequestHandle { raw },
166        },
167        Ok(Err(error)) => MacosFfiRequestResult {
168            code: error.error_code(),
169            handle: MacosFfiRequestHandle {
170                raw: ptr::null_mut(),
171            },
172        },
173        Err(_) => MacosFfiRequestResult {
174            code: MacosErrorCode::Internal,
175            handle: MacosFfiRequestHandle {
176                raw: ptr::null_mut(),
177            },
178        },
179    }
180}
181
182/// Starts idempotent closing for one live extension session.
183///
184/// # Safety
185///
186/// `session` must be a live unreleased handle returned by this library.
187#[unsafe(no_mangle)]
188pub unsafe extern "C" fn aster_forge_cloud_files_macos_session_begin_closing(
189    session: MacosFfiSessionHandle,
190) -> MacosErrorCode {
191    ffi_code(|| {
192        // SAFETY: required by this exported function's handle contract.
193        let session = unsafe { MacosExtensionSession::clone_from_ffi_handle(session.raw) }?;
194        let _transitioned = session.begin_closing();
195        Ok(())
196    })
197}
198
199/// Records native disconnect and moves the session to draining/closed.
200///
201/// # Safety
202///
203/// `session` must be a live unreleased handle returned by this library.
204#[unsafe(no_mangle)]
205pub unsafe extern "C" fn aster_forge_cloud_files_macos_session_mark_disconnected(
206    session: MacosFfiSessionHandle,
207) -> MacosErrorCode {
208    ffi_code(|| {
209        // SAFETY: required by this exported function's handle contract.
210        let session = unsafe { MacosExtensionSession::clone_from_ffi_handle(session.raw) }?;
211        session.mark_disconnected()
212    })
213}
214
215/// Releases one accepted request lease after exactly one terminal Swift completion path.
216///
217/// # Safety
218///
219/// `request` must be null or one live unreleased handle returned by `begin_request`.
220#[unsafe(no_mangle)]
221pub unsafe extern "C" fn aster_forge_cloud_files_macos_request_release(
222    request: MacosFfiRequestHandle,
223) {
224    // SAFETY: required by this exported function's request-handle contract.
225    unsafe { MacosExtensionRequestLease::release_ffi_handle(request.raw) };
226}
227
228/// Releases one extension session owner after native disconnect and request cleanup.
229///
230/// # Safety
231///
232/// `session` must be null or one live unreleased handle returned by `session_create`.
233#[unsafe(no_mangle)]
234pub unsafe extern "C" fn aster_forge_cloud_files_macos_session_release(
235    session: MacosFfiSessionHandle,
236) {
237    // SAFETY: required by this exported function's session-handle contract.
238    unsafe { MacosExtensionSession::release_ffi_handle(session.raw) };
239}
240
241/// Encodes three UTF-8 identity fields into one persistent File Provider identifier.
242///
243/// # Safety
244///
245/// Every non-null pointer must reference its declared readable byte length for this call. The
246/// referenced memory must not be mutated concurrently.
247#[unsafe(no_mangle)]
248pub unsafe extern "C" fn aster_forge_cloud_files_macos_identifier_encode(
249    namespace_ptr: *const u8,
250    namespace_len: usize,
251    root_ptr: *const u8,
252    root_len: usize,
253    item_ptr: *const u8,
254    item_len: usize,
255) -> MacosFfiResult {
256    ffi_result(|| {
257        // SAFETY: upheld by the exported function contract; each value is copied before return.
258        let namespace =
259            unsafe { read_utf8(namespace_ptr, namespace_len, MAX_IDENTITY_FIELD_BYTES) }?;
260        // SAFETY: upheld by the exported function contract; each value is copied before return.
261        let root = unsafe { read_utf8(root_ptr, root_len, MAX_IDENTITY_FIELD_BYTES) }?;
262        // SAFETY: upheld by the exported function contract; each value is copied before return.
263        let item = unsafe { read_utf8(item_ptr, item_len, MAX_IDENTITY_FIELD_BYTES) }?;
264        if namespace.contains('\0') || root.contains('\0') || item.contains('\0') {
265            return Err(MacosBridgeError::InvalidFfiInput {
266                reason: "FFI identity fields must not contain NUL",
267            });
268        }
269        let namespace =
270            CloudNamespaceId::new(namespace).map_err(|_| MacosBridgeError::InvalidFfiInput {
271                reason: "FFI namespace must not be empty",
272            })?;
273        let root = CloudRootId::new(root).map_err(|_| MacosBridgeError::InvalidFfiInput {
274            reason: "FFI root must not be empty",
275        })?;
276        let item = CloudItemId::new(item).map_err(|_| MacosBridgeError::InvalidFfiInput {
277            reason: "FFI item must not be empty",
278        })?;
279        let key = CloudItemKey::new(CloudScope::new(namespace, root), item);
280        Ok(MacosFileProviderIdentifier::encode(&key)?
281            .into_string()
282            .into_bytes())
283    })
284}
285
286/// Decodes one item identifier into `namespace\0root\0item` owned UTF-8 bytes.
287///
288/// # Safety
289///
290/// A non-null pointer must reference `identifier_len` readable bytes for this call. The referenced
291/// memory must not be mutated concurrently.
292#[unsafe(no_mangle)]
293pub unsafe extern "C" fn aster_forge_cloud_files_macos_identifier_decode(
294    identifier_ptr: *const u8,
295    identifier_len: usize,
296) -> MacosFfiResult {
297    ffi_result(|| {
298        // SAFETY: upheld by the exported function contract; the identifier is copied.
299        let identifier = unsafe {
300            read_utf8(
301                identifier_ptr,
302                identifier_len,
303                MAX_FILE_PROVIDER_IDENTIFIER_BYTES,
304            )
305        }?;
306        let parsed = MacosFileProviderIdentifier::parse(identifier)?;
307        let key = parsed.item_key()?;
308        let fields = [
309            key.scope().namespace_id().as_str(),
310            key.scope().root_id().as_str(),
311            key.item_id().as_str(),
312        ];
313        if fields.iter().any(|field| field.contains('\0')) {
314            return Err(MacosBridgeError::InvalidFfiInput {
315                reason: "decoded FFI identity fields must not contain NUL",
316            });
317        }
318        let total_len = fields.iter().try_fold(2usize, |total, field| {
319            total
320                .checked_add(field.len())
321                .ok_or(MacosBridgeError::InvalidFfiInput {
322                    reason: "decoded identifier fields exceed addressable memory",
323                })
324        })?;
325        let mut output = Vec::with_capacity(total_len);
326        for (index, field) in fields.into_iter().enumerate() {
327            if index != 0 {
328                output.push(0);
329            }
330            output.extend_from_slice(field.as_bytes());
331        }
332        Ok(output)
333    })
334}
335
336/// Releases one owned buffer returned by this library. Empty buffers are ignored.
337///
338/// # Safety
339///
340/// `buffer` must be an unreleased value returned by this library with every field unchanged.
341#[unsafe(no_mangle)]
342pub unsafe extern "C" fn aster_forge_cloud_files_macos_buffer_release(buffer: MacosFfiBuffer) {
343    if buffer.ptr.is_null() {
344        return;
345    }
346    if buffer.len > buffer.capacity || buffer.capacity == 0 {
347        return;
348    }
349    // SAFETY: non-empty buffers exposed by this module originate from `Vec<u8>` with these exact
350    // pointer, length, and capacity values. Ownership is transferred to this release call once.
351    let bytes = unsafe { Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.capacity) };
352    drop(bytes);
353}
354
355fn ffi_result(operation: impl FnOnce() -> Result<Vec<u8>>) -> MacosFfiResult {
356    match std::panic::catch_unwind(AssertUnwindSafe(operation)) {
357        Ok(Ok(bytes)) => MacosFfiResult::success(bytes),
358        Ok(Err(error)) => MacosFfiResult::failure(error.error_code()),
359        Err(_) => MacosFfiResult::failure(MacosErrorCode::Internal),
360    }
361}
362
363fn ffi_code(operation: impl FnOnce() -> Result<()>) -> MacosErrorCode {
364    match std::panic::catch_unwind(AssertUnwindSafe(operation)) {
365        Ok(Ok(())) => MacosErrorCode::Success,
366        Ok(Err(error)) => error.error_code(),
367        Err(_) => MacosErrorCode::Internal,
368    }
369}
370
371unsafe fn read_utf8(pointer: *const u8, length: usize, maximum_length: usize) -> Result<String> {
372    if length == 0 {
373        return Err(MacosBridgeError::InvalidFfiInput {
374            reason: "FFI string must not be empty",
375        });
376    }
377    if pointer.is_null() {
378        return Err(MacosBridgeError::InvalidFfiInput {
379            reason: "non-empty FFI string used a null pointer",
380        });
381    }
382    if length > maximum_length {
383        return Err(MacosBridgeError::InvalidFfiInput {
384            reason: "FFI string exceeds the accepted byte length",
385        });
386    }
387    // SAFETY: the C caller promises `pointer` references `length` readable bytes for this call.
388    // The slice is copied into an owned `String` before the function returns.
389    let bytes = unsafe { std::slice::from_raw_parts(pointer, length) };
390    let text = std::str::from_utf8(bytes).map_err(|_| MacosBridgeError::InvalidFfiInput {
391        reason: "FFI string is not valid UTF-8",
392    })?;
393    Ok(text.to_owned())
394}