From 3dd9d159b4fe56151bbcff664e1d800d7e90738b Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 9 Jul 2026 16:45:39 +0800 Subject: [PATCH 1/2] gh-153636: Raise InvalidHeaderError for malformed GNU sparse pax numbers in tarfile When a pax extended header carried a malformed GNU sparse number, tarfile parsed it with a bare int() and let the resulting ValueError escape to the caller from _proc_gnusparse_01, _proc_gnusparse_10, and the GNU.sparse.size and GNU.sparse.realsize branches of _apply_pax_info, unlike the GNU sparse 0.0 handler which already reports such corruption as InvalidHeaderError. Wrap those conversions so a bad number raises tarfile.InvalidHeaderError, which the reader treats as end of archive, giving the whole GNU sparse family consistent behavior on a corrupt header. --- Lib/tarfile.py | 25 +++++-- Lib/test/test_tarfile.py | 71 +++++++++++++++++++ ...-07-09-00-00-00.gh-issue-153636.8pWrHC.rst | 3 + 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-09-00-00-00.gh-issue-153636.8pWrHC.rst diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 5d5cef2f139a42..302986fa0807e5 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -1647,7 +1647,10 @@ def _proc_gnusparse_00(self, next, raw_headers): def _proc_gnusparse_01(self, next, pax_headers): """Process a GNU tar extended sparse header, version 0.1. """ - sparse = [int(x) for x in pax_headers["GNU.sparse.map"].split(",")] + try: + sparse = [int(x) for x in pax_headers["GNU.sparse.map"].split(",")] + except ValueError: + raise InvalidHeaderError("invalid header") next.sparse = list(zip(sparse[::2], sparse[1::2])) def _proc_gnusparse_10(self, next, pax_headers, tarfile): @@ -1657,12 +1660,18 @@ def _proc_gnusparse_10(self, next, pax_headers, tarfile): sparse = [] buf = tarfile.fileobj.read(BLOCKSIZE) fields, buf = buf.split(b"\n", 1) - fields = int(fields) + try: + fields = int(fields) + except ValueError: + raise InvalidHeaderError("invalid header") while len(sparse) < fields * 2: if b"\n" not in buf: buf += tarfile.fileobj.read(BLOCKSIZE) number, buf = buf.split(b"\n", 1) - sparse.append(int(number)) + try: + sparse.append(int(number)) + except ValueError: + raise InvalidHeaderError("invalid header") next.offset_data = tarfile.fileobj.tell() next.sparse = list(zip(sparse[::2], sparse[1::2])) @@ -1674,9 +1683,15 @@ def _apply_pax_info(self, pax_headers, encoding, errors): if keyword == "GNU.sparse.name": setattr(self, "path", value) elif keyword == "GNU.sparse.size": - setattr(self, "size", int(value)) + try: + setattr(self, "size", int(value)) + except ValueError: + raise InvalidHeaderError("invalid header") elif keyword == "GNU.sparse.realsize": - setattr(self, "size", int(value)) + try: + setattr(self, "size", int(value)) + except ValueError: + raise InvalidHeaderError("invalid header") elif keyword in PAX_FIELDS: if keyword in PAX_NUMBER_FIELDS: try: diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 514cfbb07cd76a..0c8a36df6dfd04 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -2435,6 +2435,77 @@ def test_pax_extended_header(self): finally: tar.close() + def _make_malformed_gnusparse_tar(self, pax_entries, following=b""): + # Build an in-memory tar whose pax extended ('x') header carries the + # given GNU.sparse.* records, followed by a regular file member (and + # optionally a leading data block for the sparse-1.0 map). + def pax_record(key, value): + kv = key.encode() + b"=" + value.encode() + b"\n" + n = len(kv) + p = len(str(n)) + 1 + n + while len(str(p)) + 1 + n != p: + p = len(str(p)) + 1 + n + return str(p).encode() + b" " + kv + + def header(name, size, typeflag): + buf = bytearray(512) + buf[0:len(name)] = name + buf[100:108] = b"0000644\x00" + buf[108:116] = b"0000000\x00" + buf[116:124] = b"0000000\x00" + buf[124:136] = ("%011o\x00" % size).encode() + buf[136:148] = b"00000000000\x00" + buf[148:156] = b" " + buf[156] = ord(typeflag) + buf[257:265] = b"ustar\x0000" + chksum = sum(buf) & 0o777777 + buf[148:156] = ("%06o\x00 " % chksum).encode() + return bytes(buf) + + records = b"".join(pax_record(k, v) for k, v in pax_entries) + blocks = header(b"././@PaxHeader", len(records), "x") + blocks += records + b"\x00" * (-len(records) % 512) + blocks += header(b"testfile", 0, "0") + if following: + blocks += following + blocks += b"\x00" * 1024 + return io.BytesIO(blocks) + + def test_pax_gnusparse_bad_number(self): + # A malformed GNU sparse number in a pax extended header is reported + # through InvalidHeaderError instead of a bare ValueError, so the + # reader stops cleanly like it does for the GNU sparse 0.0 format. + cases = [ + [("GNU.sparse.map", "1,notanumber")], # sparse 0.1 + [("GNU.sparse.size", "notanumber")], + [("GNU.sparse.realsize", "notanumber")], + ] + for entries in cases: + with self.subTest(entries=entries): + fobj = self._make_malformed_gnusparse_tar(entries) + with tarfile.open(fileobj=fobj) as tar: + self.assertEqual(tar.getmembers(), []) + + for data in (b"notanumber\n", b"1\nnotanumber\n"): # sparse 1.0 + with self.subTest(data=data): + following = data + b"\x00" * (-len(data) % 512) + fobj = self._make_malformed_gnusparse_tar( + [("GNU.sparse.major", "1"), ("GNU.sparse.minor", "0")], + following=following) + with tarfile.open(fileobj=fobj) as tar: + self.assertEqual(tar.getmembers(), []) + + def test_pax_gnusparse_bad_number_direct(self): + # The parsing helpers themselves raise InvalidHeaderError, not + # ValueError, mirroring _proc_gnusparse_00. + ti = tarfile.TarInfo("x") + with self.assertRaises(tarfile.InvalidHeaderError): + ti._apply_pax_info({"GNU.sparse.size": "notanumber"}, + "utf-8", "strict") + with self.assertRaises(tarfile.InvalidHeaderError): + ti._apply_pax_info({"GNU.sparse.realsize": "notanumber"}, + "utf-8", "strict") + 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-07-09-00-00-00.gh-issue-153636.8pWrHC.rst b/Misc/NEWS.d/next/Library/2026-07-09-00-00-00.gh-issue-153636.8pWrHC.rst new file mode 100644 index 00000000000000..f912cc939cdc3f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-09-00-00-00.gh-issue-153636.8pWrHC.rst @@ -0,0 +1,3 @@ +:mod:`tarfile` now raises :exc:`tarfile.InvalidHeaderError` instead of a bare +:exc:`ValueError` when a pax extended header contains a malformed GNU sparse +number, matching the existing handling of the GNU sparse 0.0 format. From bb12234d89de6bb287071c6b3d289aea0f79e311 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Mon, 13 Jul 2026 09:49:33 +0800 Subject: [PATCH 2/2] Trim the test comments --- Lib/test/test_tarfile.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 0c8a36df6dfd04..4c1fdf6db8f2bb 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -2436,9 +2436,8 @@ def test_pax_extended_header(self): tar.close() def _make_malformed_gnusparse_tar(self, pax_entries, following=b""): - # Build an in-memory tar whose pax extended ('x') header carries the - # given GNU.sparse.* records, followed by a regular file member (and - # optionally a leading data block for the sparse-1.0 map). + # Build an in-memory tar: a pax ('x') header with the given + # GNU.sparse.* records, then a file member (and any `following` data). def pax_record(key, value): kv = key.encode() + b"=" + value.encode() + b"\n" n = len(kv) @@ -2472,9 +2471,8 @@ def header(name, size, typeflag): return io.BytesIO(blocks) def test_pax_gnusparse_bad_number(self): - # A malformed GNU sparse number in a pax extended header is reported - # through InvalidHeaderError instead of a bare ValueError, so the - # reader stops cleanly like it does for the GNU sparse 0.0 format. + # A malformed GNU sparse number surfaces as InvalidHeaderError, not a + # bare ValueError, so the reader stops (getmembers() truncates). cases = [ [("GNU.sparse.map", "1,notanumber")], # sparse 0.1 [("GNU.sparse.size", "notanumber")],