diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 46d1c35..2500ca7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,3 +40,41 @@ jobs: uses: codecov/codecov-action@v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + embedded: + name: Embedded (${{ matrix.os }}, py ${{ matrix.pair.pyversion }}, julia ${{ matrix.pair.julia }}, py-tls-context-first ${{ matrix.sslfirst }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + pair: + - {pyversion: "3.10", julia: "~1.12"} + - {pyversion: "3.14", julia: "~1.10"} + sslfirst: ["1", "0"] + steps: + - uses: actions/checkout@v7 + - name: Set up Python ${{ matrix.pair.pyversion }} + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.pair.pyversion }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . juliacall + - name: Remove Chocolatey Julia shim from PATH + if: runner.os == 'Windows' + shell: pwsh + run: | + $chocolateyBin = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath('C:\ProgramData\Chocolatey\bin')) + $pathEntries = $env:PATH -split [IO.Path]::PathSeparator | Where-Object { + [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($_)) -ine $chocolateyBin + } + "PATH=$($pathEntries -join [IO.Path]::PathSeparator)" >> $env:GITHUB_ENV + - name: Embedded session with mismatched OpenSSL + run: python test/embedded_openssl.py + env: + JULIAPKG_TEST_JULIA: ${{ matrix.pair.julia }} + JULIAPKG_TEST_SSL_FIRST: ${{ matrix.sslfirst }} + PYTHONFAULTHANDLER: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 55daa9c..da20cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased +* Julia's bundled OpenSSL is given private library names after installation, and its + stdlib `OpenSSL_jll` is pointed at those names, so a libcrypto already loaded by CPython + can no longer be substituted for it. Python's OpenSSL version therefore no longer + restricts which Julia may be used, and the `<=python` bound on `OpenSSL_jll` is dropped + from Julia 1.12 on, where it is a stdlib and cannot be pinned by Pkg. Only installations + juliapkg created are renamed. Where the Julia that would collide was found on the system, + one is installed and renamed instead; where even that is not possible, the previous + restriction to Julia <1.12 still applies. Projects resolved by an earlier version resolve + once more, so that their installed Julia is renamed too. + ## v0.1.26 (2026-08-14) * Add `julia_args` argument to `resolve()`. diff --git a/src/juliapkg/deps.py b/src/juliapkg/deps.py index 252949f..d0d11e0 100644 --- a/src/juliapkg/deps.py +++ b/src/juliapkg/deps.py @@ -10,6 +10,7 @@ import tomlkit from filelock import FileLock +from . import openssl from .compat import Compat, Version from .find_julia import find_julia, julia_version from .install_julia import log, log_script @@ -27,8 +28,10 @@ # 4 - changed from timestamp/sys_path to deps_files tracking # 5 - added hash_sha256 to deps_files for content verification # 6 - added libjulia path to meta +# 8 - Julia's OpenSSL is renamed after installation, so a project resolved by an earlier +# version has an install that was never renamed and a cap that no longer applies # increment whenever the format changes -META_VERSION = 7 +META_VERSION = 8 def load_meta(): @@ -347,6 +350,8 @@ def find_requirements(): compats = {} all_deps = {} + python_openssl_bounds = set() + python_openssl_compat = None for fn in deps_files(): log("Found dependencies: {}".format(fn)) with open(fn) as fp: @@ -360,16 +365,15 @@ def find_requirements(): os.path.normpath(os.path.join(os.path.dirname(fn), v)) ) dep.setdefault(k, {})[fn] = v - # special handling of `verion = "<=python"` for `OpenSSL_jll if ( name == "OpenSSL_jll" - and dep.get("uuid").get(fn) == "458c3c95-2e84-50aa-8efc-19380b2a3a95" - and dep.get("version").get(fn) == "<=python" + and dep.get("uuid", {}).get(fn) == _OPENSSL_JLL_UUID + and dep.get("version", {}).get(fn) == "<=python" ): - oc, jc = openssl_compat() - dep["version"][fn] = oc - if jc is not None: - compats[fn + " (OpenSSL_jll)"] = Compat.parse(jc) + python_openssl_bounds.add(fn) + if python_openssl_compat is None: + python_openssl_compat = openssl_compat()[0] + dep["version"][fn] = python_openssl_compat c = deps.get("julia") if c is not None: compats[fn] = Compat.parse(c) @@ -449,6 +453,7 @@ def merge_preferences(dep, kfvs, k): deps = [] for name, kfvs in all_deps.items(): kw = {"name": name} + version_files = set(kfvs.get("version", {})) merge_unique(kw, kfvs, "uuid") merge_unique(kw, kfvs, "path") merge_unique(kw, kfvs, "subdir") @@ -457,7 +462,14 @@ def merge_preferences(dep, kfvs, k): merge_compat(kw, kfvs, "version") merge_any(kw, kfvs, "dev") merge_preferences(kw, kfvs, "preferences") - deps.append(PkgSpec(**kw)) + pkg = PkgSpec(**kw) + pkg._openssl_python_bound = ( + name == "OpenSSL_jll" + and pkg.uuid == _OPENSSL_JLL_UUID + and bool(version_files) + and version_files <= python_openssl_bounds + ) + deps.append(pkg) # julia compat compat = None for c in compats.values(): @@ -476,6 +488,86 @@ def merge_preferences(dep, kfvs, k): return compat, deps +_OPENSSL_JLL_UUID = "458c3c95-2e84-50aa-8efc-19380b2a3a95" + + +def _installed_by_juliapkg(exe): + install = os.path.realpath(STATE["install"]) + exe = os.path.realpath(exe) + try: + return os.path.commonpath((install, exe)) == install + except ValueError: + return False + + +def _shield_julia(exe, ver, *, owned): + safe, note = openssl.shield(exe, ver, owned=owned) + log(f"Julia's OpenSSL: {note}") + return safe + + +def _drop_generated_openssl_bound(pkgs, ver): + for pkg in pkgs: + if getattr(pkg, "_openssl_python_bound", False): + log(f"Dropping the '<=python' bound on {pkg.name}; Julia {ver} pins it") + pkg.version = None + + +def _reconcile_openssl(exe, ver, compat, pkgs): + """Use a private OpenSSL name or select a compatible Julia.""" + if (ver.major, ver.minor) < (1, 12): + return exe, ver + + _, python_cap = openssl_compat() + owned = _installed_by_juliapkg(exe) + safe = _shield_julia(exe, ver, owned=owned) + compatible_foreign = ( + not owned + and sys.platform.startswith("linux") + and (ver.major, ver.minor) == (1, 12) + and python_cap is None + ) + if safe or compatible_foreign: + _drop_generated_openssl_bound(pkgs, ver) + return exe, ver + + if STATE["override_executable"]: + raise Exception( + f"juliapkg_exe={exe} selects Julia {ver}, whose OpenSSL cannot be " + "used safely in this process. Use Julia 1.11 or earlier, use Python " + "with OpenSSL 3.5 or newer, or unset juliapkg_exe so juliapkg can " + "install and protect Julia." + ) + + if not owned and not STATE["offline"]: + exe, ver = find_julia( + compat=compat, + prefix=STATE["install"], + install=True, + upgrade=True, + system=False, + ) + if (ver.major, ver.minor) < (1, 12): + return exe, ver + owned = _installed_by_juliapkg(exe) + if _shield_julia(exe, ver, owned=owned): + _drop_generated_openssl_bound(pkgs, ver) + return exe, ver + + safe_julia = Compat.parse("1 - 1.11") + log( + f"WARNING: restricting Julia to {safe_julia}, because its OpenSSL cannot be " + "given a private name" + ) + compat = safe_julia if compat is None else compat & safe_julia + return find_julia( + compat=compat, + prefix=STATE["install"], + install=True, + upgrade=True, + ) + + def resolve(force=False, dry_run=False, update=False, julia_args=None): """ Resolve the dependencies. @@ -531,6 +623,7 @@ def resolve(force=False, dry_run=False, update=False, julia_args=None): exe, ver = find_julia( compat=compat, prefix=STATE["install"], install=True, upgrade=True ) + exe, ver = _reconcile_openssl(exe, ver, compat, pkgs) log(f"Using Julia {ver} at {exe}") # get libjulia path libjulia_script = [ diff --git a/src/juliapkg/find_julia.py b/src/juliapkg/find_julia.py index 3f709f8..e5d86f5 100644 --- a/src/juliapkg/find_julia.py +++ b/src/juliapkg/find_julia.py @@ -21,7 +21,7 @@ def julia_version(exe): pass -def find_julia(compat=None, prefix=None, install=False, upgrade=False): +def find_julia(compat=None, prefix=None, install=False, upgrade=False, system=True): """Find a Julia executable compatible with compat. Args: @@ -30,6 +30,8 @@ def find_julia(compat=None, prefix=None, install=False, upgrade=False): install: If True, install Julia if it is not found. This will use JuliaUp if available, otherwise will install into the given prefix. upgrade: If True, find the latest compatible release. Implies install=True. + system: If False, skip JuliaUp and PATH. A configured executable is checked + first and still wins; otherwise only the given prefix is used. As a special case, upgrade=True does not apply when Julia is found in the PATH, because if it is already installed then the user is already managing their own Julia @@ -66,8 +68,8 @@ def find_julia(compat=None, prefix=None, install=False, upgrade=False): if bestcompat is None or pr_ver in bestcompat: return (pr_exe, pr_ver) # see if juliaup is installed - try_jl = True - ju_exe = shutil.which("juliaup") + try_jl = system + ju_exe = shutil.which("juliaup") if system else None if ju_exe: ju_compat = ( Compat.parse("=" + ju_best_julia_version(compat)[0]) if upgrade else compat diff --git a/src/juliapkg/openssl.py b/src/juliapkg/openssl.py new file mode 100644 index 0000000..6e73913 --- /dev/null +++ b/src/juliapkg/openssl.py @@ -0,0 +1,160 @@ +"""Give an owned Linux Julia install private OpenSSL sonames. + +CPython and embedded Julia share glibc's link map, where an already loaded +bare soname wins. Rewriting Julia's exact NUL-delimited names prevents the +host OpenSSL from being substituted for Julia's copy. +""" + +import mmap +import os +import stat +import sys +import tempfile + +# Each replacement has the same byte length as the name it replaces. +PRIVATE_NAMES = { + b"libcrypto.so.3": b"libcrypto.jl.3", + b"libssl.so.3": b"libssl.jl.3", +} + +assert all(len(old) == len(new) for old, new in PRIVATE_NAMES.items()) + + +def _name_offsets(path): + offsets = [] + with open(path, "rb") as fp: + if fp.read(4) != b"\x7fELF": + return offsets + with mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) as contents: + for old, new in PRIVATE_NAMES.items(): + needle = b"\0" + old + b"\0" + start = 0 + while (found := contents.find(needle, start)) >= 0: + offsets.append((found + 1, new)) + start = found + len(needle) + return offsets + + +def rewrite_names(path): + """Rewrite every exact old OpenSSL name in one ELF file.""" + offsets = _name_offsets(path) + if not offsets: + return False + with open(path, "r+b") as fp: + for offset, new in offsets: + fp.seek(offset) + if fp.write(new) != len(new): + raise OSError(f"short write while rewriting {path}") + if _name_offsets(path): + raise OSError(f"old OpenSSL name remains in {path}") + return True + + +def _jll_source(root, version): + path = os.path.join( + root, + "share", + "julia", + "stdlib", + f"v{version.major}.{version.minor}", + "OpenSSL_jll", + "src", + "OpenSSL_jll.jl", + ) + with open(path, "rb") as fp: + source = fp.read() + + candidate = source + for old, new in PRIVATE_NAMES.items(): + candidate = candidate.replace(b'"' + old + b'"', b'"' + new + b'"') + old_literals = (b'"' + old + b'"' for old in PRIVATE_NAMES) + private_literals = (b'"' + new + b'"' for new in PRIVATE_NAMES.values()) + if any(literal in candidate for literal in old_literals) or not all( + literal in candidate for literal in private_literals + ): + raise OSError("unrecognized OpenSSL_jll wrapper") + return path, source, candidate + + +def _replace_atomic(path, contents): + """Replace one file without exposing a partial write.""" + mode = stat.S_IMODE(os.stat(path).st_mode) + directory = os.path.dirname(path) + prefix = f".{os.path.basename(path)}." + fd, temporary = tempfile.mkstemp(dir=directory, prefix=prefix, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as fp: + os.fchmod(fp.fileno(), mode) + if fp.write(contents) != len(contents): + raise OSError(f"short write while replacing {path}") + fp.flush() + os.fsync(fp.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def _ensure_alias(provider, old, new): + link = os.path.join(os.path.dirname(provider), new.decode()) + if os.path.lexists(link): + try: + if os.path.samefile(provider, link): + return + except OSError: + pass + raise OSError(f"private OpenSSL alias has the wrong target: {link}") + os.symlink(old.decode(), link) + if not os.path.samefile(provider, link): + raise OSError(f"private OpenSSL alias has the wrong target: {link}") + + +def _install_files(root): + providers = {old: [] for old in PRIVATE_NAMES} + files = [] + + def raise_walk_error(error): + raise error + + for directory, _, names in os.walk(root, onerror=raise_walk_error): + for name in names: + path = os.path.join(directory, name) + old = name.encode() + if old in providers: + providers[old].append(path) + if not os.path.islink(path): + files.append(path) + return providers, files + + +def shield(exe, version, *, owned): + """Privatize OpenSSL in an owned Julia install, failing closed on Linux.""" + if sys.platform == "darwin": + return True, "not needed on macOS" + if sys.platform == "win32": + return True, "not needed on Windows" + if not sys.platform.startswith("linux"): + return False, "unsupported platform" + if not owned: + return False, "Julia was not installed by juliapkg" + if not os.path.isfile(exe): + return False, "incomplete Julia OpenSSL layout" + + root = os.path.dirname(os.path.dirname(os.path.realpath(exe))) + try: + source_path, source, repointed = _jll_source(root, version) + providers, files = _install_files(root) + if any(not paths for paths in providers.values()): + return False, "incomplete Julia OpenSSL layout" + for old, paths in providers.items(): + for provider in paths: + _ensure_alias(provider, old, PRIVATE_NAMES[old]) + for path in files: + rewrite_names(path) + if repointed != source: + _replace_atomic(source_path, repointed) + except OSError as error: + return False, f"could not privatize Julia's OpenSSL ({error})" + return True, "private OpenSSL names ready" diff --git a/test/embedded_openssl.py b/test/embedded_openssl.py new file mode 100755 index 0000000..812663e --- /dev/null +++ b/test/embedded_openssl.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Exercise embedded Julia when Python and Julia use different OpenSSL builds. + +JULIAPKG_TEST_JULIA optionally constrains Julia. JULIAPKG_TEST_SSL_FIRST selects +whether Python creates a TLS context before juliacall (default "1") or resolves in +a child before importing juliacall ("0"). +""" + +import importlib.metadata +import os +import socket +import subprocess +import sys + +_juliacall = importlib.metadata.version("juliacall") +if tuple(int(part) for part in _juliacall.split(".")[:3]) < (0, 9, 35): + sys.exit(f"juliacall {_juliacall} is too old for this check; need 0.9.35 or newer") + +results = [] + + +def check(name, ok, detail=""): + results.append(ok) + suffix = f" | {detail}" if detail else "" + print(f"[{'PASS' if ok else 'FAIL'}] {name}{suffix}", flush=True) + + +compat = os.environ.get("JULIAPKG_TEST_JULIA") +if os.environ.get("JULIAPKG_TEST_SSL_FIRST", "1") == "1": + import ssl + + ssl.create_default_context() + print(f"python TLS context before juliacall: {ssl.OPENSSL_VERSION}", flush=True) + import juliapkg + + if compat: + juliapkg.require_julia(compat) +else: + script = "import juliapkg" + ( + f"; juliapkg.require_julia({compat!r})" if compat else "" + ) + subprocess.run([sys.executable, "-c", script + "; juliapkg.resolve()"], check=True) + check( + "ssl untouched by this script before importing juliacall", + "ssl" not in sys.modules, + ) + +from juliacall import Main as jl # noqa: E402, I001 + +import ssl # noqa: E402 + +print(f"python {sys.version.split()[0]} | {ssl.OPENSSL_VERSION}", flush=True) +jl.seval("using Pkg, OpenSSL_jll, Downloads, Libdl") + +version = jl.seval("string(VERSION)") +print(f"julia version: {version}", flush=True) +if compat: + from juliapkg.compat import Compat, Version + + wanted = Compat.parse(compat) + check( + f"julia version satisfies {wanted}", Version.parse(version) in wanted, version + ) +check("Pkg usable", bool(jl.seval("Pkg.project().path"))) +size = jl.seval('filesize(Downloads.download("https://julialang.org"))') +check("julia HTTPS download", size > 0, f"{size} bytes") + +if jl.seval('Sys.islinux() && VERSION >= v"1.12"'): + names = list(jl.seval("[OpenSSL_jll.libcrypto, OpenSSL_jll.libssl]")) + check( + "OpenSSL_jll exposes private names", + names == ["libcrypto.jl.3", "libssl.jl.3"], + ", ".join(names), + ) + +jl.seval(""" +function _openssl_libs() + dirs = [joinpath(Sys.BINDIR, "..", "lib", "julia"), Sys.BINDIR] + libs = String[] + for d in dirs, f in (isdir(d) ? readdir(d, join=true) : String[]) + b = basename(f) + if (startswith(b, "libssl") || startswith(b, "libcrypto")) && !islink(f) + push!(libs, abspath(f)) + end + end + unique(libs) +end +""") +libs = list(jl.seval("_openssl_libs()")) +libs += list( + jl.seval("[abspath(OpenSSL_jll.libcrypto_path), abspath(OpenSSL_jll.libssl_path)]") +) +for lib in dict.fromkeys(libs): + try: + jl.seval(f'Libdl.dlopen(raw"{lib}")') + check(f"dlopen {os.path.basename(lib)}", True, lib) + except Exception as error: + detail = str(error).strip().splitlines()[0][:120] + check(f"dlopen {os.path.basename(lib)}", False, detail) + +try: + jll_path, jll_is_own = jl.seval(""" + let path = realpath(Libdl.dlpath(OpenSSL_jll.libcrypto)), + roots = [realpath(p) for p in [joinpath(Sys.BINDIR, ".."); DEPOT_PATH] + if ispath(p)] + path, any(roots) do root + try + relative = relpath(path, root) + relative == "." || first(splitpath(relative)) != ".." + catch + false + end + end + end + """) + check("OpenSSL_jll is Julia's own", bool(jll_is_own), str(jll_path)) +except Exception as error: + check("OpenSSL_jll is Julia's own", False, str(error)) + +version_call = ( + "unsafe_string(ccall((:OpenSSL_version, OpenSSL_jll.libcrypto), " + "Cstring, (Cint,), 0))" +) +print("julia openssl in use:", jl.seval(version_call), flush=True) + +try: + context = ssl.create_default_context() +except Exception as error: + check("python TLS context after Julia", False, str(error)[:120]) + check("python HTTPS handshake after Julia", False, "TLS context unavailable") +else: + check("python TLS context after Julia", True, ssl.OPENSSL_VERSION) + try: + with socket.create_connection(("pypi.org", 443), timeout=60) as sock: + with context.wrap_socket(sock, server_hostname="pypi.org") as tls: + check("python HTTPS handshake after Julia", True, tls.version()) + except Exception as error: + check("python HTTPS handshake after Julia", False, str(error)[:120]) + +passed = all(results) +print("VERDICT:", "PASS" if passed else "FAIL", flush=True) +sys.exit(0 if passed else 1) diff --git a/test/test_internals.py b/test/test_internals.py index 979b6f7..f0395a7 100644 --- a/test/test_internals.py +++ b/test/test_internals.py @@ -4,9 +4,30 @@ import pytest import juliapkg +from juliapkg.compat import Compat, Version from juliapkg.deps import PkgSpec +def test_old_meta_forces_one_resolve(monkeypatch, tmp_path): + meta = tmp_path / "meta.json" + monkeypatch.setitem(juliapkg.deps.STATE, "meta", str(meta)) + body = { + "meta_version": juliapkg.deps.META_VERSION, + "override_executable": None, + "executable": str(tmp_path / "bin" / "julia"), + "version": "1.11.9", + "offline": False, + "deps_files": {}, + "libjulia": None, + "pkgs": [], + } + meta.write_text(json.dumps(body)) + assert juliapkg.deps.load_meta() == body + meta.write_text(json.dumps({**body, "meta_version": 7})) + assert juliapkg.deps.load_meta() is None + assert juliapkg.deps.can_skip_resolve() is False + + def test_openssl_compat(): assert juliapkg.deps.openssl_compat((1, 2, 3)) == ("1.2 - 1.2.3", "1 - 1.11") assert juliapkg.deps.openssl_compat((2, 3, 4)) == ("2.3 - 2.3.4", "1 - 1.11") @@ -21,6 +42,362 @@ def test_openssl_compat(): assert c[1] is None or isinstance(c[1], str) +_PROVIDERS = ("libcrypto.so.3", "libssl.so.3") +_ALIASES = ("libcrypto.jl.3", "libssl.jl.3") +_OPENSSL_JLL_UUID = "458c3c95-2e84-50aa-8efc-19380b2a3a95" + + +def _hardening_elf(*names): + return b"\x7fELF" + bytes(60) + b"\0" + b"\0".join(names) + b"\0" + + +def _hardening_jll(root, source="old"): + names = { + "old": (b"libcrypto.so.3", b"libssl.so.3"), + "partial": (b"libcrypto.jl.3", b"libssl.so.3"), + "private": (b"libcrypto.jl.3", b"libssl.jl.3"), + } + if source in names: + crypto, ssl = names[source] + contents = ( + b'const libcrypto = "' + crypto + b'"\nconst libssl = "' + ssl + b'"\n' + ) + else: + contents = b"module OpenSSL_jll\nend\n" + path = root / "share/julia/stdlib/v1.12/OpenSSL_jll/src/OpenSSL_jll.jl" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + return path + + +def _hardening_julia(root, providers=_PROVIDERS, rewritten=False, wrapper="old"): + exe = root / "bin/julia" + exe.parent.mkdir(parents=True) + exe.write_bytes(b"") + libdir = root / "lib/julia" + libdir.mkdir(parents=True) + for name in providers: + old = name.encode() + embedded = juliapkg.openssl.PRIVATE_NAMES[old] if rewritten else old + (libdir / name).write_bytes(_hardening_elf(embedded)) + source = _hardening_jll(root, wrapper) if wrapper is not None else None + return exe, libdir, source + + +@pytest.fixture +def linux_openssl(monkeypatch): + if os.name == "nt": + pytest.skip("requires POSIX symlinks") + monkeypatch.setattr(juliapkg.openssl.sys, "platform", "linux") + + +def _hardening_shield(exe, owned=True): + return juliapkg.openssl.shield(str(exe), Version.parse("1.12.7"), owned=owned) + + +def _state(paths): + return { + path: path.read_bytes() for path in paths if path is not None and path.exists() + } + + +def test_rewrite_names_is_exact_size_preserving_and_idempotent(tmp_path): + path = tmp_path / "libmine.so" + original = _hardening_elf( + b"libcrypto.so.3", b"libssl.so.3", b"mylibssl.so.3", b"libssl.so.3x" + ) + expected = original + for old, new in juliapkg.openssl.PRIVATE_NAMES.items(): + expected = expected.replace(b"\0" + old + b"\0", b"\0" + new + b"\0") + path.write_bytes(original) + + assert juliapkg.openssl.rewrite_names(str(path)) + assert path.read_bytes() == expected + assert len(path.read_bytes()) == len(original) + assert not juliapkg.openssl.rewrite_names(str(path)) + assert path.read_bytes() == expected + + +def test_rewrite_names_ignores_non_elf(tmp_path): + path = tmp_path / "script" + original = b"\0libcrypto.so.3\0" + path.write_bytes(original) + assert not juliapkg.openssl.rewrite_names(str(path)) + assert path.read_bytes() == original + + +@pytest.mark.parametrize( + ("platform", "safe"), [("darwin", True), ("win32", True), ("freebsd14", False)] +) +def test_non_linux_platform_is_immutable(monkeypatch, tmp_path, platform, safe): + exe, libdir, source = _hardening_julia(tmp_path) + paths = (source, *(libdir / name for name in _PROVIDERS)) + before = _state(paths) + monkeypatch.setattr( + juliapkg.openssl, "sys", type("Platform", (), {"platform": platform}) + ) + + assert _hardening_shield(exe)[0] is safe + assert _state(paths) == before + assert not any(os.path.lexists(libdir / name) for name in _ALIASES) + + +def test_owned_linux_shield_is_complete_and_idempotent(tmp_path, linux_openssl): + exe, libdir, source = _hardening_julia(tmp_path) + source.chmod(0o640) + assert _hardening_shield(exe)[0] + + wrapper = source.read_bytes() + for old, new in juliapkg.openssl.PRIVATE_NAMES.items(): + provider = libdir / old.decode() + assert os.path.samefile(provider, libdir / new.decode()) + assert b"\0" + old + b"\0" not in provider.read_bytes() + assert b"\0" + new + b"\0" in provider.read_bytes() + assert b'"' + old + b'"' not in wrapper + assert b'"' + new + b'"' in wrapper + assert source.stat().st_mode & 0o777 == 0o640 + paths = (source, *(libdir / name for name in _PROVIDERS)) + first = _state(paths) + assert _hardening_shield(exe)[0] + assert _state(paths) == first + + +@pytest.mark.parametrize( + ("providers", "wrapper", "missing", "message"), + [ + (_PROVIDERS, "old", True, "incomplete"), + ((_PROVIDERS[0],), "old", False, "incomplete"), + (_PROVIDERS, None, False, "could not privatize"), + (_PROVIDERS, "unknown", False, "unrecognized"), + ], +) +def test_missing_incomplete_or_unrecognized_layout_is_immutable( + tmp_path, linux_openssl, providers, wrapper, missing, message +): + if missing: + exe, libdir, source = tmp_path / "bin/julia", tmp_path / "lib/julia", None + else: + exe, libdir, source = _hardening_julia(tmp_path, providers, wrapper=wrapper) + paths = [source, *(libdir / name for name in providers)] + before = _state(paths) + + safe, note = _hardening_shield(exe) + assert not safe and message in note + assert _state(paths) == before + assert not any(os.path.lexists(libdir / name) for name in _ALIASES) + + +@pytest.mark.parametrize("kind", ["file", "wrong", "dangling"]) +def test_existing_private_path_fails_closed(tmp_path, linux_openssl, kind): + exe, libdir, source = _hardening_julia(tmp_path) + private = libdir / "libcrypto.jl.3" + if kind == "file": + private.write_bytes(b"foreign") + private_state = private.read_bytes() + else: + private.symlink_to("libssl.so.3" if kind == "wrong" else "missing") + private_state = os.readlink(private) + paths = (source, *(libdir / name for name in _PROVIDERS)) + before = _state(paths) + + safe, note = _hardening_shield(exe) + assert not safe and "private OpenSSL" in note + assert _state(paths) == before + assert ( + private.read_bytes() if kind == "file" else os.readlink(private) + ) == private_state + + +def test_partial_shield_is_retry_safe(tmp_path, linux_openssl): + exe, libdir, source = _hardening_julia(tmp_path, rewritten=True, wrapper="partial") + assert _hardening_shield(exe)[0] + wrapper = source.read_bytes() + for old, new in juliapkg.openssl.PRIVATE_NAMES.items(): + assert os.path.samefile(libdir / old.decode(), libdir / new.decode()) + assert b'"' + old + b'"' not in wrapper + assert b'"' + new + b'"' in wrapper + + +def test_atomic_wrapper_interruption_preserves_source( + monkeypatch, tmp_path, linux_openssl +): + exe, _, source = _hardening_julia(tmp_path) + original = source.read_bytes() + + def interrupt(*_): + raise OSError("interrupted replacement") + + monkeypatch.setattr(juliapkg.openssl.os, "replace", interrupt) + safe, note = _hardening_shield(exe) + assert not safe and "interrupted replacement" in note + assert source.read_bytes() == original + assert list(source.parent.iterdir()) == [source] + + +def test_walk_error_fails_closed(monkeypatch, tmp_path, linux_openssl): + exe, libdir, source = _hardening_julia(tmp_path) + paths = (source, *(libdir / name for name in _PROVIDERS)) + before = _state(paths) + + def fail_walk(root, onerror=None): + onerror(OSError("walk failed")) + + monkeypatch.setattr(juliapkg.openssl.os, "walk", fail_walk) + safe, note = _hardening_shield(exe) + assert not safe and "walk failed" in note + assert _state(paths) == before + + +def test_shield_refuses_foreign_install_without_mutation(tmp_path, linux_openssl): + exe, libdir, source = _hardening_julia(tmp_path) + paths = (source, *(libdir / name for name in _PROVIDERS)) + before = _state(paths) + assert not _hardening_shield(exe, owned=False)[0] + assert _state(paths) == before + assert not any(os.path.lexists(libdir / name) for name in _ALIASES) + + +@pytest.mark.skipif(os.name == "nt", reason="requires POSIX symlinks") +def test_installed_by_juliapkg_rejects_symlink_escape(monkeypatch, tmp_path): + install = tmp_path / "install" + outside = tmp_path / "outside" + (outside / "bin").mkdir(parents=True) + (outside / "bin/julia").write_bytes(b"") + install.mkdir() + (install / "escape").symlink_to(outside, target_is_directory=True) + monkeypatch.setitem(juliapkg.deps.STATE, "install", str(install)) + assert juliapkg.deps._installed_by_juliapkg(str(install / "bin/julia")) + assert not juliapkg.deps._installed_by_juliapkg(str(install / "escape/bin/julia")) + + +def _hardening_reconcile(monkeypatch, tmp_path, version, **options): + deps = juliapkg.deps + install = tmp_path / "install" + ours, theirs = str(install / "bin/julia"), "/usr/local/bin/julia" + owned = options.get("owned", True) + monkeypatch.setitem(deps.STATE, "install", str(install)) + monkeypatch.setitem(deps.STATE, "offline", options.get("offline", False)) + monkeypatch.setitem(deps.STATE, "override_executable", options.get("override")) + monkeypatch.setattr(deps.sys, "platform", "linux") + pin, cap = options.get("pin", "3 - 3.0"), options.get("cap", "1 - 1.11") + monkeypatch.setattr(deps, "openssl_compat", lambda *_: (pin, cap)) + + def shield(*_, owned): + return owned and options.get("shield_ok", True), "note" + + monkeypatch.setattr(juliapkg.openssl, "shield", shield) + found, calls = iter(options.get("found", ())), [] + + def find(compat=None, system=True, **kwargs): + calls.append((compat, system, kwargs.get("prefix"))) + is_ours, selected = next(found) + return (ours if is_ours else theirs), Version.parse(selected) + + monkeypatch.setattr(deps, "find_julia", find) + pkg = PkgSpec(name="OpenSSL_jll", uuid=_OPENSSL_JLL_UUID, version=pin) + pkg._openssl_python_bound = True + exe, selected = deps._reconcile_openssl( + ours if owned else theirs, Version.parse(version), Compat.parse("1"), [pkg] + ) + return exe, selected, pkg, calls, ours, str(install) + + +def _case(version, selected, bound, searches="", ours=True, **options): + if options.pop("uncapped", False): + options.update(pin="3 - 3.5", cap=None) + return version, options, selected, bound, searches.split(), ours + + +_OLD, _MODERN = (((True, "1.11.9"),), ((True, "1.12.7"),)) +_FAILED = dict(owned=False, shield_ok=False, found=(*_MODERN, *_OLD)) +_OFFLINE = dict(owned=False, offline=True, found=_OLD) +_FORCED = dict(owned=False, uncapped=True, override="/usr/local/bin/julia") +_UNCAPPED = dict(shield_ok=False, uncapped=True, found=_OLD) +_FUTURE = dict(_FAILED, uncapped=True, found=((True, "1.13.1"), *_OLD)) +_RESOLVER_CASES = [ + _case("1.11.9", "1.11.9", "3 - 3.0"), + _case("1.12.7", "1.12.7", None), + _case("1.12.6", "1.12.7", None, "prefix", owned=False, found=_MODERN), + _case("1.12.6", "1.11.9", "3 - 3.0", "prefix fallback", **_FAILED), + _case("1.12.7", "1.11.9", "3 - 3.0", "fallback", shield_ok=False, found=_OLD), + _case("1.12.7", "1.11.9", "3 - 3.0", "fallback", **_OFFLINE), + _case("1.12.7", "1.12.7", None, ours=False, **_FORCED), + _case("1.12.7", "1.11.9", "3 - 3.5", "fallback", **_UNCAPPED), + _case("1.13.0", "1.11.9", "3 - 3.5", "prefix fallback", **_FUTURE), +] + + +@pytest.mark.parametrize( + ("version", "options", "selected", "bound", "searches", "ours"), _RESOLVER_CASES +) +def test_resolver_state_matrix( + monkeypatch, tmp_path, version, options, selected, bound, searches, ours +): + exe, actual, pkg, calls, own_exe, install = _hardening_reconcile( + monkeypatch, tmp_path, version, **options + ) + assert exe == (own_exe if ours else "/usr/local/bin/julia") + assert str(actual) == selected + assert pkg.version == bound + assert [("fallback" if system else "prefix") for _, system, _ in calls] == searches + safe = Compat.parse("1") & Compat.parse("1 - 1.11") + assert all(prefix == install for _, _, prefix in calls) + assert all((compat == safe) is system for compat, system, _ in calls) + + +def test_unsafe_forced_modern_julia_has_actionable_error(monkeypatch, tmp_path): + with pytest.raises(Exception) as excinfo: + _hardening_reconcile( + monkeypatch, + tmp_path, + "1.12.7", + owned=False, + override="/usr/local/bin/julia", + ) + message = str(excinfo.value) + for choice in ( + "juliapkg_exe", + "Julia 1.11 or earlier", + "OpenSSL 3.5 or newer", + "unset juliapkg_exe", + ): + assert choice in message + + +def _openssl_requirement(monkeypatch, tmp_path, versions): + deps, files = juliapkg.deps, [] + for index, version in enumerate(versions): + path = tmp_path / str(index) / "juliapkg.json" + path.parent.mkdir() + package = {"uuid": _OPENSSL_JLL_UUID, "version": version} + path.write_text(json.dumps({"packages": {"OpenSSL_jll": package}})) + files.append(str(path)) + monkeypatch.setattr(deps, "deps_files", lambda: files) + monkeypatch.setattr(deps, "openssl_compat", lambda *_: ("3 - 3.0", "1 - 1.11")) + pkg = deps.find_requirements()[1][0] + install = tmp_path / "install" + exe = str(install / "bin/julia") + monkeypatch.setitem(deps.STATE, "install", str(install)) + monkeypatch.setitem(deps.STATE, "override_executable", None) + monkeypatch.setattr(juliapkg.openssl, "shield", lambda *_, **__: (True, "note")) + deps._reconcile_openssl(exe, Version.parse("1.12.7"), Compat.parse("1"), [pkg]) + return pkg + + +@pytest.mark.parametrize( + ("versions", "expected"), + [ + (("<=python",), None), + (("3 - 3.0",), "~3.0"), + (("<=python", "3 - 3.0"), "~3.0"), + ], +) +def test_generated_and_explicit_openssl_bound_wiring( + monkeypatch, tmp_path, versions, expected +): + assert _openssl_requirement(monkeypatch, tmp_path, versions).version == expected + + def test_pkgspec_validation(): # Test valid construction spec = PkgSpec(name="Example", uuid="123e4567-e89b-12d3-a456-426614174000")