From ced8735af42fabccb4f75d2548a1c7df813b140d Mon Sep 17 00:00:00 2001 From: cangen185-sudo <234582933+cangen185-sudo@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:51:50 +0800 Subject: [PATCH 1/3] fix(file-io): refuse edits after incomplete bounded reads --- reme/steps/file_io/_file_io.py | 12 +++- reme/steps/file_io/edit.py | 3 +- tests/unit/test_edit_read_limits.py | 102 ++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_edit_read_limits.py diff --git a/reme/steps/file_io/_file_io.py b/reme/steps/file_io/_file_io.py index 82c5264d..54f28c22 100644 --- a/reme/steps/file_io/_file_io.py +++ b/reme/steps/file_io/_file_io.py @@ -102,15 +102,23 @@ def _decode_known_file(data: bytes, file_extension: str) -> tuple[str, str]: # --------------------------------------------------------------------------- -async def read_file_safe(file_path, max_bytes: int = MAX_FILE_READ_BYTES) -> tuple[str, str]: +async def read_file_safe( + file_path, max_bytes: int = MAX_FILE_READ_BYTES, *, require_complete: bool = False +) -> tuple[str, str]: """Read file in byte mode and decode using extension-aware strategy. - Returns ``(text, encoding)``. + Returns ``(text, encoding)``. With ``require_complete``, reject files above + the byte limit or reads that do not reach EOF, so a caller cannot write back + truncated contents during a read-modify-write operation. """ stat = await aiofiles.os.stat(str(file_path)) + if require_complete and stat.st_size > max_bytes: + raise ValueError(f"file size {stat.st_size} exceeds read limit of {max_bytes} bytes") read_size = min(stat.st_size, max_bytes) async with aiofiles.open(str(file_path), "rb") as f: data = await f.read(read_size) + if require_complete and await f.read(1): + raise ValueError("file changed during read; refusing to return incomplete contents") return _decode_known_file(data, Path(file_path).suffix) diff --git a/reme/steps/file_io/edit.py b/reme/steps/file_io/edit.py index b9a457d5..19162cf4 100644 --- a/reme/steps/file_io/edit.py +++ b/reme/steps/file_io/edit.py @@ -7,6 +7,7 @@ from ._base import _FileIOStep from ._file_io import get_path_lock, read_file_safe, write_file_safe from ._path import _check_path_permission, NON_MD_WARNING, gate_md, resolve_path from ...components import R +from ...constants import MAX_FILE_READ_BYTES @R.register("edit_step") @@ -60,7 +61,7 @@ class EditStep(_FileIOStep): return None try: - raw_text, encoding = await read_file_safe(target) + raw_text, encoding = await read_file_safe(target, max_bytes=MAX_FILE_READ_BYTES, require_complete=True) except Exception as e: # pylint: disable=broad-except self._fail(f"read failed: {e}", path=str(target)) return None diff --git a/tests/unit/test_edit_read_limits.py b/tests/unit/test_edit_read_limits.py new file mode 100644 index 00000000..6543b3d4 --- /dev/null +++ b/tests/unit/test_edit_read_limits.py @@ -0,0 +1,102 @@ +"""Edits must not write back a prefix produced by a bounded file read.""" + +import asyncio +from functools import partial +from types import SimpleNamespace + +import pytest + +from reme.components import ApplicationContext +from reme.steps.file_io import _file_io, edit as edit_module + + +def _small_edit_limit(monkeypatch): + """Exercise the real bounded reader with small files, without allocating 200 MiB.""" + monkeypatch.setattr(edit_module, "MAX_FILE_READ_BYTES", 64, raising=False) + monkeypatch.setattr(edit_module, "read_file_safe", partial(_file_io.read_file_safe, max_bytes=64)) + + +@pytest.mark.parametrize("suffix", [".md", ".txt"]) +@pytest.mark.parametrize("encoding", ["utf-8", "utf-16"]) +def test_edit_rejects_truncated_read_without_writing(tmp_path, monkeypatch, suffix, encoding): + """An occurrence in the prefix must not cause the unread tail to be discarded.""" + _small_edit_limit(monkeypatch) + path = tmp_path / ("note" + suffix) + source = ("target" + "x" * 80 + "TAIL").encode(encoding) + path.write_bytes(source) + app = ApplicationContext(workspace_dir=str(tmp_path)) + + async def run(): + step = edit_module.EditStep(app_context=app) + await step(path=path.name, old="target", new="edited") + assert path.read_bytes() == source + assert step.context.response.success is False + assert "limit" in step.context.response.answer + + asyncio.run(run()) + + +@pytest.mark.parametrize("suffix", [".md", ".txt"]) +@pytest.mark.parametrize("encoding,filler", [("utf-8", 58), ("utf-16", 25)]) +def test_edit_at_read_limit_preserves_encoding(tmp_path, monkeypatch, suffix, encoding, filler): + """The exact byte limit remains editable, with the existing write rules.""" + _small_edit_limit(monkeypatch) + text = "target" + "x" * filler + source = text.encode(encoding) + assert len(source) == 64 + path = tmp_path / ("note" + suffix) + path.write_bytes(source) + app = ApplicationContext(workspace_dir=str(tmp_path)) + + async def run(): + step = edit_module.EditStep(app_context=app) + await step(path=path.name, old="target", new="edited") + assert step.context.response.success is True + expected = text.replace("target", "edited") + ("\n" if suffix == ".md" else "") + assert path.read_bytes() == expected.encode(encoding) + + asyncio.run(run()) + + +def test_edit_rejects_growth_after_stat(tmp_path, monkeypatch): + """A stale small stat cannot turn a larger actual read into a destructive edit.""" + _small_edit_limit(monkeypatch) + path = tmp_path / "note.txt" + source = b"target" + b"x" * 80 + b"TAIL" + path.write_bytes(source) + app = ApplicationContext(workspace_dir=str(tmp_path)) + + async def stale_stat(_path): + return SimpleNamespace(st_size=8) + + monkeypatch.setattr(_file_io.aiofiles.os, "stat", stale_stat) + + async def run(): + step = edit_module.EditStep(app_context=app) + await step(path=path.name, old="target", new="edited") + assert step.context.response.success is False + assert "incomplete contents" in step.context.response.answer + assert path.read_bytes() == source + + asyncio.run(run()) + + +@pytest.mark.parametrize("size", [0, 63, 64, 65]) +def test_read_helper_complete_mode_and_default_truncation(tmp_path, size): + """Ordinary limited reads keep their behavior; complete reads reject only overflow.""" + path = tmp_path / "note.txt" + source = b"x" * size + path.write_bytes(source) + + async def run(): + text, _ = await _file_io.read_file_safe(path, max_bytes=64) + assert text == "x" * min(size, 64) + if size > 64: + with pytest.raises(ValueError, match="limit"): + await _file_io.read_file_safe(path, max_bytes=64, require_complete=True) + else: + text, _ = await _file_io.read_file_safe(path, max_bytes=64, require_complete=True) + assert text == "x" * size + assert path.read_bytes() == source + + asyncio.run(run()) From ac4b0da14c0c416a44087ef6782fde853ec8365a Mon Sep 17 00:00:00 2001 From: cangen185-sudo <234582933+cangen185-sudo@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:22:11 +0800 Subject: [PATCH 2/3] style(file-io): keep complete-read signature formatter-stable --- reme/steps/file_io/_file_io.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/reme/steps/file_io/_file_io.py b/reme/steps/file_io/_file_io.py index 54f28c22..56c23dde 100644 --- a/reme/steps/file_io/_file_io.py +++ b/reme/steps/file_io/_file_io.py @@ -103,7 +103,10 @@ def _decode_known_file(data: bytes, file_extension: str) -> tuple[str, str]: async def read_file_safe( - file_path, max_bytes: int = MAX_FILE_READ_BYTES, *, require_complete: bool = False + file_path, + max_bytes: int = MAX_FILE_READ_BYTES, + *, + require_complete: bool = False, ) -> tuple[str, str]: """Read file in byte mode and decode using extension-aware strategy. From b6dc05de39538632c5f77bdeb76c98c97cb60069 Mon Sep 17 00:00:00 2001 From: cangen185-sudo <234582933+cangen185-sudo@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:49:16 +0800 Subject: [PATCH 3/3] docs(file-io): clarify bounded-read safety guarantee --- reme/steps/file_io/_file_io.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/reme/steps/file_io/_file_io.py b/reme/steps/file_io/_file_io.py index 56c23dde..ee11e13b 100644 --- a/reme/steps/file_io/_file_io.py +++ b/reme/steps/file_io/_file_io.py @@ -111,8 +111,10 @@ async def read_file_safe( """Read file in byte mode and decode using extension-aware strategy. Returns ``(text, encoding)``. With ``require_complete``, reject files above - the byte limit or reads that do not reach EOF, so a caller cannot write back - truncated contents during a read-modify-write operation. + the byte limit or reads that leave unread bytes at the EOF check. This only + prevents returning a truncated bounded read for write-back; it does not + support oversized-file editing, provide a stable snapshot against concurrent + changes, or make subsequent writes atomic. """ stat = await aiofiles.os.stat(str(file_path)) if require_complete and stat.st_size > max_bytes: