diff --git a/.github/workflows/research-book-pages.yml b/.github/workflows/research-book-pages.yml index 34702a30..1b31c0e9 100644 --- a/.github/workflows/research-book-pages.yml +++ b/.github/workflows/research-book-pages.yml @@ -1,10 +1,10 @@ name: Rheon research book and Pages on: pull_request: - paths: ['browser/viewer/**', 'browser/solver/**', 'tests/forces_contract.rs', 'tests/simulation_contract.rs', 'docs/education/**', 'docs/research-book/companion/**', 'docs/research-book/compressible/**', 'docs/research-book/chapters/**', 'docs/research-book/engineering/**', 'docs/research-book/implementation/**', 'docs/research-book/sources/**', 'docs/research-book/appendices/**', 'docs/research-book/figures/**', 'docs/research-book/expansion/**', 'proofs/**', 'src/**', 'examples/aligned_strain.rs', 'tests/aligned_strain_contract.rs', 'tools/aligned_strain_oracle.py', 'tools/test_aligned_strain_oracle.py', 'tools/qualify_aligned_strain.py', 'tools/test_qualify_aligned_strain.py', 'rust-toolchain.toml', 'examples/static_obstacle.rs', 'examples/obstacle_flow.rs', 'tests/obstacle_flow_contract.rs', 'Cargo.*', 'evidence/bounded-physics-contracts/**', '.github/workflows/research-book-pages.yml'] + paths: ['browser/viewer/**', 'browser/solver/**', 'tests/forces_contract.rs', 'tests/simulation_contract.rs', 'docs/education/**', 'docs/research-book/companion/**', 'docs/research-book/compressible/**', 'docs/research-book/chapters/**', 'docs/research-book/engineering/**', 'docs/research-book/implementation/**', 'docs/research-book/sources/**', 'docs/research-book/appendices/**', 'docs/research-book/figures/**', 'docs/research-book/expansion/**', 'proofs/**', 'src/**', 'examples/aligned_strain.rs', 'tests/aligned_strain_contract.rs', 'tools/aligned_strain_oracle.py', 'tools/test_aligned_strain_oracle.py', 'tools/qualify_aligned_strain.py', 'tools/test_qualify_aligned_strain.py', 'rust-toolchain.toml', 'tools/setup_ci_rust.py', 'tools/test_setup_ci_rust.py', 'examples/static_obstacle.rs', 'examples/obstacle_flow.rs', 'tests/obstacle_flow_contract.rs', 'Cargo.*', 'evidence/bounded-physics-contracts/**', '.github/workflows/research-book-pages.yml'] push: branches: [main] - paths: ['browser/viewer/**', 'browser/solver/**', 'tests/forces_contract.rs', 'tests/simulation_contract.rs', 'docs/education/**', 'docs/research-book/companion/**', 'docs/research-book/compressible/**', 'docs/research-book/chapters/**', 'docs/research-book/engineering/**', 'docs/research-book/implementation/**', 'docs/research-book/sources/**', 'docs/research-book/appendices/**', 'docs/research-book/figures/**', 'docs/research-book/expansion/**', 'proofs/**', 'src/**', 'examples/aligned_strain.rs', 'tests/aligned_strain_contract.rs', 'tools/aligned_strain_oracle.py', 'tools/test_aligned_strain_oracle.py', 'tools/qualify_aligned_strain.py', 'tools/test_qualify_aligned_strain.py', 'rust-toolchain.toml', 'examples/static_obstacle.rs', 'examples/obstacle_flow.rs', 'tests/obstacle_flow_contract.rs', 'Cargo.*', 'evidence/bounded-physics-contracts/**', '.github/workflows/research-book-pages.yml'] + paths: ['browser/viewer/**', 'browser/solver/**', 'tests/forces_contract.rs', 'tests/simulation_contract.rs', 'docs/education/**', 'docs/research-book/companion/**', 'docs/research-book/compressible/**', 'docs/research-book/chapters/**', 'docs/research-book/engineering/**', 'docs/research-book/implementation/**', 'docs/research-book/sources/**', 'docs/research-book/appendices/**', 'docs/research-book/figures/**', 'docs/research-book/expansion/**', 'proofs/**', 'src/**', 'examples/aligned_strain.rs', 'tests/aligned_strain_contract.rs', 'tools/aligned_strain_oracle.py', 'tools/test_aligned_strain_oracle.py', 'tools/qualify_aligned_strain.py', 'tools/test_qualify_aligned_strain.py', 'rust-toolchain.toml', 'tools/setup_ci_rust.py', 'tools/test_setup_ci_rust.py', 'examples/static_obstacle.rs', 'examples/obstacle_flow.rs', 'tests/obstacle_flow_contract.rs', 'Cargo.*', 'evidence/bounded-physics-contracts/**', '.github/workflows/research-book-pages.yml'] workflow_dispatch: permissions: contents: read @@ -145,9 +145,8 @@ jobs: echo "RHEON_STRAIN=$RUNNER_TEMP/rheon-aligned-strain-records" >> "$GITHUB_ENV" echo "RHEON_STRAIN_QUALIFICATION=$RUNNER_TEMP/rheon-aligned-strain-qualification" >> "$GITHUB_ENV" echo "CARGO_TARGET_DIR=$RUNNER_TEMP/rheon-geometry-target" >> "$GITHUB_ENV" - echo "CARGO_HOME=$RUNNER_TEMP/rheon-cargo" >> "$GITHUB_ENV" - echo "$RUNNER_TEMP/rheon-cargo/bin" >> "$GITHUB_PATH" - echo "RUSTUP_HOME=$RUNNER_TEMP/rheon-rustup" >> "$GITHUB_ENV" + - name: Install isolated official pinned Rust toolchain + run: python3 tools/setup_ci_rust.py - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '24' @@ -202,7 +201,6 @@ jobs: python3 -O -m unittest discover -s docs/education -p test_compressible_lab.py - name: Construct and qualify only static geometry controls run: | - rustup toolchain install 1.92.0 --profile minimal --no-self-update mkdir -p "$RHEON_OBSTACLE" cargo +1.92.0 build --locked --no-default-features --example static_obstacle "$CARGO_TARGET_DIR/debug/examples/static_obstacle" "$RHEON_OBSTACLE/records.json" diff --git a/.github/workflows/rust-rheon.yml b/.github/workflows/rust-rheon.yml index 43cfc483..27107137 100644 --- a/.github/workflows/rust-rheon.yml +++ b/.github/workflows/rust-rheon.yml @@ -5,6 +5,8 @@ on: - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' + - 'tools/setup_ci_rust.py' + - 'tools/test_setup_ci_rust.py' - 'src/**' - 'tests/**' - 'examples/**' @@ -36,6 +38,8 @@ on: - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' + - 'tools/setup_ci_rust.py' + - 'tools/test_setup_ci_rust.py' - 'src/**' - 'tests/**' - 'examples/**' @@ -86,10 +90,7 @@ jobs: with: persist-credentials: false - name: Select official pinned Rust toolchain - run: | - rustup toolchain install 1.92.0 --profile minimal --component rustfmt --component clippy - rustc --version --verbose - cargo --version + run: python3 tools/setup_ci_rust.py - name: Strict lints for selected features run: | cargo clippy --locked ${{ matrix.flags }} --all-targets -- -D warnings @@ -113,11 +114,12 @@ jobs: run: | python3 -m venv "$RUNNER_TEMP/rheon-tests" "$RUNNER_TEMP/rheon-tests/bin/python" -m pip install --index-url https://pypi.org/simple --only-binary=:all: Pillow==12.3.0 numpy==2.3.5 - - name: Select official pinned Rust toolchain + - name: Check isolated Rust bootstrap contracts run: | - rustup toolchain install 1.92.0 --profile minimal --component rustfmt --component clippy - rustc --version --verbose - cargo --version + python3 -m unittest discover -s tools -p test_setup_ci_rust.py + python3 -O -m unittest discover -s tools -p test_setup_ci_rust.py + - name: Select official pinned Rust toolchain + run: python3 tools/setup_ci_rust.py - name: Formatting run: | cargo fmt --all --check diff --git a/tools/setup_ci_rust.py b/tools/setup_ci_rust.py new file mode 100644 index 00000000..299e0773 --- /dev/null +++ b/tools/setup_ci_rust.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Install the repository's pinned Rust into fresh GitHub-job-owned homes. + +Do not repair, copy, or invoke the runner image's Rust installation. A failed +setup publishes no usable tool environment; its disposable directory remains +under RUNNER_TEMP for diagnosis and runner cleanup. This is Ubuntu x86-64 CI +setup, not a hermetic build or a reproducibility claim. +""" +import hashlib +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +import tempfile +import tomllib +import urllib.request + + +HOST = "x86_64-unknown-linux-gnu" +RUSTUP_VERSION = "1.28.2" +RUSTUP_INIT_URL = ( + f"https://static.rust-lang.org/rustup/archive/{RUSTUP_VERSION}/{HOST}/rustup-init" +) +# Official adjacent .sha256, verified 2026-10-16; pin bootstrap bytes as well as +# the repository's rust-toolchain.toml version/components. +RUSTUP_INIT_SHA256 = "20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c" +MAX_BOOTSTRAP_BYTES = 32 * 1024 * 1024 +DIST_SERVER = "https://static.rust-lang.org" +UPDATE_ROOT = "https://static.rust-lang.org/rustup" + + +def download_bootstrap(destination): + with urllib.request.urlopen(RUSTUP_INIT_URL, timeout=60) as response: + data = response.read(MAX_BOOTSTRAP_BYTES + 1) + if len(data) > MAX_BOOTSTRAP_BYTES: + raise ValueError("rustup bootstrap exceeds download limit") + if hashlib.sha256(data).hexdigest() != RUSTUP_INIT_SHA256: + raise ValueError("rustup bootstrap SHA-256 mismatch") + destination.write_bytes(data) + destination.chmod(0o700) + + +def run(command, environment, capture=False): + result = subprocess.run( + [str(arg) for arg in command], env=environment, check=True, + capture_output=capture, text=True, timeout=900, + ) + if capture: + print(result.stdout, end="", flush=True) + return result.stdout + return "" + + +def setup(repository, runner_temp, github_env, github_path): + if platform.system() != "Linux" or platform.machine() != "x86_64": + raise ValueError("CI Rust setup supports Linux x86-64 only") + configuration = tomllib.loads((repository / "rust-toolchain.toml").read_text()) + toolchain = configuration["toolchain"] + channel = toolchain["channel"] + if ( + not isinstance(channel, str) + or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", channel) is None + or toolchain.get("profile") != "minimal" + or toolchain.get("components") != ["rustfmt", "clippy"] + or toolchain.keys() != {"channel", "profile", "components"} + ): + raise ValueError("expected a pinned minimal Rust toolchain with rustfmt and clippy") + if not runner_temp.is_absolute() or not runner_temp.is_dir(): + raise ValueError("RUNNER_TEMP must name an existing absolute directory") + # mkdtemp exclusively creates this job's root. Never reuse a predictable + # preexisting home or import runner toolchains/cache into it. + root = Path(tempfile.mkdtemp(prefix="rheon-ci-rust-", dir=runner_temp)) + cargo_home = root / "cargo" + rustup_home = root / "rustup" + cargo_home.mkdir() + rustup_home.mkdir() + selected = f"{channel}-{HOST}" + environment = { + key: value for key, value in os.environ.items() + if not key.startswith(("CARGO", "RUST")) + } + environment.update({ + "CARGO_HOME": str(cargo_home), + "RUSTUP_HOME": str(rustup_home), + "RUSTUP_TOOLCHAIN": selected, + "RUSTUP_DIST_SERVER": DIST_SERVER, + "RUSTUP_UPDATE_ROOT": UPDATE_ROOT, + # Download/install must not discover preinstalled runner Rust proxies. + "PATH": "/usr/bin:/bin", + }) + bootstrap = root / "rustup-init" + print(f"Install official rustup {RUSTUP_VERSION} into {root}", flush=True) + download_bootstrap(bootstrap) + run([ + bootstrap, "-y", "--no-modify-path", "--default-host", HOST, + "--default-toolchain", "none", "--profile", "minimal", + ], environment) + rustup = cargo_home / "bin/rustup" + run([ + rustup, "toolchain", "install", selected, "--profile", "minimal", + "--component", "rustfmt", "--component", "clippy", "--no-self-update", + ], environment) + installed = set(run([ + rustup, "component", "list", "--installed", "--toolchain", selected, + ], environment, capture=True).splitlines()) + required = {f"{component}-{HOST}" for component in ("rustc", "cargo", "rust-std", "rustfmt", "clippy")} + if not required <= installed: + raise ValueError("installed Rust toolchain lacks required components") + # Use the newly installed proxies explicitly, including their +toolchain + # support needed by existing book build commands. + binaries = cargo_home / "bin" + rustc = run([binaries / "rustc", "--version", "--verbose"], environment, capture=True) + cargo = run([binaries / "cargo", "--version"], environment, capture=True) + if f"release: {channel}" not in rustc.splitlines() or not cargo.startswith(f"cargo {channel} "): + raise ValueError("installed Rust/Cargo version differs from repository pin") + run([binaries / "rustfmt", "--version"], environment, capture=True) + run([binaries / "cargo-clippy", "--version"], environment, capture=True) + published = {key: environment[key] for key in ( + "CARGO_HOME", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN", "RUSTUP_DIST_SERVER", "RUSTUP_UPDATE_ROOT", + )} + # Export only after download, installation, versions, and components pass. + with github_env.open("a", encoding="utf-8") as output: + for key, value in published.items(): + if "\n" in value or "\r" in value: + raise ValueError("invalid CI tool path") + output.write(f"{key}={value}\n") + with github_path.open("a", encoding="utf-8") as output: + output.write(f"{binaries}\n") + return published + + +def main(): + try: + setup( + Path(__file__).resolve().parent.parent, + Path(os.environ["RUNNER_TEMP"]), + Path(os.environ["GITHUB_ENV"]), + Path(os.environ["GITHUB_PATH"]), + ) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(f"CI Rust setup failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_column_interface_verifier.py b/tools/test_column_interface_verifier.py index 99bdfa43..da815024 100644 --- a/tools/test_column_interface_verifier.py +++ b/tools/test_column_interface_verifier.py @@ -26,7 +26,7 @@ def ci_path_ledger(): return dict(tracked_examples=examples,additional_paths=required[len(examples):],controls=controls) def verify_split_ci_budget(frozen,current): - """Retain exact former step bodies/pins while allowing the approved partition. + """Retain exact workloads/pins with the approved split and isolated setup. This intentionally verifies this one layout, not general YAML. The original snapshot remains immutable. No new Python dependency is required by CI. @@ -46,6 +46,20 @@ def steps(body): return result old=steps(frozen.split(' steps:\n',1)[1]) require(len(old)==8,'immutable former eight-step baseline') + # Only this reviewed bootstrap replacement departs from the frozen setup. + # Do not discard arbitrary setup steps or normalize their command bodies. + require(old[2]==( + '- name: Select official pinned Rust toolchain\n' + ' run: |\n' + ' rustup toolchain install 1.92.0 --profile minimal --component rustfmt --component clippy\n' + ' rustc --version --verbose\n' + ' cargo --version\n'), 'immutable former Rust setup') + isolated_setup='- name: Select official pinned Rust toolchain\n run: python3 tools/setup_ci_rust.py\n' + bootstrap_contracts=( + '- name: Check isolated Rust bootstrap contracts\n' + ' run: |\n' + ' python3 -m unittest discover -s tools -p test_setup_ci_rust.py\n' + ' python3 -O -m unittest discover -s tools -p test_setup_ci_rust.py\n') matches=list(re.finditer(r'^ ([a-z-]+):\n(.*?)(?=^ [a-z-]+:|\Z)',current.split('jobs:\n',1)[1],re.M|re.S)) require(len(matches)==3,'exact job inventory without duplicates') parsed={m.group(1):m.group(2) for m in matches} @@ -86,11 +100,12 @@ def steps(body): require(list(parsed)==list(expected_prefixes),'exact independent jobs and original required status') for name,prefix in expected_prefixes.items(): require(parsed[name].split(' steps:\n',1)[0]==prefix,'runner/budget/matrix/dependencies: '+name) - feature=[old[0],old[2], + feature=[old[0],isolated_setup, '- name: Strict lints for selected features\n run: |\n cargo clippy --locked ${{ matrix.flags }} --all-targets -- -D warnings\n', '- name: Contract tests for selected features\n run: |\n cargo test --locked ${{ matrix.flags }}\n', "- name: Inspect core-only dependency tree\n if: matrix.mode == 'core-only'\n run: |\n cargo tree --locked --no-default-features\n"] - common=old[:3]+['- name: Formatting\n run: |\n cargo fmt --all --check\n']+old[5:] + common=old[:2]+[bootstrap_contracts,isolated_setup, + '- name: Formatting\n run: |\n cargo fmt --all --check\n']+old[5:] gate=['- name: Require every existing qualification path\n env:\n FEATURE_RESULT: ${{ needs.feature-contracts.result }}\n EXECUTABLE_RESULT: ${{ needs.executable-contracts.result }}\n run: |\n test "$FEATURE_RESULT" = success\n test "$EXECUTABLE_RESULT" = success\n'] for name,expected in [('feature-contracts',feature),('executable-contracts',common),('core-and-executable',gate)]: require(steps(parsed[name].split(' steps:\n',1)[1])==expected,'exact retained steps: '+name) @@ -144,30 +159,6 @@ def test_rust_ci_triggers_for_each_example_and_column_verifier(self): for row in self.path_ledger['controls']: with self.subTest(event=row['event'],path=row['path']): self.assertTrue(row['matched_patterns']) - def test_ci_budget_preserves_every_existing_job_check(self): - frozen=(ROOT/'evidence/column-interface-final-artifacts/trials/frozen-workflow.yml').read_text() - current=(ROOT/'.github/workflows/rust-rheon.yml').read_text() - verify_split_ci_budget(frozen,current) - def test_split_ci_budget_rejects_removed_checks_and_early_success(self): - frozen=(ROOT/'evidence/column-interface-final-artifacts/trials/frozen-workflow.yml').read_text() - current=(ROOT/'.github/workflows/rust-rheon.yml').read_text() - mutations={ - 'desktop':(" - mode: desktop\n flags: --features desktop\n",''), - 'lint':(' --all-targets -- -D warnings',''), - 'test':('cargo test --locked ${{ matrix.flags }}','cargo test --locked'), - 'dependency tree':('cargo tree --locked --no-default-features','cargo tree --locked'), - 'smoke gate':("assert result['last_divergence_max'] <= 1e-5","assert True"), - 'early success':(' - executable-contracts\n',''), - 'skip success':('test "$EXECUTABLE_RESULT" = success','true'), - 'credential persistence':('persist-credentials: false','persist-credentials: true'), - 'Python dependency':('Pillow==12.3.0','Pillow'), - 'time limit':('timeout-minutes: 30','timeout-minutes: 60'), - } - for name,(before,after) in mutations.items(): - with self.subTest(change=name): - self.assertIn(before,current) - with self.assertRaises(ValueError): - verify_split_ci_budget(frozen,current.replace(before,after)) def test_adversarial_finite_fields_ledgers_geometry_and_pixels(self): changes=('nan_pressure','inf_velocity','nan_fraction','nan_geometry','before','inward','outward','balance','budget','carry','nan_ledger','reconstruction_change','end_geometry','held_geometry','pressure_version','air_pressure','pixel','missing_frame','advection_fraction','advection_shape_mass_preserved') for change in changes: @@ -216,4 +207,67 @@ def test_adversarial_finite_fields_ledgers_geometry_and_pixels(self): with path.open('w',newline='') as stream: writer=csv.DictWriter(stream,fieldnames=fields);writer.writeheader();writer.writerows(rows) with self.assertRaises((ValueError,FileNotFoundError)): verify(demo) +class CiBudgetContract(unittest.TestCase): + # These source-only checks must not construct the numerical verifier fixture. + def test_ci_budget_preserves_every_existing_job_check(self): + frozen=(ROOT/'evidence/column-interface-final-artifacts/trials/frozen-workflow.yml').read_text() + current=(ROOT/'.github/workflows/rust-rheon.yml').read_text() + verify_split_ci_budget(frozen,current) + def test_split_ci_budget_rejects_removed_checks_and_early_success(self): + frozen=(ROOT/'evidence/column-interface-final-artifacts/trials/frozen-workflow.yml').read_text() + current=(ROOT/'.github/workflows/rust-rheon.yml').read_text() + mutations={ + 'desktop':(" - mode: desktop\n flags: --features desktop\n",''), + 'lint':(' --all-targets -- -D warnings',''), + 'test':('cargo test --locked ${{ matrix.flags }}','cargo test --locked'), + 'dependency tree':('cargo tree --locked --no-default-features','cargo tree --locked'), + 'smoke gate':("assert result['last_divergence_max'] <= 1e-5","assert True"), + 'early success':(' - executable-contracts\n',''), + 'skip success':('test "$EXECUTABLE_RESULT" = success','true'), + 'credential persistence':('persist-credentials: false','persist-credentials: true'), + 'Python dependency':('Pillow==12.3.0','Pillow'), + 'time limit':('timeout-minutes: 30','timeout-minutes: 60'), + } + for name,(before,after) in mutations.items(): + with self.subTest(change=name): + self.assertIn(before,current) + with self.assertRaises(ValueError): + verify_split_ci_budget(frozen,current.replace(before,after)) + def test_only_exact_reviewed_isolated_setup_and_additive_contracts_are_allowed(self): + frozen=(ROOT/'evidence/column-interface-final-artifacts/trials/frozen-workflow.yml').read_text() + current=(ROOT/'.github/workflows/rust-rheon.yml').read_text() + verify_split_ci_budget(frozen,current) + setup=(' - name: Select official pinned Rust toolchain\n' + ' run: python3 tools/setup_ci_rust.py\n') + contracts=(' - name: Check isolated Rust bootstrap contracts\n' + ' run: |\n' + ' python3 -m unittest discover -s tools -p test_setup_ci_rust.py\n' + ' python3 -O -m unittest discover -s tools -p test_setup_ci_rust.py\n') + self.assertEqual(current.count(setup),2) + self.assertEqual(current.count(contracts),1) + legacy=(' - name: Select official pinned Rust toolchain\n' + ' run: |\n' + ' rustup toolchain install 1.92.0 --profile minimal --component rustfmt --component clippy\n' + ' rustc --version --verbose\n' + ' cargo --version\n') + mutations={ + 'ambient setup restored':(setup,legacy), + 'different helper':('run: python3 tools/setup_ci_rust.py','run: python3 tools/unreviewed_setup.py'), + 'swallowed setup failure':('run: python3 tools/setup_ci_rust.py','run: python3 tools/setup_ci_rust.py || true'), + 'conditional setup skip':(setup,setup.replace(' run:', ' if: false\n run:')), + 'removed setup':(setup,''), + 'removed bootstrap contracts':(contracts,''), + 'removed optimized check':(' python3 -O -m unittest discover -s tools -p test_setup_ci_rust.py\n',''), + 'contracts after setup':(contracts+setup,setup+contracts), + 'duplicate bootstrap contracts':(contracts,contracts+contracts), + 'unreviewed additive step':(contracts,contracts+' - name: Unreviewed bypass\n run: true\n'), + } + for name,(before,after) in mutations.items(): + with self.subTest(change=name): + self.assertIn(before,current) + with self.assertRaises(ValueError): + verify_split_ci_budget(frozen,current.replace(before,after,1)) + with self.assertRaisesRegex(ValueError,'immutable former Rust setup'): + verify_split_ci_budget(frozen.replace('rustup toolchain install 1.92.0','rustup toolchain install stable'),current) + if __name__=='__main__': unittest.main() diff --git a/tools/test_setup_ci_rust.py b/tools/test_setup_ci_rust.py new file mode 100644 index 00000000..8442964a --- /dev/null +++ b/tools/test_setup_ci_rust.py @@ -0,0 +1,204 @@ +"""Bootstrap isolation contracts with an explicit fake distribution boundary. + +These tests execute small fake installer/proxy subprocesses. They do not claim +to install or compile Rust; the real official-install smoke is separate. +""" +import contextlib +import hashlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import setup_ci_rust as ci + + +FAKE = r'''#!/usr/bin/python3 +import json, os +from pathlib import Path +import sys +FAIL_STAGE = "none" +name = Path(sys.argv[0]).name +cargo = Path(os.environ["CARGO_HOME"]) +rustup = Path(os.environ["RUSTUP_HOME"]) +host = "x86_64-unknown-linux-gnu" +if name == "rustup-init": + assert list(cargo.iterdir()) == [] and list(rustup.iterdir()) == [] + (cargo.parent / "bootstrap-observed.json").write_text(json.dumps({ + "argv": sys.argv[1:], "cargo": str(cargo), "rustup": str(rustup), + "path": os.environ["PATH"], + "selectors": {k:v for k,v in os.environ.items() if k.startswith(("RUST", "CARGO"))}, + })) + if FAIL_STAGE == "install": + sys.exit(17) + (cargo / "bin").mkdir() + for program in ["rustup", "rustc", "cargo", "rustfmt", "cargo-clippy"]: + output = cargo / "bin" / program + output.write_bytes(Path(__file__).read_bytes()) + output.chmod(0o700) +elif name == "rustup": + if sys.argv[1:3] == ["toolchain", "install"]: + (cargo.parent / "install-argv.json").write_text(json.dumps(sys.argv[1:])) + elif sys.argv[1:3] == ["component", "list"]: + for component in ["rustc", "cargo", "rust-std", "rustfmt", "clippy"]: + if FAIL_STAGE != "component" or component != "clippy": + print(component + "-" + host) + else: + sys.exit(19) +elif name == "rustc": + print("rustc fixture\nrelease: " + ("1.91.0" if FAIL_STAGE == "version" else "1.92.0")) +elif name == "cargo": + print("cargo 1.92.0 (explicit fake distribution fixture)") +else: + print(name + " explicit fake distribution fixture") +''' + + +class SetupCiRustContract(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="rheon-ci-bootstrap-test-") + self.root = Path(self.directory.name) + self.repository = self.root / "repository" + self.repository.mkdir() + (self.repository / "rust-toolchain.toml").write_text( + '[toolchain]\nchannel = "1.92.0"\ncomponents = ["rustfmt", "clippy"]\nprofile = "minimal"\n' + ) + self.runner = self.root / "runner" + self.runner.mkdir() + self.env_file = self.root / "github-env" + self.path_file = self.root / "github-path" + self.env_file.write_text("previous=entry\n") + self.path_file.write_text("/previous/path\n") + + def tearDown(self): + self.directory.cleanup() + + def setup(self, stage="none", corrupt=False): + payload = FAKE.replace('FAIL_STAGE = "none"', f'FAIL_STAGE = {stage!r}').encode() + digest = hashlib.sha256(payload).hexdigest() + with ( + patch.object(ci, "RUSTUP_INIT_SHA256", "0" * 64 if corrupt else digest), + patch.object(ci.urllib.request, "urlopen", return_value=io.BytesIO(payload)) as request, + patch.object(ci.platform, "system", return_value="Linux"), + patch.object(ci.platform, "machine", return_value="x86_64"), + contextlib.redirect_stdout(io.StringIO()), + ): + result = ci.setup(self.repository, self.runner, self.env_file, self.path_file) + request.assert_called_once_with(ci.RUSTUP_INIT_URL, timeout=60) + return result + + def assert_unpublished(self): + self.assertEqual(self.env_file.read_text(), "previous=entry\n") + self.assertEqual(self.path_file.read_text(), "/previous/path\n") + + def test_fresh_homes_and_explicit_tools_ignore_polluted_runner_installation(self): + polluted = self.root / "polluted" + polluted.mkdir() + (polluted / "cargo-clippy").write_text("runner partial-install sentinel") + (polluted / "rustup").write_text("#!/bin/sh\nexit 77\n") + (polluted / "rustup").chmod(0o700) + with patch.dict(os.environ, { + "CARGO_HOME": str(polluted), "RUSTUP_HOME": str(polluted), + "RUSTUP_TOOLCHAIN": "unexpected", "RUSTUP_DIST_SERVER": "https://invalid.example", + "RUSTFLAGS": "poisoned", "CARGO_ENCODED_RUSTFLAGS": "poisoned", + "RUSTUP_INIT_SKIP_PATH_CHECK": "poisoned", "PATH": str(polluted), + }): + published = self.setup() + cargo = Path(published["CARGO_HOME"]) + rustup = Path(published["RUSTUP_HOME"]) + self.assertEqual(cargo.parent, rustup.parent) + self.assertEqual(cargo.parent.parent, self.runner) + self.assertNotEqual(cargo, polluted) + observed = json.loads((cargo.parent / "bootstrap-observed.json").read_text()) + self.assertEqual(observed["path"], "/usr/bin:/bin") + self.assertEqual(observed["selectors"], published) + self.assertEqual(observed["argv"], [ + "-y", "--no-modify-path", "--default-host", ci.HOST, + "--default-toolchain", "none", "--profile", "minimal", + ]) + install = json.loads((cargo.parent / "install-argv.json").read_text()) + self.assertEqual(install, [ + "toolchain", "install", "1.92.0-" + ci.HOST, "--profile", "minimal", + "--component", "rustfmt", "--component", "clippy", "--no-self-update", + ]) + self.assertEqual((polluted / "cargo-clippy").read_text(), "runner partial-install sentinel") + self.assertEqual(published["RUSTUP_DIST_SERVER"], "https://static.rust-lang.org") + self.assertEqual(published["RUSTUP_UPDATE_ROOT"], "https://static.rust-lang.org/rustup") + for key, value in published.items(): + self.assertIn(f"{key}={value}\n", self.env_file.read_text()) + self.assertEqual(self.path_file.read_text(), f"/previous/path\n{cargo / 'bin'}\n") + + def test_each_setup_exclusively_creates_new_homes_without_reusing_previous_partial_state(self): + first = self.setup() + old = Path(first["RUSTUP_HOME"]) / "partial" + old.write_text("do not repair or delete") + second = self.setup() + self.assertNotEqual(first["RUSTUP_HOME"], second["RUSTUP_HOME"]) + self.assertEqual(old.read_text(), "do not repair or delete") + self.assertFalse((Path(second["RUSTUP_HOME"]) / "partial").exists()) + + def test_checksum_mismatch_never_executes_or_exports(self): + with self.assertRaisesRegex(ValueError, "SHA-256 mismatch"): + self.setup(corrupt=True) + self.assert_unpublished() + self.assertEqual(list(self.runner.glob("*/bootstrap-observed.json")), []) + + def test_download_size_is_bounded_before_writing_executable(self): + target = self.root / "bootstrap" + with ( + patch.object(ci, "MAX_BOOTSTRAP_BYTES", 4), + patch.object(ci.urllib.request, "urlopen", return_value=io.BytesIO(b"12345")), + self.assertRaisesRegex(ValueError, "download limit"), + ): + ci.download_bootstrap(target) + self.assertFalse(target.exists()) + + def test_failed_install_or_missing_components_or_wrong_version_never_export(self): + for stage in ["install", "component", "version"]: + with self.subTest(stage=stage), self.assertRaises((ValueError, ci.subprocess.CalledProcessError)): + self.setup(stage) + self.assert_unpublished() + + def test_unpinned_or_changed_component_contract_refuses_before_download(self): + for fragment in [ + 'channel = "stable"\ncomponents = ["rustfmt", "clippy"]\nprofile = "minimal"', + 'channel = "1.92.0"\ncomponents = ["rustfmt"]\nprofile = "minimal"', + ]: + (self.repository / "rust-toolchain.toml").write_text("[toolchain]\n" + fragment) + with self.assertRaisesRegex(ValueError, "pinned minimal"), patch.object(ci, "download_bootstrap") as download: + ci.setup(self.repository, self.runner, self.env_file, self.path_file) + download.assert_not_called() + self.assert_unpublished() + self.assertEqual(list(self.runner.iterdir()), []) + + def test_repaired_workflows_use_shared_setup_and_retain_required_qualification_commands(self): + workflows = Path(__file__).resolve().parent.parent / ".github/workflows" + rust = (workflows / "rust-rheon.yml").read_text() + book = (workflows / "research-book-pages.yml").read_text() + self.assertEqual(rust.count("run: python3 tools/setup_ci_rust.py"), 2) + self.assertEqual(book.count("run: python3 tools/setup_ci_rust.py"), 1) + executable = rust.split("\n executable-contracts:\n", 1)[1].split("\n core-and-executable:\n", 1)[0] + self.assertLess(executable.index("name: Check isolated Rust bootstrap contracts"), executable.index("run: python3 tools/setup_ci_rust.py")) + self.assertIn("python3 -m unittest discover -s tools -p test_setup_ci_rust.py", executable) + self.assertIn("python3 -O -m unittest discover -s tools -p test_setup_ci_rust.py", executable) + lessons, build = book.split("\n build:\n", 1) + build, _ = build.split("\n deploy:\n", 1) + self.assertNotIn("run: python3 tools/setup_ci_rust.py", lessons) + self.assertLess(build.index("run: python3 tools/setup_ci_rust.py"), build.index("cargo +1.92.0 build")) + for workflow in [rust, book]: + self.assertNotIn("rustup toolchain install", workflow) + self.assertEqual(workflow.count("'tools/setup_ci_rust.py'"), 2) + self.assertEqual(workflow.count("'tools/test_setup_ci_rust.py'"), 2) + self.assertIn("cargo clippy --locked ${{ matrix.flags }} --all-targets -- -D warnings", rust) + self.assertIn("cargo test --locked ${{ matrix.flags }}", rust) + self.assertIn("cargo fmt --all --check", rust) + self.assertIn("rustup target add --toolchain 1.92.0 wasm32-unknown-unknown", book) + self.assertIn("python3 tools/qualify_aligned_strain.py", book) + self.assertIn("python3 docs/education/check_native_admission.py", book) + + +if __name__ == "__main__": + unittest.main()