diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 5d5cef2f139a426..302986fa0807e55 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 514cfbb07cd76ad..4c1fdf6db8f2bbf 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -2435,6 +2435,75 @@ def test_pax_extended_header(self): finally: tar.close() + def _make_malformed_gnusparse_tar(self, pax_entries, following=b""): + # 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) + 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 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")], + [("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 000000000000000..f912cc939cdc3f9 --- /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.