Skip to content

Commit 11b13fd

Browse files
committed
Tests: Add coverage for in-place patching and metadata clearing
Signed-off-by: alighazi288 <51366992+alighazi288@users.noreply.github.com>
1 parent 2d4e1c5 commit 11b13fd

2 files changed

Lines changed: 192 additions & 1 deletion

File tree

src/borg/testsuite/archive_test.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import os
3+
import stat
34
from collections import OrderedDict
45
from datetime import datetime, timezone
56
from io import StringIO
@@ -12,7 +13,7 @@
1213
from ..archive import Archive, CacheChunkBuffer, RobustUnpacker, valid_msgpacked_dict, ITEM_KEYS, Statistics
1314
from ..archive import BackupOSError, backup_io, backup_io_iter, get_item_uid_gid
1415
from ..helpers import msgpack
15-
from ..item import Item, ArchiveItem
16+
from ..item import Item, ArchiveItem, ChunkListEntry
1617
from ..manifest import Archives, Manifest
1718
from ..platform import uid2user, gid2group, is_win32
1819

@@ -435,3 +436,167 @@ def test_archives_get_by_id_missing_returns_none():
435436
manifest = Mock()
436437
archives = Archives(repo, manifest)
437438
assert archives.get_by_id(b"\x01" * 32) is None
439+
440+
441+
# ---- borg extract: in-place chunk comparison / selective extraction (#5638) ----
442+
443+
CHUNK_SIZE = 4
444+
445+
446+
class FetchManyPipeline:
447+
"""Minimal pipeline stand-in that records which chunk ids fetch_many() requested."""
448+
449+
def __init__(self, objects):
450+
self.objects = objects # id -> data
451+
self.fetched = []
452+
453+
def fetch_many(self, chunks, ro_type=None):
454+
assert ro_type is not None
455+
for chunk in chunks:
456+
self.fetched.append(chunk.id)
457+
yield self.objects[chunk.id]
458+
459+
460+
@pytest.fixture
461+
def extractor(tmpdir):
462+
repository = Mock()
463+
key = PlaintextKey(repository)
464+
manifest = Manifest(key, repository)
465+
archive = Archive(manifest=manifest, name="test", create=True)
466+
archive.key = key
467+
archive.cwd = str(tmpdir)
468+
return archive
469+
470+
471+
def make_item(key, objects, data):
472+
"""Chunk *data* into CHUNK_SIZE pieces, register them in *objects*, return an Item."""
473+
chunks = []
474+
for i in range(0, len(data), CHUNK_SIZE):
475+
piece = data[i : i + CHUNK_SIZE]
476+
cid = key.id_hash(piece)
477+
chunks.append(ChunkListEntry(id=cid, size=len(piece)))
478+
objects[cid] = piece
479+
item = Item(path="test", mode=stat.S_IFREG | 0o644, size=len(data))
480+
item.chunks = chunks
481+
return item
482+
483+
484+
@pytest.mark.parametrize(
485+
"name, item_data, fs_data, expected_fetched",
486+
[
487+
("no_change", b"11112222", b"11112222", 0),
488+
("first_chunk", b"11112222", b"33332222", 1),
489+
("second_chunk", b"11112222", b"11113333", 1),
490+
("both_chunks", b"11112222", b"33334444", 2),
491+
("cross_boundary", b"11112222", b"11333322", 2),
492+
("partial_last_chunk", b"1111222233", b"1111222244", 1),
493+
("fs_shorter", b"11112222", b"111122", 1),
494+
("fs_longer", b"11112222", b"1111222233", 0),
495+
("empty_item", b"", b"11112222", 0),
496+
("empty_fs", b"11112222", b"", 2),
497+
],
498+
)
499+
def test_compare_and_extract_chunks(extractor, tmpdir, name, item_data, fs_data, expected_fetched):
500+
objects = {}
501+
item = make_item(extractor.key, objects, item_data)
502+
pipeline = FetchManyPipeline(objects)
503+
extractor.pipeline = pipeline
504+
# we only exercise the data path here; attribute (re)storing is covered elsewhere.
505+
extractor.clear_attrs = Mock()
506+
extractor.restore_attrs = Mock()
507+
508+
path = str(tmpdir.join("test"))
509+
with open(path, "wb") as f:
510+
f.write(fs_data)
511+
st = os.stat(path)
512+
513+
assert extractor.compare_and_extract_chunks(item, path, st=st)
514+
assert len(pipeline.fetched) == expected_fetched
515+
with open(path, "rb") as f:
516+
assert f.read() == item_data
517+
518+
519+
def test_compare_and_extract_chunks_fetches_only_differing(extractor, tmpdir):
520+
objects = {}
521+
item = make_item(extractor.key, objects, b"11112222")
522+
pipeline = FetchManyPipeline(objects)
523+
extractor.pipeline = pipeline
524+
extractor.clear_attrs = Mock()
525+
extractor.restore_attrs = Mock()
526+
527+
path = str(tmpdir.join("test"))
528+
with open(path, "wb") as f:
529+
f.write(b"1111XXXX") # only the second chunk differs
530+
531+
extractor.compare_and_extract_chunks(item, path, st=os.stat(path))
532+
# exactly the (differing) second chunk should have been fetched, not the first.
533+
assert pipeline.fetched == [item.chunks[1].id]
534+
535+
536+
@pytest.mark.parametrize("st_is_none", [True, False])
537+
def test_compare_and_extract_chunks_skips_non_regular(extractor, tmpdir, st_is_none):
538+
objects = {}
539+
item = make_item(extractor.key, objects, b"11112222")
540+
extractor.pipeline = FetchManyPipeline(objects)
541+
if st_is_none:
542+
st = None
543+
else:
544+
st = os.stat(str(tmpdir)) # a directory, not a regular file
545+
assert extractor.compare_and_extract_chunks(item, str(tmpdir.join("test")), st=st) is False
546+
547+
548+
def test_compare_and_extract_chunks_skips_hardlinks(extractor, tmpdir):
549+
objects = {}
550+
item = make_item(extractor.key, objects, b"11112222")
551+
item.hlid = b"\x00" * 32 # a hard link must use the normal (preloaded) extraction path
552+
path = str(tmpdir.join("test"))
553+
with open(path, "wb") as f:
554+
f.write(b"11112222")
555+
assert extractor.compare_and_extract_chunks(item, path, st=os.stat(path)) is False
556+
557+
558+
def test_compare_and_extract_chunks_skips_hardlinked_file(extractor, tmpdir):
559+
# a destination file with other hard links (st_nlink > 1) must not be patched in place,
560+
# as that would change the content seen through those other links.
561+
objects = {}
562+
item = make_item(extractor.key, objects, b"11112222")
563+
extractor.pipeline = FetchManyPipeline(objects)
564+
path = str(tmpdir.join("test"))
565+
with open(path, "wb") as f:
566+
f.write(b"11112222")
567+
os.link(path, str(tmpdir.join("other-link"))) # st_nlink becomes 2
568+
assert extractor.compare_and_extract_chunks(item, path, st=os.stat(path)) is False
569+
570+
571+
def test_compare_and_extract_chunks_skips_file_with_extended_acl(extractor, tmpdir):
572+
# a file carrying an extended ACL must not be patched in place, because clear_attrs() does
573+
# not reset ACLs; such files fall back to normal extraction (fresh inode, clean metadata).
574+
objects = {}
575+
item = make_item(extractor.key, objects, b"11112222")
576+
extractor.pipeline = FetchManyPipeline(objects)
577+
extractor._fs_has_extended_acl = Mock(return_value=True)
578+
path = str(tmpdir.join("test"))
579+
with open(path, "wb") as f:
580+
f.write(b"11112222")
581+
assert extractor.compare_and_extract_chunks(item, path, st=os.stat(path)) is False
582+
583+
584+
@pytest.mark.skipif(is_win32, reason="xattrs/clear_attrs are POSIX-only")
585+
def test_compare_and_extract_chunks_clears_stale_xattr(extractor, tmpdir):
586+
from .. import xattr as xattr_mod
587+
588+
path = str(tmpdir.join("test")).encode()
589+
with open(path, "wb") as f:
590+
f.write(b"oldcontent")
591+
if not xattr_mod.is_enabled(str(tmpdir)):
592+
pytest.skip("xattrs not supported on this filesystem")
593+
xattr_mod.set_all(path, {b"user.stale": b"1"})
594+
595+
objects = {}
596+
item = make_item(extractor.key, objects, b"11112222")
597+
extractor.pipeline = FetchManyPipeline(objects)
598+
extractor.restore_attrs = Mock() # real clear_attrs, but skip restoring archived attrs
599+
600+
assert extractor.compare_and_extract_chunks(item, path.decode(), st=os.stat(path))
601+
# the stale xattr that was not part of the archive item must be gone.
602+
assert b"user.stale" not in xattr_mod.get_all(path)

src/borg/testsuite/archiver/extract_cmd_test.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,32 @@ def test_extract_continue(archivers, request):
771771
assert f.read() == CONTENTS3
772772

773773

774+
def test_extract_patches_existing_file_in_place(archivers, request):
775+
# when extracting over an existing regular file, borg updates it in place (only fetching
776+
# the chunks that differ, see #5638) instead of unlinking and recreating it.
777+
archiver = request.getfixturevalue(archivers)
778+
# use a reasonably large, chunkable content so multiple chunks exist.
779+
contents = os.urandom(4 * 1024 * 1024)
780+
cmd(archiver, "repo-create", RK_ENCRYPTION)
781+
create_regular_file(archiver.input_path, "file", contents=contents)
782+
cmd(archiver, "create", "arch", "input")
783+
784+
with changedir("output"):
785+
cmd(archiver, "extract", "arch")
786+
st_before = os.stat("input/file")
787+
# locally modify a few bytes near the start, leaving the rest identical.
788+
with open("input/file", "rb+") as f:
789+
f.seek(5)
790+
f.write(b"DIFFERENT")
791+
# extract again (no --continue): the existing file should be patched in place.
792+
cmd(archiver, "extract", "arch")
793+
st_after = os.stat("input/file")
794+
if not is_win32:
795+
assert st_before.st_ino == st_after.st_ino # same inode -> updated in place
796+
with open("input/file", "rb") as f:
797+
assert f.read() == contents # content fully restored
798+
799+
774800
def test_dry_run_extraction_flags(archivers, request):
775801
archiver = request.getfixturevalue(archivers)
776802
cmd(archiver, "repo-create", RK_ENCRYPTION)

0 commit comments

Comments
 (0)