Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .codescene/code-health-rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"usage": "Repo-scoped Code Health overrides. Each rule_set documents why the override is safe; keep them narrowly scoped and justified.",
"rule_sets": [
{
"matching_content_path": "rust/cuprum-rust/src/lib.rs",
"matching_content_path_doc": "PyO3 FFI boundary. rust_pump_stream / rust_consume_stream / convert_fd / validate_buffer_size / checked_buffer_size receive raw i64 file descriptors and buffer sizes handed over by Python. Those primitives are the untyped inputs this boundary exists to validate INTO domain newtypes (BufferSize, ReaderFd, WriterFd, PlatformFd). Wrapping the raw i64 inputs in further domain types before validation would add ceremony without value - the conversion IS the abstraction - so Primitive Obsession is disabled for this file only.",
"rules": [
{ "name": "Primitive Obsession", "weight": 0.0 }
]
},
{
"matching_content_path": "rust/cuprum-rust/src/io_utils/tests.rs",
"matching_content_path_doc": "The EINTR-retry tests for the read seam (read_raw_fd_with) and the write seam (write_all_unix_with) are deliberately parallel to document that both seams honour the identical retry contract. Extracting a shared representation would couple independent module boundaries and obscure which seam each test exercises. Scoped to this exact file so the exception does not silently cover unrelated future test modules.",
"rules": [
{ "name": "Code Duplication", "weight": 0.0 }
]
}
]
}
13 changes: 8 additions & 5 deletions cuprum/_streams_rs.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ def rust_pump_stream(
File descriptor to write to.
buffer_size : int, optional
Buffer size in bytes for each read/write cycle. Must be greater than
zero. Defaults to ``65536`` (64 KiB).
zero and no larger than 1 GiB (``1 << 30``). Defaults to ``65536``
(64 KiB).

Returns
-------
Expand All @@ -81,7 +82,8 @@ def rust_pump_stream(
ImportError
If the Rust backend native module cannot be imported.
ValueError
If ``buffer_size`` is not a positive integer.
If ``buffer_size`` is not a positive integer or exceeds the 1 GiB
maximum.
OSError
If an I/O error occurs while pumping bytes.
"""
Expand Down Expand Up @@ -119,8 +121,8 @@ def rust_consume_stream(
reader_fd : int
File descriptor to read from.
buffer_size : int, optional
Buffer size in bytes for each read cycle. Must be greater than zero.
Defaults to ``65536`` (64 KiB).
Buffer size in bytes for each read cycle. Must be greater than zero and
no larger than 1 GiB (``1 << 30``). Defaults to ``65536`` (64 KiB).

Returns
-------
Expand All @@ -132,7 +134,8 @@ def rust_consume_stream(
ImportError
If the Rust backend native module cannot be imported.
ValueError
If ``buffer_size`` is not a positive integer.
If ``buffer_size`` is not a positive integer or exceeds the 1 GiB
maximum.
OSError
If an I/O error occurs while reading.
"""
Expand Down
1 change: 1 addition & 0 deletions cuprum/unittests/__snapshots__/test_maturin_build.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
'cuprum/unittests/test_rust_extension.py',
'cuprum/unittests/test_rust_splice.py',
'cuprum/unittests/test_rust_streams.py',
'cuprum/unittests/test_rust_streams_boundary_property.py',
'cuprum/unittests/test_safe_cmd_context.py',
'cuprum/unittests/test_safe_cmd_run.py',
'cuprum/unittests/test_safe_cmd_stdin.py',
Expand Down
220 changes: 220 additions & 0 deletions cuprum/unittests/test_rust_streams_boundary_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Property-based boundary tests for the Rust stream entry points.

The example-based suite in ``test_rust_streams.py`` covers a curated set of
buffer sizes and I/O failures. These properties fuzz the argument-validation
boundary of ``rust_pump_stream`` / ``rust_consume_stream`` at the Python/Rust
seam:

- Non-positive and over-cap ``buffer_size`` values raise ``ValueError``
(validation happens before any descriptor is touched).
- Negative and out-of-``i32``-range descriptors raise ``ValueError``.
- Omitting ``buffer_size`` is equivalent to passing the explicit default.

Buffer-size validation runs before descriptor conversion, so the
buffer-size properties can pass a throwaway descriptor without performing
I/O. The descriptor properties use the default (valid) buffer size so that
conversion is the failing step.
"""

from __future__ import annotations

import contextlib
import os
import sys
import typing as typ

import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

if typ.TYPE_CHECKING:
from types import ModuleType

# Mirror of MAX_BUFFER_SIZE in rust/cuprum-rust/src/lib.rs (1 GiB).
_MAX_BUFFER_SIZE = 1 << 30
_DEFAULT_BUFFER_SIZE = 65536
_I32_MAX = (1 << 31) - 1
_I64_MAX = (1 << 63) - 1
# A descriptor value that is deterministically invalid, used only where
# validation fails before the descriptor is dereferenced. -1 never names an
# open descriptor, so a validation-order regression fails loudly instead of
# blocking on a real fd such as stdin (0).
_UNUSED_FD = -1

# The descriptor properties assert the Unix i32 file-descriptor conversion
# contract. On Windows the wrapper routes fds through msvcrt.get_osfhandle and
# the native path accepts pointer-sized handles, so those assertions do not
# hold; skip them there rather than encode platform-specific error semantics.
_unix_only = pytest.mark.skipif(
sys.platform == "win32",
reason="asserts the Unix i32 file-descriptor conversion contract",
)

_SUPPRESS_FIXTURE = settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
max_examples=50,
)


def _safe_close(fd: int) -> None:
"""Close ``fd``, ignoring an already-closed or invalid descriptor.

Defined locally so the packaged test module stays importable from an
installed wheel, which ships ``cuprum/unittests`` but not ``tests``.
"""
with contextlib.suppress(OSError):
os.close(fd)


@_SUPPRESS_FIXTURE
@given(bad_size=st.integers(min_value=-(1 << 62), max_value=0))
def test_consume_rejects_nonpositive_buffer(
rust_streams: ModuleType,
bad_size: int,
) -> None:
"""A non-positive ``buffer_size`` raises ``ValueError`` before any read."""
with pytest.raises(ValueError, match="buffer_size"):
rust_streams.rust_consume_stream(_UNUSED_FD, buffer_size=bad_size)


@_SUPPRESS_FIXTURE
@given(bad_size=st.integers(min_value=_MAX_BUFFER_SIZE + 1, max_value=_I64_MAX))
def test_consume_rejects_oversized_buffer(
rust_streams: ModuleType,
bad_size: int,
) -> None:
"""A ``buffer_size`` above the 1 GiB cap raises ``ValueError``."""
with pytest.raises(ValueError, match="buffer_size"):
rust_streams.rust_consume_stream(_UNUSED_FD, buffer_size=bad_size)


@_SUPPRESS_FIXTURE
@given(bad_size=st.integers(min_value=-(1 << 62), max_value=0))
def test_pump_rejects_nonpositive_buffer(
rust_streams: ModuleType,
bad_size: int,
) -> None:
"""``rust_pump_stream`` rejects a non-positive ``buffer_size``."""
with pytest.raises(ValueError, match="buffer_size"):
rust_streams.rust_pump_stream(_UNUSED_FD, _UNUSED_FD, buffer_size=bad_size)


@_unix_only
@_SUPPRESS_FIXTURE
@given(
bad_fd=st.one_of(
st.integers(min_value=-(1 << 62), max_value=-1),
st.integers(min_value=_I32_MAX + 1, max_value=_I64_MAX),
Comment thread
leynos marked this conversation as resolved.
),
)
def test_consume_rejects_invalid_descriptor(
rust_streams: ModuleType,
bad_fd: int,
) -> None:
"""Negative or out-of-i32-range descriptors raise ``ValueError``."""
with pytest.raises(ValueError, match="file descriptor"):
rust_streams.rust_consume_stream(bad_fd)


@_unix_only
@_SUPPRESS_FIXTURE
@given(
bad_fd=st.one_of(
st.integers(min_value=-(1 << 62), max_value=-1),
st.integers(min_value=_I32_MAX + 1, max_value=_I64_MAX),
),
)
def test_pump_rejects_invalid_reader_descriptor(
rust_streams: ModuleType,
bad_fd: int,
) -> None:
"""``rust_pump_stream`` rejects an invalid reader descriptor.

The writer is a genuinely valid descriptor, so the ``ValueError`` can only
originate from the invalid reader, not from a coincidentally invalid writer.
"""
with contextlib.ExitStack() as stack:
writer_fd = os.open(os.devnull, os.O_WRONLY)
stack.callback(_safe_close, writer_fd)
with pytest.raises(ValueError, match="file descriptor"):
rust_streams.rust_pump_stream(bad_fd, writer_fd)


def _feed_pipe(write_fd: int, payload: bytes) -> None:
"""Write ``payload`` fully into ``write_fd``."""
view = memoryview(payload)
while view:
written = os.write(write_fd, view)
view = view[written:]


def _consume_via_pipe(
rust_streams: ModuleType,
payload: bytes,
**kwargs: object,
) -> str:
"""Write ``payload`` through a pipe and consume it with the Rust decoder."""
with contextlib.ExitStack() as stack:
read_fd, write_fd = os.pipe()
stack.callback(_safe_close, read_fd)
stack.callback(_safe_close, write_fd)
_feed_pipe(write_fd, payload)
# Close the writer so the consumer observes EOF; the ExitStack's
# second close of the same descriptor is a harmless no-op.
_safe_close(write_fd)
return typ.cast(
"str",
rust_streams.rust_consume_stream(read_fd, **kwargs),
)


def _pump_via_pipes(
rust_streams: ModuleType,
payload: bytes,
**kwargs: object,
) -> int:
"""Pump ``payload`` from a source pipe to a sink pipe; return bytes written."""
with contextlib.ExitStack() as stack:
src_read, src_write = os.pipe()
sink_read, sink_write = os.pipe()
for fd in (src_read, src_write, sink_read, sink_write):
stack.callback(_safe_close, fd)
_feed_pipe(src_write, payload)
# Close the source writer so the pump reaches EOF; the payload is small
# enough to fit the sink pipe buffer without draining `sink_read`.
_safe_close(src_write)
return int(rust_streams.rust_pump_stream(src_read, sink_write, **kwargs))


@_SUPPRESS_FIXTURE
@given(payload=st.binary(max_size=96))
def test_default_buffer_matches_explicit(
rust_streams: ModuleType,
payload: bytes,
) -> None:
"""Omitting ``buffer_size`` equals passing the explicit default."""
explicit = _consume_via_pipe(
rust_streams, payload, buffer_size=_DEFAULT_BUFFER_SIZE
)
default = _consume_via_pipe(rust_streams, payload)
assert explicit == default, (
"omitting buffer_size must equal the explicit 65536 default"
)
assert default == payload.decode("utf-8", errors="replace"), (
"decoded output must match Python's UTF-8 replace decoding"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@_SUPPRESS_FIXTURE
@given(payload=st.binary(max_size=96))
def test_pump_default_buffer_matches_explicit(
rust_streams: ModuleType,
payload: bytes,
) -> None:
"""``rust_pump_stream`` omitting ``buffer_size`` equals the explicit default."""
explicit = _pump_via_pipes(rust_streams, payload, buffer_size=_DEFAULT_BUFFER_SIZE)
default = _pump_via_pipes(rust_streams, payload)
assert explicit == default, (
"omitting buffer_size must equal the explicit 65536 default"
)
assert default == len(payload), "the pump must transfer every payload byte"
9 changes: 6 additions & 3 deletions docs/cuprum-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1707,7 +1707,8 @@ def rust_pump_stream(
writer_fd:
File descriptor to write to (stdin of downstream process).
buffer_size:
Size of the internal transfer buffer in bytes.
Size of the internal transfer buffer in bytes. Must be at least 1 and
no larger than 1 GiB (``1 << 30``).

Returns
-------
Expand All @@ -1717,7 +1718,8 @@ def rust_pump_stream(
Raises
------
ValueError
When ``buffer_size`` is less than 1.
When ``buffer_size`` is not a positive integer or exceeds the 1 GiB
maximum.
OSError
When an I/O error occurs during transfer. Expected broken-pipe
conditions are swallowed while the reader continues draining.
Expand All @@ -1738,7 +1740,8 @@ def rust_consume_stream(
reader_fd:
File descriptor to read from.
buffer_size:
Size of the internal read buffer in bytes.
Size of the internal read buffer in bytes. Must be at least 1 and no
larger than 1 GiB (``1 << 30``).

Returns
-------
Expand Down
16 changes: 16 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,22 @@ uv run pytest cuprum/unittests/test_maturin_build.py \
--snapshot-update -k test_maturin_wheel_build_snapshot
```

## Rust stream buffer-size validation

`rust/cuprum-rust/src/lib.rs` validates the `buffer_size` argument to
`rust_pump_stream` / `rust_consume_stream` at the PyO3 boundary through a pure
`checked_buffer_size(i64) -> Result<usize, &'static str>` helper, wrapped by
`validate_buffer_size` (which maps the message to `PyValueError`). The contract
is: reject non-positive values, values that overflow `usize` on the target
platform, and values above `MAX_BUFFER_SIZE` (1 GiB, `1 << 30`) — the cap
guards against absurd allocations while comfortably exceeding any realistic
transfer buffer (the default is 64 KiB). `checked_buffer_size` is kept pure so
its boundaries are property tested directly in
`rust/cuprum-rust/src/buffer_size_tests.rs`; the Python-side error mapping is
exercised in `cuprum/unittests/test_rust_streams_boundary_property.py`. Keep the
`_streams_rs.py` wrapper docstrings, `docs/cuprum-design.md`, and the
users' guide aligned with this contract when the cap changes.

## Workflow pins and Dependabot

Dependabot owns the upgrade of GitHub Actions and reusable workflows, including
Expand Down
9 changes: 9 additions & 0 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,15 @@ The Rust extension now includes an internal pump function exposed as
Cuprum's internal pipeline dispatcher and may change without notice. Public
command execution remains unchanged until the dispatcher integration lands.

Both `rust_pump_stream` and `rust_consume_stream` accept an optional
`buffer_size` (bytes, default 64 KiB). It must be a positive integer no larger
than 1 GiB (`1 << 30`). Once PyO3 has converted the argument to a signed 64-bit
integer, Rust validation runs and rejects a value below 1 or above the cap with
`ValueError`. A value that cannot be converted to that integer in the first
place — a non-integer, or a Python integer outside the signed 64-bit range —
may instead fail earlier, during PyO3 argument conversion, with a different
exception.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### Rust stream consumption (internal)

The Rust extension also exposes `cuprum._streams_rs.rust_consume_stream`, which
Expand Down
Loading
Loading