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
3 changes: 3 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions src/borgstore/backends/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions src/borgstore/backends/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
3 changes: 3 additions & 0 deletions src/borgstore/backends/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
BackendError,
BackendMustBeOpen,
BackendMustNotBeOpen,
ReadRangeError,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions src/borgstore/server/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
BackendError,
BackendMustBeOpen,
BackendMustNotBeOpen,
ReadRangeError,
)
from ..backends._utils import parse_range_header
from ..store import get_backend
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/borgstore/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion tests/test_server_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
Loading