From 2ebcada9c464fdf1fdb0a43ff422d8ba3e19779b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=B2=D1=86=20=D0=B2=D0=BA?= Date: Sun, 6 Sep 2026 00:20:04 +0300 Subject: [PATCH] Make rootfs backups safe to extract --- extcli/src/rootfs/export.py | 26 ++++++++++++++++++++++---- tests/test_rootfs.py | 5 ++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/extcli/src/rootfs/export.py b/extcli/src/rootfs/export.py index 9c7d967..24903de 100644 --- a/extcli/src/rootfs/export.py +++ b/extcli/src/rootfs/export.py @@ -12,6 +12,7 @@ """ import os +import posixpath import tarfile import time @@ -49,9 +50,11 @@ def measure(root): def archive(root, target, on_progress=None): """Writes the container to `target`. Returns (ok, detail). - Symlinks are stored as symlinks, not followed: a rootfs is full of - absolute ones pointing at its own `/`, and following them would both - inflate the archive and bake this phone's paths into it. + Symlinks are stored as symlinks, not followed. Absolute links that point + at the rootfs's own `/` are made relative: they still resolve to the same + member after restore, but archives also unpack with Python's safe default + extraction filter. Following links would both inflate the archive and + bake this phone's paths into it. """ if not os.path.isdir(root): return False, "there is no container to export" @@ -68,7 +71,7 @@ def entries(): for path in entries(): try: archive_file.add(path, arcname=os.path.relpath(path, root), - recursive=False) + recursive=False, filter=_portable_link) except (OSError, ValueError) as e: # a socket, or something that went away mid-walk; the rest # of the container is still worth having @@ -92,3 +95,18 @@ def entries(): except Exception: pass return True, target + + +def _portable_link(info): + """Return a tar member whose rootfs-local absolute link is relative. + + Alpine uses links such as ``/bin/busybox`` to mean a file below its own + root, not a file in the Android host filesystem. A relative target has + that same meaning once the archive is restored and, unlike an absolute + target, is accepted by :mod:`tarfile`'s data filter. + """ + if info.issym() and info.linkname.startswith("/"): + info.linkname = posixpath.relpath( + info.linkname.lstrip("/"), posixpath.dirname(info.name) or "." + ) + return info diff --git a/tests/test_rootfs.py b/tests/test_rootfs.py index e986c42..8de41b9 100644 --- a/tests/test_rootfs.py +++ b/tests/test_rootfs.py @@ -2573,8 +2573,7 @@ def test_the_archive_holds_the_container(tmp_path): def test_a_symlink_is_stored_as_itself(tmp_path): - """Following them would bake this phone's paths into the archive, and - inflate it with a copy of every file a link points at.""" + """Links remain links, but rootfs-local absolute targets become portable.""" import tarfile from extcli_src.rootfs import export @@ -2585,7 +2584,7 @@ def test_a_symlink_is_stored_as_itself(tmp_path): with tarfile.open(target) as archive: link = archive.getmember("bin/sh") assert link.issym() - assert link.linkname == "/bin/busybox" + assert link.linkname == "busybox" def test_the_archive_can_be_unpacked_back(tmp_path):