diff --git a/pm/package.py b/pm/package.py index 9487015744..344bcbbb3e 100644 --- a/pm/package.py +++ b/pm/package.py @@ -288,14 +288,23 @@ class DebPackage(Package): target.chmod(member.mode & 0o777) except OSError: pass - for _linkname, target, resolved_path in deferred_links: - if not _contained(resolved_path): - raise InstallError( - self.name, - f"symlink {target.name} resolves outside the staged tree", - ) - if not (target.exists() or target.is_symlink()) and resolved_path.is_file(): - shutil.copy2(resolved_path, target) + while deferred_links: + pending = [] + for linkname, target, resolved_path in deferred_links: + if not _contained(resolved_path): + raise InstallError( + self.name, + f"symlink {target.name} resolves outside the staged tree", + ) + if target.exists() or target.is_symlink(): + continue + if resolved_path.is_file(): + shutil.copy2(resolved_path, target) + else: + pending.append((linkname, target, resolved_path)) + if len(pending) == len(deferred_links): + break + deferred_links = pending # Termux debs carry owner-only modes across the whole tree (700 on # binaries n libs, 600 on stdlib .py files) -- postinst would # normalize on a real phone, but pm extracts without postinst, and diff --git a/tests/pm/test_deb_safety.py b/tests/pm/test_deb_safety.py index 17b4303c72..a0e467a1b7 100644 --- a/tests/pm/test_deb_safety.py +++ b/tests/pm/test_deb_safety.py @@ -65,6 +65,25 @@ def _symlink_member(name: str, linkname: str) -> tarfile.TarInfo: return info +def test_chained_aliases_survive_without_symlink_support(tmp_path, monkeypatch): + def unavailable(*args, **kwargs): + raise OSError("symlinks unavailable") + + monkeypatch.setattr(Path, "symlink_to", unavailable) + deb = tmp_path / "aliases.deb" + lib = "data/data/com.termux/files/usr/lib" + _build_deb(deb, [ + _symlink_member(f"{lib}/libexample.so", "libexample.so.1"), + _symlink_member(f"{lib}/libexample.so.1", "libexample.so.1.2"), + (f"{lib}/libexample.so.1.2", b"library payload"), + ]) + staged = tmp_path / "staged" + staged.mkdir() + _P().unpack(deb, staged, "linux-arm64-bionic") + for name in ("libexample.so", "libexample.so.1", "libexample.so.1.2"): + assert (staged / lib / name).read_bytes() == b"library payload" + + def test_absolute_symlink_target_rejected(tmp_path: Path): """A member whose symlink target is ABSOLUTE must be refused outright: the link points outside the staged tree the moment it is created."""