borg/scripts/endian_interop_test.py
Thomas Waldmann d69838c891
CI: add a 32-bit test job (armv7, emulated)
All our other CI machines are 64-bit, so nothing exercises borg where a
pointer, size_t, Py_ssize_t and off_t are 32 bits wide. armv7 is the 32-bit
platform borg users still run (Raspberry Pi OS 32-bit, NAS / router /
embedded boxes); it additionally does not tolerate unaligned accesses
everywhere and has an unsigned plain "char", so it exercises C portability,
too.

Modelled on the big-endian s390x job: debian trixie (python 3.13, and its
OpenSSL 3.5 satisfies our OpenSSL >= 3.2 requirement - bookworm's 3.0 would
not) under qemu user-mode emulation, the same trigger policy (native/format
relevant paths, weekly, manual), the word size sensitive tests, and a
cross-architecture interoperability test - a repository written on the
emulated 32-bit machine is read (and written to) by the 64-bit runner and the
other way round, requiring identical chunk IDs on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:32:16 +02:00

261 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Cross-architecture repository interoperability test.
borg's on-disk / on-the-wire format is architecture independent, but almost all
of our CI runs on little-endian machines only, so the big-endian code paths in
the native code (e.g. the __builtin_bswap64() in the chunker kernels) are never
executed. This script drives a repository from two sides, so a repository that
was written on one architecture can be verified on the other one:
# on machine/container A (e.g. big-endian):
endian_interop_test.py testdata WORKDIR # deterministic test data
endian_interop_test.py write WORKDIR be # write archives "be-*"
# on machine/container B (e.g. little-endian), same WORKDIR:
endian_interop_test.py verify WORKDIR be # read what A wrote
endian_interop_test.py write WORKDIR le # write archives "le-*"
endian_interop_test.py compare WORKDIR be le # same data, same chunks?
# on machine/container A again:
endian_interop_test.py verify WORKDIR le # read what B wrote
"verify" runs "borg check --verify-data", extracts the other side's archives and
compares the extracted files against the test data. "compare" additionally
requires that both sides cut the data into exactly the same chunks with exactly
the same chunk IDs - if they did not, the archives would still be correct, but
deduplication between machines of different endianness would silently not work.
The labels are just names for the two sides, so the same script also drives the
32-bit interoperability test (.github/workflows/32bit.yml), where the two sides
are an emulated 32-bit machine and the 64-bit CI runner.
The environment (BORG_REPO, BORG_PASSPHRASE, cache and config dir) is set up by
this script; the borg to use can be given via the BORG environment variable.
"""
import argparse
import hashlib
import json
import os
import random
import shutil
import subprocess
import sys
# fastcdc is borg's default chunker (and its kernel is one of the places with a
# byte-order dependent code path). Much smaller chunks than the default: the
# test data then gets cut into thousands of chunks == thousands of chances to
# notice a byte-order dependent cut point.
#
# The second archive uses different chunker parameters on purpose: with the same
# parameters it would just deduplicate against the first one and its compression
# would never be exercised. So both sides also have to agree about the chunk
# format of more than one compression method.
ARCHIVE_SPECS = [
("fastcdc-zstd", ["--chunker-params", "fastcdc,10,16,12,2", "--compression", "zstd,3"]),
("fastcdc-lz4", ["--chunker-params", "fastcdc,12,18,14,2", "--compression", "lz4"]),
]
PASSPHRASE = "borg-endian-interop-test"
# the test data lives in this subdirectory of the work directory
DATA = "data"
def borg(workdir, label, *args, cwd=None, capture=False):
"""run a borg command against the test repository"""
env = os.environ.copy()
env["BORG_REPO"] = os.path.join(workdir, "repo")
env["BORG_PASSPHRASE"] = PASSPHRASE
# the KDF does not need to be strong for a throwaway test repository and
# argon2 is *slow* under emulation:
env["BORG_TESTONLY_WEAKEN_KDF"] = "1"
# keep the two sides (and the machine's own borg config) apart:
env["BORG_BASE_DIR"] = os.path.join(workdir, "home-%s" % label)
env["BORG_CACHE_DIR"] = os.path.join(workdir, "home-%s" % label, "cache")
env["BORG_CONFIG_DIR"] = os.path.join(workdir, "home-%s" % label, "config")
cmd = [os.environ.get("BORG", "borg")] + [str(a) for a in args]
print("+ %s" % " ".join(cmd), flush=True)
if capture:
return subprocess.run(cmd, env=env, cwd=cwd, check=True, stdout=subprocess.PIPE).stdout
subprocess.run(cmd, env=env, cwd=cwd, check=True)
return None
def datadir(workdir):
return os.path.join(workdir, DATA)
def cmd_testdata(args):
"""create deterministic test data - content must not depend on the machine"""
path = datadir(args.workdir)
if os.path.exists(path):
shutil.rmtree(path)
os.makedirs(os.path.join(path, "nested", "dir"))
rnd = random.Random(20260814)
# incompressible, gets cut into many chunks:
with open(os.path.join(path, "random.bin"), "wb") as f:
f.write(rnd.randbytes(8 * 1024 * 1024))
# compressible, but not uniform - many different chunker cut points:
with open(os.path.join(path, "text.bin"), "wb") as f:
for i in range(64 * 1024):
f.write(b"line %d: %s\n" % (i, b"borg" * (i % 17)))
# all-zero data (the chunkers have a shortcut for this):
with open(os.path.join(path, "zeros.bin"), "wb") as f:
f.write(b"\0" * (2 * 1024 * 1024))
# the same data as random.bin, but shifted by a few bytes: the rolling hash
# has to find the same cut points again after the insertion.
with open(os.path.join(path, "random.bin"), "rb") as f:
data = f.read()
with open(os.path.join(path, "nested", "shifted.bin"), "wb") as f:
f.write(b"insertion" + data)
with open(os.path.join(path, "nested", "dir", "small.txt"), "wb") as f:
f.write(b"hello borg\n")
with open(os.path.join(path, "nested", "empty"), "wb"):
pass
with open(os.path.join(path, "nested", "\u00fcmlaut-\u65e5\u672c\u8a9e.txt"), "wb") as f:
f.write("non-ascii file name and content: \u00e4\u00f6\u00fc\n".encode())
os.symlink("dir/small.txt", os.path.join(path, "nested", "symlink"))
os.link(os.path.join(path, "nested", "dir", "small.txt"), os.path.join(path, "nested", "hardlink"))
print("test data created in %s" % path)
def scan(path):
"""map relative path -> file content hash / symlink target, for comparing 2 trees"""
result = {}
for dirpath, dirnames, filenames in os.walk(path):
dirnames.sort()
for name in sorted(dirnames + filenames):
full = os.path.join(dirpath, name)
rel = os.path.relpath(full, path)
if os.path.islink(full):
result[rel] = "symlink:%s" % os.readlink(full)
elif os.path.isdir(full):
result[rel] = "dir"
else:
digest = hashlib.sha256()
with open(full, "rb") as f:
for block in iter(lambda: f.read(1024 * 1024), b""):
digest.update(block)
result[rel] = "file:%s" % digest.hexdigest()
return result
def dump_path(workdir, archive, label):
return os.path.join(workdir, "dumps", "%s.read-by-%s.json" % (archive, label))
def dump_archive(workdir, label, archive):
"""dump the archive metadata as read by *this* machine"""
os.makedirs(os.path.join(workdir, "dumps"), exist_ok=True)
borg(workdir, label, "debug", "dump-archive", archive, dump_path(workdir, archive, label))
def chunk_list(dump_file):
"""(path, size, chunks) of all items, everything that must not depend on the architecture"""
with open(dump_file) as f:
dump = json.load(f)
items = []
for item in dump["_items"]:
items.append((item["path"], item.get("size"), item.get("target"), item.get("chunks")))
return items
def cmd_write(args):
workdir, label = args.workdir, args.label
if not os.path.exists(os.path.join(workdir, "repo")):
borg(workdir, label, "repo-create", "--encryption=aes256-ocb")
for suffix, options in ARCHIVE_SPECS:
archive = "%s-%s" % (label, suffix)
# --files-cache=disabled: really chunk the files, do not trust any cache.
# Archive the relative path "data" from within the work directory: the
# two sides usually see the work directory at different absolute paths
# (e.g. one of them inside a container), but the item paths in the
# archives must be comparable.
borg(workdir, label, "create", "--files-cache=disabled", *options, archive, DATA, cwd=workdir)
dump_archive(workdir, label, archive)
borg(workdir, label, "repo-list")
def cmd_verify(args):
"""check and extract the archives written by the *other* side"""
workdir, other, label = args.workdir, args.label, args.as_label
borg(workdir, label, "check", "--verify-data")
expected = scan(datadir(workdir))
assert expected, "no test data found in %s" % datadir(workdir)
for suffix, _ in ARCHIVE_SPECS:
archive = "%s-%s" % (other, suffix)
extract_to = os.path.join(workdir, "extract-%s-by-%s" % (archive, label))
if os.path.exists(extract_to):
shutil.rmtree(extract_to)
os.makedirs(extract_to)
borg(workdir, label, "extract", archive, cwd=extract_to)
got = scan(os.path.join(extract_to, DATA))
if got != expected:
for key in sorted(set(expected) | set(got)):
if expected.get(key) != got.get(key):
print("MISMATCH %s: expected %r, got %r" % (key, expected.get(key), got.get(key)))
raise SystemExit("archive %s does not extract to the original test data!" % archive)
print("OK: %s extracted identically (%d entries)" % (archive, len(got)))
# the same archive, but read (and its metadata decoded) on this machine:
dump_archive(workdir, label, archive)
written_by_other = chunk_list(dump_path(workdir, archive, other))
read_by_us = chunk_list(dump_path(workdir, archive, label))
if written_by_other != read_by_us:
raise SystemExit("archive %s does not decode identically on both architectures!" % archive)
print("OK: %s metadata (incl. chunk ids) decodes identically on both architectures" % archive)
def cmd_compare(args):
"""both sides archived the same data: they must have produced the same chunks"""
workdir, a, b = args.workdir, args.label, args.other_label
for suffix, _ in ARCHIVE_SPECS:
archive_a, archive_b = "%s-%s" % (a, suffix), "%s-%s" % (b, suffix)
items_a = chunk_list(dump_path(workdir, archive_a, a))
items_b = chunk_list(dump_path(workdir, archive_b, b))
if items_a != items_b:
for item_a, item_b in zip(items_a, items_b):
if item_a != item_b:
print("MISMATCH %s:\n %s: %r\n %s: %r" % (item_a[0], a, item_a[1:], b, item_b[1:]))
raise SystemExit(
"%s and %s chunked the same data differently - "
"deduplication between these architectures would not work!" % (archive_a, archive_b)
)
chunks = sum(len(item[3] or []) for item in items_a)
print("OK: %s and %s have identical items and chunk ids (%d chunks)" % (archive_a, archive_b, chunks))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
commands = parser.add_subparsers(dest="command", required=True)
sub = commands.add_parser("testdata", help="create the deterministic test data")
sub.add_argument("workdir")
sub.set_defaults(func=cmd_testdata)
sub = commands.add_parser("write", help="create the repository and this side's archives")
sub.add_argument("workdir")
sub.add_argument("label", help="label of this side, e.g. 'be'")
sub.set_defaults(func=cmd_write)
sub = commands.add_parser("verify", help="check/extract the archives the other side wrote")
sub.add_argument("workdir")
sub.add_argument("label", help="label of the *other* side, e.g. 'be'")
sub.add_argument("--as", dest="as_label", required=True, help="label of this side, e.g. 'le'")
sub.set_defaults(func=cmd_verify)
sub = commands.add_parser("compare", help="compare the chunk ids both sides produced")
sub.add_argument("workdir")
sub.add_argument("label")
sub.add_argument("other_label")
sub.set_defaults(func=cmd_compare)
args = parser.parse_args()
os.makedirs(args.workdir, exist_ok=True)
args.func(args)
if __name__ == "__main__":
sys.exit(main())