From f69a507b94ccae9b94e1b32fa6a06ae7b3b9311b Mon Sep 17 00:00:00 2001 From: Fredrik Blau <40271584+fredrikblau@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:15:26 +0330 Subject: [PATCH] fix: support sdist extraction on older Python versions Python versions without a working tarfile data filter fall back to the filter copied from CPython, which clears ownership by setting uid, gid, uname and gname to None. TarFile.chown() in those same versions cannot cope with that: it passes the ids to os.chown() and resolves the names with pwd/grp, catching only KeyError. Extracting an sdist therefore failed with "uid should be integer, not NoneType", or a TypeError from the name lookup when the archive itself carried no owner names. Clear ownership with values those versions accept instead: -1 for the ids, which os.chown() reads as "leave unchanged", and "" for the names, which fails the lookup with KeyError and falls back to the ids. Fixes: #11036 --- src/poetry/utils/helpers.py | 17 +++++----- tests/utils/test_helpers.py | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/poetry/utils/helpers.py b/src/poetry/utils/helpers.py index ebd9e010306..197495fb86d 100644 --- a/src/poetry/utils/helpers.py +++ b/src/poetry/utils/helpers.py @@ -350,14 +350,15 @@ def _get_filtered_attrs( if mode != member.mode: new_attrs["mode"] = mode # Ignore ownership for 'data' - if member.uid is not None: - new_attrs["uid"] = None - if member.gid is not None: - new_attrs["gid"] = None - if member.uname is not None: - new_attrs["uname"] = None - if member.gname is not None: - new_attrs["gname"] = None + # Unlike newer versions, TarFile.chown() of older Python + # versions does not tolerate None here: it passes the ids + # straight to os.chown() and looks the names up with + # pwd/grp, catching only KeyError. Use values that are + # accepted and resolve to "leave ownership alone". + new_attrs["uid"] = -1 + new_attrs["gid"] = -1 + new_attrs["uname"] = "" + new_attrs["gname"] = "" # Check link destination for 'data' if member.islnk() or member.issym(): if os.path.isabs(member.linkname): diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index a44f29c4a1d..c46d79477a5 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextlib +import sys import tarfile from pathlib import Path @@ -223,6 +224,67 @@ def test_extractall_sdist_no_path_traversal( assert (dest / target.as_posix().lstrip("/")).exists() +def test_extractall_sdist_old_python_owner_filter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The fallback filter must not hand None to old TarFile.chown().""" + import io + + archive = tmp_path / "owner.tar.gz" + dest = tmp_path / "dest" + dest.mkdir() + with tarfile.open(archive, "w:gz") as tar: + member = tarfile.TarInfo("file.txt") + member.size = 4 + tar.addfile(member, io.BytesIO(b"data")) + + # Take the fallback path and skip the stdlib filter, as on old Python. + monkeypatch.setattr(sys, "version_info", (3, 10, 12)) + monkeypatch.setattr( + tarfile.TarFile, + "_get_filter_function", + lambda self, filter: lambda member, dest_path: member, + raising=False, + ) + + original_getmembers = tarfile.TarFile.getmembers + + def unowned_members(self: tarfile.TarFile) -> list[tarfile.TarInfo]: + members = original_getmembers(self) + for member in members: + for attr in ("uid", "gid", "uname", "gname"): + # object.__setattr__ because the declared types are not optional + object.__setattr__(member, attr, None) + return members + + monkeypatch.setattr(tarfile.TarFile, "getmembers", unowned_members) + + def lookup(name: str) -> int: + """Behaves like pwd.getpwnam()/grp.getgrnam() for an unknown name.""" + if not isinstance(name, str): + raise TypeError(f"argument must be str, not {type(name).__name__}") + raise KeyError(name) + + chown_args: list[tuple[int, int, str, str]] = [] + + def old_chown( + self: tarfile.TarFile, member: tarfile.TarInfo, target: str, numeric_owner: bool + ) -> None: + # Old TarFile.chown() resolved the names and caught only KeyError. + with contextlib.suppress(KeyError): + lookup(member.gname) + with contextlib.suppress(KeyError): + lookup(member.uname) + chown_args.append((member.uid, member.gid, member.uname, member.gname)) + + monkeypatch.setattr(tarfile.TarFile, "chown", old_chown) + + extractall(source=archive, dest=dest, zip=False) + + assert (dest / "file.txt").read_bytes() == b"data" + assert chown_args == [(-1, -1, "", "")] + + @pytest.mark.parametrize("link_type", [tarfile.SYMTYPE, tarfile.LNKTYPE]) @pytest.mark.parametrize("relative", [False, True]) @pytest.mark.parametrize("existing", [False, True])