From 82b96a8341b3613e0c40d1d16e4556e2579baf11 Mon Sep 17 00:00:00 2001 From: zoeyrose <3865595+zoeyrose@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:43:19 +0000 Subject: [PATCH 1/7] feat(portable): add Debian 12 Classic build baseline --- .dockerignore | 6 ++ .github/workflows/publish-linux.yml | 87 +++++++++++++++++ .github/workflows/validate.yml | 96 ++++++++++++++++++- .gitignore | 3 + AGENTS.md | 10 +- README.md | 49 ++++++++++ portable/Dockerfile | 83 ++++++++++++++++ portable/abi.py | 141 ++++++++++++++++++++++++++++ portable/build.py | 118 +++++++++++++++++++++++ portable/consumer.sh | 43 +++++++++ portable/contract.json | 120 +++++++++++++++++++++++ portable/inventory.py | 35 +++++++ portable/legal.py | 43 +++++++++ portable/media-smoke.c | 37 ++++++++ portable/packages.lock | 43 +++++++++ portable/shader-record.py | 26 +++++ portable/smoke.sh | 23 +++++ portable/test_abi.py | 61 ++++++++++++ tools/require-image-checks.sh | 8 +- tools/test-require-image-checks.sh | 27 ++++-- 20 files changed, 1045 insertions(+), 14 deletions(-) create mode 100644 portable/Dockerfile create mode 100644 portable/abi.py create mode 100644 portable/build.py create mode 100755 portable/consumer.sh create mode 100644 portable/contract.json create mode 100644 portable/inventory.py create mode 100644 portable/legal.py create mode 100644 portable/media-smoke.c create mode 100644 portable/packages.lock create mode 100644 portable/shader-record.py create mode 100755 portable/smoke.sh create mode 100644 portable/test_abi.py diff --git a/.dockerignore b/.dockerignore index ba6eed0..e4eb5e5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,9 @@ .git **/.DS_Store **/*~ + +# Consumer checkouts, generated evidence and bytecode are not image inputs. +/build/ +**/.git +**/__pycache__ +**/*.pyc diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml index b2738aa..2dcfd09 100644 --- a/.github/workflows/publish-linux.yml +++ b/.github/workflows/publish-linux.yml @@ -109,6 +109,60 @@ jobs: type=gha,scope=classic-build-image cache-to: type=gha,mode=max,scope=classic-build-image,ignore-error=true + # The portable target has its own ABI and real consumer; validate it before aliases move. + - name: Read portable consumer revision + if: ${{ !inputs.candidate_only }} + id: portable-consumer + run: echo "commit=$(jq -er '.consumer.commit' portable/contract.json)" >> "${GITHUB_OUTPUT}" + + - name: Check out portable Classic consumer + if: ${{ !inputs.candidate_only }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: atrinik/classic + ref: ${{ steps.portable-consumer.outputs.commit }} + path: build/portable-classic + persist-credentials: false + + - name: Check portable Dockerfile + if: ${{ !inputs.candidate_only }} + run: docker build --check --file portable/Dockerfile . + + - name: Validate portable baseline + if: ${{ !inputs.candidate_only }} + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: portable/Dockerfile + target: portable-validation + platforms: linux/amd64 + outputs: type=cacheonly + cache-from: type=gha,scope=classic-portable-build-image + cache-to: type=gha,mode=max,scope=classic-portable-build-image,ignore-error=true + + - name: Load portable release candidate + if: ${{ !inputs.candidate_only }} + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: portable/Dockerfile + target: portable-final + platforms: linux/amd64 + load: true + tags: atrinik-classic-portable:release-validation + cache-from: type=gha,scope=classic-portable-build-image + + - name: Validate real portable consumer before publication + if: ${{ !inputs.candidate_only }} + run: | + install -d build/portable-reports + docker run --rm --network none \ + --user "$(id -u):$(id -g)" --env HOME=/tmp/portable-home \ + --volume "${GITHUB_WORKSPACE}/build/portable-classic:/classic:ro" \ + --volume "${GITHUB_WORKSPACE}/build/portable-reports:/reports" \ + atrinik-classic-portable:release-validation \ + /opt/atrinik-portable/consumer.sh /classic /reports + - name: Build and publish Linux image if: ${{ !inputs.candidate_only }} uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 @@ -150,3 +204,36 @@ jobs: cache-to: | type=inline type=gha,mode=max,scope=classic-build-image,ignore-error=true + + - name: Publish immutable portable candidate + if: ${{ !inputs.candidate_only }} + id: portable-publish + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: portable/Dockerfile + target: portable-final + platforms: linux/amd64 + push: true + tags: ghcr.io/${{ github.repository_owner }}/classic-portable-build:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + sbom: true + provenance: mode=max + cache-from: type=gha,scope=classic-portable-build-image + + - name: Promote validated portable aliases + if: ${{ !inputs.candidate_only }} + env: + PORTABLE_DIGEST: ${{ steps.portable-publish.outputs.digest }} + run: | + [[ "${PORTABLE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/classic-portable-build" + tags=(--tag "${image}:latest" --tag "${image}:debian-12") + if [[ "${GITHUB_REF_TYPE}" == tag ]]; then + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + tags+=(--tag "${image}:${GITHUB_REF_NAME#v}") + fi + docker buildx imagetools create "${tags[@]}" "${image}@${PORTABLE_DIGEST}" + printf '%s@%s\n' "${image}" "${PORTABLE_DIGEST}" >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index cde6812..c9eba89 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,6 +15,7 @@ jobs: name: Select changed images runs-on: ubuntu-26.04 outputs: + portable: ${{ steps.changes.outputs.portable }} classic: ${{ steps.changes.outputs.classic }} linux: ${{ steps.changes.outputs.linux }} windows: ${{ steps.changes.outputs.windows }} @@ -30,10 +31,21 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | + portable=false classic=false linux=false windows=false while IFS= read -r path; do + case "${path}" in + .dockerignore | .github/actionlint.yaml | .github/workflows/* | \ + portable/* | classic-packages.lock | \ + classic-shader-toolchain.json | classic-shader-toolchain.spdx.json | \ + audio-toolchain.json | audio-toolchain.spdx.json | tools/audio/* | \ + tools/build-sdl3-mixer.sh | tools/install_classic_shader_toolchain.py | \ + tools/require-image-checks.sh | tools/test-require-image-checks.sh) + portable=true + ;; + esac case "${path}" in .dockerignore | .github/actionlint.yaml | \ .github/workflows/* | linux/* | \ @@ -97,11 +109,15 @@ jobs: esac done < <(git diff --no-renames --name-only "${BASE_SHA}" "${HEAD_SHA}") { + echo "portable=${portable}" echo "classic=${classic}" echo "linux=${linux}" echo "windows=${windows}" } >> "${GITHUB_OUTPUT}" + - name: Test portable ABI checks + run: python3 -m unittest discover -s portable -p 'test_*.py' + - name: Test required-check aggregation run: tools/test-require-image-checks.sh @@ -407,10 +423,85 @@ jobs: atrinik-classic-build:validation \ /image-source/tools/validate-classic-check.sh /workspace + portable: + name: Portable Classic image + needs: changes + if: needs.changes.outputs.portable == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 180 + permissions: + contents: read + steps: + - name: Check out image sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Read immutable portable consumer + id: consumer + run: echo "commit=$(jq -er '.consumer.commit' portable/contract.json)" >> "${GITHUB_OUTPUT}" + + - name: Check out immutable Classic sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: atrinik/classic + ref: ${{ steps.consumer.outputs.commit }} + path: build/portable-classic + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Check portable Dockerfile + run: docker build --check --file portable/Dockerfile . + + - name: Build and smoke baseline as non-root + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: portable/Dockerfile + target: portable-validation + platforms: linux/amd64 + outputs: type=cacheonly + cache-from: type=gha,scope=classic-portable-build-image + cache-to: type=gha,mode=max,scope=classic-portable-build-image,ignore-error=true + + - name: Load portable image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: portable/Dockerfile + target: portable-final + platforms: linux/amd64 + load: true + tags: atrinik-classic-portable:validation + cache-from: type=gha,scope=classic-portable-build-image + + - name: Build and test real non-root Classic client on the baseline + run: | + install -d build/portable-reports + docker run --rm --network none \ + --user "$(id -u):$(id -g)" --env HOME=/tmp/portable-home \ + --volume "${GITHUB_WORKSPACE}/build/portable-classic:/classic:ro" \ + --volume "${GITHUB_WORKSPACE}/build/portable-reports:/reports" \ + atrinik-classic-portable:validation \ + /opt/atrinik-portable/consumer.sh /classic /reports + + - name: Retain portable consumer and ABI evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: classic-portable-consumer-evidence + path: build/portable-reports + if-no-files-found: error + retention-days: 7 + required: name: Required checks needs: - changes + - portable - classic - linux - windows @@ -427,6 +518,8 @@ jobs: - name: Require successful applicable validations env: + PORTABLE_SELECTED: ${{ needs.changes.outputs.portable }} + PORTABLE_RESULT: ${{ needs.portable.result }} CHANGES_RESULT: ${{ needs.changes.result }} CLASSIC_SELECTED: ${{ needs.changes.outputs.classic }} CLASSIC_RESULT: ${{ needs.classic.result }} @@ -441,4 +534,5 @@ jobs: "${CLASSIC_SELECTED}" "${CLASSIC_RESULT}" \ "${LINUX_SELECTED}" "${LINUX_RESULT}" \ "${WINDOWS_SELECTED}" "${WINDOWS_RESULT}" \ - "${WINDOWS_NATIVE_RESULT}" + "${WINDOWS_NATIVE_RESULT}" \ + "${PORTABLE_SELECTED}" "${PORTABLE_RESULT}" diff --git a/.gitignore b/.gitignore index 84c048a..47706e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /build/ + +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index 86cffec..60d8bcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,13 @@ - Treat Dockerfile inputs, `.dockerignore`, cache scopes, build arguments, published tags, and workflow path filters as one contract. If a relevant file changes, the required aggregate validation must still run. +- `portable/Dockerfile` owns the separate `portable-final` Debian 12/glibc 2.36 + Linux/amd64 build target and its sealed shader cohort. Keep its input/package, + CPU/ABI/provider, source/license, non-root consumer, required-check and + `classic-portable-build` publication contracts synchronized. Generate shaders + with the unchanged canonical tools in the build-only Ubuntu stage; copy only + canonical-manifest-verified data into Debian. The consumer rejects changed + source commits or shader inputs. Never repurpose this target as a coordinator. - `classic-final` is the slim Classic Check target. Keep its Ubuntu snapshot, direct package lock, tool inventory, non-root ccache mount, Classic validation revision, shader-toolchain inventory, GPU runtime, smoke/SBOM checks, and @@ -40,7 +47,8 @@ must never move a rolling, platform, or version tag. - Every semantic release publishes the broad Linux, slim Linux Classic, general Windows, and task-focused Windows Classic images with their - supported tags. The Linux publisher owns `linux-build` and `classic-build`; + supported tags. The Linux publisher owns `linux-build`, `classic-build`, and + `classic-portable-build`; the Windows publisher owns both `windows-build` variants. Keep the `classic-check` target branched from the expensive shared MXE foundation before general-image Python/worldmaker additions, preserve the general diff --git a/README.md b/README.md index 55cebaa..5464a16 100644 --- a/README.md +++ b/README.md @@ -409,3 +409,52 @@ the pinned Classic client and server checks as a non-root user. The repository's original build configuration and automation are MIT licensed; see [LICENSE](LICENSE). Software installed into the published images retains its own upstream license. + +## Portable Linux Classic baseline + +`portable/Dockerfile` provides `portable-final` for Linux/amd64. It builds the +modern SDL3 family and OpenSSL 3.5 against a digest-pinned Debian 12/glibc 2.36 +base and signed package snapshot. The separate contract is +[`portable/contract.json`](portable/contract.json). This target does not change +the canonical Ubuntu coordinator, existing Classic Check, or Windows images. + +The canonical DXC payload needs newer glibc than Debian 12. A build-only Ubuntu +stage uses the unchanged shader-toolchain lock to generate the pinned Classic +cohort, compares it with the source's canonical manifest, and records input, +tool, installer and output hashes. Only shader data and notices cross into +Debian. Consumers must verify the exact source commit and every shader input, +then pass `/opt/atrinik-portable/shaders` as `ATRINIK_GPU_SHADER_DIRECTORY`. +Changed source inputs require a reviewed image update; the consumer check fails +rather than silently regenerating with a different compiler. + +The image exposes its contract, actual tool/package versions, Debian source +coordinates, source archives, notices, and shader-generation record under +`/opt/atrinik-portable`. Shared application libraries live under `/usr/local`; +Debian packages provide the baseline transitive libraries. Compiler flags use +`-march=x86-64 -mtune=generic`. Verification checks actual ELF dependency +providers, versioned symbols, loader relocations and CPU notes, plus device-free +image/font decoding, audio decoding and OpenSSL provider loading. Graphics +loaders are application dependencies; host graphics drivers stay external. + +Automatic PR CI explicitly selects `Portable Classic image`, builds/checks the +Dockerfile without registry credentials, runs non-root smoke, and compiles and +tests the exact Classic consumer offline in the baseline image. `Required +checks` fails for any selected missing, skipped, cancelled or failed portable +job. The consumer artifact retains source, compiler, test and ELF evidence. +These container checks do not qualify hardware gameplay, audible playback or +relocation across the final Ubuntu/Debian distribution matrix. + +After an authorized maintainer merge and semantic release, the Linux publisher +validates this target and consumer before release aliases move. It publishes +`classic-portable-build:sha-COMMIT` with SBOM/provenance, then promotes that exact +digest to `latest`, `debian-12` and the semantic version. Recover a partial +promotion by rerunning the failed job from the same workflow run. The existing +`candidate_only` dispatch retains its Classic-only behavior. A dispatch, +registry push, merge or release is a separate publication action; PR CI performs +none of them. Consumers select only the actual published immutable manifest +digest and retain producer-run/source coordinates. + +Redistributors must retain the pinned source archives, notices, build recipes, +and Debian source coordinates and satisfy the corresponding-source and LGPL +replacement/relinking obligations in the contract. Authored game media remain +`content@main`, resources and sound inputs owned by their respective repositories. diff --git a/portable/Dockerfile b/portable/Dockerfile new file mode 100644 index 0000000..c8ef003 --- /dev/null +++ b/portable/Dockerfile @@ -0,0 +1,83 @@ +# syntax=docker/dockerfile:1 +# Shader generators stay in the existing Ubuntu ABI; only verified data crosses. +FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03 AS portable-shaders +ARG TARGETARCH +ENV DEBIAN_FRONTEND=noninteractive +COPY classic-packages.lock /tmp/classic-packages.lock +RUN test "${TARGETARCH}" = amd64 \ + && sed -i -e 's|http://archive.ubuntu.com/ubuntu/|http://snapshot.ubuntu.com/ubuntu/20260810T000000Z/|' \ + -e 's|http://security.ubuntu.com/ubuntu/|http://snapshot.ubuntu.com/ubuntu/20260810T000000Z/|' \ + /etc/apt/sources.list.d/ubuntu.sources \ + && sed -i '/^Signed-By:/a Check-Valid-Until: no' /etc/apt/sources.list.d/ubuntu.sources \ + && apt-get -o Acquire::Retries=5 update \ + && xargs apt-get -o Acquire::Retries=5 install -y --no-install-recommends < /tmp/classic-packages.lock \ + && rm -rf /var/lib/apt/lists/* +COPY classic-shader-toolchain.json /opt/shader-toolchain.json +COPY tools/install_classic_shader_toolchain.py /opt/install-shaders.py +RUN python3 /opt/install-shaders.py --manifest /opt/shader-toolchain.json \ + --cache /tmp/shader-inputs --prefix /usr/local --jobs 2 \ + && ldconfig \ + && install -d -o ubuntu -g ubuntu /cohort +COPY portable/contract.json /opt/contract.json +USER ubuntu +RUN git init /cohort/source \ + && git -C /cohort/source remote add origin https://github.com/atrinik/classic.git \ + && commit=$(python3 -c 'import json; print(json.load(open("/opt/contract.json"))["consumer"]["commit"])') \ + && git -C /cohort/source fetch --depth 1 origin "${commit}" \ + && git -C /cohort/source checkout --detach FETCH_HEAD \ + && test "$(git -C /cohort/source rev-parse HEAD)" = "${commit}" \ + && /cohort/source/client/tools/generate_gpu_shaders.sh /usr/local/bin/dxc \ + /usr/local/bin/spirv-cross /cohort/shaders \ + && cd /cohort/shaders && sha256sum -c SHA256SUMS \ + && sha256sum -c /cohort/source/client/shaders/SHA256SUMS +COPY portable/shader-record.py /opt/shader-record.py +RUN python3 /opt/shader-record.py + + +FROM debian:bookworm-slim@sha256:5ae3c39ebd15e229dcedd5cee596b2497182493d41ff162e824ba13fc1b2b867 AS portable-build +ARG TARGETARCH +ENV DEBIAN_FRONTEND=noninteractive \ + CFLAGS="-O2 -march=x86-64 -mtune=generic" \ + CXXFLAGS="-O2 -march=x86-64 -mtune=generic" \ + PKG_CONFIG_PATH=/usr/local/lib/pkgconfig \ + OPENSSL_MODULES=/usr/local/lib/ossl-modules +COPY portable/packages.lock /tmp/portable-packages.lock +# Signed snapshot metadata and package hashes remain mandatory, including bootstrap. +RUN test "${TARGETARCH}" = amd64 \ + && test "$(dpkg --print-architecture)" = amd64 \ + && rm /etc/apt/sources.list.d/debian.sources \ + && printf '%s\n' \ + 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20260901T000000Z bookworm main' \ + 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20260901T000000Z bookworm-updates main' \ + 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260901T000000Z bookworm-security main' \ + > /etc/apt/sources.list \ + && apt-get -o Acquire::Retries=5 update \ + && xargs apt-get -o Acquire::Retries=5 install -y --no-install-recommends < /tmp/portable-packages.lock \ + && sed -i 's|http://snapshot.debian.org|https://snapshot.debian.org|g' /etc/apt/sources.list \ + && sed 's/^deb /deb-src /' /etc/apt/sources.list >> /etc/apt/sources.list.d/portable-source.list \ + && apt-get -o Acquire::Retries=5 update \ + && useradd --create-home --uid 1000 --shell /bin/bash ubuntu +COPY portable /opt/atrinik-portable +COPY audio-toolchain.json audio-toolchain.spdx.json classic-shader-toolchain.json classic-shader-toolchain.spdx.json /opt/atrinik-portable/ +COPY tools/build-sdl3-mixer.sh tools/install_classic_shader_toolchain.py /opt/atrinik-portable/tools/ +COPY tools/audio /opt/atrinik-portable/audio +RUN python3 /opt/atrinik-portable/build.py \ + && ldconfig \ + && python3 /opt/atrinik-portable/inventory.py \ + && python3 /opt/atrinik-portable/legal.py \ + && rm -rf /var/lib/apt/lists/* \ + && install -d -m 1777 /cache/ccache +COPY --from=portable-shaders /cohort/shaders /opt/atrinik-portable/shaders +COPY --from=portable-shaders /cohort/input-sha256.txt /opt/atrinik-portable/shader-inputs.txt +COPY --from=portable-shaders /cohort/generation.json /opt/atrinik-portable/shader-generation.json +# Build-time shader licenses/provenance accompany the generated cohort; no Ubuntu ELF. +COPY --from=portable-shaders /usr/local/share/licenses /opt/atrinik-portable/notices/shader-tools +USER ubuntu +WORKDIR /home/ubuntu + +FROM portable-build AS portable-validation +RUN /opt/atrinik-portable/smoke.sh + +FROM portable-build AS portable-final +LABEL org.opencontainers.image.title="Atrinik portable Classic build baseline" \ + org.opencontainers.image.description="Linux amd64 Debian 12/glibc 2.36 application ABI; sealed canonical shader cohort" diff --git a/portable/abi.py b/portable/abi.py new file mode 100644 index 0000000..7bea1f7 --- /dev/null +++ b/portable/abi.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Verify actual ELF providers, versioned symbols, loader closure and ISA notes.""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +ROOT = Path(__file__).resolve().parent +DEFAULT_DIRS = [Path("/usr/local/lib"), Path("/lib/x86_64-linux-gnu"), Path("/usr/lib/x86_64-linux-gnu"), Path("/lib64")] + + +def output(*args: str) -> str: + return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT) + + +def symbols(text: str) -> tuple[set[str], set[str]]: + defined, required = set(), set() + for line in text.splitlines(): + fields = line.split() + if len(fields) < 8 or not fields[0].rstrip(":").isdigit(): + continue + _, _, _, _, binding, visibility, index, name = fields[:8] + if index == "UND" and binding == "GLOBAL": + required.add(name.replace("@@", "@")) + elif index != "UND" and visibility in {"DEFAULT", "PROTECTED"}: + defined.add(name.replace("@@", "@")) + if "@@" in name or "@" not in name: + defined.add(name.split("@", 1)[0]) + return defined, required + + +def elf(path: Path) -> dict: + header = output("readelf", "-h", str(path)) + if "Advanced Micro Devices X86-64" not in header or "ELF64" not in header: + raise ValueError(f"wrong ELF target: {path}") + notes = output("readelf", "-n", str(path)) + for isa in re.findall(r"x86 ISA needed: ([^\n]+)", notes): + if isa.strip() != "x86-64-baseline": + raise ValueError(f"newer CPU ISA required: {path}: {isa}") + dynamic = output("readelf", "-d", str(path)) + needed = re.findall(r"\(NEEDED\).*\[([^\]]+)\]", dynamic) + paths = [] + for value in re.findall(r"\((?:RUNPATH|RPATH)\).*\[([^\]]*)\]", dynamic): + for item in value.split(":"): + item = item.replace("${ORIGIN}", str(path.parent)).replace("$ORIGIN", str(path.parent)) + if not item.startswith("/") or "$" in item: + raise ValueError(f"unsupported loader search path: {path}: {item}") + paths.append(Path(item)) + versions = output("readelf", "--version-info", str(path)) + provider = None + version_providers = {} + in_needs = False + for line in versions.splitlines(): + if line.startswith("Version needs section"): + in_needs = True + if not in_needs: + continue + match = re.search(r"File: (\S+)", line) + if match: + provider = match[1] + match = re.search(r"Name: (\S+)", line) + if match and provider: + version = match[1] + version_providers[version] = provider + if version.startswith("GLIBC_") and version != "GLIBC_PRIVATE": + value = version.removeprefix("GLIBC_") + if value[0].isdigit() and tuple(map(int, value.split("."))) > (2, 36): + raise ValueError(f"newer glibc requirement: {path}: {version}") + defined, required = symbols(output("readelf", "--dyn-syms", "--wide", str(path))) + return dict(needed=needed, paths=paths, defined=defined, required=required, + version_providers=version_providers) + + +def resolve(name: str, paths: list[Path]) -> Path: + if "/" in name: + raise ValueError(f"non-SONAME ELF dependency: {name}") + for directory in [*paths, *DEFAULT_DIRS]: + path = directory / name + if path.is_file(): + resolved = path.resolve() + if not any(resolved.is_relative_to(root) for root in [Path("/usr/local/lib"), Path("/usr/lib"), Path("/lib").resolve()]): + raise ValueError(f"provider outside baseline roots: {resolved}") + return resolved + raise ValueError(f"missing baseline ELF provider: {name}") + + +def audit(roots: list[Path]) -> dict: + graph = {} + pending = list(roots) + while pending: + path = pending.pop().resolve() + if path in graph: + continue + value = elf(path) + value["providers"] = {name: resolve(name, value["paths"]) for name in value["needed"]} + graph[path] = value + pending.extend(value["providers"].values()) + exports = set().union(*(value["defined"] for value in graph.values())) + for path, value in graph.items(): + for symbol in value["required"]: + if "@" in symbol: + version = symbol.split("@", 1)[1] + provider = value["version_providers"].get(version) + resolved = value["providers"].get(provider) + if resolved is None or symbol not in graph[resolved]["defined"]: + raise ValueError(f"unmet versioned provider symbol: {path}: {symbol}: {provider}") + elif symbol not in exports: + raise ValueError(f"unresolved application symbol: {path}: {symbol}") + # The baseline's actual loader also checks its search/order and relocations. + for path in roots: + result = subprocess.run(["ldd", "-r", str(path)], text=True, capture_output=True) + text = result.stdout + result.stderr + if result.returncode or "not found" in text or "undefined symbol:" in text: + raise ValueError(f"baseline relocation check failed: {path}: {text}") + return {"schema_version": 1, "glibc": "2.36", "cpu": "x86-64", + "roots": [str(p) for p in roots], "objects": [ + {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "needed": {name: str(provider) for name, provider in value["providers"].items()}, + "required_symbols": sorted(value["required"])} + for path, value in sorted(graph.items())]} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("elf", type=Path, nargs="*") + args = parser.parse_args() + contract = json.loads((ROOT / "contract.json").read_text()) + roots = list(args.elf) + if not roots: + roots = sorted({p.resolve() for p in Path("/usr/local/lib").glob("*.so*") if p.is_file()}) + roots += [Path(p) for p in contract["runtime"]["providers"]] + roots += [resolve(name, []) for name in contract["runtime"]["graphics_loaders"]] + args.output.write_text(json.dumps(audit(roots), indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/portable/build.py b/portable/build.py new file mode 100644 index 0000000..e065b65 --- /dev/null +++ b/portable/build.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Build the locked application inputs on the Debian baseline.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "tools")) +from install_classic_shader_toolchain import ( # noqa: E402 + MAX_EXPANDED_BYTES, MAX_FILE_BYTES, MAX_MEMBERS, ToolchainError, + copy_member, download, safe_archive_path, +) + + +def extract(archive_path: Path, destination: Path) -> None: + """Extract Linux source inputs; retain full archives including unused Xcode links.""" + seen = set() + roots = set() + expanded = 0 + with tarfile.open(archive_path, "r:gz") as archive: + for count, member in enumerate(archive, 1): + if count > MAX_MEMBERS: + raise ToolchainError("source archive has too many members") + path = safe_archive_path(member.name) + roots.add(path.parts[0]) + if len(roots) != 1: + raise ToolchainError("source archive must have one root") + if len(path.parts) == 1: + continue + relative = Path(*path.parts[1:]) + # Darwin framework symlinks are not inputs to these Linux builds. + if relative.parts[0] == "Xcode": + continue + if str(relative).casefold() in seen: + raise ToolchainError("duplicate source archive member") + seen.add(str(relative).casefold()) + target = destination / relative + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + if not member.isfile() or member.size > MAX_FILE_BYTES: + raise ToolchainError("unsupported source archive member") + expanded += member.size + if expanded > MAX_EXPANDED_BYTES: + raise ToolchainError("source archive is too large") + with archive.extractfile(member) as source: + copy_member(source, target, member.size) + target.chmod(member.mode & 0o755 or 0o644) + + +def run(*args: str, cwd: Path | None = None) -> None: + subprocess.run(args, cwd=cwd, check=True) + + +def main() -> None: + contract = json.loads((ROOT / "contract.json").read_text()) + archives = ROOT / "sources" + notices = ROOT / "notices" + notices.mkdir(exist_ok=True) + options = { + "sdl3": ["-DSDL_DEPS_SHARED=OFF", "-DSDL_KMSDRM=OFF", "-DSDL_LIBDECOR=OFF", "-DSDL_TESTS=OFF", "-DSDL_TEST_LIBRARY=OFF", "-DSDL_STATIC=OFF", + "-DSDL_ALSA_SHARED=OFF", "-DSDL_PULSEAUDIO_SHARED=OFF", + "-DSDL_X11_SHARED=OFF", "-DSDL_WAYLAND_SHARED=OFF", + "-DSDL_JACK=OFF", "-DSDL_PIPEWIRE=OFF", "-DSDL_IBUS=OFF", + "-DSDL_FCITX=OFF", "-DSDL_LIBUDEV=OFF", "-DSDL_LIBDECOR_SHARED=OFF"], + "sdl3-image": ["-DSDLIMAGE_STRICT=ON", "-DSDLIMAGE_VENDORED=OFF", "-DSDLIMAGE_DEPS_SHARED=OFF", + "-DSDLIMAGE_AVIF=OFF", "-DSDLIMAGE_JXL=OFF", "-DSDLIMAGE_TIF=OFF", + "-DSDLIMAGE_WEBP=ON", "-DSDLIMAGE_PNG=ON", "-DSDLIMAGE_JPG=ON", + "-DSDLIMAGE_SAMPLES=OFF", "-DSDLIMAGE_TESTS=OFF"], + "sdl3-ttf": ["-DSDLTTF_VENDORED=OFF", "-DSDLTTF_HARFBUZZ=ON", + "-DSDLTTF_PLUTOSVG=OFF", "-DSDLTTF_SAMPLES=OFF"], + } + with tempfile.TemporaryDirectory(prefix="portable-build-") as temp: + work = Path(temp) + for entry in contract["sources"]: + source = work / entry["name"] + extract(download(entry["url"], entry["sha256"], archives), source) + license_dir = notices / entry["name"] + license_dir.mkdir() + for candidate in source.iterdir(): + if candidate.is_file() and candidate.name.upper().startswith(("LICENSE", "LICENCE", "COPYING", "NOTICE")): + shutil.copy2(candidate, license_dir / candidate.name) + if not any(license_dir.iterdir()): + raise RuntimeError(f"no upstream notice for {entry['name']}") + if entry["name"] == "openssl": + run("perl", "Configure", "linux-x86_64", "shared", "--prefix=/usr/local", + "--libdir=lib", "--openssldir=/etc/ssl", "-march=x86-64", "-mtune=generic", cwd=source) + run("make", "-j2", cwd=source) + run("make", "install_sw", cwd=source) + run("ldconfig") + else: + build = work / (entry["name"] + "-build") + run("cmake", "-S", str(source), "-B", str(build), "-G", "Ninja", + "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_INSTALL_PREFIX=/usr/local", + "-DCMAKE_INSTALL_LIBDIR=lib", "-DBUILD_SHARED_LIBS=ON", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", *options[entry["name"]]) + run("cmake", "--build", str(build), "--parallel", "2") + run("cmake", "--install", str(build)) + audio = json.loads((ROOT / "audio-toolchain.json").read_text()) + for entry in [audio["sdl_mixer"], *audio["dependencies"]]: + download(entry["source"]["url"], entry["source"]["sha256"], archives) + run("bash", str(ROOT / "tools/build-sdl3-mixer.sh"), + str(ROOT / "audio-toolchain.json"), "cmake", "/usr/local", "2") + run("ldconfig") + run("cc", *contract["compiler"]["cflags"].split(), str(ROOT / "audio/sdl3-mixer-probe.c"), + "-o", str(ROOT / "audio/sdl3-mixer-probe"), + *subprocess.check_output(["pkg-config", "--cflags", "--libs", "sdl3-mixer"], text=True).split()) + + +if __name__ == "__main__": + main() diff --git a/portable/consumer.sh b/portable/consumer.sh new file mode 100755 index 0000000..6e9d8dc --- /dev/null +++ b/portable/consumer.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# != 2 ]]; then + echo "usage: $0 CLASSIC_SOURCE REPORT_DIRECTORY" >&2 + exit 2 +fi +root=/opt/atrinik-portable +source=$(realpath "$1") +reports=$(realpath "$2") +test "$(id -u)" != 0 +expected=$(jq -er '.consumer.commit' "${root}/contract.json") +test "$(git -C "${source}" rev-parse HEAD)" = "${expected}" +test -z "$(git -C "${source}" status --porcelain --untracked-files=normal)" +while read -r expected_hash input; do + relative=${input#/cohort/source/} + [[ ${relative} == client/* && ${relative} != *..* ]] + printf '%s %s\n' "${expected_hash}" "${source}/${relative}" | sha256sum -c - +done < "${root}/shader-inputs.txt" +"${root}/smoke.sh" +work=$(mktemp -d) +trap 'rm -rf -- "${work}"' EXIT +cmake -S "${source}" -B "${work}/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DATRINIK_BUILD_CLIENT=ON -DATRINIK_BUILD_SERVER=OFF \ + -DBUILD_TESTING=ON -DPACKAGE_TYPE=none \ + -DATRINIK_GPU_SHADER_DIRECTORY="${root}/shaders" \ + '-DCMAKE_C_FLAGS=-march=x86-64 -mtune=generic' +cmake --build "${work}/build" --parallel 2 +ctest --test-dir "${work}/build" --output-on-failure --timeout 120 +python3 - "${work}/build/compile_commands.json" <<'CHECK' +import json, shlex, sys +for item in json.load(open(sys.argv[1])): + flags = item.get('arguments') or shlex.split(item['command']) + assert '-march=x86-64' in flags and '-mtune=generic' in flags, item['file'] + assert all(not flag.startswith('-march=') or flag == '-march=x86-64' for flag in flags), item['file'] +CHECK +mapfile -t clients < <(find "${work}/build" -type f -name atrinik -executable) +test "${#clients[@]}" = 1 +python3 "${root}/abi.py" --output "${reports}/consumer-abi.json" "${clients[0]}" +cp "${work}/build/compile_commands.json" "${reports}/compile_commands.json" +cp "${work}/build/Testing/Temporary/LastTest.log" "${reports}/consumer-tests.log" +printf '%s\n' "${expected}" > "${reports}/classic-commit.txt" +cp "${root}/installed.json" "${root}/contract.json" "${reports}/" diff --git a/portable/contract.json b/portable/contract.json new file mode 100644 index 0000000..40fe2f7 --- /dev/null +++ b/portable/contract.json @@ -0,0 +1,120 @@ +{ + "schema_version": 1, + "platform": "linux/amd64", + "target": "portable-final", + "image": "ghcr.io/atrinik/classic-portable-build", + "base": { + "image": "debian:bookworm-slim", + "digest": "sha256:5ae3c39ebd15e229dcedd5cee596b2497182493d41ff162e824ba13fc1b2b867", + "apt_snapshot": "20260901T000000Z", + "glibc": "2.36" + }, + "compiler": { + "c_standard": 17, + "cflags": "-O2 -march=x86-64 -mtune=generic", + "cxxflags": "-O2 -march=x86-64 -mtune=generic", + "cpu": "x86-64" + }, + "prefix": "/usr/local", + "metadata_directory": "/opt/atrinik-portable", + "sources": [ + { + "name": "openssl", + "version": "3.5.5", + "url": "https://github.com/openssl/openssl/releases/download/openssl-3.5.5/openssl-3.5.5.tar.gz", + "sha256": "b28c91532a8b65a1f983b4c28b7488174e4a01008e29ce8e69bd789f28bc2a89", + "license": "Apache-2.0" + }, + { + "name": "sdl3", + "version": "3.4.2", + "url": "https://github.com/libsdl-org/SDL/releases/download/release-3.4.2/SDL3-3.4.2.tar.gz", + "sha256": "ef39a2e3f9a8a78296c40da701967dd1b0d0d6e267e483863ce70f8a03b4050c", + "license": "Zlib" + }, + { + "name": "sdl3-image", + "version": "3.4.0", + "url": "https://github.com/libsdl-org/SDL_image/releases/download/release-3.4.0/SDL3_image-3.4.0.tar.gz", + "sha256": "2ceb75eab4235c2c7e93dafc3ef3268ad368ca5de40892bf8cffdd510f29d9d8", + "license": "Zlib" + }, + { + "name": "sdl3-ttf", + "version": "3.2.2", + "url": "https://github.com/libsdl-org/SDL_ttf/releases/download/release-3.2.2/SDL3_ttf-3.2.2.tar.gz", + "sha256": "63547d58d0185c833213885b635a2c0548201cc8f301e6587c0be1a67e1e045d", + "license": "Zlib" + } + ], + "audio_manifest": "audio-toolchain.json", + "shader_manifest": "classic-shader-toolchain.json", + "consumer": { + "repository": "atrinik/classic", + "commit": "8b920dffa0f5c161e9310b334df1cf48fbf15a36", + "shader_directory": "/opt/atrinik-portable/shaders", + "cmake_argument": "ATRINIK_GPU_SHADER_DIRECTORY", + "input_verification": "exact-source-commit-and-shader-input-sha256", + "shader_generation_record": "/opt/atrinik-portable/shader-generation.json" + }, + "pkg_config": { + "libcurl": "7.88.1", + "libidn2": "2.3.3", + "libxml-2.0": "2.9.14", + "openssl": "3.5.5", + "sdl3": "3.4.2", + "sdl3-image": "3.4.0", + "sdl3-mixer": "3.2.4", + "sdl3-ttf": "3.2.2", + "zlib": "1.2.13" + }, + "runtime": { + "image_codecs": [ + "ANI", + "BMP", + "GIF", + "JPEG", + "LBM", + "PCX", + "PNG", + "PNM", + "QOI", + "SVG", + "TGA", + "WEBP", + "XCF", + "XPM", + "XV" + ], + "audio_codecs": [ + "WAV", + "STBVORBIS", + "OPUS", + "DRMP3" + ], + "providers": [ + "/usr/local/lib/ossl-modules/legacy.so" + ], + "host_interfaces": [ + "kernel", + "display-server", + "audio-server", + "graphics-driver" + ], + "graphics_loaders": [ + "libEGL.so.1", + "libGL.so.1", + "libvulkan.so.1" + ], + "bundled_graphics_drivers": false, + "sdl_provider_linkage": "Direct DT_NEEDED linkage for available X11, Wayland, ALSA and PulseAudio client libraries; KMSDRM, libdecor, PipeWire, JACK, IBus, Fcitx and udev disabled. Graphics driver discovery remains external." + }, + "redistribution": { + "source_archives": "/opt/atrinik-portable/sources", + "notices": "/opt/atrinik-portable/notices", + "debian_notices": "/usr/share/doc", + "debian_source_metadata": "/opt/atrinik-portable/debian-sources.json", + "obligations": "Preserve notices and source archives; provide corresponding Debian sources from the recorded snapshot and source versions. Keep shared LGPL libraries replaceable; retain build recipes and permit relinking. Host drivers are external.", + "runtime_corresponding_sources": "/opt/atrinik-portable/runtime-sources.json" + } +} diff --git a/portable/inventory.py b/portable/inventory.py new file mode 100644 index 0000000..ec79c99 --- /dev/null +++ b/portable/inventory.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Record actual installed tools and Debian package/source coordinates.""" +import hashlib +import json +from pathlib import Path +import subprocess + +root = Path(__file__).resolve().parent +contract = json.loads((root / "contract.json").read_text()) + +def output(*args): + return subprocess.check_output(args, text=True).strip() + +packages = [] +for line in output("dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${source:Package}\t${source:Version}\n").splitlines(): + package, version, source, source_version = line.split("\t") + packages.append({"package": package, "version": version, "source": source, + "source_version": source_version, + "snapshot": contract["base"]["apt_snapshot"], + "notice_directory": "/usr/share/doc/" + package.split(":")[0]}) +(root / "debian-sources.json").write_text(json.dumps(packages, indent=2) + "\n") +actual = {"schema_version": 1, "contract_sha256": hashlib.sha256((root / "contract.json").read_bytes()).hexdigest(), + "architecture": output("dpkg", "--print-architecture"), "glibc": output("getconf", "GNU_LIBC_VERSION"), + "tools": {name: output(*args).splitlines()[0] for name, args in { + "cc": ["cc", "--version"], "cmake": ["cmake", "--version"], + "ninja": ["ninja", "--version"], "python": ["python3", "--version"], + "git-lfs": ["git", "lfs", "version"], "openssl": ["openssl", "version"]}.items()}, + "compiler_target": output("cc", "-dumpmachine"), + "compiler_options": output("cc", "-Q", "-march=x86-64", "-mtune=generic", "--help=target"), + "pkg_config": {name: output("pkg-config", "--modversion", name) for name in contract["pkg_config"]}} +assert actual["architecture"] == "amd64" +assert actual["glibc"] == "glibc 2.36" +for name, version in contract["pkg_config"].items(): + assert actual["pkg_config"][name] == version, (name, actual["pkg_config"][name], version) +(root / "installed.json").write_text(json.dumps(actual, indent=2) + "\n") diff --git a/portable/legal.py b/portable/legal.py new file mode 100644 index 0000000..38b2ebd --- /dev/null +++ b/portable/legal.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Retain signed-snapshot corresponding sources for the actual runtime closure.""" +import hashlib +import json +from pathlib import Path +import subprocess + +from abi import audit, resolve + +root = Path(__file__).resolve().parent +contract = json.loads((root / "contract.json").read_text()) +roots = sorted({p.resolve() for p in Path("/usr/local/lib").glob("*.so*") if p.is_file()}) +roots += [Path(p) for p in contract["runtime"]["providers"]] +roots += [resolve(name, []) for name in contract["runtime"]["graphics_loaders"]] +closure = audit(roots) +(root / "runtime-abi.json").write_text(json.dumps(closure, indent=2) + "\n") +packages = {p["package"]: p for p in json.loads((root / "debian-sources.json").read_text())} +source_packages = {} +for entry in closure["objects"]: + path = Path(entry["path"]) + if path.is_relative_to("/usr/local/lib"): + continue + candidates = [str(path)] + if str(path).startswith("/usr/lib/"): + candidates.append(str(path).removeprefix("/usr")) + found = None + for candidate in candidates: + result = subprocess.run(["dpkg-query", "-S", candidate], text=True, capture_output=True) + if result.returncode == 0: + owners = {line.split(": ", 1)[0] for line in result.stdout.splitlines()} + if len(owners) != 1: + raise RuntimeError(f"ambiguous Debian provider ownership: {path}") + found = packages[owners.pop()] + break + if found is None: + raise RuntimeError(f"unowned baseline provider: {path}") + source_packages[found["source"]] = found["source_version"] +source_root = root / "sources/debian" +source_root.mkdir(parents=True) +for name, version in sorted(source_packages.items()): + subprocess.run(["apt-get", "-o", "Acquire::Retries=5", "source", "--download-only", "--only-source", name + "=" + version], cwd=source_root, check=True) +artifacts = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(source_root.iterdir()) if path.is_file()} +(root / "runtime-sources.json").write_text(json.dumps({"source_packages": source_packages, "archives": artifacts}, indent=2) + "\n") diff --git a/portable/media-smoke.c b/portable/media-smoke.c new file mode 100644 index 0000000..ac9973e --- /dev/null +++ b/portable/media-smoke.c @@ -0,0 +1,37 @@ +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + if (argc != 2 || !SDL_Init(0) || !TTF_Init()) { + return 1; + } + SDL_Surface *source = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32); + if (!source || !SDL_FillSurfaceRect(source, NULL, + SDL_MapSurfaceRGBA(source, 40, 160, 220, 255)) || + !IMG_SavePNG(source, "probe.png") || + !IMG_SaveJPG(source, "probe.jpg", 95)) { + fprintf(stderr, "image encoder: %s\n", SDL_GetError()); + return 1; + } + SDL_Surface *png = IMG_Load("probe.png"); + SDL_Surface *jpg = IMG_Load("probe.jpg"); + TTF_Font *font = TTF_OpenFont(argv[1], 16); + SDL_Color white = {255, 255, 255, 255}; + SDL_Surface *text = font ? TTF_RenderText_Blended(font, "Atrinik", 0, white) : NULL; + if (!png || !jpg || png->w != 8 || jpg->h != 8 || !text || text->w < 1) { + fprintf(stderr, "image/font decoder: %s\n", SDL_GetError()); + return 1; + } + SDL_DestroySurface(text); + TTF_CloseFont(font); + SDL_DestroySurface(jpg); + SDL_DestroySurface(png); + SDL_DestroySurface(source); + TTF_Quit(); + SDL_Quit(); + puts("PNG/JPEG roundtrip and TrueType glyph rasterization passed without devices"); + return 0; +} diff --git a/portable/packages.lock b/portable/packages.lock new file mode 100644 index 0000000..6bb7ff0 --- /dev/null +++ b/portable/packages.lock @@ -0,0 +1,43 @@ +binutils=2.40-2 +build-essential=12.9 +ca-certificates=20250419~deb12u1 +check=0.15.2-2+b1 +cmake=3.25.1-1 +curl=7.88.1-10+deb12u15 +fonts-dejavu-core=2.37-6 +git=1:2.39.5-0+deb12u3 +git-lfs=3.3.0-1+deb12u1 +jq=1.6-2.1+deb12u2 +libasound2-dev=1.2.8-1+b1 +libcurl4-openssl-dev=7.88.1-10+deb12u15 +libdbus-1-dev=1.14.10-1~deb12u1 +libdecor-0-dev=0.1.1-2 +libegl1-mesa-dev=22.3.6-1+deb12u2 +libfreetype6-dev=2.12.1+dfsg-5+deb12u4 +libgl1-mesa-dev=22.3.6-1+deb12u2 +libgles2-mesa-dev=22.3.6-1+deb12u2 +libharfbuzz-dev=6.0.0+dfsg-3 +libidn2-dev=2.3.3-1+b1 +libjpeg62-turbo-dev=1:2.1.5-2 +libpng-dev=1.6.39-2+deb12u5 +libpulse-dev=16.1+dfsg1-2+b1 +libssl-dev=3.0.20-1~deb12u2 +libvulkan-dev=1.3.239.0-1 +libwayland-dev=1.21.0-1 +libwebp-dev=1.2.4-0.2+deb12u1 +libx11-dev=2:1.8.4-2+deb12u2 +libxcursor-dev=1:1.2.1-1 +libxext-dev=2:1.3.4-1+b1 +libxfixes-dev=1:6.0.0-2 +libxi-dev=2:1.8-1+b1 +libxkbcommon-dev=1.5.0-1 +libxml2-dev=2.9.14+dfsg-1.3~deb12u6 +libxrandr-dev=2:1.5.2-2+b1 +libxss-dev=1:1.2.3-1 +libxtst-dev=2:1.2.3-1.1 +ninja-build=1.11.1-2~deb12u1 +patchelf=0.14.3-1+b1 +pkg-config=1.8.1-1 +python3=3.11.2-1+b1 +wayland-protocols=1.31-1 +zlib1g-dev=1:1.2.13.dfsg-1 diff --git a/portable/shader-record.py b/portable/shader-record.py new file mode 100644 index 0000000..239f725 --- /dev/null +++ b/portable/shader-record.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Bind the generated shader data to the exact higher-ABI build-only inputs.""" +import hashlib +import json +from pathlib import Path +import subprocess + +source = Path("/cohort/source") +shader = Path("/cohort/shaders") +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + +paths = subprocess.check_output(["git", "-C", str(source), "ls-files", "-z", "client/shaders", "client/tools/generate_gpu_shaders.sh", "client/tools/write_gpu_shader_manifest.py", "client/tools/embed_gpu_shaders.py"]).decode().split("\0") +inputs = {name: digest(source / name) for name in sorted(filter(None, paths))} +Path("/cohort/input-sha256.txt").write_text("".join(f"{value} {source / name}\n" for name, value in inputs.items())) +record = {"schema_version": 1, + "source_commit": subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip(), + "source_inputs": inputs, + "output_manifest_sha256": digest(shader / "SHA256SUMS"), + "expected_manifest_sha256": digest(source / "client/shaders/SHA256SUMS"), + "tool_manifest_sha256": digest(Path("/opt/shader-toolchain.json")), + "installer_sha256": digest(Path("/opt/install-shaders.py")), + "builder": {"base": "ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03", "snapshot": "20260810T000000Z", "package_lock_sha256": digest(Path("/tmp/classic-packages.lock"))}, + "tools": {name: digest(Path("/usr/local") / name) for name in ["bin/dxc", "lib/libdxcompiler.so", "lib/libdxil.so", "bin/spirv-cross"]}} +assert record["output_manifest_sha256"] == record["expected_manifest_sha256"] +Path("/cohort/generation.json").write_text(json.dumps(record, indent=2) + "\n") diff --git a/portable/smoke.sh b/portable/smoke.sh new file mode 100755 index 0000000..056ae47 --- /dev/null +++ b/portable/smoke.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +root=/opt/atrinik-portable +test "$(id -u)" != 0 +test ! -e /usr/local/bin/dxc +test ! -e /usr/local/lib/libdxcompiler.so +test ! -e /usr/local/lib/libdxil.so +test "$(getconf GNU_LIBC_VERSION)" = 'glibc 2.36' +work=$(mktemp -d) +trap 'rm -rf -- "${work}"' EXIT +cd "${work}" +python3 "${root}/abi.py" --output abi.json +"${root}/audio/sdl3-mixer-probe" "${root}/audio/opus-probe.opus" +# pkg-config emits flags for the pinned build inputs; no user-provided shell code. +read -r -a media_flags <<< "$(pkg-config --cflags --libs sdl3-image sdl3-ttf)" +cc -O2 -march=x86-64 -mtune=generic "${root}/media-smoke.c" \ + -o media-smoke "${media_flags[@]}" +./media-smoke /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf +openssl list -providers -provider default -provider legacy > providers.txt +grep -q 'OpenSSL Default Provider' providers.txt +grep -q 'OpenSSL Legacy Provider' providers.txt +python3 "${root}/abi.py" --output media-abi.json ./media-smoke +(cd "${root}/shaders" && sha256sum -c SHA256SUMS) diff --git a/portable/test_abi.py b/portable/test_abi.py new file mode 100644 index 0000000..0565450 --- /dev/null +++ b/portable/test_abi.py @@ -0,0 +1,61 @@ +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("portable_abi", Path(__file__).with_name("abi.py")) +abi = importlib.util.module_from_spec(spec) +spec.loader.exec_module(abi) + + +class Symbols(unittest.TestCase): + def test_default_and_nondefault_versions_and_weak_imports(self): + text = """ + 1: 0000 0 FUNC GLOBAL DEFAULT UND memcpy@GLIBC_2.14 (2) + 2: 0010 4 FUNC GLOBAL DEFAULT 12 copy@@ABI_2 + 3: 0020 4 FUNC GLOBAL DEFAULT 12 copy@ABI_1 + 4: 0000 0 NOTYPE WEAK DEFAULT UND optional +""" + exports, imports = abi.symbols(text) + self.assertEqual(imports, {"memcpy@GLIBC_2.14"}) + self.assertEqual(exports, {"copy", "copy@ABI_1", "copy@ABI_2"}) + + def test_wrong_version_provider_cannot_be_satisfied_by_unrelated_library(self): + first, declared, unrelated = [Path("/usr/lib/") / name for name in ("first.so", "declared.so", "unrelated.so")] + def info(path): + return {"needed": ["declared.so", "unrelated.so"] if path == first else [], + "paths": [], "defined": {"copy@ABI_2"} if path == unrelated else set(), + "required": {"copy@ABI_2"} if path == first else set(), + "version_providers": {"ABI_2": "declared.so"} if path == first else {}} + with patch.object(abi, "elf", side_effect=info), patch.object(abi, "resolve", side_effect=lambda name, paths: Path("/usr/lib") / name): + with self.assertRaisesRegex(ValueError, "unmet versioned provider symbol"): + abi.audit([first]) + + def test_newer_glibc_requirement_is_rejected(self): + def read(*args): + if "-h" in args: + return "ELF64 Advanced Micro Devices X86-64" + if "--version-info" in args: + return "Version needs section\n File: libc.so.6\n Name: GLIBC_2.38\n" + return "" + with patch.object(abi, "output", side_effect=read): + with self.assertRaisesRegex(ValueError, "newer glibc requirement"): + abi.elf(Path("/candidate")) + + def test_newer_cpu_note_is_rejected(self): + def read(*args): + return "ELF64 Advanced Micro Devices X86-64" if "-h" in args else "x86 ISA needed: x86-64-v3\n" + with patch.object(abi, "output", side_effect=read): + with self.assertRaisesRegex(ValueError, "newer CPU ISA"): + abi.elf(Path("/candidate")) + + def test_loader_missing_dependency_is_failure(self): + info = dict(needed=[], paths=[], defined=set(), required=set(), version_providers={}) + with patch.object(abi, "elf", return_value=info), patch.object(abi.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "libmissing.so => not found", "")): + with self.assertRaisesRegex(ValueError, "relocation check failed"): + abi.audit([Path("/candidate")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/require-image-checks.sh b/tools/require-image-checks.sh index 175b232..8c90a18 100755 --- a/tools/require-image-checks.sh +++ b/tools/require-image-checks.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -ne 8 ]]; then - echo "Usage: $0 CHANGES CLASSIC_SELECTED CLASSIC_RESULT LINUX_SELECTED LINUX_RESULT WINDOWS_SELECTED WINDOWS_RESULT WINDOWS_NATIVE_RESULT" >&2 +if [[ $# -ne 10 ]]; then + echo "Usage: $0 CHANGES CLASSIC_SELECTED CLASSIC_RESULT LINUX_SELECTED LINUX_RESULT WINDOWS_SELECTED WINDOWS_RESULT WINDOWS_NATIVE_RESULT PORTABLE_SELECTED PORTABLE_RESULT" >&2 exit 2 fi @@ -14,6 +14,8 @@ linux_result=$5 windows_selected=$6 windows_result=$7 windows_native_result=$8 +portable_selected=$9 +portable_result=${10} if [[ ${changes_result} != success ]]; then echo "Change selection concluded ${changes_result}." >&2 @@ -44,3 +46,5 @@ require_result LINUX "${linux_selected}" "${linux_result}" require_result WINDOWS "${windows_selected}" "${windows_result}" require_result "Native Windows" "${windows_selected}" \ "${windows_native_result}" + +require_result PORTABLE "${portable_selected}" "${portable_result}" diff --git a/tools/test-require-image-checks.sh b/tools/test-require-image-checks.sh index bc54fc8..9bfe486 100755 --- a/tools/test-require-image-checks.sh +++ b/tools/test-require-image-checks.sh @@ -18,14 +18,21 @@ expect_failure() { fi } -expect_success success true success true success true success success -expect_success success false skipped false skipped false skipped skipped +expect_success success true success true success true success success false skipped +expect_success success false skipped false skipped false skipped skipped false skipped -expect_failure failure true success true success true success success -expect_failure success true success true success true success -expect_failure success '' skipped false skipped false skipped skipped -expect_failure success malformed skipped false skipped false skipped skipped -expect_failure success true skipped false skipped false skipped skipped -expect_failure success false success false skipped false skipped skipped -expect_failure success false skipped false skipped true success skipped -expect_failure success false skipped false skipped false skipped success +expect_failure failure true success true success true success success false skipped +expect_failure success true success true success true success false skipped +expect_failure success '' skipped false skipped false skipped skipped false skipped +expect_failure success malformed skipped false skipped false skipped skipped false skipped +expect_failure success true skipped false skipped false skipped skipped false skipped +expect_failure success false success false skipped false skipped skipped false skipped +expect_failure success false skipped false skipped true success skipped false skipped +expect_failure success false skipped false skipped false skipped success false skipped + +expect_success success false skipped false skipped false skipped skipped true success +expect_failure success false skipped false skipped false skipped skipped true skipped +expect_failure success false skipped false skipped false skipped skipped true failure +expect_failure success false skipped false skipped false skipped skipped true cancelled +expect_failure success false skipped false skipped false skipped skipped false success +expect_failure success false skipped false skipped false skipped skipped '' skipped From 4f8cd518494e2419e2606544a44c73e935a10780 Mon Sep 17 00:00:00 2001 From: zoeyrose <3865595+zoeyrose@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:54:15 +0000 Subject: [PATCH 2/7] fix(portable): close runtime sources and bootstrap shader tools --- README.md | 6 ++++-- portable/Dockerfile | 18 +++++++++++++----- portable/abi.py | 26 ++++++++++++++++++++++---- portable/build.py | 2 +- portable/consumer.sh | 3 ++- portable/contract.json | 14 ++++++++++---- portable/legal.py | 6 ++---- portable/packages.lock | 4 ---- portable/shader-record.py | 7 ++++++- portable/smoke.sh | 2 ++ portable/test_abi.py | 14 ++++++++++++++ 11 files changed, 76 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5464a16..6db058d 100644 --- a/README.md +++ b/README.md @@ -433,8 +433,10 @@ coordinates, source archives, notices, and shader-generation record under Debian packages provide the baseline transitive libraries. Compiler flags use `-march=x86-64 -mtune=generic`. Verification checks actual ELF dependency providers, versioned symbols, loader relocations and CPU notes, plus device-free -image/font decoding, audio decoding and OpenSSL provider loading. Graphics -loaders are application dependencies; host graphics drivers stay external. +image/font decoding, audio decoding and OpenSSL provider loading. The +Vulkan loader is an application dependency; host graphics drivers stay external. +Unused OpenGL/OpenGL ES backends are disabled so Debian Mesa driver packages are +not pulled into this Classic SDL_GPU build target. Automatic PR CI explicitly selects `Portable Classic image`, builds/checks the Dockerfile without registry credentials, runs non-root smoke, and compiles and diff --git a/portable/Dockerfile b/portable/Dockerfile index c8ef003..ad0717b 100644 --- a/portable/Dockerfile +++ b/portable/Dockerfile @@ -4,12 +4,19 @@ FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914 ARG TARGETARCH ENV DEBIAN_FRONTEND=noninteractive COPY classic-packages.lock /tmp/classic-packages.lock +# Match the canonical signed-APT CA bootstrap; restore TLS verification immediately. RUN test "${TARGETARCH}" = amd64 \ - && sed -i -e 's|http://archive.ubuntu.com/ubuntu/|http://snapshot.ubuntu.com/ubuntu/20260810T000000Z/|' \ - -e 's|http://security.ubuntu.com/ubuntu/|http://snapshot.ubuntu.com/ubuntu/20260810T000000Z/|' \ + && sed -i -e 's|http://archive.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/20260810T000000Z/|' \ + -e 's|http://security.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/20260810T000000Z/|' \ /etc/apt/sources.list.d/ubuntu.sources \ && sed -i '/^Signed-By:/a Check-Valid-Until: no' /etc/apt/sources.list.d/ubuntu.sources \ - && apt-get -o Acquire::Retries=5 update \ + && apt-get -o Acquire::Retries=5 -o APT::Update::Error-Mode=any -o Acquire::https::Verify-Peer=false update \ + && awk -F= '$1 == "ca-certificates" || $1 == "libssl3t64" || $1 == "openssl" || $1 == "openssl-provider-legacy" { print }' \ + /tmp/classic-packages.lock > /tmp/ca-bootstrap.lock \ + && xargs apt-get -o Acquire::Retries=5 -o Acquire::https::Verify-Peer=false \ + install -y --no-install-recommends < /tmp/ca-bootstrap.lock \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get -o Acquire::Retries=5 -o APT::Update::Error-Mode=any update \ && xargs apt-get -o Acquire::Retries=5 install -y --no-install-recommends < /tmp/classic-packages.lock \ && rm -rf /var/lib/apt/lists/* COPY classic-shader-toolchain.json /opt/shader-toolchain.json @@ -51,11 +58,11 @@ RUN test "${TARGETARCH}" = amd64 \ 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20260901T000000Z bookworm-updates main' \ 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260901T000000Z bookworm-security main' \ > /etc/apt/sources.list \ - && apt-get -o Acquire::Retries=5 update \ + && apt-get -o Acquire::Retries=5 -o APT::Update::Error-Mode=any update \ && xargs apt-get -o Acquire::Retries=5 install -y --no-install-recommends < /tmp/portable-packages.lock \ && sed -i 's|http://snapshot.debian.org|https://snapshot.debian.org|g' /etc/apt/sources.list \ && sed 's/^deb /deb-src /' /etc/apt/sources.list >> /etc/apt/sources.list.d/portable-source.list \ - && apt-get -o Acquire::Retries=5 update \ + && apt-get -o Acquire::Retries=5 -o APT::Update::Error-Mode=any update \ && useradd --create-home --uid 1000 --shell /bin/bash ubuntu COPY portable /opt/atrinik-portable COPY audio-toolchain.json audio-toolchain.spdx.json classic-shader-toolchain.json classic-shader-toolchain.spdx.json /opt/atrinik-portable/ @@ -70,6 +77,7 @@ RUN python3 /opt/atrinik-portable/build.py \ COPY --from=portable-shaders /cohort/shaders /opt/atrinik-portable/shaders COPY --from=portable-shaders /cohort/input-sha256.txt /opt/atrinik-portable/shader-inputs.txt COPY --from=portable-shaders /cohort/generation.json /opt/atrinik-portable/shader-generation.json +COPY --from=portable-shaders /cohort/corresponding-source /opt/atrinik-portable/sources/classic-shaders # Build-time shader licenses/provenance accompany the generated cohort; no Ubuntu ELF. COPY --from=portable-shaders /usr/local/share/licenses /opt/atrinik-portable/notices/shader-tools USER ubuntu diff --git a/portable/abi.py b/portable/abi.py index 7bea1f7..565beeb 100644 --- a/portable/abi.py +++ b/portable/abi.py @@ -123,6 +123,23 @@ def audit(roots: list[Path]) -> dict: for path, value in sorted(graph.items())]} +def default_roots(contract: dict) -> list[Path]: + roots = sorted({p.resolve() for p in Path("/usr/local/lib").glob("*.so*") if p.is_file()}) + roots += [Path(p) for p in contract["runtime"]["providers"]] + roots += [resolve(name, []) for name in [*contract["runtime"]["application_libraries"], *contract["runtime"]["graphics_loaders"]]] + return sorted(set(roots)) + + +def require_consumer_coverage(consumer: dict, image: dict) -> None: + provided = {entry["path"]: entry["sha256"] for entry in image["objects"]} + consumer_roots = {str(Path(path).resolve()) for path in consumer["roots"]} + for entry in consumer["objects"]: + if entry["path"] in consumer_roots: + continue + if provided.get(entry["path"]) != entry["sha256"]: + raise ValueError(f"consumer dependency absent or changed in image source closure: {entry['path']}") + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--output", type=Path, required=True) @@ -131,10 +148,11 @@ def main() -> None: contract = json.loads((ROOT / "contract.json").read_text()) roots = list(args.elf) if not roots: - roots = sorted({p.resolve() for p in Path("/usr/local/lib").glob("*.so*") if p.is_file()}) - roots += [Path(p) for p in contract["runtime"]["providers"]] - roots += [resolve(name, []) for name in contract["runtime"]["graphics_loaders"]] - args.output.write_text(json.dumps(audit(roots), indent=2) + "\n") + roots = default_roots(contract) + result = audit(roots) + if args.elf: + require_consumer_coverage(result, json.loads((ROOT / "runtime-abi.json").read_text())) + args.output.write_text(json.dumps(result, indent=2) + "\n") if __name__ == "__main__": diff --git a/portable/build.py b/portable/build.py index e065b65..5d51cba 100644 --- a/portable/build.py +++ b/portable/build.py @@ -65,7 +65,7 @@ def main() -> None: notices = ROOT / "notices" notices.mkdir(exist_ok=True) options = { - "sdl3": ["-DSDL_DEPS_SHARED=OFF", "-DSDL_KMSDRM=OFF", "-DSDL_LIBDECOR=OFF", "-DSDL_TESTS=OFF", "-DSDL_TEST_LIBRARY=OFF", "-DSDL_STATIC=OFF", + "sdl3": ["-DSDL_OPENGL=OFF", "-DSDL_OPENGLES=OFF", "-DSDL_VULKAN=ON", "-DSDL_DEPS_SHARED=OFF", "-DSDL_KMSDRM=OFF", "-DSDL_LIBDECOR=OFF", "-DSDL_TESTS=OFF", "-DSDL_TEST_LIBRARY=OFF", "-DSDL_STATIC=OFF", "-DSDL_ALSA_SHARED=OFF", "-DSDL_PULSEAUDIO_SHARED=OFF", "-DSDL_X11_SHARED=OFF", "-DSDL_WAYLAND_SHARED=OFF", "-DSDL_JACK=OFF", "-DSDL_PIPEWIRE=OFF", "-DSDL_IBUS=OFF", diff --git a/portable/consumer.sh b/portable/consumer.sh index 6e9d8dc..29f3fc3 100755 --- a/portable/consumer.sh +++ b/portable/consumer.sh @@ -13,7 +13,8 @@ test "$(git -C "${source}" rev-parse HEAD)" = "${expected}" test -z "$(git -C "${source}" status --porcelain --untracked-files=normal)" while read -r expected_hash input; do relative=${input#/cohort/source/} - [[ ${relative} == client/* && ${relative} != *..* ]] + [[ ${relative} != *..* && ${relative} != /* ]] + case "${relative}" in client/* | LICENSE.md | ATTRIBUTIONS.md | PROVENANCE.md) ;; *) exit 1 ;; esac printf '%s %s\n' "${expected_hash}" "${source}/${relative}" | sha256sum -c - done < "${root}/shader-inputs.txt" "${root}/smoke.sh" diff --git a/portable/contract.json b/portable/contract.json index 40fe2f7..64fa467 100644 --- a/portable/contract.json +++ b/portable/contract.json @@ -102,12 +102,17 @@ "graphics-driver" ], "graphics_loaders": [ - "libEGL.so.1", - "libGL.so.1", "libvulkan.so.1" ], "bundled_graphics_drivers": false, - "sdl_provider_linkage": "Direct DT_NEEDED linkage for available X11, Wayland, ALSA and PulseAudio client libraries; KMSDRM, libdecor, PipeWire, JACK, IBus, Fcitx and udev disabled. Graphics driver discovery remains external." + "sdl_provider_linkage": "Direct DT_NEEDED linkage for available X11, Wayland, ALSA and PulseAudio client libraries; KMSDRM, libdecor, PipeWire, JACK, IBus, Fcitx and udev disabled. Graphics driver discovery remains external.", + "application_libraries": [ + "libcurl.so.4", + "libidn2.so.0", + "libxml2.so.2", + "libz.so.1" + ], + "graphics_backend": "SDL_GPU Vulkan; unused OpenGL/OpenGL ES backends disabled to avoid Debian Mesa-driver package dependencies" }, "redistribution": { "source_archives": "/opt/atrinik-portable/sources", @@ -115,6 +120,7 @@ "debian_notices": "/usr/share/doc", "debian_source_metadata": "/opt/atrinik-portable/debian-sources.json", "obligations": "Preserve notices and source archives; provide corresponding Debian sources from the recorded snapshot and source versions. Keep shared LGPL libraries replaceable; retain build recipes and permit relinking. Host drivers are external.", - "runtime_corresponding_sources": "/opt/atrinik-portable/runtime-sources.json" + "runtime_corresponding_sources": "/opt/atrinik-portable/runtime-sources.json", + "shader_corresponding_sources": "/opt/atrinik-portable/sources/classic-shaders" } } diff --git a/portable/legal.py b/portable/legal.py index 38b2ebd..665ec90 100644 --- a/portable/legal.py +++ b/portable/legal.py @@ -5,13 +5,11 @@ from pathlib import Path import subprocess -from abi import audit, resolve +from abi import audit, default_roots root = Path(__file__).resolve().parent contract = json.loads((root / "contract.json").read_text()) -roots = sorted({p.resolve() for p in Path("/usr/local/lib").glob("*.so*") if p.is_file()}) -roots += [Path(p) for p in contract["runtime"]["providers"]] -roots += [resolve(name, []) for name in contract["runtime"]["graphics_loaders"]] +roots = default_roots(contract) closure = audit(roots) (root / "runtime-abi.json").write_text(json.dumps(closure, indent=2) + "\n") packages = {p["package"]: p for p in json.loads((root / "debian-sources.json").read_text())} diff --git a/portable/packages.lock b/portable/packages.lock index 6bb7ff0..96ba433 100644 --- a/portable/packages.lock +++ b/portable/packages.lock @@ -11,11 +11,7 @@ jq=1.6-2.1+deb12u2 libasound2-dev=1.2.8-1+b1 libcurl4-openssl-dev=7.88.1-10+deb12u15 libdbus-1-dev=1.14.10-1~deb12u1 -libdecor-0-dev=0.1.1-2 -libegl1-mesa-dev=22.3.6-1+deb12u2 libfreetype6-dev=2.12.1+dfsg-5+deb12u4 -libgl1-mesa-dev=22.3.6-1+deb12u2 -libgles2-mesa-dev=22.3.6-1+deb12u2 libharfbuzz-dev=6.0.0+dfsg-3 libidn2-dev=2.3.3-1+b1 libjpeg62-turbo-dev=1:2.1.5-2 diff --git a/portable/shader-record.py b/portable/shader-record.py index 239f725..b6ff37a 100644 --- a/portable/shader-record.py +++ b/portable/shader-record.py @@ -2,6 +2,7 @@ """Bind the generated shader data to the exact higher-ABI build-only inputs.""" import hashlib import json +import shutil from pathlib import Path import subprocess @@ -10,8 +11,12 @@ def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() -paths = subprocess.check_output(["git", "-C", str(source), "ls-files", "-z", "client/shaders", "client/tools/generate_gpu_shaders.sh", "client/tools/write_gpu_shader_manifest.py", "client/tools/embed_gpu_shaders.py"]).decode().split("\0") +paths = subprocess.check_output(["git", "-C", str(source), "ls-files", "-z", "client/shaders", "client/tools/generate_gpu_shaders.sh", "client/tools/write_gpu_shader_manifest.py", "client/tools/embed_gpu_shaders.py", "LICENSE.md", "ATTRIBUTIONS.md", "PROVENANCE.md"]).decode().split("\0") inputs = {name: digest(source / name) for name in sorted(filter(None, paths))} +for name in inputs: + target = Path("/cohort/corresponding-source") / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / name, target) Path("/cohort/input-sha256.txt").write_text("".join(f"{value} {source / name}\n" for name, value in inputs.items())) record = {"schema_version": 1, "source_commit": subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip(), diff --git a/portable/smoke.sh b/portable/smoke.sh index 056ae47..e9d6671 100755 --- a/portable/smoke.sh +++ b/portable/smoke.sh @@ -2,6 +2,8 @@ set -euo pipefail root=/opt/atrinik-portable test "$(id -u)" != 0 +test ! -d /usr/lib/x86_64-linux-gnu/dri +test ! -d /usr/lib/dri test ! -e /usr/local/bin/dxc test ! -e /usr/local/lib/libdxcompiler.so test ! -e /usr/local/lib/libdxil.so diff --git a/portable/test_abi.py b/portable/test_abi.py index 0565450..cede089 100644 --- a/portable/test_abi.py +++ b/portable/test_abi.py @@ -32,6 +32,20 @@ def info(path): with self.assertRaisesRegex(ValueError, "unmet versioned provider symbol"): abi.audit([first]) + def test_consumer_dependency_must_have_corresponding_image_closure(self): + image = {"objects": [{"path": "/lib/libc.so.6", "sha256": "libc"}]} + consumer = {"roots": ["/client"], "objects": [ + {"path": "/client", "sha256": "client"}, + {"path": "/lib/libc.so.6", "sha256": "libc"}, + {"path": "/lib/libcurl.so.4", "sha256": "curl"}]} + with self.assertRaisesRegex(ValueError, "absent or changed"): + abi.require_consumer_coverage(consumer, image) + image["objects"].append({"path": "/lib/libcurl.so.4", "sha256": "curl"}) + abi.require_consumer_coverage(consumer, image) + image["objects"][-1]["sha256"] = "changed" + with self.assertRaisesRegex(ValueError, "absent or changed"): + abi.require_consumer_coverage(consumer, image) + def test_newer_glibc_requirement_is_rejected(self): def read(*args): if "-h" in args: From 1eda477d76ea8f76df1a4dc28b8dc5d60c5883c2 Mon Sep 17 00:00:00 2001 From: zoeyrose <3865595+zoeyrose@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:18:56 +0000 Subject: [PATCH 3/7] fix(portable): verify GNU and dynamic loader notes separately --- README.md | 7 ++- portable/abi.py | 109 ++++++++++++++++++++++++++++++++++++++--- portable/build.py | 2 +- portable/contract.json | 20 ++++++-- portable/packages.lock | 3 +- portable/test_abi.py | 37 +++++++++++--- 6 files changed, 159 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6db058d..a175181 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,12 @@ Debian packages provide the baseline transitive libraries. Compiler flags use `-march=x86-64 -mtune=generic`. Verification checks actual ELF dependency providers, versioned symbols, loader relocations and CPU notes, plus device-free image/font decoding, audio decoding and OpenSSL provider loading. The -Vulkan loader is an application dependency; host graphics drivers stay external. +Vulkan, X11-XCB and D-Bus loaders receive the same recursive ABI and source +checks as linked dependencies. FDO dlopen notes are parsed independently of GNU +CPU properties for compatibility with Debian 12 binutils. X11 is the supported +display backend; host graphics drivers stay external. SDL Steam user storage is +unsupported by this Classic target; its exact SDL feature/provider declaration +is recorded as excluded in the contract. Unused OpenGL/OpenGL ES backends are disabled so Debian Mesa driver packages are not pulled into this Classic SDL_GPU build target. diff --git a/portable/abi.py b/portable/abi.py index 565beeb..aea24a7 100644 --- a/portable/abi.py +++ b/portable/abi.py @@ -7,13 +7,17 @@ from pathlib import Path import re import subprocess +import struct ROOT = Path(__file__).resolve().parent DEFAULT_DIRS = [Path("/usr/local/lib"), Path("/lib/x86_64-linux-gnu"), Path("/usr/lib/x86_64-linux-gnu"), Path("/lib64")] def output(*args: str) -> str: - return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT) + result = subprocess.run(args, text=True, capture_output=True) + if result.returncode or result.stderr: + raise ValueError(f"ELF inspection failed: {args}: {result.stdout}{result.stderr}") + return result.stdout def symbols(text: str) -> tuple[set[str], set[str]]: @@ -32,14 +36,87 @@ def symbols(text: str) -> tuple[set[str], set[str]]: return defined, required +def section_bytes(path: Path, name: str) -> bytes: + sections = output("readelf", "-SW", str(path)) + if not re.search(r"\]\s+" + re.escape(name) + r"\s", sections): + return b"" + # Binutils 2.40 returns failure for valid FDO dlopen notes with -n. Hex + # section inspection preserves those bytes without interpreting note types. + dump = output("readelf", "-x", name, str(path)) + chunks = [] + for line in dump.splitlines(): + match = re.match(r"\s+0x[0-9a-f]+\s+((?:[0-9a-f]{2,8}\s+){1,4})", line) + if match: + chunks.append(bytes.fromhex(match[1])) + if not chunks: + raise ValueError(f"empty ELF note section: {path}: {name}") + return b"".join(chunks) + + +def notes(data: bytes, alignment: int) -> list[tuple[bytes, int, bytes]]: + result, offset = [], 0 + while offset < len(data): + if len(data) - offset < 12: + raise ValueError("truncated ELF note header") + namesz, descsz, kind = struct.unpack_from(" len(data) or next_offset > len(data): + raise ValueError("invalid ELF note bounds") + result.append((data[start:start + namesz], kind, data[desc:end])) + offset = next_offset + return result + + +def cpu_notes(data: bytes) -> None: + for owner, kind, desc in notes(data, 8): + if owner != b"GNU\0" or kind != 5: + raise ValueError("unexpected GNU property note") + offset = 0 + while offset < len(desc): + if len(desc) - offset < 8: + raise ValueError("truncated GNU property") + kind, size = struct.unpack_from(" len(desc): + raise ValueError("invalid GNU property bounds") + if kind == 0xc0008002: # GNU_PROPERTY_X86_ISA_1_NEEDED + if size != 4 or struct.unpack_from(" len(desc): + raise ValueError("invalid GNU property padding") + + +def dlopen_notes(data: bytes) -> list[dict]: + result = [] + for owner, kind, desc in notes(data, 4): + if owner != b"FDO\0" or kind != 0x407c0c0a or not desc.endswith(b"\0"): + raise ValueError("unexpected FDO dlopen note") + entries = json.loads(desc[:-1]) + if not isinstance(entries, list) or not entries: + raise ValueError("invalid FDO dlopen entries") + for entry in entries: + if (not isinstance(entry, dict) or not isinstance(entry.get("feature"), str) + or not isinstance(entry.get("soname"), list) or not entry["soname"] + or not all(isinstance(name, str) and name and "/" not in name for name in entry["soname"])): + raise ValueError("invalid FDO dlopen provider") + result.extend(entries) + return result + + def elf(path: Path) -> dict: header = output("readelf", "-h", str(path)) if "Advanced Micro Devices X86-64" not in header or "ELF64" not in header: raise ValueError(f"wrong ELF target: {path}") - notes = output("readelf", "-n", str(path)) - for isa in re.findall(r"x86 ISA needed: ([^\n]+)", notes): - if isa.strip() != "x86-64-baseline": - raise ValueError(f"newer CPU ISA required: {path}: {isa}") + if "little endian" not in header: + raise ValueError(f"wrong ELF byte order: {path}") + properties = section_bytes(path, ".note.gnu.property") + cpu_notes(properties) + loaders = dlopen_notes(section_bytes(path, ".note.dlopen")) dynamic = output("readelf", "-d", str(path)) needed = re.findall(r"\(NEEDED\).*\[([^\]]+)\]", dynamic) paths = [] @@ -71,7 +148,7 @@ def elf(path: Path) -> dict: raise ValueError(f"newer glibc requirement: {path}: {version}") defined, required = symbols(output("readelf", "--dyn-syms", "--wide", str(path))) return dict(needed=needed, paths=paths, defined=defined, required=required, - version_providers=version_providers) + version_providers=version_providers, dlopen=loaders, gnu_property_present=bool(properties)) def resolve(name: str, paths: list[Path]) -> Path: @@ -88,6 +165,8 @@ def resolve(name: str, paths: list[Path]) -> Path: def audit(roots: list[Path]) -> dict: + contract = json.loads((ROOT / "contract.json").read_text()) + excluded = contract["runtime"].get("unsupported_dlopen_features", []) graph = {} pending = list(roots) while pending: @@ -96,6 +175,21 @@ def audit(roots: list[Path]) -> dict: continue value = elf(path) value["providers"] = {name: resolve(name, value["paths"]) for name in value["needed"]} + value["dlopen_providers"] = {} + for entry in value.get("dlopen", []): + if any(item["object"] == str(path) and item["feature"] == entry["feature"] + and item["soname"] == entry["soname"] for item in excluded): + continue + candidates = [] + for name in entry["soname"]: + try: + candidates.append(resolve(name, value["paths"])) + except ValueError: + pass + if not candidates: + raise ValueError(f"missing dlopen feature provider: {path}: {entry}") + value["dlopen_providers"].setdefault(entry["feature"], []).extend(candidates) + pending.extend(candidates) graph[path] = value pending.extend(value["providers"].values()) exports = set().union(*(value["defined"] for value in graph.values())) @@ -119,6 +213,9 @@ def audit(roots: list[Path]) -> dict: "roots": [str(p) for p in roots], "objects": [ {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "needed": {name: str(provider) for name, provider in value["providers"].items()}, + "gnu_property_present": value.get("gnu_property_present", False), + "dlopen": value.get("dlopen", []), + "dlopen_providers": {feature: [str(p) for p in paths] for feature, paths in value["dlopen_providers"].items()}, "required_symbols": sorted(value["required"])} for path, value in sorted(graph.items())]} diff --git a/portable/build.py b/portable/build.py index 5d51cba..419e40d 100644 --- a/portable/build.py +++ b/portable/build.py @@ -67,7 +67,7 @@ def main() -> None: options = { "sdl3": ["-DSDL_OPENGL=OFF", "-DSDL_OPENGLES=OFF", "-DSDL_VULKAN=ON", "-DSDL_DEPS_SHARED=OFF", "-DSDL_KMSDRM=OFF", "-DSDL_LIBDECOR=OFF", "-DSDL_TESTS=OFF", "-DSDL_TEST_LIBRARY=OFF", "-DSDL_STATIC=OFF", "-DSDL_ALSA_SHARED=OFF", "-DSDL_PULSEAUDIO_SHARED=OFF", - "-DSDL_X11_SHARED=OFF", "-DSDL_WAYLAND_SHARED=OFF", + "-DSDL_X11_SHARED=OFF", "-DSDL_WAYLAND=OFF", "-DSDL_WAYLAND_SHARED=OFF", "-DSDL_LIBURING=OFF", "-DSDL_JACK=OFF", "-DSDL_PIPEWIRE=OFF", "-DSDL_IBUS=OFF", "-DSDL_FCITX=OFF", "-DSDL_LIBUDEV=OFF", "-DSDL_LIBDECOR_SHARED=OFF"], "sdl3-image": ["-DSDLIMAGE_STRICT=ON", "-DSDLIMAGE_VENDORED=OFF", "-DSDLIMAGE_DEPS_SHARED=OFF", diff --git a/portable/contract.json b/portable/contract.json index 64fa467..5d90d51 100644 --- a/portable/contract.json +++ b/portable/contract.json @@ -102,17 +102,29 @@ "graphics-driver" ], "graphics_loaders": [ - "libvulkan.so.1" + "libvulkan.so.1", + "libX11-xcb.so.1" ], "bundled_graphics_drivers": false, - "sdl_provider_linkage": "Direct DT_NEEDED linkage for available X11, Wayland, ALSA and PulseAudio client libraries; KMSDRM, libdecor, PipeWire, JACK, IBus, Fcitx and udev disabled. Graphics driver discovery remains external.", + "sdl_provider_linkage": "Direct DT_NEEDED linkage for X11, ALSA and PulseAudio; declared D-Bus and X11/Vulkan loaders included. Wayland, KMSDRM, libdecor, PipeWire, JACK, IBus, Fcitx and udev disabled. Graphics driver discovery remains external.", "application_libraries": [ "libcurl.so.4", "libidn2.so.0", "libxml2.so.2", - "libz.so.1" + "libz.so.1", + "libdbus-1.so.3" ], - "graphics_backend": "SDL_GPU Vulkan; unused OpenGL/OpenGL ES backends disabled to avoid Debian Mesa-driver package dependencies" + "graphics_backend": "SDL_GPU Vulkan; unused OpenGL/OpenGL ES backends disabled to avoid Debian Mesa-driver package dependencies", + "unsupported_dlopen_features": [ + { + "object": "/usr/local/lib/libSDL3.so.0.4.2", + "feature": "storage-steam", + "soname": [ + "libsteam_api.so" + ], + "reason": "Classic does not use the SDL Steam user-storage API; Steam integration is unsupported and its proprietary provider is not included." + } + ] }, "redistribution": { "source_archives": "/opt/atrinik-portable/sources", diff --git a/portable/packages.lock b/portable/packages.lock index 96ba433..040b50a 100644 --- a/portable/packages.lock +++ b/portable/packages.lock @@ -5,8 +5,8 @@ check=0.15.2-2+b1 cmake=3.25.1-1 curl=7.88.1-10+deb12u15 fonts-dejavu-core=2.37-6 -git=1:2.39.5-0+deb12u3 git-lfs=3.3.0-1+deb12u1 +git=1:2.39.5-0+deb12u3 jq=1.6-2.1+deb12u2 libasound2-dev=1.2.8-1+b1 libcurl4-openssl-dev=7.88.1-10+deb12u15 @@ -22,6 +22,7 @@ libvulkan-dev=1.3.239.0-1 libwayland-dev=1.21.0-1 libwebp-dev=1.2.4-0.2+deb12u1 libx11-dev=2:1.8.4-2+deb12u2 +libx11-xcb1=2:1.8.4-2+deb12u2 libxcursor-dev=1:1.2.1-1 libxext-dev=2:1.3.4-1+b1 libxfixes-dev=1:6.0.0-2 diff --git a/portable/test_abi.py b/portable/test_abi.py index cede089..adf9216 100644 --- a/portable/test_abi.py +++ b/portable/test_abi.py @@ -1,6 +1,7 @@ import importlib.util from pathlib import Path import subprocess +import struct import unittest from unittest.mock import patch @@ -49,7 +50,7 @@ def test_consumer_dependency_must_have_corresponding_image_closure(self): def test_newer_glibc_requirement_is_rejected(self): def read(*args): if "-h" in args: - return "ELF64 Advanced Micro Devices X86-64" + return "ELF64 little endian Advanced Micro Devices X86-64" if "--version-info" in args: return "Version needs section\n File: libc.so.6\n Name: GLIBC_2.38\n" return "" @@ -57,12 +58,36 @@ def read(*args): with self.assertRaisesRegex(ValueError, "newer glibc requirement"): abi.elf(Path("/candidate")) - def test_newer_cpu_note_is_rejected(self): - def read(*args): - return "ELF64 Advanced Micro Devices X86-64" if "-h" in args else "x86 ISA needed: x86-64-v3\n" - with patch.object(abi, "output", side_effect=read): + def test_cpu_notes_allow_baseline_and_absent_reject_newer_and_malformed(self): + def note(mask): + return struct.pack(" Date: Wed, 9 Sep 2026 09:33:08 +0000 Subject: [PATCH 4/7] fix(portable): bind symbols through ELF version indexes --- portable/abi.py | 37 ++++++++++++++++++++++++++++++++----- portable/test_abi.py | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/portable/abi.py b/portable/abi.py index aea24a7..1dd552c 100644 --- a/portable/abi.py +++ b/portable/abi.py @@ -36,6 +36,26 @@ def symbols(text: str) -> tuple[set[str], set[str]]: return defined, required +def symbol_providers(text: str, versions: dict[int, str]) -> dict[str, dict]: + result = {} + for line in text.splitlines(): + fields = line.split() + if len(fields) < 8 or not fields[0].rstrip(":").isdigit(): + continue + if fields[4] != "GLOBAL" or fields[6] != "UND" or "@" not in fields[7]: + continue + match = re.search(r"\((\d+)\)$", line) + index = int(match[1]) if match else None + if index not in versions: + raise ValueError(f"missing ELF symbol version provider: {fields[7]}") + name = fields[7].replace("@@", "@") + binding = {"version_index": index, "provider": versions[index]} + if name in result and result[name] != binding: + raise ValueError(f"ambiguous ELF symbol version provider: {name}") + result[name] = binding + return result + + def section_bytes(path: Path, name: str) -> bytes: sections = output("readelf", "-SW", str(path)) if not re.search(r"\]\s+" + re.escape(name) + r"\s", sections): @@ -141,14 +161,21 @@ def elf(path: Path) -> dict: match = re.search(r"Name: (\S+)", line) if match and provider: version = match[1] - version_providers[version] = provider + index = re.search(r"Version: (\d+)", line) + if index: + number = int(index[1]) + if number in version_providers and version_providers[number] != provider: + raise ValueError(f"ambiguous ELF version index: {number}") + version_providers[number] = provider if version.startswith("GLIBC_") and version != "GLIBC_PRIVATE": value = version.removeprefix("GLIBC_") if value[0].isdigit() and tuple(map(int, value.split("."))) > (2, 36): raise ValueError(f"newer glibc requirement: {path}: {version}") - defined, required = symbols(output("readelf", "--dyn-syms", "--wide", str(path))) + dynamic_symbols = output("readelf", "--dyn-syms", "--wide", str(path)) + defined, required = symbols(dynamic_symbols) + required_providers = symbol_providers(dynamic_symbols, version_providers) return dict(needed=needed, paths=paths, defined=defined, required=required, - version_providers=version_providers, dlopen=loaders, gnu_property_present=bool(properties)) + required_providers=required_providers, dlopen=loaders, gnu_property_present=bool(properties)) def resolve(name: str, paths: list[Path]) -> Path: @@ -196,8 +223,7 @@ def audit(roots: list[Path]) -> dict: for path, value in graph.items(): for symbol in value["required"]: if "@" in symbol: - version = symbol.split("@", 1)[1] - provider = value["version_providers"].get(version) + provider = value["required_providers"].get(symbol, {}).get("provider") resolved = value["providers"].get(provider) if resolved is None or symbol not in graph[resolved]["defined"]: raise ValueError(f"unmet versioned provider symbol: {path}: {symbol}: {provider}") @@ -216,6 +242,7 @@ def audit(roots: list[Path]) -> dict: "gnu_property_present": value.get("gnu_property_present", False), "dlopen": value.get("dlopen", []), "dlopen_providers": {feature: [str(p) for p in paths] for feature, paths in value["dlopen_providers"].items()}, + "required_providers": value["required_providers"], "required_symbols": sorted(value["required"])} for path, value in sorted(graph.items())]} diff --git a/portable/test_abi.py b/portable/test_abi.py index adf9216..fffee73 100644 --- a/portable/test_abi.py +++ b/portable/test_abi.py @@ -22,13 +22,43 @@ def test_default_and_nondefault_versions_and_weak_imports(self): self.assertEqual(imports, {"memcpy@GLIBC_2.14"}) self.assertEqual(exports, {"copy", "copy@ABI_1", "copy@ABI_2"}) + def test_duplicate_version_names_bind_each_symbol_to_its_index_provider(self): + text = """ + 1: 0000 0 OBJECT GLOBAL DEFAULT UND _rtld_global_ro@GLIBC_PRIVATE (5) + 2: 0000 0 FUNC GLOBAL DEFAULT UND __libc_early_init@GLIBC_PRIVATE (7) +""" + self.assertEqual(abi.symbol_providers(text, {5: "ld-linux-x86-64.so.2", 7: "libc.so.6"}), { + "_rtld_global_ro@GLIBC_PRIVATE": {"version_index": 5, "provider": "ld-linux-x86-64.so.2"}, + "__libc_early_init@GLIBC_PRIVATE": {"version_index": 7, "provider": "libc.so.6"}}) + with self.assertRaisesRegex(ValueError, "missing ELF symbol version provider"): + abi.symbol_providers(text, {7: "libc.so.6"}) + + def test_same_version_namespace_still_requires_each_exact_provider_export(self): + first, loader, libc = [Path("/usr/lib/") / name for name in ("first.so", "loader.so", "libc.so")] + bindings = abi.symbol_providers(""" + 1: 0000 0 OBJECT GLOBAL DEFAULT UND loader_symbol@GLIBC_PRIVATE (5) + 2: 0000 0 FUNC GLOBAL DEFAULT UND libc_symbol@GLIBC_PRIVATE (7) +""", {5: "loader.so", 7: "libc.so"}) + exports = {loader: {"loader_symbol@GLIBC_PRIVATE"}, libc: {"libc_symbol@GLIBC_PRIVATE"}} + def info(path): + return dict(needed=["loader.so", "libc.so"] if path == first else [], paths=[], + defined=exports.get(path, set()), required=set(bindings) if path == first else set(), + required_providers=bindings if path == first else {}) + with patch.object(abi, "elf", side_effect=info), patch.object(abi, "resolve", side_effect=lambda name, paths: Path("/usr/lib") / name), \ + patch.object(abi.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "", "")), \ + patch.object(Path, "read_bytes", return_value=b"fixture"): + abi.audit([first]) + exports[loader], exports[libc] = exports[libc], exports[loader] + with self.assertRaisesRegex(ValueError, "unmet versioned provider symbol"): + abi.audit([first]) + def test_wrong_version_provider_cannot_be_satisfied_by_unrelated_library(self): first, declared, unrelated = [Path("/usr/lib/") / name for name in ("first.so", "declared.so", "unrelated.so")] def info(path): return {"needed": ["declared.so", "unrelated.so"] if path == first else [], "paths": [], "defined": {"copy@ABI_2"} if path == unrelated else set(), "required": {"copy@ABI_2"} if path == first else set(), - "version_providers": {"ABI_2": "declared.so"} if path == first else {}} + "required_providers": {"copy@ABI_2": {"version_index": 2, "provider": "declared.so"}} if path == first else {}} with patch.object(abi, "elf", side_effect=info), patch.object(abi, "resolve", side_effect=lambda name, paths: Path("/usr/lib") / name): with self.assertRaisesRegex(ValueError, "unmet versioned provider symbol"): abi.audit([first]) @@ -84,13 +114,13 @@ def test_fdo_notes_validate_headers_json_and_alternative_providers(self): def test_missing_supported_dlopen_provider_and_changed_exclusion_fail(self): for entry in ({"feature": "x11-vulkan", "soname": ["missing.so"]}, {"feature": "storage-steam", "soname": ["changed.so"]}): - info = dict(needed=[], paths=[], defined=set(), required=set(), version_providers={}, dlopen=[entry]) + info = dict(needed=[], paths=[], defined=set(), required=set(), required_providers={}, dlopen=[entry]) with patch.object(abi, "elf", return_value=info), patch.object(abi, "resolve", side_effect=ValueError("missing")): with self.assertRaisesRegex(ValueError, "missing dlopen feature"): abi.audit([Path("/usr/local/lib/libSDL3.so.0.4.2")]) def test_loader_missing_dependency_is_failure(self): - info = dict(needed=[], paths=[], defined=set(), required=set(), version_providers={}) + info = dict(needed=[], paths=[], defined=set(), required=set(), required_providers={}) with patch.object(abi, "elf", return_value=info), patch.object(abi.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "libmissing.so => not found", "")): with self.assertRaisesRegex(ValueError, "relocation check failed"): abi.audit([Path("/candidate")]) From 6e82039b176d5330b9d380fe9c3f026304e5539b Mon Sep 17 00:00:00 2001 From: zoeyrose <3865595+zoeyrose@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:34:38 +0000 Subject: [PATCH 5/7] docs(portable): clarify Wayland qualification boundary --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a175181..fb473d2 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,9 @@ image/font decoding, audio decoding and OpenSSL provider loading. The Vulkan, X11-XCB and D-Bus loaders receive the same recursive ABI and source checks as linked dependencies. FDO dlopen notes are parsed independently of GNU CPU properties for compatibility with Debian 12 binutils. X11 is the supported -display backend; host graphics drivers stay external. SDL Steam user storage is +display backend; host graphics drivers stay external. Native Wayland is not +enabled. A Wayland desktop requires an XWayland display route, which remains +subject to parent integration qualification. SDL Steam user storage is unsupported by this Classic target; its exact SDL feature/provider declaration is recorded as excluded in the contract. Unused OpenGL/OpenGL ES backends are disabled so Debian Mesa driver packages are From e768f88554f8312a2b90692262c01fdf1c55e595 Mon Sep 17 00:00:00 2001 From: zoeyrose <3865595+zoeyrose@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:01:15 +0000 Subject: [PATCH 6/7] fix(portable): expose verified inputs to non-root consumers --- portable/Dockerfile | 3 +++ portable/shader-record.py | 5 +++++ portable/smoke.sh | 16 ++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/portable/Dockerfile b/portable/Dockerfile index ad0717b..c3d26c2 100644 --- a/portable/Dockerfile +++ b/portable/Dockerfile @@ -80,6 +80,9 @@ COPY --from=portable-shaders /cohort/generation.json /opt/atrinik-portable/shade COPY --from=portable-shaders /cohort/corresponding-source /opt/atrinik-portable/sources/classic-shaders # Build-time shader licenses/provenance accompany the generated cohort; no Ubuntu ELF. COPY --from=portable-shaders /usr/local/share/licenses /opt/atrinik-portable/notices/shader-tools +# Download caches use private temporary files; published inputs must be readable +# by arbitrary non-root consumer UIDs, without adding write permissions. +RUN chmod -R a+rX /opt/atrinik-portable /usr/local/share/licenses USER ubuntu WORKDIR /home/ubuntu diff --git a/portable/shader-record.py b/portable/shader-record.py index b6ff37a..06a809e 100644 --- a/portable/shader-record.py +++ b/portable/shader-record.py @@ -29,3 +29,8 @@ def digest(path): "tools": {name: digest(Path("/usr/local") / name) for name in ["bin/dxc", "lib/libdxcompiler.so", "lib/libdxil.so", "bin/spirv-cross"]}} assert record["output_manifest_sha256"] == record["expected_manifest_sha256"] Path("/cohort/generation.json").write_text(json.dumps(record, indent=2) + "\n") + +# The canonical manifest writer uses a private temporary file. Consumers may +# run with a different non-root UID, so seal verified data with public reads. +for path in [shader, *shader.rglob("*")]: + path.chmod(0o555 if path.is_dir() else 0o444) diff --git a/portable/smoke.sh b/portable/smoke.sh index e9d6671..9fd17a2 100755 --- a/portable/smoke.sh +++ b/portable/smoke.sh @@ -2,6 +2,22 @@ set -euo pipefail root=/opt/atrinik-portable test "$(id -u)" != 0 +python3 - <<'READABLE' +import os +from pathlib import Path +for root in (Path("/opt/atrinik-portable"), Path("/usr/local/share/licenses")): + for path in (root, *root.rglob("*")): + if path.is_dir(): + if not os.access(path, os.R_OK | os.X_OK): + raise RuntimeError(f"unreadable published input directory: {path}") + else: + with path.open("rb") as source: + source.read(1) +for path in Path("/usr/share/doc").glob("*/copyright"): + with path.open("rb") as source: + source.read(1) +print("Published sources, notices and metadata are readable as the consumer UID") +READABLE test ! -d /usr/lib/x86_64-linux-gnu/dri test ! -d /usr/lib/dri test ! -e /usr/local/bin/dxc From c85e833ea8e3f0681c88f65a3dbb76ec4e455910 Mon Sep 17 00:00:00 2001 From: zoeyrose <3865595+zoeyrose@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:19:42 +0000 Subject: [PATCH 7/7] fix(portable): omit transient consumer RPATH entries --- portable/consumer.sh | 4 +++- portable/test_abi.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/portable/consumer.sh b/portable/consumer.sh index 29f3fc3..b1d68e1 100755 --- a/portable/consumer.sh +++ b/portable/consumer.sh @@ -20,10 +20,12 @@ done < "${root}/shader-inputs.txt" "${root}/smoke.sh" work=$(mktemp -d) trap 'rm -rf -- "${work}"' EXIT +# Resolve pinned providers through the baseline loader cache; omit transient +# CMake build-tree RPATH padding, whose empty entries mean the current directory. cmake -S "${source}" -B "${work}/build" -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ -DATRINIK_BUILD_CLIENT=ON -DATRINIK_BUILD_SERVER=OFF \ - -DBUILD_TESTING=ON -DPACKAGE_TYPE=none \ + -DBUILD_TESTING=ON -DPACKAGE_TYPE=none -DCMAKE_SKIP_RPATH=ON \ -DATRINIK_GPU_SHADER_DIRECTORY="${root}/shaders" \ '-DCMAKE_C_FLAGS=-march=x86-64 -mtune=generic' cmake --build "${work}/build" --parallel 2 diff --git a/portable/test_abi.py b/portable/test_abi.py index fffee73..6d2e1f4 100644 --- a/portable/test_abi.py +++ b/portable/test_abi.py @@ -119,6 +119,17 @@ def test_missing_supported_dlopen_provider_and_changed_exclusion_fail(self): with self.assertRaisesRegex(ValueError, "missing dlopen feature"): abi.audit([Path("/usr/local/lib/libSDL3.so.0.4.2")]) + def test_empty_loader_search_entry_is_rejected(self): + def read(*args): + if "-h" in args: + return "ELF64 little endian Advanced Micro Devices X86-64" + if "-d" in args: + return "(RUNPATH) Library runpath: [/usr/local/lib:]" + return "" + with patch.object(abi, "output", side_effect=read): + with self.assertRaisesRegex(ValueError, "unsupported loader search path"): + abi.elf(Path("/candidate")) + def test_loader_missing_dependency_is_failure(self): info = dict(needed=[], paths=[], defined=set(), required=set(), required_providers={}) with patch.object(abi, "elf", return_value=info), patch.object(abi.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "libmissing.so => not found", "")):