Skip to content

fix(file): write every byte or fail; evict a payload that shrank under read (LAB-2682) - #272

Open
27Bslash6 wants to merge 8 commits into
mainfrom
lab-2682-file-short-write
Open

fix(file): write every byte or fail; evict a payload that shrank under read (LAB-2682)#272
27Bslash6 wants to merge 8 commits into
mainfrom
lab-2682-file-short-write

Conversation

@27Bslash6

Copy link
Copy Markdown
Contributor

Closes LAB-2682. Stacked on #267 (base is its branch; retargets to main when it merges) so the write fix reuses its _read_fully twin.

Problem

FileBackend.set() discarded os.write's return value. Linux caps one write(2) at ~2 GiB while max_value_mb allows far more, so a large value was silently truncated, fsync'd and renamed into place as a successful set. The truncated ciphertext then failed AES-GCM, which the envelope deliberately classifies as tamper-class, and encryption_fail_closed retained it as evidence forever: a permanent false tamper alarm from a benign short write. The end-to-end test in this PR reproduces exactly that log line against the pre-fix code ("L2 cache decrypt AUTHENTICATION failure ... failing closed") from a one-byte truncation.

Change

  • _write_fully(fd, data): loops over short writes via a non-copying memoryview, raises EIO on zero progress; twin of _read_fully. Backs set() and refresh_ttl()'s in-place expiry rewrite.
  • get(): a payload shorter than st_size - HEADER_SIZE (the file shrank between fstat and read) is unlinked and returned as a miss, matching the four header-corruption branches, instead of reaching the envelope's integrity check.
  • Same-length modifications are untouched: they still surface through AES-GCM and the fail-closed policy. Tests pin both directions, backend-level and end-to-end on a real FileBackend under fail_closed=True.
  • Panel round: get/exists/get_ttl/refresh_ttl no longer release the flock on an fd they already closed (reused fd number hazard).
  • Docs: docs/backends/file.md gains a "Corruption vs. tampering" limitation note.

Not covered, by design: a file truncated at rest (consistent st_size) is indistinguishable from tampering at the backend, since the header carries no length field (format shared with cachekit-rs). That case stays tamper-class per the Rust binding's provenance rule. AC1 removes the only benign source of such files.

Verification

  • 6 new tests; 4 fail against the pre-fix code (short-write loop, zero-progress raise + temp cleanup, shrink-under-read unlink, end-to-end shrink under fail-closed).
  • pytest tests/unit -m "not slow": 1954 passed locally.
  • Expert panel (bug-hunter, security, craftsman, catchphrase) at high stakes: security no findings; bug-hunter's release-after-close applied here, unlink-by-path race filed as LAB-2685 (pre-existing, cross-process only); prose/test trims applied.

27Bslash6 and others added 6 commits August 31, 2026 05:08
…in zero-copy in default gate (LAB-770)

Expert-panel findings applied:
- deserialize() catches BufferError so a non-u8 exporter (e.g. numpy float
  array) rejected at the PyO3 boundary still raises SerializationError,
  as documented (pre-change bytes() coercion surfaced these as ValueError)
- retrieve(): single detach/map_err tail; SAFETY comment states the
  data-race residual is UB accepted per the hashlib GIL-release precedent;
  empty-buffer arm documents the from_raw_parts non-null requirement
- new non-slow tracemalloc test pins the zero-copy borrow (<1.5x payload)
  so a to_vec revert fails the default gate, not just the slow suite
- dropped a redundant equivalence assert
…p pip for PYSEC-2026-3721 (LAB-770)

CodeRabbit correctly flagged that readonly() describes the view, not the
backing storage: memoryview(bytearray).toreadonly() passed the old gate,
making the detached read a data race (UB). retrieve() now takes
&Bound<PyAny>: a bytes argument borrows via the safe as_bytes API; a
buffer-protocol argument borrows only when a pointer-range check proves
its memory lies inside the immutable bytes object its view exports
(.obj) — attribute trust alone is spoofable by a PEP 688 __buffer__
exporter with a decoy .obj, so the proof is the pointer range, held
alive across the GIL release. Everything else copies (pre-LAB-770
semantics). The production shape — unwrap's memoryview over the bytes
envelope — still takes the zero-copy path (end-to-end 3.5x guard green).

Regression tests: readonly-view-over-bytearray and spoofed-.obj exporter
round-trip via the copy path; zero-copy test docstring now states its
tracemalloc blind spot honestly (a Rust-side copy is invisible to it).

Also: uv lock pip 26.1.2 -> 26.2.1 — pip-audit fails the Python
Dependency CVEs check on PYSEC-2026-3721 (CVE-2026-13346); red on main
for the same reason, this unblocks it here.
… (LAB-770)

A single read(2) may return fewer bytes than asked (POSIX; Linux caps one call
at ~2 GiB), and values above MMAP_MAX_BYTES take this path, so a >2 GiB payload
was silently truncated into a spurious integrity failure. One helper,
_read_fully, now backs all six fd reads. Error semantics are unchanged: EOF still
yields a short result the header/checksum checks reject as before. The
single-chunk join aliases its input, so the 3.5x read-peak guard holds.

Kody review follow-up on #267.
…ffer's short header (LAB-770)

Expert-panel re-run over the whole #267 diff (the earlier panels predated
a8b7960, so the pointer-range borrow it introduced had never been reviewed).

The gate already proved the buffer's memory lies inside the base `bytes`, so
the offset into that object is computable — which makes the borrow expressible
as an ordinary slice. from_raw_parts is gone; this was the crate's only unsafe
block. Same pointer, same zero-copy path, same 3.5x guard, now bounds-checked
by the compiler with no SAFETY comment that can drift from the code. The range
check also uses checked_add: the old `ptr + item_count` could wrap past the
bound it was supposed to enforce (unreachable from Python, but the comment
claimed a proof the arithmetic did not deliver).

This turns the two gate tests from tautologies into real ones. They asserted
only round-trip equality, which held on either branch, so nothing failed if the
gate was weakened. Verified by deleting the containment check and re-running:
the spoofed-.obj test now aborts on the slice bounds check, where the raw
borrow passed green while reading out of bounds.

Also from the panel:
- get_buffer was the one header read of five without a length check; st_size is
  sampled before the read, so a file truncated in between made header[2] raise
  IndexError straight past this backend's OSError handling.
- deserialize's BufferError comment had the mechanism backwards: bytes() did not
  reject a numpy float array, it coerced it to raw bytes that envelope
  validation then rejected. Same contract, different cause.
- test_retrieve_memoryview_is_zero_copy claimed what its own docstring admits
  tracemalloc cannot see; renamed to _adds_no_python_copy.

Two findings deliberately left out of scope, filed separately: set() ignores
os.write's return value (the destructive mirror of the short-read bug this PR
fixed), and get() never unlinks a truncated payload the way its sibling
corruption branches do.
…r read (LAB-2682)

set() discarded os.write's return value. Linux caps one write(2) at ~2 GiB
and max_value_mb allows far more, so a large value was silently truncated,
fsync'd and renamed into place as a successful set. The truncated ciphertext
then failed AES-GCM as tamper-class, which encryption_fail_closed retains as
evidence forever: a permanent false tamper alarm from a benign short write.

- _write_fully(fd, data): loop over short writes via a non-copying
  memoryview, raise EIO on zero progress; twin of _read_fully. Backs set()
  and refresh_ttl()'s in-place expiry rewrite so there is one write idiom.
- get(): a payload shorter than st_size - HEADER_SIZE (file shrank between
  fstat and read) is unlinked and returned as a miss, like the header
  corruption branches, instead of reaching the envelope's integrity check.
- Same-length modifications are untouched: they still surface through the
  envelope and the fail-closed policy (tests pin both directions, including
  end-to-end on a real FileBackend under fail_closed=True).
…r; doc wording (LAB-2682)

Panel findings applied:
- get/exists/get_ttl/refresh_ttl released the flock on an fd they had
  already closed. flock(LOCK_UN) on a closed fd is EBADF and swallowed, but
  if the number was reused by another thread in between it unlocks a
  stranger's file. Guard on fd_closed; close already drops the lock.
- The truncation-vs-tamper rationale was stated five times; the get()
  branch comment is now the single anchor, others point at it.
- Cut test_refresh_ttl_loops_over_short_writes: an 8-byte in-place write
  to a regular file does not short; set()'s two tests cover _write_fully.
- docs: short writes are resumed, not retried; expiry is not structural
  breakage; the length reference is st_size, the header has no length.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 45 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 106 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: 3d190e11-20a4-4053-8dce-1318ad735c2b

📥 Commits

Reviewing files that changed from the base of the PR and between f7b15d9 and 9df26a9.

📒 Files selected for processing (3)
  • docs/backends/file.md
  • src/cachekit/backends/file/backend.py
  • tests/unit/backends/test_file_backend.py

Comment @coderabbitai help to get the list of available commands.

@kodus-27b

kodus-27b Bot commented Sep 2, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

Comment thread tests/unit/backends/test_file_backend.py
kodus-27b[bot]
kodus-27b Bot previously requested changes Sep 2, 2026

@kodus-27b kodus-27b Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found critical issues please review the requested changes

@27Bslash6
27Bslash6 dismissed kodus-27b[bot]’s stale review September 2, 2026 02:14

Finding rebutted in the review thread and resolved: the rule targets assert used for production input validation, every cited line is a pytest assertion in a test body. No src/ assert added by this diff.

kodus-27b[bot]
kodus-27b Bot previously approved these changes Sep 2, 2026
Base automatically changed from lab-770-file-read-copy-elimination to main September 2, 2026 02:18
@27Bslash6
27Bslash6 dismissed kodus-27b[bot]’s stale review September 2, 2026 02:18

The base branch was changed.

kodus-27b[bot]
kodus-27b Bot previously approved these changes Sep 2, 2026
…rite

# Conflicts:
#	src/cachekit/backends/file/backend.py
@27Bslash6

Copy link
Copy Markdown
Contributor Author

Resolved src/cachekit/backends/file/backend.py (HEAD side in both hunks: main carries #267 as squash f7e236b, which this branch already contains commit-for-commit, so its diff is a strict subset of the head's — nothing dropped). Auto-rebased onto main @ fb3e633; CI will re-run. Local: ruff, format, basedpyright, and pytest tests/unit -m 'not slow' 1959 passed.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant