From e4430a1f73436f88a72a29c7a4852132b3c22194 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:42:29 +0100 Subject: [PATCH 1/9] pax_time_to_ns: clamp before converting, catch decimal.Overflow A PAX timestamp header with a huge exponent made import-tar crash or hang: - "1e3000000": Decimal.scaleb() raises decimal.Overflow, which is an ArithmeticError, but not an OverflowError, so it was not caught. - "1e999999": int() of the scaled Decimal builds an integer with a million digits before safe_ns() clamps it, taking ~15s per header. Now the Decimal is clamped to [0, MAX_S] before the int conversion (same result as safe_ns), NaN/Infinity are rejected and all decimal exceptions are caught. Co-Authored-By: Claude Fable 5.1 --- src/borg/helpers/time.py | 18 ++++++++++++++---- src/borg/testsuite/helpers/time_test.py | 9 +++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/borg/helpers/time.py b/src/borg/helpers/time.py index ef4f23ad01..30537e1d0e 100644 --- a/src/borg/helpers/time.py +++ b/src/borg/helpers/time.py @@ -1,7 +1,7 @@ import os import re from datetime import UTC, datetime, timedelta, timezone -from decimal import Decimal, InvalidOperation +from decimal import Decimal from zoneinfo import ZoneInfo @@ -107,11 +107,21 @@ def ns_to_pax_time(ns): def pax_time_to_ns(value): - """Parse a tar PAX header timestamp (decimal seconds string) into nanoseconds, return None if invalid.""" + """Parse a tar PAX header timestamp (decimal seconds string) into nanoseconds, return None if invalid. + + Out of range values are clamped like safe_ns does. + """ try: - return safe_ns(int(Decimal(value).scaleb(9))) - except (InvalidOperation, ValueError, OverflowError): + seconds = Decimal(value) + except (ArithmeticError, ValueError): # decimal.InvalidOperation is an ArithmeticError + return None + if not seconds.is_finite(): # NaN, sNaN, Infinity return None + # clamp before converting to int: a Decimal with a huge exponent (e.g. "1e999999") is cheap to + # compare, but int() would build an integer with that many digits, which takes very long. + # also, scaleb() overflows for exponents beyond the decimal context's limit. + seconds = min(max(seconds, Decimal(0)), Decimal(MAX_S)) + return int(seconds.scaleb(9)) def safe_timestamp(item_timestamp_ns): diff --git a/src/borg/testsuite/helpers/time_test.py b/src/borg/testsuite/helpers/time_test.py index 44ba2da92e..b5f1245067 100644 --- a/src/borg/testsuite/helpers/time_test.py +++ b/src/borg/testsuite/helpers/time_test.py @@ -3,7 +3,7 @@ from ...helpers.time import safe_ns, safe_s, safe_timestamp, SUPPORT_32BIT_PLATFORMS, calculate_relative_offset from ...helpers.time import format_time, format_time_ns, OutputTimestamp -from ...helpers.time import ns_to_pax_time, pax_time_to_ns, parse_local_timestamp +from ...helpers.time import ns_to_pax_time, pax_time_to_ns, parse_local_timestamp, MAX_NS from ...helpers import time as time_module @@ -99,10 +99,15 @@ def test_ns_to_pax_time(ns, expected): ("1700000000.987654321", 1700000000_987654321), ("1700000000", 1700000000_000000000), ("1700000000.5", 1700000000_500000000), - ("-1.5", 0), # safe_ns clamps + ("-1.5", 0), # clamped like safe_ns does + ("1e30", MAX_NS), + ("1e999999", MAX_NS), # must not build a 1-million-digit integer + ("-1e999999", 0), + ("1e3000000", MAX_NS), # beyond the decimal context's exponent limit ("", None), ("garbage", None), ("NaN", None), + ("sNaN", None), ("Infinity", None), ], ) From e6169a81e2ae62ef0b0f2ade28272967dad33200 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:44:11 +0100 Subject: [PATCH 2/9] export-tar: skip xattrs with a non-UTF-8 name, warn about it A pax header keyword must be valid UTF-8: the tarfile module encodes it strictly, only the value may be arbitrary bytes (hdrcharset=BINARY). Linux xattr names are arbitrary bytes, so export-tar --tar-format=PAX crashed with a UnicodeEncodeError on such an xattr. Now the xattr is skipped with a warning (rc 1). With the BORG format, it is skipped silently, because BORG.item.meta has it anyway. Co-Authored-By: Claude Fable 5.1 --- src/borg/archiver/tar_cmds.py | 18 +++++++++--- src/borg/testsuite/archiver/tar_cmds_test.py | 29 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index c9e12969ca..cc62851ae3 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -112,8 +112,11 @@ def item_to_tarinfo(item, hlm, *, warning=None): return tarinfo, needs_content -def item_to_paxheaders(format, item): - """Transform (parts of) a Borg *item* into a pax_headers dict.""" +def item_to_paxheaders(format, item, *, warning=None): + """Transform (parts of) a Borg *item* into a pax_headers dict. + + *warning* is an optional callback(format, *args) for reporting metadata that can not be represented. + """ # PAX format # ---------- # When using the PAX (POSIX) format, we can support some things that aren't possible @@ -139,7 +142,14 @@ def item_to_paxheaders(format, item): for bkey, bvalue in item.xattrs.items(): # we have bytes key and bytes value, but the tarfile code # expects str key and str value. - key = SCHILY_XATTR + bkey.decode("utf-8", errors="surrogateescape") + try: + # a pax header keyword must be valid UTF-8 (the tarfile module encodes it strictly), + # only the value may be arbitrary bytes (hdrcharset=BINARY). + key = SCHILY_XATTR + bkey.decode("utf-8") + except UnicodeDecodeError: + if format != "BORG" and warning is not None: # the BORG format still has it in BORG.item.meta + warning("%s: xattr name %r is not valid UTF-8, not exported", remove_surrogates(item.path), bkey) + continue value = bvalue.decode("utf-8", errors="surrogateescape") ph[key] = value # Add POSIX access and default ACL if present @@ -499,7 +509,7 @@ def sparsify_tarinfo(item, tarinfo): tarinfo, needs_content = item_to_tarinfo(item, hlm, warning=self.print_warning) if tarinfo: if args.tar_format in ("BORG", "PAX"): - tarinfo.pax_headers = item_to_paxheaders(args.tar_format, item) + tarinfo.pax_headers = item_to_paxheaders(args.tar_format, item, warning=self.print_warning) if output_list: logging.getLogger("borg.output.list").info(remove_surrogates(orig_path)) sparse_content = sparsify_tarinfo(item, tarinfo) if args.sparse and needs_content else None diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index 97573567ea..65b54d054e 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -599,6 +599,35 @@ def test_roundtrip_pax_xattrs(archivers, request): assert xa_value_extracted == xa_value +def test_export_tar_pax_xattr_name_not_utf8(archivers, request): + """export-tar --tar-format=PAX skips xattrs with a non-UTF-8 name (PAX keywords must be UTF-8) with a warning.""" + archiver = request.getfixturevalue(archivers) + if not xattr.is_enabled(archiver.input_path): + pytest.skip("xattrs not supported") + create_regular_file(archiver.input_path, "file") + original_path = os.path.join(archiver.input_path, "file").encode() + xa_key, xa_value = b"user.not-utf8-\xff", b"value" + try: + xattr.setxattr(original_path, xa_key, xa_value) + except OSError as e: + pytest.skip(f"can not set an xattr with a non-UTF-8 name here: {e}") + xattr.setxattr(original_path, b"user.utf8", b"other") + cmd(archiver, "repo-create", "--encryption=authenticated-sha256") + cmd(archiver, "create", "src", "input") + output = cmd(archiver, "export-tar", "src", "pax.tar", "--tar-format=PAX", exit_code=1) + assert "is not valid UTF-8, not exported" in output + with tarfile.open("pax.tar") as tar: + ph = tar.getmember("input/file").pax_headers + assert ph[SCHILY_XATTR + "user.utf8"] == "other" + assert not any(key.startswith(SCHILY_XATTR + "user.not-utf8") for key in ph) + # the BORG format has all item metadata in BORG.item.meta, so nothing is lost there (and no warning given). + cmd(archiver, "export-tar", "src", "borg.tar", "--tar-format=BORG") + cmd(archiver, "import-tar", "dst", "borg.tar") + with changedir(archiver.output_path): + cmd(archiver, "extract", "dst") + assert xattr.getxattr(os.path.abspath("input/file").encode(), xa_key) == xa_value + + def test_roundtrip_pax_timestamps(archivers, request): """export-tar --tar-format=PAX and import-tar keep the timestamps with exact ns precision.""" archiver = request.getfixturevalue(archivers) From cf8af9fbbd6649fd38cbeaab7f02eb4cf64a6937 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:45:15 +0100 Subject: [PATCH 3/9] import-tar: reject malformed POSIX ACL entries from PAX headers tar_acl_to_borg() passed entries it did not understand through unchanged. An entry with too few fields (e.g. "user:foo") was stored and made borg extract crash on Linux: acl_use_local_uid_gid() indexes fields[3], raising an IndexError, which is not an OSError and thus not turned into a per-file warning. Now tar_acl_to_borg() raises ValueError for an entry with an unknown tag or a wrong number of fields and import-tar ignores such an ACL header with a warning. TarfileObjectProcessors got a warning callback for that. Co-Authored-By: Claude Fable 5.1 --- src/borg/archive.py | 34 ++++++++++++++++---- src/borg/archiver/tar_cmds.py | 1 + src/borg/testsuite/archive_test.py | 17 ++++++++++ src/borg/testsuite/archiver/tar_cmds_test.py | 20 +++++++++++- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 733b96122c..f6ccf2503c 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -2032,6 +2032,9 @@ def tar_acl_to_borg(acl): star appends it too, but separates entries by commas. GNU tar separates entries by newlines and does not append the numeric id, so we look it up locally (like borg create does), falling back to the name. + + Raise ValueError for an entry with an unknown tag or a wrong number of fields, as the + platform code converting borg's ACL format at extract time relies on the field layout. """ entries = [] for entry in acl.replace(",", "\n").split("\n"): @@ -2039,9 +2042,15 @@ def tar_acl_to_borg(acl): if not entry: continue fields = entry.split(":") - if len(fields) == 3 and fields[1] and fields[0] in ("user", "group"): + tag = fields[0] + if tag not in ("user", "group", "mask", "other"): + raise ValueError(f"invalid POSIX ACL entry: {entry!r}") + named = tag in ("user", "group") and len(fields) > 1 and fields[1] != "" + if named and len(fields) == 3: # tag:name:perms -> append the numeric id name = fields[1] - fields.append(str(user2uid(name, name) if fields[0] == "user" else group2gid(name, name))) + fields.append(str(user2uid(name, name) if tag == "user" else group2gid(name, name))) + elif len(fields) != (4 if named else 3): # tag:name:perms:id or tag::perms + raise ValueError(f"invalid POSIX ACL entry: {entry!r}") entries.append(":".join(fields)) return "\n".join(entries).encode("utf-8", errors="surrogateescape") @@ -2058,6 +2067,7 @@ def __init__( show_progress, log_json, file_status_printer=None, + warning=None, digest_algos=DIGEST_ALGOS_DEFAULT, strip_components=0, ): @@ -2068,6 +2078,13 @@ def __init__( self.show_progress = show_progress self.strip_components = strip_components self.print_file_status = file_status_printer or (lambda *args: None) + if warning is None: + + def warning(msg, *args): + logger.warning(msg, *args) + set_ec(EXIT_WARNING) + + self.print_warning = warning self.stats = Statistics(output_json=log_json) # threading: done by cache (including progress) self.chunker = get_chunker(*chunker_params, key=key, sparse=False) @@ -2121,10 +2138,15 @@ def s_to_ns(s): bkey = key.encode("utf-8", errors="surrogateescape") bvalue = value.encode("utf-8", errors="surrogateescape") xattrs[bkey] = bvalue - elif key == SCHILY_ACL_ACCESS: - item.acl_access = tar_acl_to_borg(value) - elif key == SCHILY_ACL_DEFAULT: - item.acl_default = tar_acl_to_borg(value) + elif key in (SCHILY_ACL_ACCESS, SCHILY_ACL_DEFAULT): + try: + acl = tar_acl_to_borg(value) + except ValueError as e: + self.print_warning( + "%s: ignoring the %s PAX header: %s", remove_surrogates(item.path), key, e + ) + else: + setattr(item, "acl_access" if key == SCHILY_ACL_ACCESS else "acl_default", acl) if xattrs: item.xattrs = xattrs if self.strip_components: diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index cc62851ae3..e8633e5c07 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -576,6 +576,7 @@ def _import_tar(self, args, repository, manifest, key, cache, tarstream): show_progress=args.progress, log_json=args.log_json, file_status_printer=self.print_file_status, + warning=self.print_warning, strip_components=strip_components, ) diff --git a/src/borg/testsuite/archive_test.py b/src/borg/testsuite/archive_test.py index a521598fb3..341ffb9299 100644 --- a/src/borg/testsuite/archive_test.py +++ b/src/borg/testsuite/archive_test.py @@ -876,6 +876,23 @@ def test_tar_acl_to_borg(acl, expected): assert tar_acl_to_borg(acl) == expected.encode() +@pytest.mark.parametrize( + "acl", + [ + "user:foo", # too few fields: the Linux acl_set code would crash on this at extract time + "user:foo:rw-:0:x", # too many fields + "user::rw-:0", # an id for an unnamed entry + "mask:x:rw-:0", + "bogus::rw-", # unknown tag + "rw-", + "user::rw-\ngroup::r--\nother::r--\nuser:foo", # one invalid entry spoils it all + ], +) +def test_tar_acl_to_borg_invalid(acl): + with pytest.raises(ValueError, match="invalid POSIX ACL entry"): + tar_acl_to_borg(acl) + + def test_reject_non_sanitized_item(): for path in rejected_dotdot_paths: with pytest.raises(ValueError, match="unexpected '..' element in path"): diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index 65b54d054e..e25e5dc26d 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -614,7 +614,7 @@ def test_export_tar_pax_xattr_name_not_utf8(archivers, request): xattr.setxattr(original_path, b"user.utf8", b"other") cmd(archiver, "repo-create", "--encryption=authenticated-sha256") cmd(archiver, "create", "src", "input") - output = cmd(archiver, "export-tar", "src", "pax.tar", "--tar-format=PAX", exit_code=1) + output = cmd(archiver, "export-tar", "src", "pax.tar", "--tar-format=PAX", exit_code=EXIT_WARNING) assert "is not valid UTF-8, not exported" in output with tarfile.open("pax.tar") as tar: ph = tar.getmember("input/file").pax_headers @@ -668,6 +668,24 @@ def test_import_tar_invalid_pax_timestamp(archivers, request): assert item.ctime == 1700000000_500000000 +def test_import_tar_invalid_pax_acl(archivers, request): + """import-tar ignores a malformed SCHILY.acl.* PAX header with a warning (stored, it would crash extract).""" + archiver = request.getfixturevalue(archivers) + with tarfile.open("input.tar", "w", format=tarfile.PAX_FORMAT) as tar: + tarinfo = tarfile.TarInfo("file") + tarinfo.pax_headers = {SCHILY_ACL_ACCESS: "user::rw-\ngroup::r--\nother::r--\nuser:foo"} + tar.addfile(tarinfo, io.BytesIO()) + cmd(archiver, "repo-create", "--encryption=authenticated-sha256") + output = cmd(archiver, "import-tar", "dst", "input.tar", exit_code=EXIT_WARNING) + assert "file: ignoring the SCHILY.acl.access PAX header: invalid POSIX ACL entry: 'user:foo'" in output + archive_obj, repository = open_archive(archiver.repository_path, "dst") + with repository: + item = next(archive_obj.iter_items()) + assert "acl_access" not in item + with changedir(archiver.output_path): + cmd(archiver, "extract", "dst") + + def _sparse_entries(sizes): return [ChunkListEntry(id=bytes([i]) * 32, size=size) for i, size in enumerate(sizes)] From eb55c70d79e058c833072006e3cdd62a1c1dad07 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:46:32 +0100 Subject: [PATCH 4/9] import-tar: do not crash on a pax mtime of inf or nan The tarfile module parses the pax mtime header with float() and does not check the result, so a header like "1e999" or "nan" gave a float inf or nan, and int() of that raised OverflowError or ValueError in create_helper(). Now such a value falls back to 0 (a valid pax mtime still overrides it via pax_time_to_ns, which clamps "1e999" to MAX_NS). Co-Authored-By: Claude Fable 5.1 --- src/borg/archive.py | 6 +++++- src/borg/testsuite/archiver/tar_cmds_test.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index f6ccf2503c..96cf3b7011 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -2103,7 +2103,11 @@ def create_helper(self, tarinfo, status=None, type=None): else: def s_to_ns(s): - return safe_ns(int(float(s) * 1e9)) + try: + return safe_ns(int(float(s) * 1e9)) + except (OverflowError, ValueError): + # inf or nan: the tarfile module parses a pax mtime like "1e999" or "nan" without complaint. + return 0 # if the tar has names starting with "./", normalize them like borg create also does. # ./dir/file must become dir/file in the borg archive. diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index e25e5dc26d..15a162d0a4 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -15,6 +15,7 @@ from ...archiver.tar_cmds import chunks_to_sparse_info, gnu_sparse_10_map, SparseTarInfo from ...constants import * # NOQA from ...helpers import Error +from ...helpers.time import MAX_NS from ...item import ChunkListEntry from .. import changedir from . import assert_dirs_equal, _extract_hardlinks_setup, cmd, open_archive, requires_hardlinks, RK_ENCRYPTION @@ -668,6 +669,22 @@ def test_import_tar_invalid_pax_timestamp(archivers, request): assert item.ctime == 1700000000_500000000 +def test_import_tar_inf_nan_pax_mtime(archivers, request): + """import-tar survives pax mtime headers that the tarfile module parses into float inf / nan.""" + archiver = request.getfixturevalue(archivers) + with tarfile.open("input.tar", "w", format=tarfile.PAX_FORMAT) as tar: + for name, mtime in (("inf", "1e999"), ("nan", "nan")): + tarinfo = tarfile.TarInfo(name) + tarinfo.pax_headers = {"mtime": mtime} + tar.addfile(tarinfo, io.BytesIO()) + cmd(archiver, "repo-create", "--encryption=authenticated-sha256") + cmd(archiver, "import-tar", "dst", "input.tar") + archive_obj, repository = open_archive(archiver.repository_path, "dst") + with repository: + mtimes = {item.path: item.mtime for item in archive_obj.iter_items()} + assert mtimes == {"inf": MAX_NS, "nan": 0} # "1e999" gets clamped, "nan" is unparsable + + def test_import_tar_invalid_pax_acl(archivers, request): """import-tar ignores a malformed SCHILY.acl.* PAX header with a warning (stored, it would crash extract).""" archiver = request.getfixturevalue(archivers) From 9c264c73878f6e26092137467f8b3e64b8b06917 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:48:58 +0100 Subject: [PATCH 5/9] import-tar: warn about unknown user/group names in POSIX ACLs When a named ACL entry from a tar PAX header has no numeric id (GNU tar writes them that way) and the name is unknown locally, the name is stored in place of the id, like borg create does. Unlike with borg create, where the name always comes from the same host, this makes every later borg extract of that file fail (also with --numeric-ids, as the "stored id" is the name). Now import-tar warns once per unknown name, so the user learns about it when it can still be fixed (create the user/group, or use --noacls). Co-Authored-By: Claude Fable 5.1 --- src/borg/archive.py | 25 ++++++++++++++++---- src/borg/testsuite/archive_test.py | 9 +++++++ src/borg/testsuite/archiver/tar_cmds_test.py | 21 ++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 96cf3b7011..18130dbaf4 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -2024,14 +2024,15 @@ def process_file(self, *, path, parent_fd, name, st, cache, flags=flags_normal, return status -def tar_acl_to_borg(acl): +def tar_acl_to_borg(acl, unknown_names=None): """Convert a POSIX ACL text from a tar PAX header (SCHILY.acl.*) to borg's ACL format. Borg separates entries by newlines and appends the numeric uid/gid as a 4th field to named user/group entries (user:name:perms:uid), see acl_get on Linux and FreeBSD. star appends it too, but separates entries by commas. GNU tar separates entries by newlines and does not append the numeric id, so we look it up locally (like borg create - does), falling back to the name. + does), falling back to the name. *unknown_names* is an optional set, the (tag, name) of + the entries whose name could not be looked up get added to it. Raise ValueError for an entry with an unknown tag or a wrong number of fields, as the platform code converting borg's ACL format at extract time relies on the field layout. @@ -2048,7 +2049,12 @@ def tar_acl_to_borg(acl): named = tag in ("user", "group") and len(fields) > 1 and fields[1] != "" if named and len(fields) == 3: # tag:name:perms -> append the numeric id name = fields[1] - fields.append(str(user2uid(name, name) if tag == "user" else group2gid(name, name))) + numeric_id = user2uid(name) if tag == "user" else group2gid(name) + if numeric_id is None: + if unknown_names is not None: + unknown_names.add((tag, name)) + numeric_id = name + fields.append(str(numeric_id)) elif len(fields) != (4 if named else 3): # tag:name:perms:id or tag::perms raise ValueError(f"invalid POSIX ACL entry: {entry!r}") entries.append(":".join(fields)) @@ -2085,6 +2091,7 @@ def warning(msg, *args): set_ec(EXIT_WARNING) self.print_warning = warning + self.unknown_acl_names = set() # (tag, name) of POSIX ACL entries we already warned about self.stats = Statistics(output_json=log_json) # threading: done by cache (including progress) self.chunker = get_chunker(*chunker_params, key=key, sparse=False) @@ -2143,14 +2150,24 @@ def s_to_ns(s): bvalue = value.encode("utf-8", errors="surrogateescape") xattrs[bkey] = bvalue elif key in (SCHILY_ACL_ACCESS, SCHILY_ACL_DEFAULT): + unknown_names = set() try: - acl = tar_acl_to_borg(value) + acl = tar_acl_to_borg(value, unknown_names=unknown_names) except ValueError as e: self.print_warning( "%s: ignoring the %s PAX header: %s", remove_surrogates(item.path), key, e ) else: setattr(item, "acl_access" if key == SCHILY_ACL_ACCESS else "acl_default", acl) + for tag, name in sorted(unknown_names - self.unknown_acl_names): + self.print_warning( + "%s: POSIX ACL refers to unknown %s %r, extracting it will fail unless that %s exists", + remove_surrogates(item.path), + tag, + name, + tag, + ) + self.unknown_acl_names |= unknown_names if xattrs: item.xattrs = xattrs if self.strip_components: diff --git a/src/borg/testsuite/archive_test.py b/src/borg/testsuite/archive_test.py index 341ffb9299..3522c99e32 100644 --- a/src/borg/testsuite/archive_test.py +++ b/src/borg/testsuite/archive_test.py @@ -893,6 +893,15 @@ def test_tar_acl_to_borg_invalid(acl): tar_acl_to_borg(acl) +@pytest.mark.skipif(is_win32, reason="no name lookups on Windows") +def test_tar_acl_to_borg_unknown_names(): + unknown_names = set() + acl = "user::rw-\nuser:nosuchuser-borgtest:rw-\ngroup:nosuchgroup-borgtest:r--\ngroup:root:r--:0\nother::r--" + tar_acl_to_borg(acl, unknown_names=unknown_names) + # group:root has its id already, so no lookup is needed for it. + assert unknown_names == {("user", "nosuchuser-borgtest"), ("group", "nosuchgroup-borgtest")} + + def test_reject_non_sanitized_item(): for path in rejected_dotdot_paths: with pytest.raises(ValueError, match="unexpected '..' element in path"): diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index 15a162d0a4..fc960e070e 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -703,6 +703,27 @@ def test_import_tar_invalid_pax_acl(archivers, request): cmd(archiver, "extract", "dst") +def test_import_tar_acl_unknown_name(archivers, request): + """import-tar warns once per user/group name in POSIX ACL PAX headers that is unknown locally.""" + archiver = request.getfixturevalue(archivers) + acl = "user::rw-\nuser:nosuchuser-borgtest:rw-\ngroup::r--\nmask::rw-\nother::r--\n" + with tarfile.open("input.tar", "w", format=tarfile.PAX_FORMAT) as tar: + for name in ("file1", "file2"): + tarinfo = tarfile.TarInfo(name) + tarinfo.pax_headers = {SCHILY_ACL_ACCESS: acl} + tar.addfile(tarinfo, io.BytesIO()) + cmd(archiver, "repo-create", "--encryption=authenticated-sha256") + output = cmd(archiver, "import-tar", "dst", "input.tar", exit_code=EXIT_WARNING) + assert "file1: POSIX ACL refers to unknown user 'nosuchuser-borgtest'" in output + assert output.count("unknown user 'nosuchuser-borgtest'") == 1 + # the ACL is stored nevertheless, with the name in place of the numeric id (like borg create does) + archive_obj, repository = open_archive(archiver.repository_path, "dst") + with repository: + acls = {item.path: item.acl_access for item in archive_obj.iter_items()} + expected = b"user::rw-\nuser:nosuchuser-borgtest:rw-:nosuchuser-borgtest\ngroup::r--\nmask::rw-\nother::r--" + assert acls == {"file1": expected, "file2": expected} + + def _sparse_entries(sizes): return [ChunkListEntry(id=bytes([i]) * 32, size=size) for i, size in enumerate(sizes)] From 71788c2d4cc15d8a1e347fa84124ef76475b819b Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:49:14 +0100 Subject: [PATCH 6/9] export-tar docs: say what the PAX and GNU formats do not transfer The --tar-format table only listed what PAX adds over GNU; the items' BSD flags, NFSv4 ACLs (FreeBSD), extended ACLs (macOS), birthtime and content digests are not exported with either format. Co-Authored-By: Claude Fable 5.1 --- src/borg/archiver/tar_cmds.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index e8633e5c07..b0a6104ebf 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -674,6 +674,9 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): | | | no ACLs/xattrs/bsdflags | +--------------+---------------------------+----------------------------+ + The PAX and GNU formats are lossy: BSD flags, NFSv4 ACLs (FreeBSD), extended ACLs + (macOS), birthtime and content digests are not exported. Only the BORG format has them. + With ``--sparse``, files whose content contains runs of all-zero chunks are written as sparse tar members (GNU sparse format 1.0, as GNU tar creates it in POSIX mode), storing only a hole map and the non-zero data. This requires ``--tar-format BORG`` From 813e38d451949c2b27d6833f49c47fb03273fc43 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 3 Nov 2026 22:50:23 +0100 Subject: [PATCH 7/9] tests: check the atime PAX header in test_roundtrip_pax_timestamps The test created the archive without --atime, so the atime header was never produced and the only header checked was mtime. Co-Authored-By: Claude Fable 5.1 --- src/borg/testsuite/archiver/tar_cmds_test.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index fc960e070e..a4a3cdccc4 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -636,7 +636,7 @@ def test_roundtrip_pax_timestamps(archivers, request): mtime_ns = 1700000000_987654321 # float seconds would round this to ~240 ns os.utime(os.path.join(archiver.input_path, "file"), ns=(mtime_ns, mtime_ns)) cmd(archiver, "repo-create", "--encryption=authenticated-sha256") - cmd(archiver, "create", "src", "input") + cmd(archiver, "create", "--atime", "src", "input") cmd(archiver, "export-tar", "src", "pax.tar", "--tar-format=PAX") cmd(archiver, "import-tar", "dst", "pax.tar") @@ -649,8 +649,9 @@ def get_times(archive): src_times, dst_times = get_times("src"), get_times("dst") assert dst_times == src_times with tarfile.open("pax.tar") as tar: - pax_mtime = tar.getmember("input/file").pax_headers["mtime"] - assert pax_mtime == f"{src_times['mtime'] // 10**9}.{src_times['mtime'] % 10**9:09d}" + ph = tar.getmember("input/file").pax_headers + for name in ("atime", "ctime", "mtime"): + assert ph[name] == f"{src_times[name] // 10**9}.{src_times[name] % 10**9:09d}" def test_import_tar_invalid_pax_timestamp(archivers, request): From f1502e2359ee1594dc2c8cce9377600e87640fa9 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 4 Nov 2026 10:42:11 +0100 Subject: [PATCH 8/9] TarfileObjectProcessors: require the warning callback The default fallback (logger + set_ec) was dead code: the only caller passes Archiver.print_warning. Co-Authored-By: Claude Fable 5.1 --- src/borg/archive.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 18130dbaf4..7bfcd2aa12 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -2073,7 +2073,7 @@ def __init__( show_progress, log_json, file_status_printer=None, - warning=None, + warning, digest_algos=DIGEST_ALGOS_DEFAULT, strip_components=0, ): @@ -2084,13 +2084,7 @@ def __init__( self.show_progress = show_progress self.strip_components = strip_components self.print_file_status = file_status_printer or (lambda *args: None) - if warning is None: - - def warning(msg, *args): - logger.warning(msg, *args) - set_ec(EXIT_WARNING) - - self.print_warning = warning + self.print_warning = warning # callback(format, *args), like Archiver.print_warning self.unknown_acl_names = set() # (tag, name) of POSIX ACL entries we already warned about self.stats = Statistics(output_json=log_json) # threading: done by cache (including progress) From b2d09dea5eea787994f2cf124bbfe0f6bff1c41a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 4 Nov 2026 10:45:06 +0100 Subject: [PATCH 9/9] tests: fix the new PAX tests on Windows - test_roundtrip_pax_timestamps: borg create stores no ctime on Windows (st_ctime is the creation time there, archived as birthtime), so do not expect a ctime PAX header then. - test_import_tar_acl_unknown_name: Windows has no user/group name lookups (every name maps to 0), so no name is unknown there. Co-Authored-By: Claude Fable 5.1 --- src/borg/testsuite/archiver/tar_cmds_test.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index a4a3cdccc4..28a38f9421 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -22,6 +22,7 @@ from . import create_test_files, create_regular_file from . import generate_archiver_tests from ...platform import acl_get, acl_set +from ...platformflags import is_win32 from ..platform.platform_test import skipif_not_linux, skipif_acls_not_working pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary") # NOQA @@ -650,8 +651,11 @@ def get_times(archive): assert dst_times == src_times with tarfile.open("pax.tar") as tar: ph = tar.getmember("input/file").pax_headers - for name in ("atime", "ctime", "mtime"): - assert ph[name] == f"{src_times[name] // 10**9}.{src_times[name] % 10**9:09d}" + for name, ns in src_times.items(): + if ns is None: # e.g. no ctime on Windows + assert name not in ph + else: + assert ph[name] == f"{ns // 10**9}.{ns % 10**9:09d}" def test_import_tar_invalid_pax_timestamp(archivers, request): @@ -704,6 +708,7 @@ def test_import_tar_invalid_pax_acl(archivers, request): cmd(archiver, "extract", "dst") +@pytest.mark.skipif(is_win32, reason="no name lookups on Windows") def test_import_tar_acl_unknown_name(archivers, request): """import-tar warns once per user/group name in POSIX ACL PAX headers that is unknown locally.""" archiver = request.getfixturevalue(archivers)