mirror of
https://github.com/borgbackup/borg.git
synced 2026-10-12 02:11:36 +00:00
repository: salvage_pack review fixes, refs #10026
This commit is contained in:
+13
-8
@@ -292,18 +292,23 @@ def object_validator(repo_objs):
|
||||
return validate
|
||||
|
||||
|
||||
def object_authenticator(repo_objs):
|
||||
def whole_object_authenticator(repo_objs):
|
||||
"""Return authenticate(chunk_id, obj): True if obj is the whole repo object with id chunk_id.
|
||||
|
||||
obj is an object's header, metadata slot and data slot. Parsing it checks that the header's sizes
|
||||
add up to len(obj) and verifies the tags of both slots, each computed over the slot and over the
|
||||
header prefix (magic, version, chunk id) and chunk_id as AAD (additional authenticated data: bytes
|
||||
the tag covers without being part of the ciphertext). The data is neither decompressed nor hashed,
|
||||
so a plaintext that does not hash to chunk_id is accepted.
|
||||
obj is an object's header, metadata slot and data slot (object_validator gets the header and the
|
||||
metadata slot only). Parsing it checks that the header's sizes add up to len(obj) and verifies
|
||||
the tags of both slots, each computed over the slot and over the header prefix (magic, version,
|
||||
chunk id) and chunk_id as AAD (additional authenticated data: bytes the tag covers without being
|
||||
part of the ciphertext). Only the tags are checked: an object whose plaintext does not hash to
|
||||
chunk_id is accepted.
|
||||
|
||||
With the authenticated_no_key workaround, the "authenticated-*" modes do not verify the tags, so
|
||||
this accepts any object whose metadata slot unpacks.
|
||||
Raises Error for an "authenticated-*" key with the authenticated_no_key workaround, which skips
|
||||
the tag verification.
|
||||
"""
|
||||
from .crypto.key import MACKeyBase # crypto.key imports this module
|
||||
|
||||
if AUTHENTICATED_NO_KEY and isinstance(repo_objs.key, MACKeyBase):
|
||||
raise Error("Objects can not be authenticated with BORG_WORKAROUNDS=authenticated_no_key.")
|
||||
|
||||
def authenticate(chunk_id, obj):
|
||||
try:
|
||||
|
||||
+57
-59
@@ -31,7 +31,6 @@ from .helpers.lrucache import LRUCache
|
||||
from .storelocking import Lock
|
||||
from .logger import create_logger
|
||||
from .repoobj import RepoObj, OBJ_MAGIC
|
||||
from .crypto import key as crypto_key
|
||||
from .crypto.key import is_keyfile, key_factory, store_hash, STORE_HASH_NAME
|
||||
|
||||
logger = create_logger(__name__)
|
||||
@@ -473,7 +472,7 @@ class PackReader:
|
||||
end = start + size
|
||||
obj = buf[start:end] if end <= len(buf) else self.read(offset, size)
|
||||
if not validate(hdr.chunk_id, obj):
|
||||
return "object does not authenticate"
|
||||
return "object header or metadata does not authenticate"
|
||||
return None
|
||||
|
||||
def _find_header(self, offset, pack_size, validate):
|
||||
@@ -690,8 +689,8 @@ def remove_missing_pack_entries(chunks, missing_pack_ids):
|
||||
SALVAGE_INTACT = "intact" # the pack's store hash matches its name
|
||||
SALVAGE_DONE = "salvaged" # the pack was replaced by one holding only its authenticated objects
|
||||
SALVAGE_NOTHING_AUTHENTICATES = "nothing authenticates" # no object in the pack authenticates
|
||||
SALVAGE_UNSTABLE = "unstable" # two reads of the pack gave different results
|
||||
SALVAGE_READ_ERROR = "read error" # reading the pack raised OSError
|
||||
SALVAGE_READS_DIFFER = "reads differ" # two reads of the pack disagree
|
||||
SALVAGE_READ_ERROR = "read error" # reading the pack failed
|
||||
|
||||
# status: one of the SALVAGE_* outcomes.
|
||||
# new_pack_id: id of the replacement pack (SALVAGE_DONE), else None.
|
||||
@@ -977,9 +976,8 @@ class Repository:
|
||||
if cache_size:
|
||||
ns_config["packs/"]["size"] = int(cache_size)
|
||||
cache_url = cache_dir.as_uri()
|
||||
# True if BORG_STORE_CACHE set up a local cache of packs: store.load() of a pack may then return the
|
||||
# cached copy instead of reading the backend.
|
||||
self.pack_store_cache = cache_url is not None
|
||||
# True if packs are cached locally (BORG_STORE_CACHE): store.load() of a pack may return the cached copy.
|
||||
self.uses_pack_store_cache = cache_url is not None
|
||||
|
||||
propagate_rsh() # borgstore shall use the same remote shell command as borg
|
||||
|
||||
@@ -2382,7 +2380,7 @@ class Repository:
|
||||
self.store_delete(pack_key)
|
||||
return new_pack_id, len(pack_data)
|
||||
|
||||
def salvage_pack(self, pack_id, *, validate, authenticate, chunks, before_old_pack_delete=None):
|
||||
def salvage_pack(self, pack_id, *, validate, authenticate, chunks=None, before_old_pack_delete=None):
|
||||
"""Replace pack <pack_id> by a pack holding only the objects in it that authenticate.
|
||||
|
||||
A pack is named by the store hash (STORE_HASH_NAME) of its content.
|
||||
@@ -2391,8 +2389,9 @@ class Repository:
|
||||
is the repo object with id chunk_id, see repoobj.object_validator. The pack is walked with
|
||||
PackReader.iter_headers(validate).
|
||||
authenticate: authenticate(chunk_id, obj) -> bool, True if obj (a whole object: header, metadata
|
||||
slot and data slot) is the repo object with id chunk_id, see repoobj.object_authenticator.
|
||||
chunks: the ChunkIndex to update, or None to leave the chunk index unchanged.
|
||||
slot and data slot) is the repo object with id chunk_id, see repoobj.whole_object_authenticator.
|
||||
It must verify the tags of both slots.
|
||||
chunks: the ChunkIndex to update. Default: self.chunks.
|
||||
before_old_pack_delete: callable without arguments, called once just before the old pack is deleted,
|
||||
see SALVAGE_DONE.
|
||||
|
||||
@@ -2402,10 +2401,11 @@ class Repository:
|
||||
objects' bytes in their old order.
|
||||
|
||||
Returns a SalvageResult. The store and the chunk index change only for SALVAGE_DONE:
|
||||
- SALVAGE_UNSTABLE: the loaded bytes hash to the pack's name, or a second load of the pack
|
||||
differs from the first. An object is dropped only if two identical loads both fail it.
|
||||
- SALVAGE_READ_ERROR: reading the pack raised OSError. All reads happen before the first
|
||||
store change.
|
||||
- SALVAGE_READS_DIFFER: the loaded bytes hash to the pack's name although the store hash did
|
||||
not, or a second load of the pack differs from the first, e.g. due to corruption in memory
|
||||
or in transfer. Objects are dropped only if both loads return the same bytes.
|
||||
- SALVAGE_READ_ERROR: reading the pack raised OSError or a store backend error other than
|
||||
StoreObjectNotFound. All reads happen before the first store change.
|
||||
- SALVAGE_DONE: the replacement pack is stored, before_old_pack_delete is called, chunks is
|
||||
updated, then the old pack is deleted. If the replacement pack has the old pack's name
|
||||
(the kept bytes are the undamaged pack), storing it overwrites the old pack, and
|
||||
@@ -2416,44 +2416,43 @@ class Repository:
|
||||
has no entry gets one with flags F_USED and size 0 (the plaintext size is unknown). Entries
|
||||
of other packs and F_PENDING entries stay as they are.
|
||||
|
||||
Raises Error before any store access if BORG_WORKAROUNDS=authenticated_no_key is set (then
|
||||
decrypting skips the tag verification, so authenticate accepts damaged objects) or if
|
||||
pack_store_cache is set (then two loads can return the same cached copy). Raises
|
||||
PermissionDenied unless the repo permissions allow compaction (see assert_writable), and
|
||||
StoreObjectNotFound if the pack is missing. Requires the exclusive lock.
|
||||
Raises Error before any store access if uses_pack_store_cache is set (then two loads can
|
||||
return the same cached copy). Raises PermissionDenied unless the repo permissions allow
|
||||
compaction (see assert_writable), and StoreObjectNotFound if the pack is missing. Requires the
|
||||
exclusive lock.
|
||||
"""
|
||||
if crypto_key.AUTHENTICATED_NO_KEY:
|
||||
raise Error(
|
||||
"Pack salvage refused: with BORG_WORKAROUNDS=authenticated_no_key objects are not authenticated."
|
||||
)
|
||||
if self.pack_store_cache:
|
||||
if self.uses_pack_store_cache:
|
||||
raise Error("Pack salvage refused: with BORG_STORE_CACHE, pack reads may return the cached copy.")
|
||||
self._lock_refresh()
|
||||
if chunks is None:
|
||||
chunks = self.chunks
|
||||
self.assert_writable()
|
||||
pack_hex = bin_to_hex(pack_id)
|
||||
pack_key = "packs/" + pack_hex
|
||||
|
||||
def untouched(status):
|
||||
def unchanged(status):
|
||||
return SalvageResult(status, None, [], 0, [])
|
||||
|
||||
try:
|
||||
if self.store.hash(pack_key, algorithm=STORE_HASH_NAME) == pack_hex:
|
||||
return untouched(SALVAGE_INTACT)
|
||||
return unchanged(SALVAGE_INTACT)
|
||||
pack_contents = self.store.load(pack_key)
|
||||
if store_hash(pack_contents).digest() == pack_id:
|
||||
return unchanged(SALVAGE_READS_DIFFER)
|
||||
reader = PackReader(pack_id=pack_id, pack_contents=pack_contents)
|
||||
kept_old = [] # (chunk_id, old offset, size) of each kept object, offset-ordered
|
||||
for chunk_id, offset, size in reader.iter_headers(validate=validate):
|
||||
if authenticate(chunk_id, reader.read(offset, size)):
|
||||
kept_old.append((chunk_id, offset, size))
|
||||
if not kept_old:
|
||||
return untouched(SALVAGE_NOTHING_AUTHENTICATES)
|
||||
if store_hash(pack_contents).digest() == pack_id:
|
||||
return untouched(SALVAGE_UNSTABLE)
|
||||
return unchanged(SALVAGE_NOTHING_AUTHENTICATES)
|
||||
if self.store.load(pack_key) != pack_contents:
|
||||
return untouched(SALVAGE_UNSTABLE)
|
||||
except OSError as exc:
|
||||
return unchanged(SALVAGE_READS_DIFFER)
|
||||
except StoreObjectNotFound:
|
||||
raise
|
||||
except (OSError, StoreBackendError) as exc:
|
||||
logger.warning(f"pack {pack_hex}: {exc}, not salvaging it.")
|
||||
return untouched(SALVAGE_READ_ERROR)
|
||||
return unchanged(SALVAGE_READ_ERROR)
|
||||
|
||||
new_pack_data = b"".join(pack_contents[offset : offset + size] for _, offset, size in kept_old)
|
||||
# equal to pack_id if the kept bytes are the undamaged pack, e.g. when the damage is appended bytes.
|
||||
@@ -2469,34 +2468,33 @@ class Repository:
|
||||
if before_old_pack_delete is not None and new_pack_id != pack_id:
|
||||
before_old_pack_delete()
|
||||
|
||||
new_by_old_offset = {old[1]: new for old, new in zip(kept_old, kept)}
|
||||
new_by_id = {} # chunk_id -> its first kept copy
|
||||
for new in kept:
|
||||
new_by_id.setdefault(new[0], new)
|
||||
# collect first: the index must not be mutated while iterating it.
|
||||
listed = [
|
||||
(chunk_id, entry.obj_offset)
|
||||
for chunk_id, entry in chunks.iteritems()
|
||||
if entry.pack_id == pack_id and not (entry.flags & ChunkIndex.F_PENDING)
|
||||
]
|
||||
new_locations = []
|
||||
removed_ids = []
|
||||
if chunks is not None:
|
||||
new_by_old_offset = {old[1]: new for old, new in zip(kept_old, kept)}
|
||||
new_by_id = {} # chunk_id -> its first kept copy
|
||||
for new in kept:
|
||||
new_by_id.setdefault(new[0], new)
|
||||
# collect first: the index must not be mutated while iterating it.
|
||||
listed = [
|
||||
(chunk_id, entry.obj_offset)
|
||||
for chunk_id, entry in chunks.iteritems()
|
||||
if entry.pack_id == pack_id and not (entry.flags & ChunkIndex.F_PENDING)
|
||||
]
|
||||
new_locations = []
|
||||
for chunk_id, old_offset in listed:
|
||||
new = new_by_old_offset.get(old_offset)
|
||||
if new is None or new[0] != chunk_id:
|
||||
new = new_by_id.get(chunk_id)
|
||||
if new is None:
|
||||
del chunks[chunk_id]
|
||||
removed_ids.append(chunk_id)
|
||||
else:
|
||||
new_locations.append((chunk_id, new_pack_id, new[1], new[2]))
|
||||
chunks.update_pack_info(new_locations)
|
||||
for chunk_id, (_, offset, size) in new_by_id.items():
|
||||
if chunk_id not in chunks:
|
||||
chunks[chunk_id] = ChunkIndexEntry(
|
||||
flags=ChunkIndex.F_USED, size=0, pack_id=new_pack_id, obj_offset=offset, obj_size=size
|
||||
)
|
||||
for chunk_id, old_offset in listed:
|
||||
new = new_by_old_offset.get(old_offset)
|
||||
if new is None or new[0] != chunk_id:
|
||||
new = new_by_id.get(chunk_id)
|
||||
if new is None:
|
||||
del chunks[chunk_id]
|
||||
removed_ids.append(chunk_id)
|
||||
else:
|
||||
new_locations.append((chunk_id, new_pack_id, new[1], new[2]))
|
||||
chunks.update_pack_info(new_locations)
|
||||
for chunk_id, (_, offset, size) in new_by_id.items():
|
||||
if chunk_id not in chunks:
|
||||
chunks[chunk_id] = ChunkIndexEntry(
|
||||
flags=ChunkIndex.F_USED, size=0, pack_id=new_pack_id, obj_offset=offset, obj_size=size
|
||||
)
|
||||
|
||||
if new_pack_id != pack_id: # else storing the replacement pack overwrote the old one
|
||||
self.store_delete(pack_key)
|
||||
|
||||
@@ -1101,7 +1101,8 @@ def test_repair_resyncs_pack_with_corrupt_object_header(archivers, request, dama
|
||||
repository.store_store(key, pack)
|
||||
|
||||
output = cmd(archiver, "check", "--repair", "--debug", exit_code=0)
|
||||
problem = {"magic": "no object header", "data_size": "object does not authenticate"}[damaged_field]
|
||||
problems = {"magic": "no object header", "data_size": "object header or metadata does not authenticate"}
|
||||
problem = problems[damaged_field]
|
||||
assert f"{problem} at offset {damaged_offset}" in output
|
||||
assert f"continuing at the object at offset {next_offset}" in output # the rebuild resumed at the next object
|
||||
# the resync dropped an object, so the summary reports a problem.
|
||||
|
||||
@@ -12,8 +12,8 @@ from ..repoobj import (
|
||||
OBJ_MAGIC,
|
||||
OBJ_VERSION,
|
||||
RepoObj,
|
||||
object_authenticator,
|
||||
object_validator,
|
||||
whole_object_authenticator,
|
||||
)
|
||||
from ..legacy.repoobj import RepoObj1
|
||||
from ..compress import LZ4
|
||||
@@ -524,7 +524,7 @@ def test_object_validator_accepts_every_compression(compression):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key_class", [AuthenticatedKey, CHPOKey, AESOCBKey])
|
||||
def test_object_authenticator_checks_both_slots(key_class):
|
||||
def test_whole_object_authenticator_checks_both_slots(key_class):
|
||||
# a flipped byte in the metadata slot or in the data slot, a wrong chunk id or a truncated object
|
||||
# fails authentication, for each key family.
|
||||
key = key_class(None)
|
||||
@@ -534,7 +534,7 @@ def test_object_authenticator_checks_both_slots(key_class):
|
||||
data = b"payload" * 100
|
||||
chunk_id = repo_objs.id_hash(data)
|
||||
obj = repo_objs.format(chunk_id, {}, data, ro_type=ROBJ_FILE_STREAM)
|
||||
authenticate = object_authenticator(repo_objs)
|
||||
authenticate = whole_object_authenticator(repo_objs)
|
||||
assert authenticate(chunk_id, obj)
|
||||
assert authenticate(chunk_id, memoryview(obj))
|
||||
for pos in (RepoObj.obj_header.size, len(obj) - 1): # first byte of the metadata slot, last of the data slot
|
||||
@@ -545,15 +545,29 @@ def test_object_authenticator_checks_both_slots(key_class):
|
||||
assert not authenticate(chunk_id, obj[:-1])
|
||||
|
||||
|
||||
def test_object_authenticator_does_not_check_the_id(aead_key):
|
||||
# the data is not decompressed and not hashed: an object whose plaintext does not match its id,
|
||||
# but whose slots authenticate, is accepted.
|
||||
@pytest.mark.parametrize("key_class", [AuthenticatedKey, CHPOKey, AESOCBKey])
|
||||
def test_whole_object_authenticator_refuses_authenticated_no_key(key_class, monkeypatch):
|
||||
# the workaround skips the tag verification of the "authenticated-*" keys only.
|
||||
key = key_class(None)
|
||||
key.init_from_random_data()
|
||||
key.init_ciphers()
|
||||
monkeypatch.setattr("borg.repoobj.AUTHENTICATED_NO_KEY", True)
|
||||
if key_class is AuthenticatedKey:
|
||||
with pytest.raises(Error, match="authenticated_no_key"):
|
||||
whole_object_authenticator(RepoObj(key))
|
||||
else:
|
||||
assert callable(whole_object_authenticator(RepoObj(key)))
|
||||
|
||||
|
||||
def test_whole_object_authenticator_does_not_check_the_id(aead_key):
|
||||
# only the tags are checked: an object whose slots authenticate but whose plaintext does not match
|
||||
# its id is accepted.
|
||||
repo_objs = RepoObj(aead_key)
|
||||
chunk_id = repo_objs.id_hash(b"foobar" * 10)
|
||||
assert object_authenticator(repo_objs)(chunk_id, wrong_content_object(repo_objs, chunk_id))
|
||||
assert whole_object_authenticator(repo_objs)(chunk_id, wrong_content_object(repo_objs, chunk_id))
|
||||
|
||||
|
||||
def test_object_authenticator_propagates_an_unexpected_exception(monkeypatch):
|
||||
def test_whole_object_authenticator_propagates_an_unexpected_exception(monkeypatch):
|
||||
# only a failed authentication or unpacking gives False, any other exception propagates.
|
||||
repo_objs = RepoObj(make_test_key())
|
||||
data = b"payload" * 100
|
||||
@@ -565,4 +579,4 @@ def test_object_authenticator_propagates_an_unexpected_exception(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(repo_objs, "parse", parse_raising_valueerror)
|
||||
with pytest.raises(ValueError):
|
||||
object_authenticator(repo_objs)(chunk_id, obj)
|
||||
whole_object_authenticator(repo_objs)(chunk_id, obj)
|
||||
|
||||
@@ -23,8 +23,8 @@ from ..platform import get_process_id
|
||||
from ..repository import Repository, MAX_DATA_SIZE, MAX_VALIDATED_META_SIZE, propagate_rsh, rest_serve_command
|
||||
from ..repository import PackWriter, PackReader, PackTracker, superseded_gap_ranges
|
||||
from ..repository import SALVAGE_DONE, SALVAGE_INTACT, SALVAGE_NOTHING_AUTHENTICATES
|
||||
from ..repository import SALVAGE_READ_ERROR, SALVAGE_UNSTABLE
|
||||
from ..repoobj import RepoObj, OBJ_MAGIC, OBJ_VERSION, object_authenticator, object_validator
|
||||
from ..repository import SALVAGE_READ_ERROR, SALVAGE_READS_DIFFER, StoreBackendError, StoreObjectNotFound
|
||||
from ..repoobj import RepoObj, OBJ_MAGIC, OBJ_VERSION, object_validator, whole_object_authenticator
|
||||
from . import make_test_key, set_test_key_on_open
|
||||
from .hashindex_test import H
|
||||
from .repoobj_test import CHUNK_ID_OFFSET, DATA_SIZE_OFFSET, META_SIZE_OFFSET
|
||||
@@ -3272,7 +3272,10 @@ def test_superseded_gap_ranges_warns_where_it_keeps_bytes(tmp_path, caplog):
|
||||
assert gap_ranges(bytes(damaged) + garbage, chunks, validate) == []
|
||||
assert gap_ranges(b"\0" * 3, chunks, validate) == []
|
||||
|
||||
assert f"pack {pack_hex}: object does not authenticate at offset 0 in a gap, keeping its bytes." in caplog.text
|
||||
assert (
|
||||
f"pack {pack_hex}: object header or metadata does not authenticate at offset 0 in a gap, keeping its bytes."
|
||||
in caplog.text
|
||||
)
|
||||
assert (
|
||||
f"pack {pack_hex}: no object header at offset {len(rejected)} in a gap, "
|
||||
f"keeping the remaining {len(garbage)} bytes of the gap." in caplog.text
|
||||
@@ -3442,7 +3445,7 @@ def test_create_failure_leaves_no_store_behind(tmp_path, monkeypatch):
|
||||
assert os.path.exists(os.path.join(location, "config", "config"))
|
||||
|
||||
|
||||
def store_salvage_pack(repository, objs, *, listed, flip=(), tail=b""):
|
||||
def store_damaged_pack(repository, objs, *, listed, flip=(), tail=b""):
|
||||
"""Store objs as one pack named by the store hash of their bytes, then damage it.
|
||||
|
||||
The byte at each position in flip is flipped and tail is appended, so a flip or a tail makes the
|
||||
@@ -3468,16 +3471,14 @@ def store_salvage_pack(repository, objs, *, listed, flip=(), tail=b""):
|
||||
return pack_id, offsets
|
||||
|
||||
|
||||
def offsets_end(objs, i):
|
||||
def last_byte_offset(objs, i):
|
||||
# position of the last byte of objs[i] in a pack of objs: the last byte of its data slot.
|
||||
return sum(len(obj) for _, obj in objs[: i + 1]) - 1
|
||||
|
||||
|
||||
def salvage(repository, repo_objs, pack_id, **kwargs):
|
||||
kwargs.setdefault("chunks", repository.chunks)
|
||||
return repository.salvage_pack(
|
||||
pack_id, validate=object_validator(repo_objs), authenticate=object_authenticator(repo_objs), **kwargs
|
||||
)
|
||||
kwargs.setdefault("authenticate", whole_object_authenticator(repo_objs))
|
||||
return repository.salvage_pack(pack_id, validate=object_validator(repo_objs), **kwargs)
|
||||
|
||||
|
||||
def store_contents(repository):
|
||||
@@ -3503,7 +3504,7 @@ def three_objects(repo_objs):
|
||||
def test_salvage_pack_leaves_an_intact_pack_alone(salvage_repository):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3))
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3))
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
called = []
|
||||
|
||||
@@ -3522,7 +3523,7 @@ def test_salvage_pack_drops_an_object_failing_authentication(salvage_repository)
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
(id0, obj0), (id1, obj1), (id2, obj2) = objs
|
||||
pack_id, offsets = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
pack_id, offsets = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
list(salvage_repository.get_many([id0])) # loads the pack into the pack cache
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id)
|
||||
@@ -3544,7 +3545,7 @@ def test_salvage_pack_keeps_an_object_the_index_does_not_list(salvage_repository
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
(id0, obj0), (id1, obj1), (id2, obj2) = objs
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=[0, 2], flip=[offsets_end(objs, 2)])
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=[0, 2], flip=[last_byte_offset(objs, 2)])
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id)
|
||||
|
||||
@@ -3560,8 +3561,8 @@ def test_salvage_pack_keeps_an_object_the_index_does_not_list(salvage_repository
|
||||
def test_salvage_pack_leaves_a_pack_alone_if_nothing_authenticates(salvage_repository):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
flip = [offsets_end(objs, i) for i in range(3)]
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=flip)
|
||||
flip = [last_byte_offset(objs, i) for i in range(3)]
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=flip)
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id)
|
||||
@@ -3576,7 +3577,7 @@ def test_salvage_pack_leaves_a_pack_alone_if_nothing_authenticates(salvage_repos
|
||||
def test_salvage_pack_drops_uncovered_trailing_bytes(salvage_repository, tail):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), tail=tail)
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), tail=tail)
|
||||
index_before = index_contents(salvage_repository.chunks)
|
||||
called = []
|
||||
|
||||
@@ -3595,33 +3596,14 @@ def test_salvage_pack_drops_uncovered_trailing_bytes(salvage_repository, tail):
|
||||
assert bytes(salvage_repository.get(chunk_id)) == obj
|
||||
|
||||
|
||||
def test_salvage_pack_refuses_under_authenticated_no_key(salvage_repository, monkeypatch):
|
||||
# with the workaround, decrypting skips the tag verification, so a damaged object would authenticate.
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
damaged = bytearray(objs[1][1])
|
||||
damaged[-1] ^= 0xFF
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
monkeypatch.setattr("borg.crypto.key.AUTHENTICATED_NO_KEY", True)
|
||||
assert object_authenticator(repo_objs)(objs[1][0], bytes(damaged))
|
||||
|
||||
with pytest.raises(Error, match="authenticated_no_key"):
|
||||
salvage(salvage_repository, repo_objs, pack_id)
|
||||
|
||||
assert store_contents(salvage_repository) == packs_before
|
||||
assert index_contents(salvage_repository.chunks) == index_before
|
||||
|
||||
|
||||
def test_salvage_pack_refuses_with_a_pack_store_cache(tmp_path, monkeypatch):
|
||||
# with BORG_STORE_CACHE, store.load() of a pack can return the cached copy, so a second read is not
|
||||
# a second read of the pack.
|
||||
# with BORG_STORE_CACHE, both loads of a pack can return the same cached copy.
|
||||
monkeypatch.setenv("BORG_STORE_CACHE", os.fspath(tmp_path / "cache"))
|
||||
with Repository(os.fspath(tmp_path / "repo"), exclusive=True, create=True) as repository:
|
||||
repository.chunks = ChunkIndex()
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
pack_id, _ = store_damaged_pack(repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
with pytest.raises(Error, match="BORG_STORE_CACHE"):
|
||||
salvage(repository, repo_objs, pack_id)
|
||||
assert bin_to_hex(pack_id) in store_contents(repository)
|
||||
@@ -3630,17 +3612,20 @@ def test_salvage_pack_refuses_with_a_pack_store_cache(tmp_path, monkeypatch):
|
||||
def test_salvage_pack_refuses_without_write_permission(salvage_repository):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
salvage_repository.permissions = {"packs": "lrw", "index": "lrwWD"} # packs/ has no delete
|
||||
with pytest.raises(Repository.PermissionDenied):
|
||||
salvage(salvage_repository, repo_objs, pack_id)
|
||||
assert store_contents(salvage_repository) == packs_before
|
||||
assert index_contents(salvage_repository.chunks) == index_before
|
||||
|
||||
|
||||
def test_salvage_pack_leaves_a_pack_alone_if_two_reads_differ(salvage_repository, monkeypatch):
|
||||
# the first load has an extra flipped byte in object 0, the second load does not.
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
load = salvage_repository.store.load
|
||||
loads = []
|
||||
@@ -3650,7 +3635,7 @@ def test_salvage_pack_leaves_a_pack_alone_if_two_reads_differ(salvage_repository
|
||||
loads.append(name)
|
||||
if len(loads) == 1:
|
||||
data = bytearray(data)
|
||||
data[offsets_end(objs, 0)] ^= 0xFF
|
||||
data[last_byte_offset(objs, 0)] ^= 0xFF
|
||||
data = bytes(data)
|
||||
return data
|
||||
|
||||
@@ -3658,7 +3643,7 @@ def test_salvage_pack_leaves_a_pack_alone_if_two_reads_differ(salvage_repository
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id)
|
||||
|
||||
assert result.status == SALVAGE_UNSTABLE
|
||||
assert result.status == SALVAGE_READS_DIFFER
|
||||
assert len(loads) == 2
|
||||
monkeypatch.undo()
|
||||
assert store_contents(salvage_repository) == packs_before
|
||||
@@ -3666,28 +3651,39 @@ def test_salvage_pack_leaves_a_pack_alone_if_two_reads_differ(salvage_repository
|
||||
|
||||
|
||||
def test_salvage_pack_leaves_a_pack_that_reads_intact_alone(salvage_repository, monkeypatch):
|
||||
# the store hash does not match the name, but the loaded bytes do: the reads disagree.
|
||||
# the store hash does not match the name, but the loaded bytes do: the reads disagree, and no object
|
||||
# is authenticated.
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3))
|
||||
packs_before = store_contents(salvage_repository)
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3))
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
monkeypatch.setattr(salvage_repository.store, "hash", lambda name, algorithm: "0" * 64)
|
||||
authenticated = []
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id)
|
||||
def authenticate(chunk_id, obj):
|
||||
authenticated.append(chunk_id)
|
||||
return False
|
||||
|
||||
assert result.status == SALVAGE_UNSTABLE
|
||||
result = salvage(salvage_repository, repo_objs, pack_id, authenticate=authenticate)
|
||||
|
||||
assert result.status == SALVAGE_READS_DIFFER
|
||||
assert authenticated == []
|
||||
assert store_contents(salvage_repository) == packs_before
|
||||
assert index_contents(salvage_repository.chunks) == index_before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["hash", "load"])
|
||||
def test_salvage_pack_leaves_a_pack_alone_on_a_read_error(salvage_repository, monkeypatch, method):
|
||||
@pytest.mark.parametrize(
|
||||
"error", [OSError(5, "Input/output error"), StoreBackendError("server error")], ids=["OSError", "BackendError"]
|
||||
)
|
||||
def test_salvage_pack_leaves_a_pack_alone_on_a_read_error(salvage_repository, monkeypatch, method, error):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
packs_before, index_before = store_contents(salvage_repository), index_contents(salvage_repository.chunks)
|
||||
|
||||
def failing(*args, **kwargs):
|
||||
raise OSError(5, "Input/output error")
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(salvage_repository.store, method, failing)
|
||||
|
||||
@@ -3699,18 +3695,25 @@ def test_salvage_pack_leaves_a_pack_alone_on_a_read_error(salvage_repository, mo
|
||||
assert index_contents(salvage_repository.chunks) == index_before
|
||||
|
||||
|
||||
def test_salvage_pack_without_chunks_leaves_the_index_alone(salvage_repository):
|
||||
def test_salvage_pack_raises_if_the_pack_is_missing(salvage_repository):
|
||||
repo_objs = plain_repo_objs()
|
||||
with pytest.raises(StoreObjectNotFound):
|
||||
salvage(salvage_repository, repo_objs, bytes(32))
|
||||
|
||||
|
||||
def test_salvage_pack_updates_the_given_chunk_index(salvage_repository):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
index_before = index_contents(salvage_repository.chunks)
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
chunks = salvage_repository.chunks
|
||||
salvage_repository.chunks = ChunkIndex()
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id, chunks=None)
|
||||
result = salvage(salvage_repository, repo_objs, pack_id, chunks=chunks)
|
||||
|
||||
assert result.status == SALVAGE_DONE
|
||||
assert result.removed_ids == []
|
||||
assert index_contents(salvage_repository.chunks) == index_before
|
||||
assert set(store_contents(salvage_repository)) == {bin_to_hex(result.new_pack_id)}
|
||||
assert result.removed_ids == [objs[1][0]]
|
||||
assert chunks[objs[0][0]].pack_id == result.new_pack_id
|
||||
assert index_contents(salvage_repository.chunks) == {}
|
||||
|
||||
|
||||
def test_salvage_pack_indexes_duplicates(salvage_repository):
|
||||
@@ -3720,11 +3723,11 @@ def test_salvage_pack_indexes_duplicates(salvage_repository):
|
||||
id_a, obj_a = real_chunk(repo_objs, b"A" * 100)
|
||||
id_b, obj_b = real_chunk(repo_objs, b"B" * 100)
|
||||
id_c, obj_c = real_chunk(repo_objs, b"C" * 100)
|
||||
other_pack_id, _ = store_salvage_pack(salvage_repository, [(id_a, obj_a)], listed=[0])
|
||||
other_pack_id, _ = store_damaged_pack(salvage_repository, [(id_a, obj_a)], listed=[0])
|
||||
objs = [(id_a, obj_a), (id_b, obj_b), (id_b, obj_b), (id_c, obj_c)]
|
||||
# id_a is unlisted here, id_b is listed at its damaged first copy, id_c is listed and its only copy is damaged.
|
||||
pack_id, _ = store_salvage_pack(
|
||||
salvage_repository, objs, listed=[1, 3], flip=[offsets_end(objs, 1), offsets_end(objs, 3)]
|
||||
pack_id, _ = store_damaged_pack(
|
||||
salvage_repository, objs, listed=[1, 3], flip=[last_byte_offset(objs, 1), last_byte_offset(objs, 3)]
|
||||
)
|
||||
|
||||
result = salvage(salvage_repository, repo_objs, pack_id)
|
||||
@@ -3743,7 +3746,7 @@ def test_salvage_pack_order_of_changes(salvage_repository, monkeypatch):
|
||||
repo_objs = plain_repo_objs()
|
||||
objs = three_objects(repo_objs)
|
||||
id0 = objs[0][0]
|
||||
pack_id, _ = store_salvage_pack(salvage_repository, objs, listed=range(3), flip=[offsets_end(objs, 1)])
|
||||
pack_id, _ = store_damaged_pack(salvage_repository, objs, listed=range(3), flip=[last_byte_offset(objs, 1)])
|
||||
events = []
|
||||
store_store, store_delete = salvage_repository.store_store, salvage_repository.store_delete
|
||||
|
||||
|
||||
Reference in New Issue
Block a user