From 9b7d95a707381b31765d1d5993cd251aee6544e0 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Thu, 22 Oct 2026 08:29:42 +0000 Subject: [PATCH] gh-161045: Fix tarfile writing stale pax fields of changed members Fields of a pax extended header that were read from an archive no longer have priority over TarInfo attributes that were changed afterwards. Fields set in TarInfo.pax_headers by the program and fields of unchanged members are written as before. --- Doc/library/tarfile.rst | 10 ++ Lib/tarfile.py | 33 ++++ Lib/test/test_tarfile.py | 142 ++++++++++++++++++ ...-10-22-18-40-00.gh-issue-161045.pXh3Rn.rst | 5 + 4 files changed, 190 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-22-18-40-00.gh-issue-161045.pXh3Rn.rst diff --git a/Doc/library/tarfile.rst b/Doc/library/tarfile.rst index 1b2c328f9cad9f6..141bd8264b3dc67 100644 --- a/Doc/library/tarfile.rst +++ b/Doc/library/tarfile.rst @@ -958,6 +958,16 @@ A ``TarInfo`` object has the following public data attributes: A dictionary containing key-value pairs of an associated pax extended header. + When the member is written in :const:`PAX_FORMAT`, the fields ``path``, + ``linkpath``, ``size``, ``mtime``, ``uid``, ``gid``, ``uname`` and ``gname`` + have priority over the corresponding attributes of the :class:`!TarInfo` + object, unless the field was read from an archive and the attribute has + been changed since, for example by :meth:`~TarInfo.replace`. + + .. versionchanged:: next + Fields read from an archive no longer have priority over attributes + that were changed afterwards. + .. method:: TarInfo.replace(name=..., mtime=..., mode=..., linkname=..., \ uid=..., gid=..., uname=..., gname=..., \ deep=True) diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 41394171832cbc3..6314472a26d789e 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -130,6 +130,21 @@ # Fields from a pax header that are affected by hdrcharset. PAX_NAME_FIELDS = {"path", "linkpath", "uname", "gname"} +# Fields from a pax header and the TarInfo attribute they override. +_PAX_ATTRIBUTES = { + "path": "name", + "linkpath": "linkname", + "size": "size", + "mtime": "mtime", + "uid": "uid", + "gid": "gid", + "uname": "uname", + "gname": "gname", + "GNU.sparse.name": "name", + "GNU.sparse.size": "size", + "GNU.sparse.realsize": "size", +} + # Fields in a pax header that are numbers, all other fields # are treated as strings. PAX_NUMBER_FIELDS = { @@ -978,6 +993,7 @@ class TarInfo(object): sparse = 'Sparse member information.', _sparse_structs = None, _link_target = None, + _pax_applied = None, ) def __init__(self, name=""): @@ -1003,6 +1019,7 @@ def __init__(self, name=""): self.sparse = None # sparse member information self.pax_headers = {} # pax header information + self._pax_applied = {} # pax header fields applied when read @property def path(self): @@ -1135,6 +1152,16 @@ def create_pax_header(self, info, encoding): info["magic"] = POSIX_MAGIC pax_headers = self.pax_headers.copy() + # A field that was read from an archive loses its priority if the + # attribute it was applied to has been changed since, e.g. by + # replace(). Otherwise the change would be undone by the field. + # (TarInfo objects pickled by older versions lack _pax_applied.) + pax_applied = getattr(self, "_pax_applied", {}) + for keyword, (attr, record, value) in pax_applied.items(): + if (pax_headers.get(keyword) == record + and getattr(self, attr) != value): + del pax_headers[keyword] + # Test string fields for values that exceed the field length or cannot # be represented in ASCII encoding. for name, hname, length in ( @@ -1723,6 +1750,12 @@ def _apply_pax_info(self, pax_headers, encoding, errors): setattr(self, keyword, value) self.pax_headers = pax_headers.copy() + # Remember the applied fields and the resulting attribute values, so + # that create_pax_header() is able to detect later changes. + self._pax_applied = { + keyword: (attr, pax_headers[keyword], getattr(self, attr)) + for keyword, attr in _PAX_ATTRIBUTES.items() + if keyword in pax_headers} def _decode_pax_field(self, value, encoding, fallback_encoding, fallback_errors): """Decode a single field from a pax record. diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index dba51d7131aa356..e9f4d9ef9a90b4b 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -2730,6 +2730,148 @@ def test_pax_extended_header(self): finally: tar.close() + def _roundtrip_pax_member(self, tarinfo): + # Write the member in PAX format and return it as it is read back. + with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar: + tar.addfile(tarinfo) + with tarfile.open(tmpname) as tar: + return tar.getmembers()[0] + + def _read_pax_member(self, pax_headers, type=tarfile.REGTYPE): + # Return a member that was read from an archive with the given + # pax extended header. + t = tarfile.TarInfo("ustar") + t.type = type + with open(tmpname, "wb") as fobj: + fobj.write(t._create_pax_generic_header( + pax_headers, tarfile.XHDTYPE, "utf-8")) + fobj.write(t.tobuf(tarfile.USTAR_FORMAT)) + fobj.write(bytes(tarfile.RECORDSIZE)) + with tarfile.open(tmpname) as tar: + return tar.getmembers()[0] + + def test_pax_extended_header_changed_attributes(self): + # The fields from a pax header that was read from an archive + # must not undo later changes to the TarInfo. + pax_headers = {"path": "../foo", "linkpath": "../bar", + "mtime": "1000.5", "uid": "123", "gid": "124", + "uname": "spam", "gname": "eggs", "comment": "ham"} + changes = {"name": "foo", "linkname": "bar", "mtime": 2000, + "uid": 1, "gid": 2, "uname": "tarfile", "gname": "tarfile"} + + def check(t): + t = self._roundtrip_pax_member(t) + for attr, value in changes.items(): + self.assertEqual(getattr(t, attr), value) + self.assertEqual(t.pax_headers, {"comment": "ham"}) + + with self.subTest("replace"): + t = self._read_pax_member(pax_headers, tarfile.SYMTYPE) + self.assertEqual(t.pax_headers, pax_headers) + check(t.replace(**changes)) + with self.subTest("replace shallow"): + t = self._read_pax_member(pax_headers, tarfile.SYMTYPE) + check(t.replace(**changes, deep=False)) + self.assertEqual(t.pax_headers, pax_headers) + with self.subTest("setattr"): + t = self._read_pax_member(pax_headers, tarfile.SYMTYPE) + for attr, value in changes.items(): + setattr(t, attr, value) + check(t) + + for attr, value in changes.items(): + with self.subTest(attr=attr): + t = self._read_pax_member(pax_headers, tarfile.SYMTYPE) + t2 = self._roundtrip_pax_member(t.replace(**{attr: value})) + self.assertEqual(getattr(t2, attr), value) + for other in changes.keys() - {attr}: + self.assertEqual(getattr(t2, other), getattr(t, other)) + + def test_pax_extended_header_renamed_copy(self): + # Members with a name that needs a pax header can be renamed + # when they are copied to another archive. + with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar: + tar.addfile(tarfile.TarInfo("\xe4\xf6\xfc")) + tar.addfile(tarfile.TarInfo("foo")) + with tarfile.open(tmpname) as tar: + members = tar.getmembers() + self.assertEqual(members[0].pax_headers, {"path": "\xe4\xf6\xfc"}) + with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar: + for t in members: + t.name = "bar/" + t.name + tar.addfile(t) + with tarfile.open(tmpname) as tar: + self.assertEqual(tar.getnames(), ["bar/\xe4\xf6\xfc", "bar/foo"]) + + def test_pax_extended_header_without_applied_fields(self): + # A TarInfo object that was pickled by an older version + # has no record of the applied fields. + t = self._read_pax_member({"path": "foo"}) + del t._pax_applied + t.name = "bar" + self.assertEqual(self._roundtrip_pax_member(t).name, "foo") + + def test_pax_extended_header_changed_size(self): + t = self._read_pax_member({"size": "0"}) + t.size = 3 + with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar: + tar.addfile(t, io.BytesIO(b"foo")) + tar.addfile(tarfile.TarInfo("bar")) + with tarfile.open(tmpname) as tar: + self.assertEqual(tar.getnames(), ["ustar", "bar"]) + self.assertEqual(tar.extractfile("ustar").read(), b"foo") + + def test_pax_extended_header_changed_sparse_name(self): + t = self._read_pax_member({"path": "foo", "GNU.sparse.name": "bar"}) + self.assertEqual(t.name, "bar") + self.assertEqual(self._roundtrip_pax_member(t).name, "bar") + t = self._roundtrip_pax_member(t.replace(name="baz")) + self.assertEqual(t.name, "baz") + self.assertEqual(t.pax_headers, {}) + + t = self._read_pax_member({"GNU.sparse.name": "bar"}) + t.name = "baz" + self.assertEqual(self._roundtrip_pax_member(t).name, "baz") + + def test_pax_extended_header_unchanged_attributes(self): + # The fields from a pax header are preserved if the + # corresponding attributes are not changed. + pax_headers = {"path": "foo", "mtime": "1000.123456789", + "uid": "123", "comment": "ham"} + t = self._read_pax_member(pax_headers) + t = self._roundtrip_pax_member(t.replace(uname="tarfile")) + self.assertEqual(t.pax_headers, pax_headers) + self.assertEqual(t.name, "foo") + self.assertEqual(t.uid, 123) + self.assertEqual(t.uname, "tarfile") + + def test_pax_extended_header_changed_pax_headers(self): + # The fields that are set after the pax header was read + # still have priority over the TarInfo. + t = self._read_pax_member({"path": "foo", "uid": "123"}) + t.name = "bar" + t.uid = 124 + t.pax_headers["path"] = "baz" + t = self._roundtrip_pax_member(t) + self.assertEqual(t.pax_headers, {"path": "baz"}) + self.assertEqual(t.name, "baz") + self.assertEqual(t.uid, 124) + + def test_pax_extended_header_gnu_longname(self): + # A GNU longname header that precedes the pax header overrides + # its path field. + t = tarfile.TarInfo("ustar") + t.pax_headers = {"path": "foo"} + with open(tmpname, "wb") as fobj: + fobj.write(t._create_gnu_long_header( + "bar", tarfile.GNUTYPE_LONGNAME, "utf-8", "strict")) + fobj.write(t.tobuf(tarfile.PAX_FORMAT)) + fobj.write(bytes(tarfile.RECORDSIZE)) + with tarfile.open(tmpname) as tar: + t = tar.getmembers()[0] + self.assertEqual(t.name, "bar") + self.assertEqual(self._roundtrip_pax_member(t).name, "bar") + def test_create_pax_header(self): # The ustar header should contain values that can be # represented reasonably, even if a better (e.g. higher diff --git a/Misc/NEWS.d/next/Library/2026-10-22-18-40-00.gh-issue-161045.pXh3Rn.rst b/Misc/NEWS.d/next/Library/2026-10-22-18-40-00.gh-issue-161045.pXh3Rn.rst new file mode 100644 index 000000000000000..890b8bcd35b6adf --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-18-40-00.gh-issue-161045.pXh3Rn.rst @@ -0,0 +1,5 @@ +Fix :mod:`tarfile` writing the old name, link name, owner, size or +modification time of a member that was read from an archive with a pax +extended header and then changed, for example with +:meth:`tarfile.TarInfo.replace`, before it was added to a +:const:`~tarfile.PAX_FORMAT` archive.