Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ def test_cached_function():
- Connection pooling with thread affinity (+28% throughput)
- Distributed locking prevents cache stampedes
- Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
- Untrusted-decode bounds: nesting depth and header-declared allocation are capped on every cache read (a forged entry is a bounded cache miss), verified against the protocol's shared [`decode-bounds.json`](https://github.com/cachekit-io/protocol/blob/main/test-vectors/decode-bounds.json) vectors

> [!NOTE]
> All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
Expand Down
13 changes: 12 additions & 1 deletion rust/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! `PyO3` bindings for `cachekit-core`
//!
//! This crate provides thin Python wrappers around the cachekit-core library.
//! All business logic lives in cachekit-core; this crate only handles Python FFI.
//! Business logic lives in cachekit-core, with one SDK-owned exception: the untrusted
//! msgpack decode bound in `msgpack_bounds` (LAB-2503), pending a core-shared walk.

// Re-export core types for use in Python bindings
pub use cachekit_core::{ByteStorage, OperationMetrics, StorageEnvelope};

/// Untrusted msgpack structural bound — pure Rust, not gated on `python`
pub mod msgpack_bounds;

#[cfg(feature = "encryption")]
pub use cachekit_core::{
derive_domain_key,
Expand All @@ -32,6 +36,13 @@ fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(python_bindings::checksum_py, m)?)?;
m.add_function(wrap_pyfunction!(python_bindings::verify_checksum_py, m)?)?;

// Untrusted-decode structural bound (LAB-2503) — zero-copy header walk that
// serializers/base.py::unpackb_bounded runs before every msgpack.unpackb
m.add_function(wrap_pyfunction!(
python_bindings::check_msgpack_structure_py,
m
)?)?;

// Add encryption functionality if feature is enabled
#[cfg(feature = "encryption")]
{
Expand Down
82 changes: 82 additions & 0 deletions rust/src/msgpack_bounds.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Structural bound for untrusted MessagePack (LAB-2503; protocol spec/interop-mode.md →
//! Decode bounds). The one algorithm this crate owns rather than delegates to cachekit-core;
//! a core-shared walk usable from py/rs/wasm is the follow-up. Mirrors the opcode table of
//! cachekit-rs `check_structure` so the two SDKs reject the same documents.

/// Header-only walk over one MessagePack document: str/bin/ext payloads are skipped by
/// offset, never read, and nothing is allocated beyond one `u64` per open collection.
///
/// Rejects, before any decoder pre-allocates a container:
/// - nesting deeper than `max_depth`;
/// - a header declaring more payload bytes than the input holds;
/// - more pending elements (across every open collection) than remaining bytes can back —
/// every element costs >= 1 byte, so a decoder's total container pre-allocation is then
/// bounded by the input length instead of by `depth × declared_len`;
/// - the reserved marker 0xc1 and input that ends mid-document.
///
/// Trailing bytes after the root element are left to the decoder (`ExtraData`).
pub fn check_msgpack_structure(bytes: &[u8], max_depth: usize) -> Result<(), String> {
fn be(bytes: &[u8], pos: usize, width: usize) -> Result<u64, String> {
let end = pos
.checked_add(width)
.filter(|e| *e <= bytes.len())
.ok_or_else(|| "ends inside a length prefix".to_owned())?;
Ok(bytes[pos..end]
.iter()
.fold(0u64, |acc, b| (acc << 8) | u64::from(*b)))
}

let mut pos = 0usize;
let mut pending: u64 = 1; // elements owed across all open collections (the root is one)
let mut open: Vec<u64> = Vec::new(); // elements still owed per open collection = depth
while pending > 0 {
while open.last() == Some(&0) {
open.pop();
}
let marker = *bytes
.get(pos)
.ok_or_else(|| "ends before the document is complete".to_owned())?;
pos += 1;
pending -= 1;
if let Some(innermost) = open.last_mut() {
*innermost -= 1;
}
// (length-prefix bytes, payload bytes after the prefix, child elements)
let (prefix, payload, children): (usize, u64, u64) = match marker {
0x00..=0x7f | 0xc0 | 0xc2 | 0xc3 | 0xe0..=0xff => (0, 0, 0),
0x80..=0x8f => (0, 0, 2 * u64::from(marker & 0x0f)),
0x90..=0x9f => (0, 0, u64::from(marker & 0x0f)),
0xa0..=0xbf => (0, u64::from(marker & 0x1f), 0),
0xc1 => return Err("contains the reserved marker 0xc1".to_owned()),
0xc4 | 0xd9 => (1, be(bytes, pos, 1)?, 0),
0xc5 | 0xda => (2, be(bytes, pos, 2)?, 0),
0xc6 | 0xdb => (4, be(bytes, pos, 4)?, 0),
0xc7 => (1, be(bytes, pos, 1)? + 1, 0), // ext: length prefix, then type byte + data
0xc8 => (2, be(bytes, pos, 2)? + 1, 0),
0xc9 => (4, be(bytes, pos, 4)? + 1, 0),
0xca..=0xd3 => (0, 1u64 << (marker & 0x03), 0), // f32/f64/u8..u64/i8..i64: 4,8,1,2,4,8,1,2,4,8
0xd4..=0xd8 => (0, 1 + (1u64 << (marker - 0xd4)), 0), // fixext: type byte + 1/2/4/8/16
0xdc => (2, 0, be(bytes, pos, 2)?),
0xdd => (4, 0, be(bytes, pos, 4)?),
0xde => (2, 0, 2 * be(bytes, pos, 2)?),
0xdf => (4, 0, 2 * be(bytes, pos, 4)?),
};
pos += prefix;
let remaining = (bytes.len() - pos) as u64;
if payload > remaining {
return Err("declares more bytes than the input holds".to_owned());
}
pos += payload as usize; // <= remaining, so it fits usize
if children > 0 {
if open.len() >= max_depth {
return Err(format!("nests deeper than {max_depth} levels"));
}
open.push(children);
}
pending += children;
if pending > remaining - payload {
return Err("declares more elements than the input can back".to_owned());
}
}
Ok(())
}
106 changes: 69 additions & 37 deletions rust/src/python_bindings.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
//! Python bindings for cachekit-core
//!
//! This module provides thin PyO3 wrappers around cachekit-core functionality.
//! All business logic is delegated to cachekit-core.
//! This module provides thin PyO3 wrappers around cachekit-core functionality, plus the
//! buffer-borrow helper they share. Business logic lives in cachekit-core, except the
//! SDK-owned msgpack decode bound in `crate::msgpack_bounds`.

use crate::msgpack_bounds::check_msgpack_structure;
use cachekit_core::ByteStorage;
use pyo3::buffer::PyBuffer;
use pyo3::exceptions::PyValueError;
Expand Down Expand Up @@ -44,6 +46,67 @@ fn borrowable_offset(buf: &PyBuffer<u8>, base: &Bound<'_, PyBytes>) -> Option<us
.then(|| ptr - start)
}

/// A read-only view of a Python buffer-protocol object's bytes, borrowed without a copy
/// whenever that is provably sound and copied otherwise. Holds whatever keeps the memory
/// alive (the `bytes` object, or the owned copy) so `as_slice` needs no `unsafe`.
enum BytesView<'py> {
/// `(base, offset, len)`: a window onto an immutable `bytes` object kept alive by the
/// Bound — the whole object, or the read-only C-contiguous `memoryview` of it that
/// `SerializationWrapper.unwrap` produces, proven by `borrowable_offset`. Zero-copy.
Borrowed(Bound<'py, PyBytes>, usize, usize),
/// Mutable, non-`bytes`-backed, strided, or empty exporter: the only safe answer is a copy.
Owned(Vec<u8>),
}

impl BytesView<'_> {
fn as_slice(&self) -> &[u8] {
match self {
BytesView::Borrowed(base, off, len) => &base.as_bytes()[*off..*off + *len],
BytesView::Owned(v) => v,
}
}
}

/// Borrow `obj`'s bytes zero-copy when the BACKING STORAGE is provably immutable, else copy.
///
/// `readonly()` describes the view, not the exporter (`memoryview(bytearray).toreadonly()`
/// passes it while another thread can still mutate the bytearray), and a PEP 688
/// `__buffer__` exporter can name a decoy `bytes` in `.obj` — so the gate is the containment
/// proof in `borrowable_offset`, whose payoff is that the borrow is an ORDINARY SLICE of that
/// `bytes`: bounds-checked by Rust, no `unsafe`, nothing for a stale comment to misstate.
fn bytes_view<'py>(py: Python<'py>, obj: &Bound<'py, PyAny>) -> PyResult<BytesView<'py>> {
if let Ok(b) = obj.cast::<PyBytes>() {
return Ok(BytesView::Borrowed(b.clone(), 0, b.len()?));
}
let buf = PyBuffer::<u8>::get(obj)?;
let base = obj
.getattr("obj")
.ok()
.and_then(|base| base.cast_into::<PyBytes>().ok());
if let Some(base) = base {
if let Some(off) = borrowable_offset(&buf, &base) {
return Ok(BytesView::Borrowed(base, off, buf.item_count()));
}
}
Ok(BytesView::Owned(buf.to_vec(py)?))
}

/// Reject a MessagePack document whose headers would make decoding it allocate out of
/// proportion to its size — see `check_msgpack_structure`. Zero-copy for `bytes` and for
/// read-only `memoryview`s of `bytes`; raises ValueError naming the violated bound.
#[pyfunction]
#[pyo3(name = "check_msgpack_structure")]
pub fn check_msgpack_structure_py(
py: Python<'_>,
data: &Bound<'_, PyAny>,
max_depth: usize,
) -> PyResult<()> {
let view = bytes_view(py, data)?;
check_msgpack_structure(view.as_slice(), max_depth).map_err(|what| {
PyValueError::new_err(format!("Unpack failed: MessagePack document {what}"))
})
}

#[pymethods]
impl PyByteStorage {
#[new]
Expand Down Expand Up @@ -86,41 +149,10 @@ impl PyByteStorage {
py: Python,
envelope_bytes: &Bound<'_, PyAny>,
) -> PyResult<(Vec<u8>, String)> {
let owned: Vec<u8>;
let buf: PyBuffer<u8>;
let base_bytes: Option<Bound<'_, PyBytes>>;
let data: &[u8] = if let Ok(b) = envelope_bytes.cast::<PyBytes>() {
// `bytes` is immutable and kept alive by the Bound for the whole call:
// a zero-copy borrow with no data-race exposure.
b.as_bytes()
} else {
buf = PyBuffer::get(envelope_bytes)?;
// Borrowing across the GIL release below is only sound when the BACKING
// STORAGE is immutable — readonly() describes the view, not the exporter
// (memoryview(bytearray).toreadonly() passes it while another thread can
// still mutate the bytearray). Attribute trust is not enough either: a
// PEP 688 __buffer__ exporter can name a decoy `bytes` in `.obj`. So the
// gate is a containment proof (borrowable_offset), and its payoff is that
// the borrow becomes expressible as an ORDINARY SLICE of that `bytes` —
// bounds-checked by Rust, no `unsafe`, nothing for a stale comment to
// misstate. Anything unproven falls back to a copy.
base_bytes = envelope_bytes
.getattr("obj")
.ok()
.and_then(|base| base.cast_into::<PyBytes>().ok());
let borrowed = base_bytes.as_ref().and_then(|base| {
borrowable_offset(&buf, base)
.map(|off| &base.as_bytes()[off..off + buf.item_count()])
});
match borrowed {
Some(slice) => slice,
None => {
// Mutable, non-bytes-backed, non-contiguous, or empty exporter.
owned = buf.to_vec(py)?;
&owned
}
}
};
// Borrowing across the GIL release below is only sound when the backing storage
// is immutable — bytes_view proves that or copies (see its doc).
let view = bytes_view(py, envelope_bytes)?;
let data = view.as_slice();
// Detach from the GIL for decompression + checksum (see store()).
py.detach(|| self.inner.retrieve(data))
.map_err(|e| PyValueError::new_err(format!("Retrieval failed: {}", e)))
Expand Down
6 changes: 2 additions & 4 deletions src/cachekit/interop.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@
from typing import Any
from uuid import UUID

import msgpack

from .serializers.base import SerializationError
from .serializers.base import SerializationError, unpackb_bounded

# Full-string match REQUIRED: re.match with a $ anchor still accepts a
# trailing newline. Pinned by the reject_trailing_newline error vector.
Expand Down Expand Up @@ -440,7 +438,7 @@ def decode_interop_value(data: bytes | bytearray | memoryview) -> Any:
"check that every writer for this key uses @cache(interop=...)."
)
try:
return msgpack.unpackb(raw, raw=False, strict_map_key=True, object_hook=_revive_sentinels)
return unpackb_bounded(raw, raw=False, strict_map_key=True, object_hook=_revive_sentinels)
except Exception as e:
raise InteropDecodeError(f"stored value is not a single well-formed MessagePack document: {e}") from e

Expand Down
Loading
Loading