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..fb473d2 100644 --- a/README.md +++ b/README.md @@ -409,3 +409,61 @@ 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. 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. 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 +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 +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..c3d26c2 --- /dev/null +++ b/portable/Dockerfile @@ -0,0 +1,94 @@ +# 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 +# Match the canonical signed-APT CA bootstrap; restore TLS verification immediately. +RUN test "${TARGETARCH}" = amd64 \ + && 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 -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 +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 -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 -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/ +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 +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 + +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..1dd552c --- /dev/null +++ b/portable/abi.py @@ -0,0 +1,283 @@ +#!/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 +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: + 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]]: + 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 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): + 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}") + 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 = [] + 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] + 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}") + 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, + required_providers=required_providers, dlopen=loaders, gnu_property_present=bool(properties)) + + +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: + contract = json.loads((ROOT / "contract.json").read_text()) + excluded = contract["runtime"].get("unsupported_dlopen_features", []) + 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"]} + 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())) + for path, value in graph.items(): + for symbol in value["required"]: + if "@" in symbol: + 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}") + 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()}, + "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())]} + + +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) + 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 = 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__": + main() diff --git a/portable/build.py b/portable/build.py new file mode 100644 index 0000000..419e40d --- /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_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=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", + "-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..b1d68e1 --- /dev/null +++ b/portable/consumer.sh @@ -0,0 +1,46 @@ +#!/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} != *..* && ${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" +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 -DCMAKE_SKIP_RPATH=ON \ + -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..5d90d51 --- /dev/null +++ b/portable/contract.json @@ -0,0 +1,138 @@ +{ + "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": [ + "libvulkan.so.1", + "libX11-xcb.so.1" + ], + "bundled_graphics_drivers": false, + "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", + "libdbus-1.so.3" + ], + "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", + "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", + "shader_corresponding_sources": "/opt/atrinik-portable/sources/classic-shaders" + } +} 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..665ec90 --- /dev/null +++ b/portable/legal.py @@ -0,0 +1,41 @@ +#!/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, default_roots + +root = Path(__file__).resolve().parent +contract = json.loads((root / "contract.json").read_text()) +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())} +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..040b50a --- /dev/null +++ b/portable/packages.lock @@ -0,0 +1,40 @@ +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-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 +libdbus-1-dev=1.14.10-1~deb12u1 +libfreetype6-dev=2.12.1+dfsg-5+deb12u4 +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 +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 +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..06a809e --- /dev/null +++ b/portable/shader-record.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""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 + +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", "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(), + "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") + +# 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 new file mode 100755 index 0000000..9fd17a2 --- /dev/null +++ b/portable/smoke.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +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 +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..6d2e1f4 --- /dev/null +++ b/portable/test_abi.py @@ -0,0 +1,141 @@ +import importlib.util +from pathlib import Path +import subprocess +import struct +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_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(), + "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]) + + 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: + 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 "" + with patch.object(abi, "output", side_effect=read): + with self.assertRaisesRegex(ValueError, "newer glibc requirement"): + abi.elf(Path("/candidate")) + + def test_cpu_notes_allow_baseline_and_absent_reject_newer_and_malformed(self): + def note(mask): + return struct.pack(" 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