Skip to main content

pith_text/
ffi.rs

1//! The C ABI surface of `pith-text`: the entry points the Python
2//! (ctypes), Node (koffi) and Go (cgo) SDKs bind through.
3//!
4//! The suite's FFI convention, defined by this module and mirrored by
5//! every `pith-*` cdylib:
6//!
7//! * one flat set of `#[unsafe(no_mangle)] pub unsafe extern "C"`
8//!   functions — raw pointers plus lengths, no structs across the
9//!   boundary;
10//! * every function returns a status code (see the constants below),
11//!   never a `Result`, never a panic: a `panic = "abort"` cdylib must
12//!   not be reachable from a foreign caller;
13//! * an operation either hands ownership to the caller (and ships a
14//!   matching `_free` — [`pith_text_free`] here) or writes into
15//!   caller-provided out-parameters — [`pith_text_jaccard`] allocates
16//!   nothing, so it ships no free;
17//! * the `unsafe` allowance is confined to this module; every core
18//!   module stays unsafe-free behind the crate-root `#![deny]`.
19//!
20//! # Wire formats (the cross-SDK contract)
21//!
22//! `pith_text_fingerprint` hands out the canonical fingerprint stream
23//! of [`crate::reference::canonical_stream`]:
24//!
25//! ```text
26//! [0..4)    word_count     u32 big-endian
27//! [4..8)    shingle_count  u32 big-endian
28//! [8..8+C)  canonical      the canonical UTF-8 bytes (C bytes)
29//! [8+C..)   signature      128 u64 little-endian words (1024 bytes)
30//! ```
31//!
32//! so `canonical_sha256 == sha256(stream[8..8+C])` and the
33//! `signature_fnv1a64` / `signature_sha256` folds of `reference.json`
34//! are the standard folds over the 1024-byte little-endian tail. The
35//! input is UTF-8: `len` is a **byte** length, and bytes that are not
36//! valid UTF-8 are rejected ([`PITH_E_REJECTED`]), never lossily
37//! coerced. The empty input is valid and pins the sentinel stream
38//! (zero counts, 128 `u64::MAX` words) — it is *not* a refusal.
39//!
40//! `pith_text_jaccard` compares two `u64` word slices: the words are
41//! read one element at a time with [`core::ptr::read_unaligned`] (the
42//! SDK-side blob is a byte buffer, alignment is not guaranteed), and
43//! the estimate crosses the ABI as its **f64 bit pattern** through a
44//! `u64` out-parameter — no float crosses the boundary, so the SDKs
45//! compare `value_bits` hex-exactly. A null pointer with `len == 0`
46//! is the legal empty slice — the sentinel operand
47//! (`reference.json`'s `empty-signature` arm passes `Vec::new()`,
48//! *not* `signature("")`, whose all-`u64::MAX` words would score
49//! ≈ 0) — while a null pointer with `len > 0` is [`PITH_E_INVALID`].
50
51#![allow(unsafe_code)]
52
53use alloc::boxed::Box;
54use alloc::vec::Vec;
55
56use crate::jaccard_estimate;
57use crate::reference::canonical_stream;
58
59/// Status: success.
60pub const PITH_OK: i32 = 0;
61/// Status: a caller argument is invalid — a null pointer (or a null
62/// buffer carrying a nonzero length where a slice is read).
63pub const PITH_E_INVALID: i32 = -1;
64/// Status: the core pipeline refused the input (the bytes are not
65/// valid UTF-8).
66pub const PITH_E_REJECTED: i32 = -2;
67
68/// Computes the full text fingerprint of a UTF-8 string into the
69/// canonical byte stream the `reference.json` vectors are defined
70/// over.
71///
72/// `data` points at `len` **bytes** of UTF-8 input (a byte length, so
73/// multi-ordinal inputs are passed whole, never truncated to a
74/// character count); `len == 0` — with or without a null `data` — is
75/// the valid empty input. On success the function allocates a buffer,
76/// writes its address through `out`, its length through `out_len`,
77/// and returns [`PITH_OK`]; the caller owns the buffer and must
78/// release it with [`pith_text_free`], passing back the same pointer
79/// *and* length. The buffer layout is documented in the [module
80/// docs](self): big-endian counts header, canonical UTF-8 bytes,
81/// 128 little-endian signature words.
82///
83/// # Safety
84///
85/// `data` must point to `len` readable bytes; `out` to one writable
86/// pointer; `out_len` to one writable `usize`. All must stay valid
87/// for the duration of the call; the function retains nothing.
88#[unsafe(no_mangle)]
89pub unsafe extern "C" fn pith_text_fingerprint(
90    data: *const u8,
91    len: usize,
92    out: *mut *mut u8,
93    out_len: *mut usize,
94) -> i32 {
95    if out.is_null() || out_len.is_null() {
96        return PITH_E_INVALID;
97    }
98    let bytes: &[u8] = if len == 0 {
99        // The empty input is legal (the canon-empty / sentinel case);
100        // a null pointer carries no bytes to read.
101        &[]
102    } else {
103        if data.is_null() {
104            return PITH_E_INVALID;
105        }
106        unsafe { core::slice::from_raw_parts(data, len) }
107    };
108    match fingerprint_stream(bytes) {
109        Ok(stream) => {
110            let len = stream.len();
111            // Hand the exact-length buffer to the caller; `pith_text_free`
112            // reconstructs the boxed slice from the same length.
113            let ptr = Box::into_raw(stream.into_boxed_slice());
114            unsafe {
115                *out = ptr.cast::<u8>();
116                *out_len = len;
117            }
118            PITH_OK
119        }
120        Err(status) => status,
121    }
122}
123
124/// Estimates the Jaccard index of the two shingle sets that produced
125/// the `a_words` and `b_words` signature words.
126///
127/// `a` and `b` point at the callers' `u64` signature words (the
128/// little-endian tail of a [`pith_text_fingerprint`] stream); the
129/// words are read unaligned-safe, so the SDK-side buffers carry no
130/// alignment promise. Callers comparing signatures pass full
131/// 128-word signatures; the empty slice (`len == 0`, null allowed)
132/// is the sentinel operand of `reference.json`'s `empty-signature`
133/// vectors. On success the estimate is written through `out_bits` as
134/// its `f64` bit pattern and [`PITH_OK`] is returned.
135///
136/// # Safety
137///
138/// `a` must point to `a_words` readable `u64` values and `b` to
139/// `b_words` readable `u64` values (null is only legal with a zero
140/// length); `out_bits` must point to one writable `u64`. All must
141/// stay valid for the duration of the call; the function retains
142/// nothing.
143#[unsafe(no_mangle)]
144pub unsafe extern "C" fn pith_text_jaccard(
145    a: *const u64,
146    a_words: usize,
147    b: *const u64,
148    b_words: usize,
149    out_bits: *mut u64,
150) -> i32 {
151    if out_bits.is_null() {
152        return PITH_E_INVALID;
153    }
154    let a = match unsafe { read_words(a, a_words) } {
155        Ok(words) => words,
156        Err(status) => return status,
157    };
158    let b = match unsafe { read_words(b, b_words) } {
159        Ok(words) => words,
160        Err(status) => return status,
161    };
162    unsafe { *out_bits = jaccard_bits(&a, &b) };
163    PITH_OK
164}
165
166/// Releases a buffer handed out by [`pith_text_fingerprint`].
167///
168/// # Safety
169///
170/// `ptr` must be a pointer returned by [`pith_text_fingerprint`]
171/// with the `out_len` value that came back with it, and must not
172/// have been released (or otherwise freed) before. Null is accepted
173/// and ignored, so callers can free unconditionally on the error
174/// path.
175#[unsafe(no_mangle)]
176pub unsafe extern "C" fn pith_text_free(ptr: *mut u8, len: usize) {
177    if ptr.is_null() {
178        return;
179    }
180    let slice = unsafe { core::slice::from_raw_parts_mut(ptr, len) };
181    drop(unsafe { Box::from_raw(slice) });
182}
183
184/// The safe core of [`pith_text_fingerprint`]: validate UTF-8, then
185/// serialize the canonical stream. Non-UTF-8 bytes map to
186/// [`PITH_E_REJECTED`].
187fn fingerprint_stream(bytes: &[u8]) -> Result<Vec<u8>, i32> {
188    let text = core::str::from_utf8(bytes).map_err(|_| PITH_E_REJECTED)?;
189    Ok(canonical_stream(text))
190}
191
192/// The safe core of [`pith_text_jaccard`]: the estimate as its `f64`
193/// bit pattern.
194fn jaccard_bits(a: &[u64], b: &[u64]) -> u64 {
195    jaccard_estimate(a, b).to_bits()
196}
197
198/// Copies `words` `u64` values out of `ptr`, one
199/// [`core::ptr::read_unaligned`] per element — the SDK-side blob is a
200/// byte buffer, alignment is not guaranteed. A null pointer with a
201/// zero length is the legal empty slice; a null pointer with a
202/// nonzero length is [`PITH_E_INVALID`].
203///
204/// # Safety
205///
206/// `ptr` must point to `words` readable `u64` values (or be null
207/// with `words == 0`).
208unsafe fn read_words(ptr: *const u64, words: usize) -> Result<Vec<u64>, i32> {
209    if ptr.is_null() {
210        if words == 0 {
211            return Ok(Vec::new());
212        }
213        return Err(PITH_E_INVALID);
214    }
215    let mut out = Vec::new();
216    for i in 0..words {
217        out.push(unsafe { core::ptr::read_unaligned(ptr.add(i)) });
218    }
219    Ok(out)
220}
221
222#[cfg(test)]
223mod tests {
224    use alloc::vec::Vec;
225
226    use super::{
227        PITH_E_INVALID, PITH_E_REJECTED, PITH_OK, fingerprint_stream, jaccard_bits,
228        pith_text_fingerprint, pith_text_free, pith_text_jaccard, read_words,
229    };
230
231    /// The canonical stream through the public module path, from raw
232    /// input bytes — exactly the wire the FFI serves.
233    fn canonical_stream_core(input: &[u8]) -> Vec<u8> {
234        crate::reference::canonical_stream(core::str::from_utf8(input).expect("utf-8"))
235    }
236
237    /// Loads a committed fixture's bytes (UTF-8 by conformance).
238    fn fixture(name: &str) -> Vec<u8> {
239        let path = format!("{}/tests/fixtures/{name}.txt", env!("CARGO_MANIFEST_DIR"));
240        std::fs::read(&path).expect("fixture")
241    }
242
243    /// The signature words of a fixture (the little-endian tail of its
244    /// fingerprint stream), for jaccard pointer round-trips.
245    fn fixture_words(name: &str) -> Vec<u64> {
246        let stream = fingerprint_stream(&fixture(name)).expect("utf-8");
247        let tail = &stream[stream.len() - 1024..];
248        tail.chunks_exact(8)
249            .map(|c| u64::from_le_bytes(c.try_into().unwrap()))
250            .collect()
251    }
252
253    /// The FFI reproduces the safe core byte-for-byte and hands the
254    /// buffer back through `pith_text_free`.
255    #[test]
256    fn ffi_fingerprint_roundtrips_the_canonical_stream() {
257        let input = b"alpha beta gamma";
258        let expected = canonical_stream_core(input);
259        assert_eq!(expected.len(), 8 + 17 + 1024);
260
261        let mut out: *mut u8 = core::ptr::null_mut();
262        let mut out_len: usize = 0;
263        let status =
264            unsafe { pith_text_fingerprint(input.as_ptr(), input.len(), &mut out, &mut out_len) };
265        assert_eq!(status, PITH_OK);
266        assert_eq!(out_len, expected.len());
267        let handed_back = unsafe { core::slice::from_raw_parts(out, out_len) };
268        assert_eq!(handed_back, expected.as_slice());
269        // The header is the documented one: 3 words, 1 shingle.
270        assert_eq!(&handed_back[..8], &[0, 0, 0, 3, 0, 0, 0, 1]);
271        unsafe { pith_text_free(out, out_len) };
272    }
273
274    /// The empty input is valid and carries the sentinel stream (this
275    /// is the canon-empty / signature-empty-sentinel case, not a
276    /// refusal).
277    #[test]
278    fn ffi_fingerprint_accepts_the_empty_input() {
279        let expected = canonical_stream_core(b"");
280        assert_eq!(expected.len(), 8 + 1 + 1024);
281
282        let empty: Vec<u8> = Vec::new();
283        let mut out: *mut u8 = core::ptr::null_mut();
284        let mut out_len: usize = 0;
285        let status =
286            unsafe { pith_text_fingerprint(empty.as_ptr(), empty.len(), &mut out, &mut out_len) };
287        assert_eq!(status, PITH_OK);
288        let handed_back = unsafe { core::slice::from_raw_parts(out, out_len) };
289        assert_eq!(handed_back, expected.as_slice());
290        assert_eq!(handed_back[8], b'\n');
291        assert!(handed_back[9..].iter().all(|&b| b == 0xff));
292        unsafe { pith_text_free(out, out_len) };
293
294        // A null pointer with len == 0 is the same legal empty input
295        // (how the Go binding passes zero-length operands).
296        let status = unsafe { pith_text_fingerprint(core::ptr::null(), 0, &mut out, &mut out_len) };
297        assert_eq!(status, PITH_OK);
298        unsafe { pith_text_free(out, out_len) };
299    }
300
301    /// Null out-parameters and a null buffer with a nonzero length are
302    /// [`PITH_E_INVALID`]; non-UTF-8 bytes are [`PITH_E_REJECTED`]; a
303    /// null buffer is a legal free.
304    #[test]
305    fn ffi_fingerprint_refusals() {
306        let mut out: *mut u8 = core::ptr::null_mut();
307        let mut out_len: usize = 0;
308        let input = b"text";
309
310        let null_out = unsafe {
311            pith_text_fingerprint(
312                input.as_ptr(),
313                input.len(),
314                core::ptr::null_mut(),
315                &mut out_len,
316            )
317        };
318        assert_eq!(null_out, PITH_E_INVALID);
319
320        let null_out_len = unsafe {
321            pith_text_fingerprint(input.as_ptr(), input.len(), &mut out, core::ptr::null_mut())
322        };
323        assert_eq!(null_out_len, PITH_E_INVALID);
324
325        let null_data = unsafe {
326            pith_text_fingerprint(core::ptr::null(), input.len(), &mut out, &mut out_len)
327        };
328        assert_eq!(null_data, PITH_E_INVALID);
329
330        let bad_utf8: &[u8] = &[b'o', b'n', b'e', 0xFF];
331        let status = unsafe {
332            pith_text_fingerprint(bad_utf8.as_ptr(), bad_utf8.len(), &mut out, &mut out_len)
333        };
334        assert_eq!(status, PITH_E_REJECTED);
335
336        unsafe { pith_text_free(core::ptr::null_mut(), 0) };
337    }
338
339    /// The safe core rejects non-UTF-8 bytes with the documented
340    /// status instead of panicking.
341    #[test]
342    fn safe_core_rejects_non_utf8() {
343        assert_eq!(fingerprint_stream(&[0xFF]), Err(PITH_E_REJECTED));
344        assert_eq!(fingerprint_stream(b"ok"), Ok(canonical_stream_core(b"ok")));
345    }
346
347    /// The jaccard FFI reproduces the `reference.json` pins through
348    /// raw, deliberately misaligned-style pointers: near-duplicate,
349    /// unrelated, identical, and the empty-slice sentinel operand.
350    #[test]
351    fn ffi_jaccard_matches_reference_pins() {
352        let doc_a = fixture_words("doc_a");
353        let doc_a_edit = fixture_words("doc_a_edit");
354        let doc_b = fixture_words("doc_b");
355        let prose = fixture_words("prose");
356
357        let mut bits: u64 = 0;
358        // jaccard-near-duplicate: doc_a vs doc_a_edit == 0x3fef800000000000.
359        let status = unsafe {
360            pith_text_jaccard(
361                doc_a.as_ptr(),
362                doc_a.len(),
363                doc_a_edit.as_ptr(),
364                doc_a_edit.len(),
365                &mut bits,
366            )
367        };
368        assert_eq!(status, PITH_OK);
369        assert_eq!(bits, 0x3fef_8000_0000_0000);
370
371        // jaccard-unrelated: doc_a vs doc_b == 0.
372        let status = unsafe {
373            pith_text_jaccard(
374                doc_a.as_ptr(),
375                doc_a.len(),
376                doc_b.as_ptr(),
377                doc_b.len(),
378                &mut bits,
379            )
380        };
381        assert_eq!(status, PITH_OK);
382        assert_eq!(bits, 0x0000_0000_0000_0000);
383
384        // jaccard-identical: prose vs prose == 1.0.
385        let status = unsafe {
386            pith_text_jaccard(
387                prose.as_ptr(),
388                prose.len(),
389                prose.as_ptr(),
390                prose.len(),
391                &mut bits,
392            )
393        };
394        assert_eq!(status, PITH_OK);
395        assert_eq!(bits, 0x3ff0_0000_0000_0000);
396
397        // jaccard-against-empty: doc_a vs the EMPTY slice (null, 0
398        // words) == 1.0 — `Vec::new()`, not signature("").
399        let status = unsafe {
400            pith_text_jaccard(doc_a.as_ptr(), doc_a.len(), core::ptr::null(), 0, &mut bits)
401        };
402        assert_eq!(status, PITH_OK);
403        assert_eq!(bits, 0x3ff0_0000_0000_0000);
404
405        // empty vs empty is the degenerate 1.0.
406        let status =
407            unsafe { pith_text_jaccard(core::ptr::null(), 0, core::ptr::null(), 0, &mut bits) };
408        assert_eq!(status, PITH_OK);
409        assert_eq!(bits, 0x3ff0_0000_0000_0000);
410    }
411
412    /// A null word pointer with a nonzero length and a null
413    /// out-parameter are [`PITH_E_INVALID`].
414    #[test]
415    fn ffi_jaccard_refusals() {
416        let words = [1u64, 2, 3];
417        let mut bits: u64 = 0;
418
419        let null_bits = unsafe {
420            pith_text_jaccard(
421                words.as_ptr(),
422                words.len(),
423                words.as_ptr(),
424                words.len(),
425                core::ptr::null_mut(),
426            )
427        };
428        assert_eq!(null_bits, PITH_E_INVALID);
429
430        let null_a = unsafe {
431            pith_text_jaccard(core::ptr::null(), 1, words.as_ptr(), words.len(), &mut bits)
432        };
433        assert_eq!(null_a, PITH_E_INVALID);
434
435        let null_b = unsafe {
436            pith_text_jaccard(words.as_ptr(), words.len(), core::ptr::null(), 2, &mut bits)
437        };
438        assert_eq!(null_b, PITH_E_INVALID);
439    }
440
441    /// [`read_words`] and [`jaccard_bits`] mirror the FFI contract in
442    /// the safe world: null+0 is empty, null+len is invalid, and the
443    /// bits are `f64::to_bits` of the estimate.
444    #[test]
445    fn safe_cores_match_the_abi_contract() {
446        assert_eq!(unsafe { read_words(core::ptr::null(), 0) }, Ok(Vec::new()));
447        assert_eq!(
448            unsafe { read_words(core::ptr::null(), 1) },
449            Err(PITH_E_INVALID)
450        );
451        let words = [7u64, 9];
452        assert_eq!(
453            unsafe { read_words(words.as_ptr(), words.len()) },
454            Ok(words.to_vec())
455        );
456        assert_eq!(jaccard_bits(&[], &[]), 1.0f64.to_bits());
457        // Equal words → 1.0; differing words at the common prefix → 0.0.
458        assert_eq!(jaccard_bits(&[1], &[1]), 1.0f64.to_bits());
459        assert_eq!(jaccard_bits(&[1], &[2]), 0.0f64.to_bits());
460    }
461}