fix(zipapp): store _solib shared libraries as files, not absolute symlinks (#4211)

Since #4165, the zipper keeps a symlink for an input whose original
source is a
symlink, looking through the absolute symlink the Bazel sandbox exposes
the
input as. For the `_solib` entries Bazel creates for shared libraries,
the
original source is itself an absolute symlink into the build machine's
output
base, so the zipapp stored entries like

    runfiles/_main/_solib_x86_64/libST-..._Slibgrpc.so
->
/home/<user>/.cache/bazel/<hash>/execroot/_main/bazel-out/.../libgrpc.so

which dangle on any other machine. Any zipapp whose Python code loads a
shared
library from `_solib` (a C extension that links a `cc_library`
dynamically, or
`libpython` for an extension that links it) fails to load it outside the
machine that built it. Before #4165 these files were copied.

To fix, only keep the looked-through target when it is relative, e.g.
`libpython3.10.so -> libpython3.10.so.1.0` inside the runtime, which is
what
the size optimization in #4165 is for. Otherwise the file is stored.

* Adds a zipper test with the `_solib` shape: a sandbox symlink to an
absolute
symlink to the library. It fails without the fix ("should NOT be a
symlink
  but is").

(cherry picked from commit 381762af4e3e7d1c83e827aec9bb14b4c477f492)

Work towards #4232
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a008107..9d3e829 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,19 @@
 directory, or view the [latest generated
 changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).
 
+{#v2-4-2}
+## [2.4.2] - 2026-10-08
+
+[2.4.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.4.2
+
+{#v2-4-2-fixed}
+### Fixed
+* (zipapp) Shared libraries that a {obj}`py_zipapp_binary` reaches through
+  Bazel's `_solib` symlinks are stored as files again, instead of as symlinks to
+  absolute paths in the build machine's output base that dangle wherever the
+  zipapp runs.
+
+
 {#v2-4-1}
 ## [2.4.1] - 2026-10-05
 
diff --git a/tests/tools/zipapp/zipper_test.py b/tests/tools/zipapp/zipper_test.py
index e0ff155..4c66c7f 100644
--- a/tests/tools/zipapp/zipper_test.py
+++ b/tests/tools/zipapp/zipper_test.py
@@ -174,6 +174,36 @@
         assert_zip_file_content(zf, "runfiles/my_ws/bin/python3.14", content="python")
 
 
+def test_create_zip_with_sandboxed_absolute_source_symlink(tmp_path):
+    # Bazel's _solib entries are absolute symlinks into the output base. Their
+    # target only exists on the build machine, so the file must be stored.
+    manifest_path = tmp_path / "manifest.txt"
+    output_zip = tmp_path / "output.zip"
+
+    library_dir = tmp_path / "external"
+    library_dir.mkdir()
+    library = library_dir / "libfoo.so"
+    library.write_text("library")
+
+    solib_dir = tmp_path / "solib"
+    solib_dir.mkdir()
+    solib_link = solib_dir / "libfoo.so"
+    solib_link.symlink_to(library)
+
+    sandbox_dir = tmp_path / "sandbox"
+    sandbox_dir.mkdir()
+    sandbox_link = sandbox_dir / "libfoo.so"
+    sandbox_link.symlink_to(solib_link)
+    manifest_path.write_text(f"rf-file|0|_solib/libfoo.so|{sandbox_link}")
+
+    create_zip(manifest_path, output_zip)
+
+    with zipfile.ZipFile(output_zip, "r") as zf:
+        assert_zip_file_content(
+            zf, "runfiles/my_ws/_solib/libfoo.so", content="library"
+        )
+
+
 def test_pathsep_normalization(tmp_path):
     manifest_path = tmp_path / "manifest.txt"
     output_zip = tmp_path / "output.zip"
diff --git a/tools/zipapp/zipper.py b/tools/zipapp/zipper.py
index c0e5472..aa96160 100644
--- a/tools/zipapp/zipper.py
+++ b/tools/zipapp/zipper.py
@@ -130,8 +130,13 @@
 
     # Bazel sandboxes expose regular inputs as absolute symlinks. Look through
     # that indirection to detect whether the original source is also a symlink.
+    # Only a relative link can be kept: an absolute one points into this
+    # machine's Bazel output base (e.g. the _solib links Bazel creates for shared
+    # libraries) and would dangle wherever the zipapp runs, so store the file.
     if os.path.islink(target):
-        return os.readlink(target)
+        source_target = os.readlink(target)
+        if not os.path.isabs(source_target):
+            return source_target
     return None