diff --git a/qualification/composer-container-trust.json b/qualification/composer-container-trust.json index 59fc56e..8e6b760 100644 --- a/qualification/composer-container-trust.json +++ b/qualification/composer-container-trust.json @@ -804,6 +804,117 @@ "mode": 420 } } + }, + "cli-php-reference": { + "target": "cli", + "workflows": { + "build.yml": [ + "rust" + ] + }, + "commands": [ + "bash \"$GITHUB_WORKSPACE/scripts/ci/setup-php-reference-container.sh\"" + ], + "environment": [ + "CLI_PHP_CI_IMAGE", + "CLI_PHP_CI_ROOT", + "CLI_PHP_CI_STATE", + "DW_PARITY_PHP" + ], + "absent_files": [ + "docker/php-ci/Dockerfile.dockerignore", + "docker/php-ci/cli.Dockerfile.dockerignore" + ], + "files": { + ".dockerignore": { + "sha256": "19f259d2473129b500ed107780da5b5bc4a7dea47fde4500539bff4b919d4aee", + "mode": 420 + }, + "docker/php-ci/Dockerfile": { + "sha256": "81d23fbbee7e4ee97ca14df9d17982aa664335b94499c5019662259fe0387eaa", + "mode": 420 + }, + "docker/php-ci/cli.Dockerfile": { + "sha256": "c2b64bd82040083fbd812a470b266861f04303ea800ba58908f1e4601fe4179f", + "mode": 420 + }, + "docker/php-ci/verify-php-trust.php": { + "sha256": "529c282e7422202bf25565431a6391de388f86dfe5bfd6c75cf21daa56ffa75e", + "mode": 420 + }, + "docker/brotli/install-vendor.sh": { + "sha256": "d7eb8b2a3e0af1c3072a56bcb81632b22f061f211d8fe5e57ee901c8bf94c92f", + "mode": 420 + }, + "docker/brotli/verify-runtime.php": { + "sha256": "580cc465e349f3872eca592355294e7320fe7f1a4560ff5d4c83d5900921c00e", + "mode": 420 + }, + "docker/brotli/verify-system-brotli.c": { + "sha256": "3353927d206aaf5e63157e46be715c6cbee46bac777e29f8f9e25c4c1f656667", + "mode": 420 + }, + "docker/brotli/version": { + "sha256": "e064953e075ddb99fb7b48ad29e215f2dfa075812c127eb1d6ab680e9920fba2", + "mode": 420 + }, + "scripts/ci/php-container-images.json": { + "sha256": "85c87a2dff21225109581876befc00177c62824e44c044765dc921879ca41a4d", + "mode": 420 + }, + "scripts/ci/setup-php-container.sh": { + "sha256": "a7007c0fbef886b7e29b62d20b76404839746620361a9dd9e6024f1ff546d49b", + "mode": 493 + }, + "scripts/ci/run-php-container.sh": { + "sha256": "baef1e07ac53ce31fed01eac37ced2f98e363fa677e129952f6e15e47213db0e", + "mode": 493 + }, + "scripts/ci/probe-php-test-runtime.php": { + "sha256": "a0bbef217128b8ffe81de5c126dfafbfd93ebed28244c2a69204a8f4991be264", + "mode": 420 + }, + "scripts/ci/test-php-container-tls.py": { + "sha256": "da11950ff46810ba425fea6bb6a453869995204388570d1f764a160154f3e7b0", + "mode": 420 + }, + "scripts/ci/test-php-container-boundary.py": { + "sha256": "8d4db79b20598e5e9f1fc64f6a730b574d6f2161cc42a01442ac0095dc8684e9", + "mode": 420 + }, + "docker/php-ci/reference.ini": { + "sha256": "94b40d9558a170b09785010b8016b19c32fadc2c43cd2b7518b2c74aac1d6b55", + "mode": 420 + }, + "scripts/ci/setup-php-reference-container.sh": { + "sha256": "7daf89ec02bed806218c4c33d8746db921cb5d1a4ad415c03fdc1072c52f0480", + "mode": 493 + }, + "scripts/ci/prepare-php-reference-container.sh": { + "sha256": "f82fb1434c1c0efbf198474c9174574c0690a946ae19a85925d7109bde1b9c5d", + "mode": 493 + }, + "scripts/ci/run-php-reference-container.sh": { + "sha256": "b1ab4dc87b55bb6693f3e39dbd1b0ba8152c0f950984d70d62c9ab123693f8dd", + "mode": 493 + }, + "scripts/ci/probe-php-reference-runtime.php": { + "sha256": "855e21c5d444038f116ea5523494d8b28c2cb59053e932cf1fff2e61c7efd1c2", + "mode": 420 + }, + "scripts/ci/test_php_reference_container.py": { + "sha256": "a6c2f0c1bd06d386f94876c0c5dcfe1278a1a13281270af7d43223730cc7f99c", + "mode": 420 + }, + "rust/parity/test_php_reference_container.py": { + "sha256": "71b1e8207bc3e3df28e7b9210ed23226139e6cd896d2baf64592f1b0dc05017c", + "mode": 420 + }, + "tests/fixtures/php-reference-workflow.yml": { + "sha256": "68131102fff3bb1fda11cc63c58454d1788e3bbe69482231fbad332247f8befe", + "mode": 420 + } + } } } } diff --git a/tests/fixtures/cli-php-reference.yml b/tests/fixtures/cli-php-reference.yml new file mode 100644 index 0000000..21babd1 --- /dev/null +++ b/tests/fixtures/cli-php-reference.yml @@ -0,0 +1,69 @@ +# Proposed caller. Activate only after exact central source admission. +jobs: + rust: + name: Rust CLI (unit tests and 2.x parity) + needs: [action-policy] + if: ${{ github.server_url == 'https://github.com' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Bootstrap verified dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python,composer,cargo,curl,git + - name: Install Rust 1.86 + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + toolchain: 1.86.0 + components: rustfmt, clippy + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets -- -D warnings + - name: Install completion test shells + # CVE-2026-109518 needs APT to retry a redirected download; hosted + # runner images still ship affected apt (durable-workflow/.github#295). + run: | + echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null + sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends bash zsh fish + - run: cargo test --locked + env: + DW_REQUIRE_COMPLETION_SHELLS: '1' + - run: cargo build --locked + - name: Check out the accepted PHP container build source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Build and prepare the pinned PHP source-reference profile + run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-reference-container.sh" + - name: Compare compatibility checks with the 2.x PHP classes + run: | + php parity/compat-vectors.php ../src ../tests/fixtures src/compat/fixtures target/php-vectors.json + DW_COMPAT_PHP_VECTORS=target/php-vectors.json cargo test --locked -- --ignored + - name: Fetch the pinned 2.x reference CLI + run: parity/fetch-reference.sh + - name: Verify actual Composer dependency trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + working-directory: ${{ github.workspace }} + - name: Install PHP main for the source reference + # Cases marked "reference": "php-main" compare against this + # checkout's PHP CLI (cli#61 behaviour 2.3.5 lacks). + working-directory: ${{ github.workspace }} + run: composer install --no-dev --prefer-dist --no-interaction + - name: Compare against the 2.x CLI + env: + DW_PARITY_PHP_MAIN: ${{ github.workspace }} + run: | + python3 -m unittest discover -s parity -p 'test_*.py' + python3 parity/parity.py --verbose diff --git a/tests/test_cli_php_reference_runtime.py b/tests/test_cli_php_reference_runtime.py new file mode 100644 index 0000000..155d89f --- /dev/null +++ b/tests/test_cli_php_reference_runtime.py @@ -0,0 +1,93 @@ +"""The PHP reference recipe admits one caller without weakening whole-target trust.""" +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import yaml + +from scripts.qualification_policy import PolicyError, verify_composer_network_sinks + + +class CliPhpReferenceRuntimeTests(unittest.TestCase): + def setUp(self): + repository = Path(__file__).resolve().parents[1] + self.policy = json.loads((repository/'qualification/policy.json').read_text()) + self.catalog = json.loads((repository/'qualification/composer-container-trust.json').read_text()) + self.document = yaml.safe_load((repository/'tests/fixtures/cli-php-reference.yml').read_text()) + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.recipe = self.catalog['runtime_setups']['cli-php-reference'] + # Inert bodies test the actual catalog selectors/refusals. Independent + # source review binds the public consumer's real blobs and Git modes. + for name, expected in self.recipe['files'].items(): + path = self.root/name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('# inert reviewed input\n') + path.chmod(expected['mode']) + expected['sha256'] = hashlib.sha256(path.read_bytes()).hexdigest() + + def check(self, document=None, filename='build.yml'): + sources = {filename:yaml.safe_dump(self.document if document is None else document)} + with mock.patch('scripts.qualification_policy._composer_container_catalog', return_value=self.catalog): + return verify_composer_network_sinks(self.policy, 'cli', sources, self.root) + + def test_exact_proposed_caller_uses_the_registered_setup_and_actual_guard(self): + self.assertEqual('ubuntu-latest', self.document['jobs']['rust']['runs-on']) + self.assertEqual(1, len(self.check())) + + def test_wrong_job_workflow_or_unguarded_extra_job_cannot_borrow_admission(self): + moved = deepcopy(self.document) + moved['jobs']['other'] = moved['jobs'].pop('rust') + with self.assertRaises(PolicyError): + self.check(moved) + with self.assertRaises(PolicyError): + self.check(filename='other.yml') + extra = deepcopy(self.document) + extra['jobs']['unguarded'] = {'steps':[{'run':'composer install --no-interaction'}]} + with self.assertRaises(PolicyError): + self.check(extra) + + def test_every_bound_file_and_absence_is_required(self): + for name in self.recipe['files']: + with self.subTest(file=name): + path = self.root/name + before = path.read_bytes() + path.write_bytes(before+b'# changed\n') + with self.assertRaises(PolicyError): + self.check() + path.write_bytes(before) + for name in self.recipe['absent_files']: + with self.subTest(absence=name): + path = self.root/name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + with self.assertRaises(PolicyError): + self.check() + path.unlink() + + def test_setup_overrides_or_all_verifier_deletion_fail_closed(self): + for field, value in (('if','false'), ('continue-on-error',True), ('env',{'CLI_PHP_CI_IMAGE':'unreviewed'})): + altered = deepcopy(self.document) + setup = next(step for step in altered['jobs']['rust']['steps'] + if step.get('run') == self.recipe['commands'][0]) + setup[field] = value + with self.subTest(field=field), self.assertRaises(PolicyError): + self.check(altered) + altered = deepcopy(self.document) + steps = altered['jobs']['rust']['steps'] + steps[:] = [step for step in steps if 'verify-composer-trust@' not in step.get('uses','')] + with self.assertRaises(PolicyError): + self.check(altered) + altered = deepcopy(self.document) + altered['jobs']['rust']['env'] = {'DW_PARITY_PHP':'unreviewed'} + with self.assertRaises(PolicyError): + self.check(altered) + + +if __name__ == '__main__': + unittest.main()