diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index bb38092d03..ab5a927818 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -300,11 +300,24 @@ def ChunkerParams(s): def FilesCacheMode(s): ENTRIES_MAP = dict(ctime="c", mtime="m", size="s", inode="i", rechunk="r", disabled="d") VALID_MODES = ("cis", "ims", "cs", "ms", "cr", "mr", "d", "s") # letters in alpha order + WIN32_INVALID_MODES = ("cis", "cs", "cr") # modes containing ctime, invalid on Windows if s in VALID_MODES: + if is_win32 and s in WIN32_INVALID_MODES: + raise ArgumentTypeError( + "ctime is not supported in files-cache mode on Windows " + "(ctime means file creation time on Windows, not inode change time). " + "Use an mtime-based mode instead." + ) return s entries = set(s.strip().split(",")) if not entries <= set(ENTRIES_MAP): raise ArgumentTypeError("cache mode must be a comma-separated list of: %s" % ",".join(sorted(ENTRIES_MAP))) + if is_win32 and "ctime" in entries: + raise ArgumentTypeError( + "ctime is not supported in files-cache mode on Windows " + "(ctime means file creation time on Windows, not inode change time). " + "Use an mtime-based mode instead." + ) short_entries = {ENTRIES_MAP[entry] for entry in entries} mode = "".join(sorted(short_entries)) if mode not in VALID_MODES: diff --git a/src/borg/remote.py b/src/borg/remote.py index 5fdafb4223..d83d7c113f 100644 --- a/src/borg/remote.py +++ b/src/borg/remote.py @@ -161,6 +161,8 @@ class RepositoryServer: # pragma: no cover "put_manifest", "store_list", "store_load", + "store_load_chunk", + "store_get_size", "store_store", "store_delete", "store_move", @@ -1049,9 +1051,16 @@ def put_manifest(self, data): def store_list(self, name, *, deleted=False): """actual remoting is done via self.call in the @api decorator""" - @api(since=parse_version("2.0.0b8")) def store_load(self, name): - """actual remoting is done via self.call in the @api decorator""" + # chunked fetch to avoid msgpack BufferFull on large repositories + total_size = self.call("store_get_size", {"name": name}) + data = bytearray() + offset = 0 + while offset < total_size: + chunk = self.call("store_load_chunk", {"name": name, "offset": offset, "size": MAX_DATA_SIZE}) + data += chunk + offset += len(chunk) + return bytes(data) @api(since=parse_version("2.0.0b8")) def store_store(self, name, value): diff --git a/src/borg/repository.py b/src/borg/repository.py index a3f8aa8cc2..feefac9feb 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -560,6 +560,16 @@ def store_store(self, name, value): self._lock_refresh() return self.store.store(name, value) + def store_get_size(self, name): + self._lock_refresh() + data = self.store.load(name) + return len(data) + + def store_load_chunk(self, name, offset, size): + self._lock_refresh() + data = self.store.load(name) + return data[offset : offset + size] + def store_delete(self, name, *, deleted=False): self._lock_refresh() return self.store.delete(name, deleted=deleted) diff --git a/src/borg/testsuite/helpers/parseformat_test.py b/src/borg/testsuite/helpers/parseformat_test.py index c9cc1b5d59..a95245caf2 100644 --- a/src/borg/testsuite/helpers/parseformat_test.py +++ b/src/borg/testsuite/helpers/parseformat_test.py @@ -1,6 +1,5 @@ import base64 import os - from datetime import datetime, timezone import pytest @@ -25,6 +24,7 @@ swidth_slice, eval_escapes, ChunkerParams, + FilesCacheMode, # <--- MUST BE ADDED HERE ) from ...helpers.time import format_timedelta, parse_timestamp from ...platformflags import is_win32 @@ -531,9 +531,7 @@ def test_clean_lines(): data2 data3 -""".splitlines( - keepends=True - ) +""".splitlines(keepends=True) assert list(clean_lines(conf)) == ["data1 #data1", "data2", "data3"] assert list(clean_lines(conf, lstrip=False)) == ["data1 #data1", "data2", " data3"] assert list(clean_lines(conf, rstrip=False)) == ["data1 #data1\n", "data2\n", "data3\n"] @@ -642,3 +640,32 @@ def test_valid_chunkerparams(chunker_params, expected_return): def test_invalid_chunkerparams(invalid_chunker_params): with pytest.raises(ArgumentTypeError): ChunkerParams(invalid_chunker_params) + + +def test_files_cache_mode_win32_restriction(monkeypatch): + from borg.helpers import parseformat + from borg.helpers.parseformat import FilesCacheMode, ArgumentTypeError + import pytest + + # 1. Simulate being on a Windows system + monkeypatch.setattr(parseformat, "is_win32", True) + + # 2. Test that 'cis' (contains ctime) raises the error + with pytest.raises(ArgumentTypeError, match="ctime is not supported"): + FilesCacheMode("cis") + + # 3. Test that comma-separated 'ctime,size' also raises the error + with pytest.raises(ArgumentTypeError, match="ctime is not supported"): + FilesCacheMode("ctime,size") + + # 4. Ensure a non-ctime mode still works on 'Windows' + assert FilesCacheMode("ims") == "ims" + + # 5. Switch back to non-Windows and ensure 'cis' works again + monkeypatch.setattr(parseformat, "is_win32", False) + assert FilesCacheMode("cis") == "cis" + + +def test_new_placeholder(): + # Simple test for a helper function like eval_escapes + assert eval_escapes(r"hello\nworld") == "hello\nworld"