diff --git a/.gitea/workflows/backend-build.yml b/.gitea/workflows/backend-build.yml new file mode 100644 index 0000000..b9fc418 --- /dev/null +++ b/.gitea/workflows/backend-build.yml @@ -0,0 +1,70 @@ +name: Backend candidate (Gitea) + +# Private Linux candidate only. The reviewed dispatcher supplies the exact Builds +# head. Fleet admits its exact canonical GitHub source parent before dispatch. +on: + workflow_dispatch: + inputs: + expected_sha: + description: Exact reviewed Builds main commit + required: true + type: string + +env: + CARGO_TERM_COLOR: never + PYTHONDONTWRITEBYTECODE: '1' + EXPECTED_BUILD_SHA: ${{ inputs.expected_sha }} + +defaults: + run: + shell: bash + +jobs: + linux: + name: Linux candidate + runs-on: ubuntu-latest + container: + image: node:22-bookworm@sha256:0557ac14e0d45d02ed563067b82856ca5e7aa3437fa28d98d4350ea9c3d9494a + env: + RUSTUP_HOME: ${{ runner.temp }}/rustup-backend-candidate + CARGO_HOME: ${{ runner.temp }}/cargo-backend-candidate + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.expected_sha }} + persist-credentials: false + - name: Require reviewed source selectors before setup + run: | + test "$GITHUB_REPOSITORY" = Builds/smirk-backend-core || { echo 'This repository is not admitted for backend Builds.' >&2; exit 1; } + test "$(git rev-parse HEAD)" = "$EXPECTED_BUILD_SHA" || { echo 'Checkout differs from the reviewed event or dispatch head.' >&2; exit 1; } + - name: Install isolated container build dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends python3 build-essential pkg-config libssl-dev binutils + - name: Admit reviewed source and exact dispatch head + run: | + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + canonical_source=$(git rev-parse HEAD) + else + read -r -a parents <<< "$(git show -s --format=%P HEAD)" + test "${#parents[@]}" -eq 2 || { echo 'Builds main requires a two-parent source wrapper.' >&2; exit 1; } + canonical_source=${parents[1]} + fi + git fetch --no-tags https://github.com/Such-Software/smirk-backend-core.git "$canonical_source" + python3 tools/check-build-source.py + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + - uses: Swatinem/rust-cache@v2 + - name: Build Linux candidate + run: cargo build --locked --release --target x86_64-unknown-linux-gnu --bin smirk-backend-core + - name: Stage binary and provenance + run: python3 tools/stage-backend-candidate.py --output "$RUNNER_TEMP/backend-candidate" + - name: Retain private candidate + uses: actions/upload-artifact@v3 + with: + name: smirk-backend-core-linux-x86_64-${{ inputs.expected_sha }} + path: ${{ runner.temp }}/backend-candidate/ + if-no-files-found: error + retention-days: 14 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8c8369b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,110 @@ +name: Backend CI (Gitea) + +# GitHub remains canonical source. Builds admits an explicitly reviewed commit; +# this workflow is the fundless CI gate, not deployment or public publication. +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: never + PYTHONDONTWRITEBYTECODE: '1' + EXPECTED_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +defaults: + run: + shell: bash + +jobs: + verify: + name: backend checks + runs-on: ubuntu-latest + container: + image: node:22-bookworm@sha256:0557ac14e0d45d02ed563067b82856ca5e7aa3437fa28d98d4350ea9c3d9494a + env: + RUSTUP_HOME: ${{ runner.temp }}/rustup-backend-ci + CARGO_HOME: ${{ runner.temp }}/cargo-backend-ci + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Require reviewed source selectors before setup + run: | + test "$GITHUB_REPOSITORY" = Builds/smirk-backend-core || { echo 'This repository is not admitted for backend Builds.' >&2; exit 1; } + test "$(git rev-parse HEAD)" = "$EXPECTED_BUILD_SHA" || { echo 'Checkout differs from the reviewed event or dispatch head.' >&2; exit 1; } + - name: Install isolated container dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends python3 build-essential pkg-config libssl-dev postgresql postgresql-client + - name: Admit reviewed canonical source + run: | + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + canonical_source=$(git rev-parse HEAD) + else + read -r -a parents <<< "$(git show -s --format=%P HEAD)" + test "${#parents[@]}" -eq 2 || { echo 'Builds main requires a two-parent source wrapper.' >&2; exit 1; } + canonical_source=${parents[1]} + fi + git fetch --no-tags https://github.com/Such-Software/smirk-backend-core.git "$canonical_source" + python3 tools/check-build-source.py + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Source admission and artifact guard regressions + run: python3 -m unittest discover -s tools/tests -v + - name: Secret shapes + run: ./tools/secret-scan.sh + - name: Rust format and clippy + run: | + cargo fmt --all -- --check + cargo clippy --locked --all-targets -- -D warnings + - name: All fundless tests with disposable PostgreSQL + run: ./tools/ci-fundless.sh + - name: OpenAPI source agreement + run: | + cargo run --locked --quiet --bin gen_openapi > "$RUNNER_TEMP/openapi.json" + diff openapi.json "$RUNNER_TEMP/openapi.json" + - name: Embedded console source agreement + working-directory: admin-ui + run: | + npm ci + npm run build + git diff --exit-code -- dist + - uses: actions/setup-go@v5 + with: + go-version: '1.24.0' + - name: Redacted history credential scan + run: | + go install github.com/zricethezav/gitleaks/v8@v8.18.4 + "$(go env GOPATH)/bin/gitleaks" detect --no-banner --redact --source . + + supply-chain: + name: dependency policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: EmbarkStudios/cargo-deny-action@v2 + + gate: + name: backend gate + if: always() + needs: [verify, supply-chain] + runs-on: ubuntu-latest + steps: + - name: Require every mandatory check + env: + VERIFY_RESULT: ${{ needs.verify.result }} + DEPENDENCY_RESULT: ${{ needs.supply-chain.result }} + run: | + if [ "$VERIFY_RESULT" != success ] || [ "$DEPENDENCY_RESULT" != success ]; then + echo 'Backend gate refused: required checks failed, were cancelled, or did not run.' >&2 + exit 1 + fi diff --git a/README.md b/README.md index 39dea37..d13262f 100644 --- a/README.md +++ b/README.md @@ -158,3 +158,6 @@ funded or otherwise sensitive wallet seed. - [ ] Regenerate OpenAPI and the console bundle from their sources and check drift. - [ ] Verify each enabled chain against its configured source. - [ ] Use reviewed Fleet plan/apply procedures for company production deployments. + +Backend CI and private Linux candidate provenance are described in +[Building the backend](docs/operations/BUILDING.md). diff --git a/docs/operations/BUILDING.md b/docs/operations/BUILDING.md new file mode 100644 index 0000000..e91cee4 --- /dev/null +++ b/docs/operations/BUILDING.md @@ -0,0 +1,51 @@ +# Backend builds and candidate provenance + +> Status: implemented, enrollment pending · Updated: 2026-09-27 · Applies to: backend maintainers and release operators + +GitHub `Such-Software/smirk-backend-core` remains canonical source. Gitea +`Builds/smirk-backend-core` is private CI and candidate ingress. A workflow file +in source does not prove that the repository, runner, branch protection or +required status has been enrolled. Those are reviewed Fleet operations. + +`.gitea/workflows/ci.yml` owns the fundless backend gate: formatting, strict +Clippy, every integration target with disposable PostgreSQL, documentation +tests, generated OpenAPI and embedded-console agreement, redacted credential +scanning and dependency policy. Its final gate refuses failed, skipped or +cancelled mandatory jobs. GitHub Actions is not the build authority. + +`tools/ci-fundless.sh` creates a temporary PostgreSQL cluster with a private Unix +socket and no TCP listener. It never accepts a database URL or a test-command +override, refuses a local `.env`, supplies `TEST_DATABASE_URL`, and removes its +cluster on exit. Without a database, ordinary `cargo test` can skip integration +targets and is not equivalent evidence. Tests use only unfunded fixtures. + +## Private Linux candidate + +`.gitea/workflows/backend-build.yml` accepts only the required `expected_sha` +input. The reviewed Fleet dispatcher verifies the admitted canonical GitHub +commit before dispatch. The workflow checks out that exact Builds commit and +requires a two-parent merge wrapper with a tree identical to its second parent. +That parent is recorded as canonical source. It is fetched from the declared +GitHub repository; an arbitrary tree with different ancestry is refused. + +The workflow builds with Rust 1.98.0 in a digest-pinned Debian 12 container. +`tools/stage-backend-candidate.py` refuses an unexpected binary architecture, +missing GNU libc evidence, or a binary requiring GNU libc newer than 2.36. +The private artifact contains the executable and `manifest.json`, which records +source and Builds commits, source tree, workflow and lockfile digests, compiler, +target, GNU libc requirements, executable digest and size. Artifact identity is +`smirk-backend-core-linux-x86_64-`. + +This is a candidate, not a deployment or public release. Fleet must independently +admit its source, workflow, artifact digest and host compatibility before a +reviewed plan. A successful upload does not prove production health. The current +configuration adapter does not install this binary or prepare a new host. + +## Reusable build checklist + +- [ ] Exact canonical source and Builds wrapper are admitted by reviewed Fleet authority. +- [ ] Actual Gitea required CI status is green for the selected source. +- [ ] Every fundless PostgreSQL target ran against disposable data. +- [ ] Candidate manifest, executable digest and target match the reviewed build. +- [ ] Host compatibility and deployment evidence are separate from build success. +- [ ] No credential values, funded wallets or production database were used. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 73cb934..aa20bc9 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.98.0" components = ["rustfmt", "clippy"] diff --git a/tools/check-build-source.py b/tools/check-build-source.py new file mode 100755 index 0000000..3d7f9f2 --- /dev/null +++ b/tools/check-build-source.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Admit one reviewed canonical source or its exact Builds merge wrapper.""" +import os +import re +import subprocess +import sys + + +class Refusal(ValueError): + pass + + +def git(*args): + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() + + +def commit(value, label): + if not re.fullmatch(r"[0-9a-f]{40}", value or ""): + raise Refusal(f"{label} must be a reviewed full lowercase commit SHA") + return value + + +def source_for_checkout(require_wrapper): + if not require_wrapper: + return git("rev-parse", "HEAD") + parents = git("show", "-s", "--format=%P", "HEAD").split() + if len(parents) != 2: + raise Refusal("release or main CI requires a two-parent Builds wrapper") + return commit(parents[1], "canonical source parent") + + +def admit(source, expected_head=None, require_wrapper=False): + source = commit(source, "canonical source") + head = git("rev-parse", "HEAD") + if expected_head is not None and head != commit(expected_head, "expected_sha"): + raise Refusal("checkout HEAD differs from the reviewed dispatch head") + if git("rev-parse", f"{source}^{{commit}}") != source: + raise Refusal("reviewed canonical source is not a commit") + tree = git("rev-parse", f"{source}^{{tree}}") + if git("rev-parse", "HEAD^{tree}") != tree: + raise Refusal("Builds checkout tree differs from reviewed canonical source") + if head != source or require_wrapper: + parents = git("show", "-s", "--format=%P", "HEAD").split() + if len(parents) != 2 or parents[1] != source: + raise Refusal("Builds HEAD is neither canonical source nor its two-parent wrapper") + if git("status", "--porcelain", "--untracked-files=all"): + raise Refusal("Builds checkout contains local changes or untracked files") + return {"source_commit": source, "build_commit": head, "source_tree": tree} + + +def main(): + if os.environ.get("GITHUB_REPOSITORY") != "Builds/smirk-backend-core": + raise Refusal("this workflow is admitted only on Builds/smirk-backend-core") + event = os.environ.get("GITHUB_EVENT_NAME") + expected = os.environ.get("EXPECTED_BUILD_SHA") + if not expected: + raise Refusal("this build requires the exact reviewed event or dispatch head") + if event not in {"push", "pull_request", "workflow_dispatch"}: + raise Refusal("this build event is not admitted") + wrapper = event != "pull_request" + canonical = source_for_checkout(wrapper) + result = admit(canonical, expected, require_wrapper=wrapper) + print(f"Admitted canonical source {result['source_commit']} at Builds {result['build_commit']}") + + +if __name__ == "__main__": + try: + main() + except Refusal as error: + sys.exit(f"Build source refused: {error}") + except subprocess.CalledProcessError: + sys.exit("Build source refused: Git could not resolve the reviewed source or checkout") diff --git a/tools/ci-fundless.sh b/tools/ci-fundless.sh new file mode 100755 index 0000000..eb55f2c --- /dev/null +++ b/tools/ci-fundless.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Own a disposable Unix-socket PostgreSQL cluster. Never use a caller's database. +set -euo pipefail +set +x +if [[ $# != 0 ]]; then + echo 'Fundless CI does not accept a database or test-command override.' >&2 + exit 1 +fi +if [[ -f .env ]]; then + echo 'Fundless CI refuses a checkout containing .env.' >&2 + exit 1 +fi +pg_bin=$(pg_config --bindir) +for tool in initdb pg_ctl createdb psql; do + [[ -x "$pg_bin/$tool" ]] || { echo "Fundless CI requires PostgreSQL $tool." >&2; exit 1; } +done +ci_pg_dir=$(mktemp -d /tmp/smirk-ci-pg.XXXXXXXX) +chmod 700 "$ci_pg_dir" +pg_user=$(id -un) +if [[ $(id -u) == 0 ]]; then + pg_user=postgres + id "$pg_user" >/dev/null 2>&1 || { echo 'The isolated CI container requires the postgres service account.' >&2; exit 1; } + chown "$pg_user" "$ci_pg_dir" +fi +pg_exec() { + if [[ $(id -u) == 0 ]]; then + runuser -u "$pg_user" -- "$@" + else + "$@" + fi +} +cleanup() { + pg_exec "$pg_bin/pg_ctl" -D "$ci_pg_dir/data" -m immediate -w stop >/dev/null 2>&1 || true + rm -rf "$ci_pg_dir" +} +trap cleanup EXIT +pg_exec "$pg_bin/initdb" -D "$ci_pg_dir/data" --auth-local=trust --auth-host=reject --no-locale >/dev/null +# No TCP listener and a private socket directory: no password or shared service. +pg_exec "$pg_bin/pg_ctl" -D "$ci_pg_dir/data" -l "$ci_pg_dir/postgres.log" \ + -o "-k $ci_pg_dir -c listen_addresses='' -c unix_socket_permissions=0700" -w start >/dev/null +pg_exec "$pg_bin/createdb" -h "$ci_pg_dir" smirk_ci +export TEST_DATABASE_URL="postgresql:///smirk_ci?host=$ci_pg_dir&user=$pg_user" +# A failing or missing DB is an error; tests must not quietly take their no-DB path. +pg_exec "$pg_bin/psql" -h "$ci_pg_dir" -d smirk_ci -Atc 'SELECT 1' | grep -qx 1 +cargo test --locked --tests +cargo test --locked --doc diff --git a/tools/stage-backend-candidate.py b/tools/stage-backend-candidate.py new file mode 100755 index 0000000..11cdb78 --- /dev/null +++ b/tools/stage-backend-candidate.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Stage a private Linux candidate with exact source and artifact provenance.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location("build_source", ROOT / "tools/check-build-source.py") +source = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source) + + +def sha256(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def elf_requirements(path): + header = Path(path).read_bytes()[:20] + if len(header) != 20 or header[:6] != b"\x7fELF\x02\x01" or header[18:20] != b"\x3e\x00": + raise source.Refusal("candidate must be a 64-bit little-endian x86_64 ELF binary") + versions = subprocess.check_output(["readelf", "--version-info", str(path)], text=True) + required = sorted(set(re.findall(r"\bGLIBC_([0-9]+(?:\.[0-9]+)+)\b", versions)), + key=lambda value: tuple(map(int, value.split(".")))) + if not required: + raise source.Refusal("candidate has no readable GNU libc compatibility evidence") + if tuple(map(int, required[-1].split("."))) > (2, 36): + raise source.Refusal("candidate requires newer GNU libc than the admitted Debian 12 baseline") + return required + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + os.chdir(ROOT) + admitted = source.admit(source.source_for_checkout(True), + os.environ.get("EXPECTED_BUILD_SHA", ""), require_wrapper=True) + binary = ROOT / "target/x86_64-unknown-linux-gnu/release/smirk-backend-core" + required = elf_requirements(binary) + rustc = subprocess.check_output(["rustc", "--version"], text=True).strip() + if not rustc.startswith("rustc 1.98.0 "): + raise source.Refusal("candidate compiler differs from the reviewed Rust 1.98.0 pin") + args.output.mkdir(mode=0o700, parents=False, exist_ok=False) + staged = args.output / "smirk-backend-core" + shutil.copyfile(binary, staged) + staged.chmod(0o755) + manifest = { + "schema_version": 1, + "product": "smirk-backend-core", + "publication": "private_candidate_only", + **admitted, + "canonical_repository": "https://github.com/Such-Software/smirk-backend-core", + "build_repository": "Builds/smirk-backend-core", + "workflow_path": ".gitea/workflows/backend-build.yml", + "workflow_sha256": sha256(ROOT / ".gitea/workflows/backend-build.yml"), + "cargo_lock_sha256": sha256(ROOT / "Cargo.lock"), + "rust_toolchain_sha256": sha256(ROOT / "rust-toolchain.toml"), + "rustc": rustc, + "target": "x86_64-unknown-linux-gnu", + "glibc_versions": required, + "artifact": {"name": staged.name, "sha256": sha256(staged), "size": staged.stat().st_size}, + } + (args.output / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + print(f"Staged private candidate for {admitted['source_commit']}; publication requires separate admission.") + + +if __name__ == "__main__": + try: + main() + except source.Refusal as error: + sys.exit(f"Candidate refused: {error}") + except (OSError, subprocess.CalledProcessError): + sys.exit("Candidate refused: required build output, compiler, or ELF evidence is unavailable") diff --git a/tools/tests/test_build_admission.py b/tools/tests/test_build_admission.py new file mode 100644 index 0000000..1843e4c --- /dev/null +++ b/tools/tests/test_build_admission.py @@ -0,0 +1,134 @@ +"""Fundless Git fixtures prove source admission and binary compatibility guards.""" +import importlib.util +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +TOOLS = Path(__file__).resolve().parents[1] + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +source = load("source", TOOLS / "check-build-source.py") +stage = load("stage", TOOLS / "stage-backend-candidate.py") + + +class Admission(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="smirk-git-admission-") + self.previous = Path.cwd() + os.chdir(self.temp.name) + self.git("init", "-q") + self.git("config", "user.name", "Fundless fixture") + self.git("config", "user.email", "fixture@example.invalid") + Path("source.txt").write_text("baseline\n") + self.git("add", ".") + self.git("commit", "-qm", "baseline") + self.base = self.git("rev-parse", "HEAD") + Path("source.txt").write_text("candidate\n") + self.git("commit", "-qam", "candidate") + self.candidate = self.git("rev-parse", "HEAD") + + def tearDown(self): + os.chdir(self.previous) + self.temp.cleanup() + + def git(self, *args, input=None): + return subprocess.check_output(["git", *args], input=input, text=True, + stderr=subprocess.PIPE).strip() + + def wrapper(self, second=None, tree=None): + tree = tree or self.git("rev-parse", self.candidate + "^{tree}") + value = self.git("commit-tree", tree, "-p", self.base, "-p", second or self.candidate, + input="wrapper\n") + self.git("reset", "--hard", value) + return value + + def test_canonical_and_tree_identical_wrapper_admitted(self): + self.assertEqual(source.admit(self.candidate)["build_commit"], self.candidate) + wrapper = self.wrapper() + admitted = source.admit(self.candidate, wrapper) + self.assertEqual(admitted["source_commit"], self.candidate) + self.assertEqual(admitted["source_tree"], self.git("rev-parse", "HEAD^{tree}")) + + def test_release_requires_wrapper_and_derives_its_source(self): + with self.assertRaises(source.Refusal): + source.source_for_checkout(True) + with self.assertRaises(source.Refusal): + source.admit(self.candidate, self.candidate, require_wrapper=True) + wrapper = self.wrapper() + self.assertEqual(source.source_for_checkout(True), self.candidate) + self.assertEqual(source.admit(self.candidate, wrapper, require_wrapper=True)["build_commit"], wrapper) + + def test_moving_dispatch_head_refused(self): + with self.assertRaises(source.Refusal): + source.admit(self.candidate, self.base) + + def test_tree_change_refused(self): + self.wrapper(tree=self.git("rev-parse", self.base + "^{tree}")) + with self.assertRaises(source.Refusal): + source.admit(self.candidate) + + def test_tree_identity_without_source_parent_refused(self): + tree = self.git("rev-parse", self.candidate + "^{tree}") + unrelated = self.git("commit-tree", tree, "-p", self.base, input="unrelated\n") + self.wrapper(second=unrelated) + with self.assertRaises(source.Refusal): + source.admit(self.candidate) + + def test_dirty_surface_and_missing_pin_refused(self): + Path("unknown.txt").write_text("unreviewed\n") + with self.assertRaises(source.Refusal): + source.admit(self.candidate) + with self.assertRaises(source.Refusal): + source.admit("") + + def test_dispatch_missing_head_or_wrong_repository_refused(self): + with patch.dict(os.environ, {"GITHUB_REPOSITORY": "Builds/smirk-backend-core", + "GITHUB_EVENT_NAME": "workflow_dispatch"}, clear=True): + with self.assertRaises(source.Refusal): + source.main() + with patch.dict(os.environ, {"GITHUB_REPOSITORY": "unreviewed/repository"}, clear=True): + with self.assertRaises(source.Refusal): + source.main() + + +class BinaryCompatibility(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="smirk-elf-fixture-") + self.binary = Path(self.temp.name) / "binary" + self.binary.write_bytes(b"\x7fELF\x02\x01" + bytes(12) + b"\x3e\x00") + + def tearDown(self): + self.temp.cleanup() + + def test_compatible_versions_recorded_in_numeric_order(self): + with patch.object(subprocess, "check_output", return_value="GLIBC_2.3 GLIBC_2.36 GLIBC_2.9"): + result = stage.elf_requirements(self.binary) + self.assertEqual(result[-1], "2.36") + self.assertIn("2.9", result) + + def test_incompatible_or_unknown_libc_refused(self): + for output in ("GLIBC_2.38", "unreadable"): + with self.subTest(output=output), patch.object(subprocess, "check_output", return_value=output): + with self.assertRaises(stage.source.Refusal): + stage.elf_requirements(self.binary) + + def test_wrong_binary_format_refused_before_tool_invocation(self): + self.binary.write_bytes(b"not ELF") + with patch.object(subprocess, "check_output") as probe: + with self.assertRaises(stage.source.Refusal): + stage.elf_requirements(self.binary) + probe.assert_not_called() + + +if __name__ == "__main__": + unittest.main()