diff --git a/docs/changes.rst b/docs/changes.rst index ba6a88a..17a3f8f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,9 @@ Fixes: request (giving up with a BackendError if it can't be reestablished), #167 - rest (stdio-over-ssh): raise BackendConnectionError on unexpected EOF during response headers or response body transfer, preventing silent data truncation. +- defrag: check for short/long reads in range requests and raise ReadRangeError exception in + case of a size mismatch, #194 + Version 0.5.4 (2026-07-09) diff --git a/src/borgstore/backends/_base.py b/src/borgstore/backends/_base.py index c333a19..a71f5df 100644 --- a/src/borgstore/backends/_base.py +++ b/src/borgstore/backends/_base.py @@ -131,8 +131,17 @@ def defrag(self, sources, *, target=None, algorithm=None, namespace=None, levels # default implementation: slow, but works for all backends. # might be overridden for performance. from ..utils.nesting import nest - - data = b"".join(self.load(source, offset=offset, size=size) for source, offset, size in sources) + from .errors import ReadRangeError + + data_parts = [] + for source, offset, size in sources: + chunk = self.load(source, offset=offset, size=size) + if len(chunk) != size: + raise ReadRangeError( + f"Read range error from {source} (requested {size} bytes at offset {offset}, got {len(chunk)})" + ) + data_parts.append(chunk) + data = b"".join(data_parts) if target is None: if algorithm is None: raise ValueError("Either target or algorithm must be given for defrag") diff --git a/src/borgstore/backends/errors.py b/src/borgstore/backends/errors.py index 0b553c6..369e217 100644 --- a/src/borgstore/backends/errors.py +++ b/src/borgstore/backends/errors.py @@ -45,3 +45,7 @@ class PermissionDenied(BackendError): class QuotaExceeded(BackendError): """Quota exceeded for the requested operation.""" + + +class ReadRangeError(BackendError): + """Read range error occurred (e.g. short read).""" diff --git a/src/borgstore/backends/rest.py b/src/borgstore/backends/rest.py index e0e2cd3..b177e60 100644 --- a/src/borgstore/backends/rest.py +++ b/src/borgstore/backends/rest.py @@ -41,6 +41,7 @@ BackendError, BackendMustBeOpen, BackendMustNotBeOpen, + ReadRangeError, ) logger = logging.getLogger(__name__) @@ -451,6 +452,8 @@ def _handle_response(self, response, name=None): raise PermissionDenied(name or self.base_url) if response.status_code == HTTP.INSUFFICIENT_STORAGE: raise QuotaExceeded(response.text) + if response.status_code == HTTP.REQUESTED_RANGE_NOT_SATISFIABLE: + raise ReadRangeError(response.text) if response.status_code == HTTP.BAD_REQUEST: raise ValueError(response.text) response.raise_for_status() diff --git a/src/borgstore/server/rest.py b/src/borgstore/server/rest.py index 472685d..fdc7a9f 100644 --- a/src/borgstore/server/rest.py +++ b/src/borgstore/server/rest.py @@ -22,6 +22,7 @@ BackendError, BackendMustBeOpen, BackendMustNotBeOpen, + ReadRangeError, ) from ..backends._utils import parse_range_header from ..store import get_backend @@ -153,6 +154,8 @@ def _handle_exception(self, e, name=None): self.send_error(HTTP.FORBIDDEN, msg) elif isinstance(e, QuotaExceeded): self.send_error(HTTP.INSUFFICIENT_STORAGE, msg) + elif isinstance(e, ReadRangeError): + self.send_error(HTTP.REQUESTED_RANGE_NOT_SATISFIABLE, msg) elif isinstance(e, (ValueError, TypeError)): self.send_error(HTTP.BAD_REQUEST, msg) logger.exception("Exception for %s", name or self.path) diff --git a/src/borgstore/store.py b/src/borgstore/store.py index 0c6bbeb..3789813 100644 --- a/src/borgstore/store.py +++ b/src/borgstore/store.py @@ -20,7 +20,7 @@ from .utils.nesting import nest, unnest from .backends._base import ItemInfo, BackendBase -from .backends.errors import ObjectNotFound, NoBackendGiven, BackendURLInvalid # noqa +from .backends.errors import ObjectNotFound, NoBackendGiven, BackendURLInvalid, ReadRangeError # noqa from .backends.posixfs import get_file_backend from .backends.rclone import get_rclone_backend from .backends.sftp import get_sftp_backend diff --git a/tests/test_server_rest.py b/tests/test_server_rest.py index 3d663bc..ae12c45 100644 --- a/tests/test_server_rest.py +++ b/tests/test_server_rest.py @@ -14,7 +14,7 @@ from borgstore.server.rest import BorgStoreRESTServer from borgstore.backends.rest import get_rest_backend from borgstore.backends.posixfs import get_file_backend -from borgstore.backends.errors import ObjectNotFound, BackendAlreadyExists, QuotaExceeded +from borgstore.backends.errors import ObjectNotFound, BackendAlreadyExists, QuotaExceeded, ReadRangeError from borgstore.store import get_backend, Store @@ -422,6 +422,12 @@ def test_rest_backend_defrag(rest_server_with_auth): with pytest.raises(ValueError, match="Unsupported hash algorithm"): be.defrag(sources, algorithm="invalid") + # Test error: short read + with pytest.raises(ReadRangeError) as exc_info: + be.defrag([("file1", 2, 20)], target="target2") + assert "Read range error from" in str(exc_info.value) + assert "requested 20 bytes" in str(exc_info.value) + # Test defrag with levels=1 and algorithm from borgstore.utils.nesting import nest diff --git a/tests/test_store.py b/tests/test_store.py index 8381be3..29c8263 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -13,7 +13,7 @@ from .test_backends import get_s3_test_backend, s3_is_available # noqa from borgstore.constants import ROOTNS -from borgstore.store import Store, ItemInfo +from borgstore.store import Store, ItemInfo, ReadRangeError CONFIG = {"zero/": {"levels": [0]}, "one/": {"levels": [1]}, "two/": {"levels": [2]}} # Layout used for most tests @@ -121,6 +121,12 @@ def test_defrag_nested(posixfs_store_created): assert res == expected_hash assert store.load(ns + "/" + res) == expected_data + # 3. Test defrag with short read + with pytest.raises(ReadRangeError) as exc_info: + store.defrag([("file1", 2, 20)], target="target2", namespace=ns) + assert "Read range error from" in str(exc_info.value) + assert "requested 20 bytes" in str(exc_info.value) + def test_hash(posixfs_store_created): ns = "two"