Skip to content

Commit 9b7d95a

Browse files
committed
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.
1 parent 3b3c692 commit 9b7d95a

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

‎Doc/library/tarfile.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,16 @@ A ``TarInfo`` object has the following public data attributes:
958958

959959
A dictionary containing key-value pairs of an associated pax extended header.
960960

961+
When the member is written in :const:`PAX_FORMAT`, the fields ``path``,
962+
``linkpath``, ``size``, ``mtime``, ``uid``, ``gid``, ``uname`` and ``gname``
963+
have priority over the corresponding attributes of the :class:`!TarInfo`
964+
object, unless the field was read from an archive and the attribute has
965+
been changed since, for example by :meth:`~TarInfo.replace`.
966+
967+
.. versionchanged:: next
968+
Fields read from an archive no longer have priority over attributes
969+
that were changed afterwards.
970+
961971
.. method:: TarInfo.replace(name=..., mtime=..., mode=..., linkname=..., \
962972
uid=..., gid=..., uname=..., gname=..., \
963973
deep=True)

‎Lib/tarfile.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@
130130
# Fields from a pax header that are affected by hdrcharset.
131131
PAX_NAME_FIELDS = {"path", "linkpath", "uname", "gname"}
132132

133+
# Fields from a pax header and the TarInfo attribute they override.
134+
_PAX_ATTRIBUTES = {
135+
"path": "name",
136+
"linkpath": "linkname",
137+
"size": "size",
138+
"mtime": "mtime",
139+
"uid": "uid",
140+
"gid": "gid",
141+
"uname": "uname",
142+
"gname": "gname",
143+
"GNU.sparse.name": "name",
144+
"GNU.sparse.size": "size",
145+
"GNU.sparse.realsize": "size",
146+
}
147+
133148
# Fields in a pax header that are numbers, all other fields
134149
# are treated as strings.
135150
PAX_NUMBER_FIELDS = {
@@ -978,6 +993,7 @@ class TarInfo(object):
978993
sparse = 'Sparse member information.',
979994
_sparse_structs = None,
980995
_link_target = None,
996+
_pax_applied = None,
981997
)
982998

983999
def __init__(self, name=""):
@@ -1003,6 +1019,7 @@ def __init__(self, name=""):
10031019

10041020
self.sparse = None # sparse member information
10051021
self.pax_headers = {} # pax header information
1022+
self._pax_applied = {} # pax header fields applied when read
10061023

10071024
@property
10081025
def path(self):
@@ -1135,6 +1152,16 @@ def create_pax_header(self, info, encoding):
11351152
info["magic"] = POSIX_MAGIC
11361153
pax_headers = self.pax_headers.copy()
11371154

1155+
# A field that was read from an archive loses its priority if the
1156+
# attribute it was applied to has been changed since, e.g. by
1157+
# replace(). Otherwise the change would be undone by the field.
1158+
# (TarInfo objects pickled by older versions lack _pax_applied.)
1159+
pax_applied = getattr(self, "_pax_applied", {})
1160+
for keyword, (attr, record, value) in pax_applied.items():
1161+
if (pax_headers.get(keyword) == record
1162+
and getattr(self, attr) != value):
1163+
del pax_headers[keyword]
1164+
11381165
# Test string fields for values that exceed the field length or cannot
11391166
# be represented in ASCII encoding.
11401167
for name, hname, length in (
@@ -1723,6 +1750,12 @@ def _apply_pax_info(self, pax_headers, encoding, errors):
17231750
setattr(self, keyword, value)
17241751

17251752
self.pax_headers = pax_headers.copy()
1753+
# Remember the applied fields and the resulting attribute values, so
1754+
# that create_pax_header() is able to detect later changes.
1755+
self._pax_applied = {
1756+
keyword: (attr, pax_headers[keyword], getattr(self, attr))
1757+
for keyword, attr in _PAX_ATTRIBUTES.items()
1758+
if keyword in pax_headers}
17261759

17271760
def _decode_pax_field(self, value, encoding, fallback_encoding, fallback_errors):
17281761
"""Decode a single field from a pax record.

‎Lib/test/test_tarfile.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2730,6 +2730,148 @@ def test_pax_extended_header(self):
27302730
finally:
27312731
tar.close()
27322732

2733+
def _roundtrip_pax_member(self, tarinfo):
2734+
# Write the member in PAX format and return it as it is read back.
2735+
with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar:
2736+
tar.addfile(tarinfo)
2737+
with tarfile.open(tmpname) as tar:
2738+
return tar.getmembers()[0]
2739+
2740+
def _read_pax_member(self, pax_headers, type=tarfile.REGTYPE):
2741+
# Return a member that was read from an archive with the given
2742+
# pax extended header.
2743+
t = tarfile.TarInfo("ustar")
2744+
t.type = type
2745+
with open(tmpname, "wb") as fobj:
2746+
fobj.write(t._create_pax_generic_header(
2747+
pax_headers, tarfile.XHDTYPE, "utf-8"))
2748+
fobj.write(t.tobuf(tarfile.USTAR_FORMAT))
2749+
fobj.write(bytes(tarfile.RECORDSIZE))
2750+
with tarfile.open(tmpname) as tar:
2751+
return tar.getmembers()[0]
2752+
2753+
def test_pax_extended_header_changed_attributes(self):
2754+
# The fields from a pax header that was read from an archive
2755+
# must not undo later changes to the TarInfo.
2756+
pax_headers = {"path": "../foo", "linkpath": "../bar",
2757+
"mtime": "1000.5", "uid": "123", "gid": "124",
2758+
"uname": "spam", "gname": "eggs", "comment": "ham"}
2759+
changes = {"name": "foo", "linkname": "bar", "mtime": 2000,
2760+
"uid": 1, "gid": 2, "uname": "tarfile", "gname": "tarfile"}
2761+
2762+
def check(t):
2763+
t = self._roundtrip_pax_member(t)
2764+
for attr, value in changes.items():
2765+
self.assertEqual(getattr(t, attr), value)
2766+
self.assertEqual(t.pax_headers, {"comment": "ham"})
2767+
2768+
with self.subTest("replace"):
2769+
t = self._read_pax_member(pax_headers, tarfile.SYMTYPE)
2770+
self.assertEqual(t.pax_headers, pax_headers)
2771+
check(t.replace(**changes))
2772+
with self.subTest("replace shallow"):
2773+
t = self._read_pax_member(pax_headers, tarfile.SYMTYPE)
2774+
check(t.replace(**changes, deep=False))
2775+
self.assertEqual(t.pax_headers, pax_headers)
2776+
with self.subTest("setattr"):
2777+
t = self._read_pax_member(pax_headers, tarfile.SYMTYPE)
2778+
for attr, value in changes.items():
2779+
setattr(t, attr, value)
2780+
check(t)
2781+
2782+
for attr, value in changes.items():
2783+
with self.subTest(attr=attr):
2784+
t = self._read_pax_member(pax_headers, tarfile.SYMTYPE)
2785+
t2 = self._roundtrip_pax_member(t.replace(**{attr: value}))
2786+
self.assertEqual(getattr(t2, attr), value)
2787+
for other in changes.keys() - {attr}:
2788+
self.assertEqual(getattr(t2, other), getattr(t, other))
2789+
2790+
def test_pax_extended_header_renamed_copy(self):
2791+
# Members with a name that needs a pax header can be renamed
2792+
# when they are copied to another archive.
2793+
with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar:
2794+
tar.addfile(tarfile.TarInfo("\xe4\xf6\xfc"))
2795+
tar.addfile(tarfile.TarInfo("foo"))
2796+
with tarfile.open(tmpname) as tar:
2797+
members = tar.getmembers()
2798+
self.assertEqual(members[0].pax_headers, {"path": "\xe4\xf6\xfc"})
2799+
with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar:
2800+
for t in members:
2801+
t.name = "bar/" + t.name
2802+
tar.addfile(t)
2803+
with tarfile.open(tmpname) as tar:
2804+
self.assertEqual(tar.getnames(), ["bar/\xe4\xf6\xfc", "bar/foo"])
2805+
2806+
def test_pax_extended_header_without_applied_fields(self):
2807+
# A TarInfo object that was pickled by an older version
2808+
# has no record of the applied fields.
2809+
t = self._read_pax_member({"path": "foo"})
2810+
del t._pax_applied
2811+
t.name = "bar"
2812+
self.assertEqual(self._roundtrip_pax_member(t).name, "foo")
2813+
2814+
def test_pax_extended_header_changed_size(self):
2815+
t = self._read_pax_member({"size": "0"})
2816+
t.size = 3
2817+
with tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT) as tar:
2818+
tar.addfile(t, io.BytesIO(b"foo"))
2819+
tar.addfile(tarfile.TarInfo("bar"))
2820+
with tarfile.open(tmpname) as tar:
2821+
self.assertEqual(tar.getnames(), ["ustar", "bar"])
2822+
self.assertEqual(tar.extractfile("ustar").read(), b"foo")
2823+
2824+
def test_pax_extended_header_changed_sparse_name(self):
2825+
t = self._read_pax_member({"path": "foo", "GNU.sparse.name": "bar"})
2826+
self.assertEqual(t.name, "bar")
2827+
self.assertEqual(self._roundtrip_pax_member(t).name, "bar")
2828+
t = self._roundtrip_pax_member(t.replace(name="baz"))
2829+
self.assertEqual(t.name, "baz")
2830+
self.assertEqual(t.pax_headers, {})
2831+
2832+
t = self._read_pax_member({"GNU.sparse.name": "bar"})
2833+
t.name = "baz"
2834+
self.assertEqual(self._roundtrip_pax_member(t).name, "baz")
2835+
2836+
def test_pax_extended_header_unchanged_attributes(self):
2837+
# The fields from a pax header are preserved if the
2838+
# corresponding attributes are not changed.
2839+
pax_headers = {"path": "foo", "mtime": "1000.123456789",
2840+
"uid": "123", "comment": "ham"}
2841+
t = self._read_pax_member(pax_headers)
2842+
t = self._roundtrip_pax_member(t.replace(uname="tarfile"))
2843+
self.assertEqual(t.pax_headers, pax_headers)
2844+
self.assertEqual(t.name, "foo")
2845+
self.assertEqual(t.uid, 123)
2846+
self.assertEqual(t.uname, "tarfile")
2847+
2848+
def test_pax_extended_header_changed_pax_headers(self):
2849+
# The fields that are set after the pax header was read
2850+
# still have priority over the TarInfo.
2851+
t = self._read_pax_member({"path": "foo", "uid": "123"})
2852+
t.name = "bar"
2853+
t.uid = 124
2854+
t.pax_headers["path"] = "baz"
2855+
t = self._roundtrip_pax_member(t)
2856+
self.assertEqual(t.pax_headers, {"path": "baz"})
2857+
self.assertEqual(t.name, "baz")
2858+
self.assertEqual(t.uid, 124)
2859+
2860+
def test_pax_extended_header_gnu_longname(self):
2861+
# A GNU longname header that precedes the pax header overrides
2862+
# its path field.
2863+
t = tarfile.TarInfo("ustar")
2864+
t.pax_headers = {"path": "foo"}
2865+
with open(tmpname, "wb") as fobj:
2866+
fobj.write(t._create_gnu_long_header(
2867+
"bar", tarfile.GNUTYPE_LONGNAME, "utf-8", "strict"))
2868+
fobj.write(t.tobuf(tarfile.PAX_FORMAT))
2869+
fobj.write(bytes(tarfile.RECORDSIZE))
2870+
with tarfile.open(tmpname) as tar:
2871+
t = tar.getmembers()[0]
2872+
self.assertEqual(t.name, "bar")
2873+
self.assertEqual(self._roundtrip_pax_member(t).name, "bar")
2874+
27332875
def test_create_pax_header(self):
27342876
# The ustar header should contain values that can be
27352877
# represented reasonably, even if a better (e.g. higher
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix :mod:`tarfile` writing the old name, link name, owner, size or
2+
modification time of a member that was read from an archive with a pax
3+
extended header and then changed, for example with
4+
:meth:`tarfile.TarInfo.replace`, before it was added to a
5+
:const:`~tarfile.PAX_FORMAT` archive.

0 commit comments

Comments
 (0)