diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f02f532..1438af41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,13 @@ jobs: run: python -I scripts/ci/test-apt-no-retries.py - name: Verify PHP container launcher boundaries run: python -I -S -B tests/ci/test_php_container_launcher.py + - name: Verify command-key package and runtime provenance controls + run: | + python3 -I -S -B tests/ci/test_command_key_supply.py + python3 -I -S -B tests/ci/test_command_key_cohort.py + python3 -I -S -B tests/ci/test_command_key_containers.py + python3 -I -S -B tests/ci/test_command_key_include_closure.py + python3 -I -S -B tests/ci/test_command_key_mount_boundary.py test: name: PHP ${{ matrix.php }} diff --git a/.github/workflows/published-command-keys.yml b/.github/workflows/published-command-keys.yml new file mode 100644 index 00000000..765c3e34 --- /dev/null +++ b/.github/workflows/published-command-keys.yml @@ -0,0 +1,272 @@ +name: Published PHP command keys + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + modern-command-keys: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + shell: bash --noprofile --norc -e -o pipefail {0} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Check the isolated system Python and inert runner controls + run: | + /usr/bin/python3 -I -S -B -c 'import sys; assert sys.version_info[:2] == (3,12)' + echo /usr/bin >> "$GITHUB_PATH" + /usr/bin/python3 -I -S -B tests/ci/test_command_key_supply.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_cohort.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_containers.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_include_closure.py + /usr/bin/python3 -I -S -B tests/ci/test_command_key_mount_boundary.py + - name: Create private registry consumer and runtime state + run: | + umask 077 + state="$(mktemp -d "$RUNNER_TEMP/php-command-keys.XXXXXX")" + mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" "$state/consumer" "$state/runtime" "$state/docker" + { + echo "COMMAND_KEY_STATE=$state" + echo "CONSUMER=$state/consumer" + echo "COMPOSER_HOME=$state/home" + echo "COMPOSER_CACHE_DIR=$state/cache" + echo "TMPDIR=$state/tmp" + } >> "$GITHUB_ENV" + python3 -I -S -B - "$state/consumer/composer.json" <<'PY' + import json,pathlib,sys + supply=json.loads(pathlib.Path('tests/smoke/command-key-supply.json').read_text()) + pathlib.Path(sys.argv[1]).write_text(json.dumps({'name':'durable-workflow/command-key-consumer','require':{'durable-workflow/sdk':supply['sdk_version']},'config':{'allow-plugins':{}}})+'\n') + PY + - name: Select authenticated dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + clients: python,composer,curl,git + - name: Set up the qualified PHP profile + uses: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php + with: + php-version: '8.3' + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 + coverage: none + - name: Verify Composer trust before registry requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.CONSUMER }} + - name: Install the selected registry dist without loading package code + run: composer install --working-dir="$CONSUMER" --no-autoloader --no-scripts --no-plugins --prefer-dist --no-interaction --no-progress + - name: Download the exact public archive and shared response-loss fixture + run: | + python3 -I -S -B - <<'PY' > "$COMMAND_KEY_STATE/public-inputs" + import json,pathlib + supply=json.loads(pathlib.Path('tests/smoke/command-key-supply.json').read_text()) + print(supply['sdk_dist_url']) + print('https://raw.githubusercontent.com/durable-workflow/.github/'+supply['loss_proxy_source']+'/conformance/command-keys/committed_response_loss.py') + PY + mapfile -t urls < "$COMMAND_KEY_STATE/public-inputs" + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-time 90 --max-filesize 41943040 "${urls[0]}" --output "$COMMAND_KEY_STATE/sdk.zip" + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-time 60 --max-filesize 65536 "${urls[1]}" --output "$COMMAND_KEY_STATE/committed_response_loss.py" + python3 -I -S -B tests/smoke/verify_command_key_supply.py "$CONSUMER" "$COMMAND_KEY_STATE/sdk.zip" > command-key-package.json + - name: Verify Composer trust before autoloader generation + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.CONSUMER }} + - name: Generate only the verified consumer autoloader + run: composer dump-autoload --working-dir="$CONSUMER" --no-scripts --no-plugins --no-interaction + - name: Capture the complete include closure without package execution + id: stage + run: | + mkdir -m 700 "$COMMAND_KEY_STATE/stage" + python3 -I -S -B -c ' + import hashlib,os,re,subprocess + from pathlib import Path + source=os.environ["GITHUB_SHA"] + if re.fullmatch("[0-9a-f]{40}",source) is None: + raise RuntimeError("An exact trusted runner source is required") + path="tests/smoke/command_key_cohort.py" + raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) + if hashlib.sha256(raw).hexdigest() != "ce34c084c0a45c861a1cbe791a6be90936d59b3c7e3bce4066dad3c38e42e0c2": + raise RuntimeError("The captured controller differs from its independently fixed digest") + exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) + ' \ + --phase stage --consumer "$CONSUMER" --archive "$COMMAND_KEY_STATE/sdk.zip" \ + --stage-root "$COMMAND_KEY_STATE/stage" --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + > command-key-staging.json + root="$(sudo mktemp -d /run/sdk-php-command-keys.XXXXXX)" + python3 -I -S -B - "$root" <<'PYROOT' + import json,os,pathlib,stat,sys + root=pathlib.Path(sys.argv[1]); metadata=root.lstat() + if root.parent!=pathlib.Path('/run') or metadata.st_uid!=0 or not stat.S_ISDIR(metadata.st_mode): + raise RuntimeError('Unexpected root-controlled stage') + captured=json.loads(pathlib.Path('command-key-staging.json').read_text()) + with open(os.environ['GITHUB_OUTPUT'],'a') as output: + output.write('root='+str(root)+'\n') + output.write('root_identity='+str(metadata.st_dev)+':'+str(metadata.st_ino)+'\n') + output.write('include_sha256='+captured['include_sha256']+'\n') + PYROOT + sudo cp -a --no-preserve=ownership -- "$COMMAND_KEY_STATE/stage/." "$root/" + sudo chmod 0555 -- "$root" + - name: Prepare the captured native and read-only PHP containers + id: prepare + env: + INCLUDE_ROOT: ${{ steps.stage.outputs.root }} + INCLUDE_IDENTITY: ${{ steps.stage.outputs.root_identity }} + INCLUDE_SHA256: ${{ steps.stage.outputs.include_sha256 }} + run: | + python3 -I -S -B -c ' + import hashlib,os,re,subprocess + from pathlib import Path + source=os.environ["GITHUB_SHA"] + if re.fullmatch("[0-9a-f]{40}",source) is None: + raise RuntimeError("An exact trusted runner source is required") + path="tests/smoke/command_key_cohort.py" + raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) + if hashlib.sha256(raw).hexdigest() != "ce34c084c0a45c861a1cbe791a6be90936d59b3c7e3bce4066dad3c38e42e0c2": + raise RuntimeError("The captured controller differs from its independently fixed digest") + exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) + ' \ + --phase prepare --stage-root "$INCLUDE_ROOT" --root-identity "$INCLUDE_IDENTITY" \ + --include-sha256 "$INCLUDE_SHA256" --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + | tee command-key-prepared.json + - name: Qualify modern retained commands and postcommit response loss + env: + INCLUDE_ROOT: ${{ steps.stage.outputs.root }} + INCLUDE_IDENTITY: ${{ steps.stage.outputs.root_identity }} + INCLUDE_SHA256: ${{ steps.stage.outputs.include_sha256 }} + SERVER_ID: ${{ steps.prepare.outputs.server_id }} + CONSUMER_ID: ${{ steps.prepare.outputs.consumer_id }} + FIXTURE_NONCE: ${{ steps.prepare.outputs.nonce }} + COMMAND_KEY_TOKEN: ${{ steps.prepare.outputs.token }} + run: | + timeout --signal=INT --kill-after=30s 12m python3 -I -S -B -c ' + import hashlib,os,re,subprocess + from pathlib import Path + source=os.environ["GITHUB_SHA"] + if re.fullmatch("[0-9a-f]{40}",source) is None: + raise RuntimeError("An exact trusted runner source is required") + path="tests/smoke/command_key_cohort.py" + raw=subprocess.check_output(["/usr/bin/git","--no-replace-objects","show",source+":"+path],timeout=10) + if hashlib.sha256(raw).hexdigest() != "ce34c084c0a45c861a1cbe791a6be90936d59b3c7e3bce4066dad3c38e42e0c2": + raise RuntimeError("The captured controller differs from its independently fixed digest") + exec(compile(raw,path,"exec"),{"__name__":"__main__","__file__":str(Path.cwd()/path)}) + ' \ + --phase cohort --stage-root "$INCLUDE_ROOT" --root-identity "$INCLUDE_IDENTITY" \ + --include-sha256 "$INCLUDE_SHA256" --runtime-state "$COMMAND_KEY_STATE/runtime" \ + --docker-config "$COMMAND_KEY_STATE/docker" \ + --server-id "$SERVER_ID" --consumer-id "$CONSUMER_ID" --nonce "$FIXTURE_NONCE" \ + --proxy-source "$COMMAND_KEY_STATE/committed_response_loss.py" \ + --runtime-owner "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --runner-source "$GITHUB_SHA" \ + | tee command-key-modern.json + - name: Remove captured fixtures independently of checkout and state + if: always() + env: + SERVER_ID: ${{ steps.prepare.outputs.server_id }} + CONSUMER_ID: ${{ steps.prepare.outputs.consumer_id }} + FIXTURE_NONCE: ${{ steps.prepare.outputs.nonce }} + INCLUDE_ROOT: ${{ steps.stage.outputs.root }} + INCLUDE_IDENTITY: ${{ steps.stage.outputs.root_identity }} + run: | + /usr/bin/python3 -I -S -B - <<'PYCLEAN' | tee command-key-cleanup.json + import json,os,pathlib,re,stat,subprocess,tempfile + owner=os.environ['GITHUB_RUN_ID']+'-'+os.environ['GITHUB_RUN_ATTEMPT'] + nonce=os.environ['FIXTURE_NONCE']; errors=[]; reconciled={}; role_absence={} + with tempfile.TemporaryDirectory(prefix='sdk-php-command-key-cleanup-',dir='/tmp') as configuration: + environment={'PATH':'/usr/bin:/bin','DOCKER_CONFIG':configuration,'DOCKER_HOST':'unix:///var/run/docker.sock'} + def invoke(arguments): + return subprocess.run(['/usr/bin/docker',*arguments],env=environment,capture_output=True,text=True,timeout=30) + def absent(result,identity): + return result.returncode==1 and result.stdout=='[]\n' and result.stderr in { + 'Error: No such object: '+identity+'\n','error: no such object: '+identity+'\n'} + def candidates(role): + result=invoke(['ps','--all','--no-trunc','--last','2', + '--filter','label=durable-workflow.php-command-keys='+owner, + '--filter','label=durable-workflow.fixture-role='+role, + '--filter','label=durable-workflow.fixture-nonce='+nonce,'--format','{{.ID}}']) + if (result.returncode!=0 or result.stderr or len(result.stdout)>130 + or re.fullmatch(r'(?:[0-9a-f]{64}\n){0,2}',result.stdout) is None): + raise RuntimeError('Role reconciliation was not complete') + identities=result.stdout.splitlines() + if len(identities)>1: + raise RuntimeError('Ambiguous role candidates preserved') + return identities + for role,key in [('consumer','CONSUMER_ID'),('server','SERVER_ID')]: + identity=os.environ.get(key,'') + if not identity and not nonce: + continue + try: + role_absence[role]=False + if (re.fullmatch('[0-9]+-[0-9]+',owner) is None or re.fullmatch('[0-9a-f]{32}',nonce) is None + or (identity and re.fullmatch('[0-9a-f]{64}',identity) is None)): + raise RuntimeError('Invalid immutable fixture outputs') + matches=candidates(role) + if identity and matches and matches!=[identity]: + raise RuntimeError('Foreign role replacement preserved') + recovered=not identity and bool(matches) + identity=identity or (matches[0] if matches else '') + if not identity: + role_absence[role]=True + continue + result=invoke(['inspect',identity]) + if absent(result,identity): + if candidates(role): + raise RuntimeError('A labelled role remains') + role_absence[role]=True + continue + if result.returncode!=0: + raise RuntimeError('The recorded fixture cannot be inspected') + rows=json.loads(result.stdout) + if len(rows)!=1 or rows[0]['Id']!=identity: + raise RuntimeError('Fixture identity differs') + labels=rows[0]['Config'].get('Labels') or {} + if (labels.get('durable-workflow.php-command-keys'),labels.get('durable-workflow.fixture-role'),labels.get('durable-workflow.fixture-nonce'))!=(owner,role,nonce): + raise RuntimeError('Foreign replacement preserved') + if recovered: + reconciled[role]=identity + removed=invoke(['rm','--force','--volumes',identity]) + if (removed.returncode!=0 or not absent(invoke(['inspect',identity]),identity) + or candidates(role)): + raise RuntimeError('Exact fixture absence was not established') + role_absence[role]=True + except Exception as error: + errors.append(role+':'+type(error).__name__) + root=os.environ['INCLUDE_ROOT'] + if root and not errors: + try: + path=pathlib.Path(root); metadata=path.lstat() + if (path.parent!=pathlib.Path('/run') or re.fullmatch(r'sdk-php-command-keys\.[A-Za-z0-9]{6}',path.name) is None + or path.resolve()!=path or not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid!=0 + or str(metadata.st_dev)+':'+str(metadata.st_ino)!=os.environ['INCLUDE_IDENTITY']): + raise RuntimeError('Foreign include root preserved') + subprocess.run(['/usr/bin/sudo','/usr/bin/rm','-rf','--',root],check=True,timeout=30) + if path.exists() or path.is_symlink(): + raise RuntimeError('Include root absence was not established') + except Exception as error: + errors.append('include:'+type(error).__name__) + print(json.dumps({'runtime_removed':not errors,'errors':errors, + 'reconciled_ids':reconciled,'role_absence':role_absence})) + if errors: + raise SystemExit(1) + PYCLEAN + - name: Retain sanitized observations and installed dependency graph + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: published-php-command-keys + path: | + command-key-package.json + command-key-staging.json + command-key-prepared.json + command-key-modern.json + command-key-cleanup.json + ${{ env.CONSUMER }}/composer.lock + retention-days: 7 diff --git a/tests/ci/test_command_key_cleanup_boundary.py b/tests/ci/test_command_key_cleanup_boundary.py new file mode 100644 index 00000000..497ebc3a --- /dev/null +++ b/tests/ci/test_command_key_cleanup_boundary.py @@ -0,0 +1,342 @@ +"""Opt-in idle-container proof of the literal cleanup after control refusal.""" + +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import stat +import subprocess +import tempfile +import textwrap +import time +import types +import unittest +from unittest.mock import patch + + +REPOSITORY = Path(__file__).resolve().parents[2] +IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_CLEANUP_IMAGE", "") +SOURCE = os.environ.get("SDK_PHP_COMMAND_KEY_CLEANUP_SOURCE", "") +CLEANUP_SHA256 = "8b30e60e4de5349c99521e0e258a5fa99be5831f8a6defed3db007ed4527f15f" +OBSERVATIONS = [] + + +def captured_bytes(relative): + path = REPOSITORY / relative + if not IMAGE: + return path.read_bytes() + if re.fullmatch(r"[0-9a-f]{40}", SOURCE) is None: + raise RuntimeError("The cleanup control needs its exact reviewed source") + raw = subprocess.check_output(["/usr/bin/git", "--no-replace-objects", "-C", str(REPOSITORY), + "show", SOURCE + ":" + relative], timeout=10) + if path.read_bytes() != raw: + raise RuntimeError("Cleanup control inputs differ from the reviewed source") + return raw + + +def load(name): + relative = "tests/smoke/" + name + ".py" + module = types.ModuleType(name) + module.__file__ = str(REPOSITORY / relative) + exec(compile(captured_bytes(relative), module.__file__, "exec"), module.__dict__) + return module + + +def cleanup_script(raw): + body = raw.decode().split("<<'PYCLEAN' | tee command-key-cleanup.json\n", 1)[1].split("\n PYCLEAN", 1)[0] + script = textwrap.dedent(body) + if hashlib.sha256(script.encode()).hexdigest() != CLEANUP_SHA256: + raise RuntimeError("The independently fixed literal cleanup digest differs") + return script + + +WORKFLOW = captured_bytes(".github/workflows/published-command-keys.yml") +CONTAINERS = load("command_key_containers") +COHORT = load("command_key_cohort") +SUPPLY = json.loads(captured_bytes("tests/smoke/command-key-supply.json")) + + +class CleanupSourceAuthority(unittest.TestCase): + def test_exact_literal_cleanup_is_bound(self): + script = cleanup_script(WORKFLOW) + compile(script, "literal-independent-cleanup", "exec") + + def test_changed_cleanup_refuses_before_compilation(self): + changed = WORKFLOW.replace(b"/usr/bin/docker", b"/bin/false", 1) + self.assertNotEqual(changed, WORKFLOW) + with self.assertRaisesRegex(RuntimeError, "fixed literal cleanup digest"): + cleanup_script(changed) + + def simulate(self, create_output="", fault=None, script=None): + server, consumer, extra = "a" * 64, "c" * 64, "e" * 64 + live = {consumer: {CONTAINERS.OWNER: "123-1", CONTAINERS.ROLE: "consumer", CONTAINERS.NONCE: "b" * 32}} + captured, removed = {}, set() + with tempfile.TemporaryDirectory(prefix="sdk-php-lost-create-inert-") as temporary: + root = Path(temporary) + configuration, state = root / "docker", root / "state" + configuration.mkdir(mode=0o700) + state.mkdir(mode=0o700) + resources = CONTAINERS.Containers(configuration, state, "123-1", "b" * 32, + lambda role, identity: captured.update({role: identity})) + + def create(arguments, **_): + self.assertEqual(arguments[0], "create") + live[server] = {CONTAINERS.OWNER: "123-1", CONTAINERS.ROLE: "server", CONTAINERS.NONCE: "b" * 32} + if isinstance(create_output, BaseException): + raise create_output + return types.SimpleNamespace(returncode=0, stdout=create_output, stderr="") + + try: + with patch.object(resources, "command", side_effect=create), self.assertRaisesRegex(RuntimeError, "one full ID"): + resources.create("server", []) + self.assertEqual(captured, {}) + finally: + resources.close() + if fault == "duplicate": + live[extra] = dict(live[server]) + + def invoke(arguments, **options): + self.assertEqual(arguments[0], "/usr/bin/docker") + self.assertEqual(options["env"], {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": "/inert-cleanup-config", + "DOCKER_HOST": "unix:///var/run/docker.sock"}) + if arguments[1] == "ps": + self.assertEqual(arguments[2:6], ["--all", "--no-trunc", "--last", "2"]) + self.assertIn("label=" + CONTAINERS.OWNER + "=123-1", arguments) + self.assertIn("label=" + CONTAINERS.NONCE + "=" + "b" * 32, arguments) + role = "server" if "label=" + CONTAINERS.ROLE + "=server" in arguments else "consumer" + if role == "consumer" and fault in {"listing-failed", "listing-stderr", "listing-partial", "listing-overflow"}: + outputs = {"listing-failed": (1, "", "engine unavailable\n"), + "listing-stderr": (0, consumer + "\n", "mixed error\n"), + "listing-partial": (0, "abc\n", ""), + "listing-overflow": (0, (consumer + "\n") * 3, "")} + code, stdout, stderr = outputs[fault] + return types.SimpleNamespace(returncode=code, stdout=stdout, stderr=stderr) + identities = [identity for identity, labels in live.items() if labels[CONTAINERS.ROLE] == role] + return types.SimpleNamespace(returncode=0, stdout="".join(identity + "\n" for identity in identities[:2]), stderr="") + identity = arguments[-1] + if arguments[1] == "rm": + self.assertIn(identity, live) + labels = live.pop(identity) + removed.add(identity) + if fault == "replacement" and identity == consumer: + live[extra] = labels + return types.SimpleNamespace(returncode=0, stdout=identity + "\n", stderr="") + self.assertEqual(arguments[1], "inspect") + if fault == "consumer-inspect" and identity == consumer: + return types.SimpleNamespace(returncode=1, stdout="[]\n", stderr="engine unavailable\n") + if identity not in live: + return types.SimpleNamespace(returncode=1, stdout="[]\n", stderr="error: no such object: " + identity + "\n") + labels = dict(live[identity]) + reported_identity = identity + if identity == server: + for label, selected_fault in ((CONTAINERS.OWNER, "foreign-owner"), (CONTAINERS.ROLE, "foreign-role"), + (CONTAINERS.NONCE, "foreign-nonce")): + if fault == selected_fault: + labels[label] = "foreign" + if fault == "foreign-id": + reported_identity = extra + return types.SimpleNamespace(returncode=0, stdout=json.dumps([{"Id": reported_identity, "Config": {"Labels": labels}}]), stderr="") + + environment = {"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1", "FIXTURE_NONCE": "b" * 32, + "SERVER_ID": "", "CONSUMER_ID": consumer, "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": "/missing-state", "TMPDIR": "/missing-temp", + "DOCKER_CONFIG": "/refused-config", "DOCKER_HOST": "tcp://example.invalid:2375"} + code = 0 + with patch.dict(os.environ, environment, clear=True), patch("tempfile.TemporaryDirectory") as temporary, \ + patch("subprocess.run", side_effect=invoke), patch("builtins.print") as printed: + temporary.return_value.__enter__.return_value = "/inert-cleanup-config" + try: + exec(compile(script if script is not None else cleanup_script(WORKFLOW), "inert-independent-cleanup", "exec"), {}) + except SystemExit as error: + code = error.code + return types.SimpleNamespace(code=code, outcome=json.loads(printed.call_args.args[0]), live=live, removed=removed) + + def test_lost_and_ambiguous_create_outputs_are_reconciled(self): + for output in ("", "ambiguous\n", CONTAINERS.FixtureTimeout("")): + result = self.simulate(output) + self.assertEqual(result.live, {}) + self.assertEqual(result.code, 0) + self.assertEqual(result.outcome["reconciled_ids"], {"server": "a" * 64}) + self.assertEqual(result.outcome["role_absence"], {"server": True, "consumer": True}) + + def test_duplicate_and_foreign_candidates_refuse(self): + for fault in ("duplicate", "foreign-owner", "foreign-role", "foreign-nonce", "foreign-id"): + result = self.simulate(fault=fault) + self.assertEqual(result.code, 1) + self.assertFalse(result.outcome["runtime_removed"]) + self.assertIn("a" * 64, result.live) + self.assertIn("c" * 64, result.removed) + + def test_one_role_error_does_not_skip_cleanup_of_the_other(self): + result = self.simulate(fault="consumer-inspect") + self.assertEqual(result.code, 1) + self.assertIn("c" * 64, result.live) + self.assertIn("a" * 64, result.removed) + self.assertEqual(result.outcome["role_absence"], {"consumer": False, "server": True}) + + def test_postdelete_role_replacement_cannot_be_reported_absent(self): + result = self.simulate(fault="replacement") + self.assertEqual(result.code, 1) + self.assertIn("e" * 64, result.live) + self.assertEqual(result.removed, {"a" * 64, "c" * 64}) + self.assertFalse(result.outcome["role_absence"]["consumer"]) + + def test_malformed_or_failed_role_listing_refuses(self): + for fault in ("listing-failed", "listing-stderr", "listing-partial", "listing-overflow"): + result = self.simulate(fault=fault) + self.assertEqual(result.code, 1) + self.assertIn("c" * 64, result.live) + self.assertIn("a" * 64, result.removed) + + +@unittest.skipUnless(IMAGE, "Cleanup control requires exact reviewed source and the cached selected PHP image.") +class SyntheticCleanupBoundary(unittest.TestCase): + def setUp(self): + self.assertEqual(IMAGE, "php@" + SUPPLY["php_index"]) + self.assertGreater(os.getuid(), 0) + captured_bytes("tests/ci/test_command_key_cleanup_boundary.py") + self.script = cleanup_script(WORKFLOW) + self.work = Path(tempfile.mkdtemp(prefix="sdk-php-cleanup-control-")) + self.active = set() + self.captured = {} + self.cleanup_proved = False + self.addCleanup(self.remove_owned_data) + + def remove_owned_data(self): + self.assertTrue(self.cleanup_proved, "Preserve evidence until both labelled roles are independently absent") + self.assertFalse(self.active, "Preserve control data until exact container absence is established") + metadata = self.work.lstat() + self.assertEqual(self.work.resolve(), self.work) + self.assertTrue(stat.S_ISDIR(metadata.st_mode)) + self.assertEqual(metadata.st_uid, os.getuid()) + self.assertTrue(shutil.rmtree.avoids_symlink_attacks) + shutil.rmtree(self.work) + + def record_created(self, role, identity): + self.assertNotIn(role, self.captured) + self.captured[role] = identity + self.active.add(identity) + + def test_literal_cleanup_removes_both_roles_after_checkout_state_and_config_refuse(self): + configuration, state, observer = (self.work / name for name in ("docker", "records", "observer")) + for path in (configuration, state, observer): + path.mkdir(mode=0o700) + run_id, attempt = str(os.getpid()), str(time.time_ns()) + nonce = secrets.token_hex(16) + resources = CONTAINERS.Containers(configuration, state, run_id + "-" + attempt, nonce, self.record_created) + observation = {"source": SOURCE, "cleanup_sha256": CLEANUP_SHA256, + "fixture": "idle-php-roles-only", "captured_ids": self.captured, + "lost_create_refused": False, + "checkout_refused": False, "state_refused": False, "config_refused": False, + "container_removed": False} + OBSERVATIONS.append(observation) + observation["ownership"] = {"run_id": run_id, "attempt": attempt, "owner": run_id + "-" + attempt, "nonce": nonce} + # Flush the reconciliation tuple before create can lose its stdout. + print(json.dumps({"scope": "synthetic-cleanup-ownership", "source": SOURCE, + "ownership": observation["ownership"]}), flush=True) + try: + for role in ("server", "consumer"): + arguments = ["--pull", "never", "--network", "none", "--user", str(os.getuid()) + ":" + str(os.getgid()), + IMAGE, "php", "-n", "-r", "sleep(90);"] + if role == "server": + command = resources.command + + def lose_create_stdout(arguments, **options): + result = command(arguments, **options) + if arguments[0] == "create": + self.assertEqual(result.returncode, 0) + self.assertRegex(result.stdout, r"\A[0-9a-f]{64}\n\Z") + observation["lost_create_id_sha256"] = hashlib.sha256(result.stdout.strip().encode()).hexdigest() + return types.SimpleNamespace(returncode=0, stdout="", stderr=result.stderr) + return result + + with patch.object(resources, "command", side_effect=lose_create_stdout), \ + self.assertRaisesRegex(RuntimeError, "one full ID"): + resources.create(role, arguments) + self.assertNotIn(role, self.captured) + observation["lost_create_refused"] = True + continue + identity = resources.create(role, arguments) + COHORT.selected_image(resources.owned(identity, role), SUPPLY, "php") + resources.start(identity, role) + record = resources.owned(identity, role) + COHORT.confined(record, SUPPLY, "php") + self.assertEqual(record["HostConfig"]["NetworkMode"], "none") + self.assertEqual(record["Config"]["User"], str(os.getuid()) + ":" + str(os.getgid())) + self.assertEqual(record["Mounts"], []) + self.assertFalse(record["HostConfig"].get("Tmpfs")) + self.assertFalse(record["NetworkSettings"]["Ports"]) + with self.assertRaisesRegex(RuntimeError, "exact reviewed runner commit"): + COHORT.capture_inputs("invalid") + observation["checkout_refused"] = True + os.fchmod(resources.state_descriptor, 0o755) + with self.assertRaisesRegex(RuntimeError, "canonical, private and owned"): + CONTAINERS.Containers(configuration, state, run_id + "-" + attempt, nonce) + observation["state_refused"] = True + descriptor = os.open(configuration / "config.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as output: + output.write("{}\n") + with self.assertRaisesRegex(RuntimeError, "configuration must remain empty"): + resources.environment() + observation["config_refused"] = True + finally: + try: + resources.close() + finally: + self.independent_cleanup(configuration, observer, run_id, attempt, nonce, observation) + self.assertEqual(set(self.captured), {"consumer"}) + recovered = observation["cleanup"]["reconciled_ids"] + self.assertEqual(set(recovered), {"server"}) + self.assertEqual(hashlib.sha256(recovered["server"].encode()).hexdigest(), observation["lost_create_id_sha256"]) + + def independent_cleanup(self, configuration, observer, run_id, attempt, nonce, observation): + environment = {"PATH": "/usr/bin:/bin", "GITHUB_RUN_ID": run_id, "GITHUB_RUN_ATTEMPT": attempt, + "FIXTURE_NONCE": nonce, "CONSUMER_ID": self.captured.get("consumer", ""), + "SERVER_ID": self.captured.get("server", ""), "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": str(self.work / "missing-state"), + "TMPDIR": str(self.work / "missing-temp"), "DOCKER_CONFIG": str(configuration), + "DOCKER_HOST": "tcp://example.invalid:2375"} + cleanup = subprocess.run(["/usr/bin/python3", "-I", "-S", "-B", "-c", self.script], + env=environment, cwd=self.work, capture_output=True, text=True, timeout=120) + observation["cleanup_exit"] = cleanup.returncode + self.assertEqual(cleanup.returncode, 0, cleanup.stderr) + outcome = json.loads(cleanup.stdout) + observation["cleanup"] = outcome + self.assertTrue(outcome["runtime_removed"]) + self.assertEqual(outcome["errors"], []) + self.assertEqual(outcome["role_absence"], {"server": True, "consumer": True}) + found = dict(self.captured) + for role, identity in outcome["reconciled_ids"].items(): + self.assertIn(role, {"server", "consumer"}) + self.assertRegex(identity, r"\A[0-9a-f]{64}\Z") + self.assertNotIn(role, found) + found[role] = identity + self.assertEqual(len(set(found.values())), len(found)) + observation["observed_ids"] = found + self.active.update(found.values()) + empty_environment = {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(observer), + "DOCKER_HOST": "unix:///var/run/docker.sock"} + for identity in found.values(): + absent = subprocess.run(["/usr/bin/docker", "inspect", identity], env=empty_environment, + capture_output=True, text=True, timeout=15) + self.assertTrue(CONTAINERS.exact_missing(absent, identity)) + self.active.remove(identity) + for role in ("server", "consumer"): + listed = subprocess.run(["/usr/bin/docker", "ps", "--all", "--no-trunc", "--last", "2", + "--filter", "label=" + CONTAINERS.OWNER + "=" + run_id + "-" + attempt, + "--filter", "label=" + CONTAINERS.ROLE + "=" + role, + "--filter", "label=" + CONTAINERS.NONCE + "=" + nonce, "--format", "{{.ID}}"], + env=empty_environment, capture_output=True, text=True, timeout=15) + self.assertEqual((listed.returncode, listed.stdout, listed.stderr), (0, "", "")) + self.cleanup_proved = True + observation["container_removed"] = not self.active + + +if __name__ == "__main__": + result = unittest.main(exit=False, failfast=True).result + if IMAGE: + print(json.dumps({"scope": "synthetic-state-refusal-cleanup-only", "observations": OBSERVATIONS})) + raise SystemExit(not result.wasSuccessful()) diff --git a/tests/ci/test_command_key_client.py b/tests/ci/test_command_key_client.py new file mode 100644 index 00000000..57828b36 --- /dev/null +++ b/tests/ci/test_command_key_client.py @@ -0,0 +1,71 @@ +"""Offline input controls for the installed PHP command-key adapter.""" + +import json +import os +from pathlib import Path +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_IMAGE", "") + + +@unittest.skipUnless(IMAGE, "Select the already-built immutable PHP image for the offline controls.") +class CommandKeyClientInputTest(unittest.TestCase): + def run_packet(self, packet, *, url="http://127.0.0.1:1", syntax=False): + self.assertRegex(IMAGE, r"\Asha256:[0-9a-f]{64}\Z") + self.addCleanup(self.assert_container_absent) + command = [ + "docker", "run", "--rm", "--init", "--interactive", "--name", self.container_name, + "--cpus", "1", "--memory", "128m", "--pids-limit", "64", + "--network", "none", "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--user", f"{os.getuid()}:{os.getgid()}", + "--env", f"COMMAND_KEY_RUNTIME_URL={url}", + "--mount", f"type=bind,source={ROOT / 'tests/smoke/command-key-client.php'},target=/fixture/client.php,readonly", + "--mount", f"type=bind,source={ROOT / 'tests/smoke/command-key-supply.json'},target=/fixture/command-key-supply.json,readonly", + IMAGE, "php", + ] + command += ["-l", "/fixture/client.php"] if syntax else ["/fixture/client.php", "/autoload-must-not-be-read.php"] + return subprocess.run(command, input=packet, capture_output=True, text=True, timeout=20) + + def setUp(self): + self.container_name = f"sdk-php-command-key-input-{os.getpid()}" + + def assert_container_absent(self): + result = subprocess.run(["docker", "inspect", self.container_name], capture_output=True, text=True, timeout=10) + self.assertNotEqual(0, result.returncode) + self.assertEqual(f"error: no such object: {self.container_name}", result.stderr.strip().lower()) + + def refused(self, packet, message, **options): + result = self.run_packet(packet, **options) + self.assertNotEqual(0, result.returncode) + self.assertIn(message, result.stderr) + self.assertNotIn("autoload-must-not-be-read", result.stderr) + + def test_unknown_operation_stops_before_autoload(self): + self.refused('{"operation":"unsupported"}', "Unknown command-key fixture operation") + + def test_foreign_workflow_stops_before_autoload(self): + self.refused(json.dumps({"operation": "signal", "workflow_id": "customer-order"}), + "workflow identity is outside its owned prefix") + + def test_foreign_namespace_stops_before_autoload(self): + self.refused(json.dumps({"operation": "signal", "workflow_id": "sdk-php-216-inert", "namespace": "customer"}), + "namespace is outside its owned prefix") + + def test_external_origin_stops_before_autoload(self): + self.refused(json.dumps({"operation": "signal", "workflow_id": "sdk-php-216-inert"}), + "requires a numeric loopback runtime", url="https://example.invalid") + + def test_oversized_packet_stops_before_autoload(self): + self.refused('x' * 65537, "fixture packet exceeds its limit") + + def test_adapter_syntax(self): + result = self.run_packet('', syntax=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("No syntax errors", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_command_key_cohort.py b/tests/ci/test_command_key_cohort.py new file mode 100644 index 00000000..61299802 --- /dev/null +++ b/tests/ci/test_command_key_cohort.py @@ -0,0 +1,328 @@ +"""Inert closure, workflow-bootstrap and receipt controls; no runtime calls.""" + +from copy import deepcopy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import textwrap +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_cohort.py" +SPEC = importlib.util.spec_from_file_location("command_key_cohort", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +SUPPLY = json.loads(SOURCE.with_name("command-key-supply.json").read_text()) +WORKFLOW = SOURCE.parents[2] / ".github/workflows/published-command-keys.yml" +IDENTITY = "a" * 64 +ROOT = Path("/run/sdk-php-command-keys.abcdef") + + +def runtime(): + return {"Id": IDENTITY, "Image": SUPPLY["server_config"], "State": {"Running": True}, + "Config": {"Image": "durableworkflow/server@" + SUPPLY["server_index"], + "Labels": {"org.opencontainers.image.revision": SUPPLY["server_source"]}}, + "HostConfig": {"ReadonlyRootfs": True, "Privileged": False, "Binds": None, + "Init": True, "CapDrop": ["ALL"], "CapAdd": None, + "SecurityOpt": ["no-new-privileges"], "Devices": [], "NetworkMode": "bridge", + "Memory": 1024**3, "NanoCpus": 10**9, "PidsLimit": 256, + "Tmpfs": {"/tmp": "mode=1777,size=64m", + "/app/database": "uid=1000,gid=1000,mode=0700,size=32m", + "/app/storage": "uid=1000,gid=1000,mode=0700,size=64m"}}, + "Mounts": [{"Type": "tmpfs", "Destination": path} for path in ("/tmp", "/app/database", "/app/storage")], + "NetworkSettings": {"Ports": {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "43123"}]}}} + + +def consumer(): + record = runtime() + record["Image"] = SUPPLY["php_config"] + record["Config"]["Image"] = "php@" + SUPPLY["php_index"] + record["Config"]["User"] = "1001:1001" + record["HostConfig"].update(NetworkMode="host", Tmpfs={"/tmp": "mode=1777,size=128m"}) + record["Mounts"] = [{"Type": "bind", "Source": str(ROOT), "Destination": "/closure", "RW": False}, + {"Type": "tmpfs", "Destination": "/tmp"}] + record["NetworkSettings"]["Ports"] = None + return record + + +class ProvenanceControls(unittest.TestCase): + def origin(self, record): + resources = Mock() + resources.owned.return_value = record + result = MODULE.runtime_origin(resources, IDENTITY, SUPPLY) + resources.owned.assert_called_once_with(IDENTITY, "server") + return result + + def php(self, record): + resources = Mock() + resources.owned.return_value = record + with patch.object(MODULE.os, "getuid", return_value=1001), patch.object(MODULE.os, "getgid", return_value=1001): + result = MODULE.consumer_record(resources, IDENTITY, SUPPLY, ROOT) + resources.owned.assert_called_once_with(IDENTITY, "consumer") + return result + + def test_complete_native_profile_selects_numeric_loopback(self): + self.assertEqual(self.origin(runtime()), "http://127.0.0.1:43123") + + def test_legacy_tmpfs_configuration_and_complete_reported_form_both_pass(self): + for factory, check in ((runtime, self.origin), (consumer, self.php)): + complete = factory() + check(complete) + legacy = deepcopy(complete) + legacy["Mounts"] = [mount for mount in legacy["Mounts"] if mount["Type"] != "tmpfs"] + check(legacy) + + def test_duplicate_reported_destinations_refuse_for_both_roles(self): + for factory, check in ((runtime, self.origin), (consumer, self.php)): + record = factory() + record["Mounts"].append(deepcopy(record["Mounts"][-1])) + with self.subTest(role=factory.__name__), self.assertRaisesRegex(RuntimeError, "mount destinations"): + check(record) + + def test_partial_reported_native_tmpfs_set_refuses(self): + record = runtime() + record["Mounts"].pop() + with self.assertRaisesRegex(RuntimeError, "mount destinations"): + self.origin(record) + + def test_tmpfs_size_permissions_and_declared_path_set_are_exact(self): + for factory, check in ((runtime, self.origin), (consumer, self.php)): + for options in ("mode=1777,size=1g", "mode=0777,size=128m", "", None): + record = factory() + record["HostConfig"]["Tmpfs"]["/tmp"] = options + with self.subTest(role=factory.__name__, options=options), self.assertRaisesRegex(RuntimeError, "tmpfs configuration"): + check(record) + for declared in ({}, {"/tmp": "mode=1777,size=128m", "/closure": "size=1m"}): + record = factory() + record["Mounts"] = [mount for mount in record["Mounts"] if mount["Type"] != "tmpfs"] + record["HostConfig"]["Tmpfs"] = declared + with self.subTest(role=factory.__name__, declared=declared), self.assertRaisesRegex(RuntimeError, "tmpfs configuration"): + check(record) + + def test_containerd_index_identity_requires_the_exact_platform_manifest(self): + for factory, role, check in ((runtime, "server", self.origin), (consumer, "php", self.php)): + record = factory() + record["Image"] = SUPPLY[role + "_index"] + record["ImageManifestDescriptor"] = { + "digest": SUPPLY[role + "_amd64"], "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"os": "linux", "architecture": "amd64"}} + check(record) + for mutation in (lambda r: r.update(ImageManifestDescriptor=None), + lambda r: r["ImageManifestDescriptor"].update(digest="sha256:" + "0" * 64), + lambda r: r["ImageManifestDescriptor"]["platform"].update(architecture="arm64"), + lambda r: r["ImageManifestDescriptor"]["platform"].update(os="windows"), + lambda r: r["ImageManifestDescriptor"].update(mediaType="application/vnd.oci.image.index.v1+json"), + lambda r: r["Config"].update(Image="php:latest"), + lambda r: r.update(Image="sha256:" + "0" * 64)): + changed = deepcopy(record) + mutation(changed) + with self.subTest(role=role), self.assertRaises(RuntimeError): + check(changed) + + def test_present_wrong_manifest_refuses_even_with_matching_classic_config(self): + record = runtime() + record["ImageManifestDescriptor"] = {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"os": "linux", "architecture": "amd64"}} + with self.assertRaisesRegex(RuntimeError, "platform manifest differs"): + self.origin(record) + + def test_wrong_selected_manifest_refuses_before_native_process_start(self): + resources = Mock() + resources.identities = {"server": IDENTITY} + resources.pull.return_value = {"Config": {"Labels": {"org.opencontainers.image.revision": SUPPLY["server_source"]}}} + resources.create.return_value = IDENTITY + record = runtime() + record["State"]["Running"] = False + record["Image"] = SUPPLY["server_index"] + record["ImageManifestDescriptor"] = {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "platform": {"os": "linux", "architecture": "amd64"}} + resources.owned.return_value = record + with self.assertRaisesRegex(RuntimeError, "platform manifest differs"): + MODULE.prepare_containers(resources, SUPPLY, "synthetic", ROOT) + resources.start.assert_not_called() + resources.remove_all.assert_called_once() + + def test_native_source_configuration_network_and_ports_are_closed(self): + mutations = [lambda r: r.update(Image="sha256:" + "0" * 64), + lambda r: r["Config"]["Labels"].update({"org.opencontainers.image.revision": "0" * 40}), + lambda r: r["State"].update(Running=False), + lambda r: r["HostConfig"].update(NetworkMode="host"), + lambda r: r["NetworkSettings"]["Ports"]["8080/tcp"][0].update(HostIp="0.0.0.0"), + lambda r: r["NetworkSettings"]["Ports"]["8080/tcp"][0].update(HostPort="65536"), + lambda r: r["NetworkSettings"]["Ports"].update({"9000/tcp": None})] + for index, mutation in enumerate(mutations): + record = runtime() + mutation(record) + with self.subTest(index=index), self.assertRaises(RuntimeError): + self.origin(record) + + def test_broader_caps_and_privileges_refuse_for_both_roles(self): + changes = [("Memory", 0), ("Memory", 2 * 1024**3), ("NanoCpus", 0), ("NanoCpus", 2 * 10**9), + ("PidsLimit", 0), ("PidsLimit", 257), ("ReadonlyRootfs", False), ("Privileged", True), + ("Init", False), ("CapDrop", []), ("CapAdd", ["SYS_ADMIN"]), + ("SecurityOpt", []), ("Devices", [{}]), ("Binds", ["unexpected"])] + for factory, check in ((runtime, self.origin), (consumer, self.php)): + for key, value in changes: + record = factory() + record["HostConfig"][key] = value + with self.subTest(role=factory.__name__, key=key, value=value), self.assertRaises(RuntimeError): + check(record) + + def test_native_writable_paths_and_mounts_cannot_expand(self): + for mutation in (lambda r: r["Mounts"].append({"Type": "bind", "Destination": "/closure"}), + lambda r: r["Mounts"].append({"Type": "tmpfs", "Destination": "/other"}), + lambda r: r["HostConfig"]["Tmpfs"].update({"/other": "size=1m"})): + record = runtime() + mutation(record) + with self.assertRaises(RuntimeError): + self.origin(record) + + def test_complete_php_include_closure_is_readonly_and_unprivileged(self): + self.assertFalse(self.php(consumer())["Mounts"][0]["RW"]) + + def test_writable_partial_replaced_or_shadowed_include_mount_refuses(self): + mutations = [lambda r: r["Mounts"][0].update(RW=True), + lambda r: r["Mounts"][0].update(Source="/other"), + lambda r: r["Mounts"][0].update(Destination="/closure/consumer/vendor"), + lambda r: r["Mounts"].append({"Type": "tmpfs", "Destination": "/closure/fixture"}), + lambda r: r["Mounts"].append({"Type": "volume", "Destination": "/cache"}), + lambda r: r["HostConfig"]["Tmpfs"].update({"/closure": "size=1m"}), + lambda r: r["Config"].update(User="0:0")] + for index, mutation in enumerate(mutations): + record = consumer() + mutation(record) + with self.subTest(index=index), self.assertRaises(RuntimeError): + self.php(record) + + def test_stage_requires_root_owner_inode_sealed_inventory_and_digest(self): + metadata = SimpleNamespace(st_mode=stat.S_IFDIR | 0o555, st_uid=0, st_dev=3, st_ino=4) + closure = Mock() + closure.inventory.return_value = {"sha256": "d" * 64} + with patch.object(Path, "lstat", return_value=metadata), patch.object(Path, "resolve", lambda path: path): + self.assertEqual(MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure, SUPPLY, {}), closure.inventory.return_value) + closure.inventory.assert_called_with(ROOT, 0, sealed=True) + closure.verify_sealed_sdk.assert_called_once_with(closure.inventory.return_value, SUPPLY["sdk_archive_members_sha256"]) + closure.bind_members.assert_called_once_with(closure.inventory.return_value, "fixture/", {}, sealed=True) + for root, digest, identity in ((Path("/tmp") / ROOT.name, "d" * 64, "3:4"), + (ROOT, "e" * 64, "3:4"), (ROOT, "d" * 64, "3:5")): + with self.assertRaises(RuntimeError): + MODULE.stage_inventory(root, digest, identity, closure, SUPPLY, {}) + metadata.st_uid = 1001 + with self.assertRaises(RuntimeError): + MODULE.stage_inventory(ROOT, "d" * 64, "3:4", closure, SUPPLY, {}) + + def bootstraps(self): + bodies = WORKFLOW.read_text().split("python3 -I -S -B -c '\n")[1:] + return [compile(textwrap.dedent(body.split("\n ' \\", 1)[0]), "literal-controller-bootstrap", "exec") + for body in bodies] + + def test_matching_dynamic_root_hash_cannot_authorize_a_replaced_sdk_leaf(self): + path = SOURCE.with_name("command_key_include_closure.py") + spec = importlib.util.spec_from_file_location("root_closure", path) + closure = importlib.util.module_from_spec(spec) + spec.loader.exec_module(closure) + approved = {"src/Client.php": {"mode": 0o644, "bytes": 5, "sha256": hashlib.sha256(b"safe!").hexdigest()}} + supply = {"sdk_archive_members_sha256": closure.members_digest(approved)} + prefix = "consumer/vendor/durable-workflow/sdk" + snapshot = {"sha256": "d" * 64, "entries": { + prefix: {"kind": "directory", "mode": 0o555}, + prefix + "/src": {"kind": "directory", "mode": 0o555}, + prefix + "/src/Client.php": {"kind": "file", "mode": 0o444, "bytes": 5, + "sha256": hashlib.sha256(b"evil!").hexdigest()}}} + metadata = SimpleNamespace(st_mode=stat.S_IFDIR | 0o555, st_uid=0, st_dev=3, st_ino=4) + with patch.object(Path, "lstat", return_value=metadata), patch.object(Path, "resolve", lambda value: value), \ + patch.object(closure, "inventory", return_value=snapshot), \ + self.assertRaisesRegex(ValueError, "approved archive member authority"): + MODULE.stage_inventory(ROOT, snapshot["sha256"], "3:4", closure, supply, {}) + + def test_all_three_phase_bootstraps_bind_git_bytes_before_execution(self): + bootstraps = self.bootstraps() + self.assertEqual(len(bootstraps), 3) + for bootstrap in bootstraps: + with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ + patch.object(MODULE.subprocess, "check_output", return_value=SOURCE.read_bytes()) as read, \ + patch("sys.argv", ["controller", "--help"]), patch("sys.stdout"), \ + self.assertRaises(SystemExit) as result: + exec(bootstrap, {}) + self.assertEqual(result.exception.code, 0) + self.assertEqual(read.call_args.args[0], ["/usr/bin/git", "--no-replace-objects", "show", + "a" * 40 + ":tests/smoke/command_key_cohort.py"]) + + def test_preopen_controller_substitution_refuses_before_compile(self): + for bootstrap in self.bootstraps(): + with patch.dict(os.environ, {"GITHUB_SHA": "a" * 40}), \ + patch.object(MODULE.subprocess, "check_output", return_value=b"inert substituted bytes"), \ + patch("builtins.compile") as compile_source, \ + self.assertRaisesRegex(RuntimeError, "independently fixed digest"): + exec(bootstrap, {}) + compile_source.assert_not_called() + + def test_independent_cleanup_survives_checkout_state_and_docker_config_refusal(self): + workflow = WORKFLOW.read_text() + body = workflow.split("<<'PYCLEAN' | tee command-key-cleanup.json\n", 1)[1].split("\n PYCLEAN", 1)[0] + cleanup = compile(textwrap.dedent(body), "literal-always-cleanup", "exec") + for foreign_consumer, mixed_error in ((False, False), (True, False), (False, True)): + calls = [] + removed = set() + + def invoke(arguments, **options): + self.assertEqual(options["env"]["DOCKER_CONFIG"], "/inert-independent-configuration") + calls.append(arguments) + if arguments[1] == "ps": + role = "server" if "label=durable-workflow.fixture-role=server" in arguments else "consumer" + selected = IDENTITY if role == "server" else "c" * 64 + return SimpleNamespace(returncode=0, stdout="" if selected in removed else selected + "\n", stderr="") + identity = arguments[-1] + if arguments[1] == "rm": + removed.add(identity) + return SimpleNamespace(returncode=0, stdout="", stderr="") + if identity in removed: + extra = "engine unavailable\n" if mixed_error and identity != IDENTITY else "" + return SimpleNamespace(returncode=1, stdout="[]\n", stderr="error: no such object: " + identity + "\n" + extra) + role = "server" if identity == IDENTITY else "consumer" + labels = {"durable-workflow.php-command-keys": "foreign" if foreign_consumer and role == "consumer" else "123-1", + "durable-workflow.fixture-role": role, "durable-workflow.fixture-nonce": "b" * 32} + return SimpleNamespace(returncode=0, stderr="", stdout=json.dumps([{"Id": identity, "Config": {"Labels": labels}}])) + + environment = {"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "1", "FIXTURE_NONCE": "b" * 32, + "SERVER_ID": IDENTITY, "CONSUMER_ID": "c" * 64, "INCLUDE_ROOT": "", "INCLUDE_IDENTITY": "", + "GITHUB_SHA": "invalid", "COMMAND_KEY_STATE": "/missing-state", "TMPDIR": "/missing-temp", + "DOCKER_CONFIG": "/foreign-configuration", "DOCKER_HOST": "tcp://example.invalid:2375"} + with patch.dict(os.environ, environment), patch("tempfile.TemporaryDirectory") as temporary, \ + patch("subprocess.run", side_effect=invoke), patch("builtins.print"): + temporary.return_value.__enter__.return_value = "/inert-independent-configuration" + if foreign_consumer or mixed_error: + with self.assertRaises(SystemExit): + exec(cleanup, {}) + else: + exec(cleanup, {}) + self.assertIn(IDENTITY, removed) + self.assertEqual("c" * 64 in removed, not foreign_consumer) + self.assertTrue(all(call[0] == "/usr/bin/docker" for call in calls)) + temporary.assert_called_once_with(prefix="sdk-php-command-key-cleanup-", dir="/tmp") + + def test_receipt_comparison_preserves_original_identity_and_verified_intent(self): + for kind in ("signal", "update"): + original = {"command_id": "command", "run_id": "run", "request_id": "key", "signal_id": "signal", + "update_id": "update", "accepted_at": "date", "command_sequence": 3} + retained = original | {"retained": True, "intent_verified": True} + MODULE.retained(retained, original, kind) + for field in MODULE.identity(original, kind): + changed = deepcopy(retained) + changed[field] = 4 if field == "command_sequence" else "changed" + with self.subTest(kind=kind, field=field), self.assertRaises(RuntimeError): + MODULE.retained(changed, original, kind) + for field in ("retained", "intent_verified"): + with self.assertRaises(RuntimeError): + MODULE.retained(retained | {field: False}, original, kind) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_command_key_containers.py b/tests/ci/test_command_key_containers.py new file mode 100644 index 00000000..3fbd98bc --- /dev/null +++ b/tests/ci/test_command_key_containers.py @@ -0,0 +1,223 @@ +"""Inert captured-ID lifecycle controls; Docker is always replaced by a stub.""" + +import importlib.util +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_containers.py" +SPEC = importlib.util.spec_from_file_location("command_key_containers", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +IDENTITY = "a" * 64 +NONCE = "b" * 32 + + +def result(code=0, output="", error=""): + return SimpleNamespace(returncode=code, stdout=output, stderr=error) + + +def owned_record(role="server"): + return {"Id": IDENTITY, "Config": {"Labels": { + MODULE.OWNER: "123-1", MODULE.ROLE: role, MODULE.NONCE: NONCE}}} + + +class ContainerControls(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.configuration, self.state = [self.root / name for name in ("docker", "state")] + self.configuration.mkdir(mode=0o700) + self.state.mkdir(mode=0o700) + self.resources = MODULE.Containers(self.configuration, self.state, "123-1", NONCE) + self.addCleanup(self.resources.close) + + def test_only_exact_missing_object_forms_are_absence(self): + for prefix in ("Error: No such object: ", "error: no such object: "): + missing = result(1, "[]\n", prefix + IDENTITY + "\n") + self.assertTrue(MODULE.exact_missing(missing, IDENTITY)) + with patch.object(self.resources, "command", return_value=missing): + self.assertIsNone(self.resources.inspect(IDENTITY, allow_missing=True)) + for changed in (result(125, missing.stdout, missing.stderr), + result(1, "", missing.stderr), result(1, "[{}]\n", missing.stderr), + result(1, missing.stdout, missing.stderr + "permission denied\n"), + result(1, missing.stdout, "engine unavailable\n" + missing.stderr)): + with self.subTest(changed=vars(changed)): + self.assertFalse(MODULE.exact_missing(changed, IDENTITY)) + with patch.object(self.resources, "command", return_value=changed), self.assertRaises(RuntimeError): + self.resources.inspect(IDENTITY, allow_missing=True) + + def test_empty_local_docker_configuration_excludes_ambient_authority(self): + with patch.dict(os.environ, {"DOCKER_AUTH_CONFIG": "inert", "DOCKER_CONTEXT": "foreign", + "DOCKER_HOST": "tcp://example.invalid:2375"}): + self.assertEqual(self.resources.environment(), { + "PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(self.configuration), + "DOCKER_HOST": "unix:///var/run/docker.sock"}) + (self.configuration / "config.json").write_text('{}') + with self.assertRaisesRegex(RuntimeError, "remain empty"): + self.resources.environment() + + def test_inspect_requires_full_exact_returned_identity(self): + for identity in ("short", "name", "A" * 64): + with self.assertRaises(RuntimeError): + self.resources.inspect(identity) + for rows in ([], [owned_record(), owned_record()], [owned_record() | {"Id": "c" * 64}]): + with patch.object(self.resources, "command", return_value=result(output=json.dumps(rows))), \ + self.assertRaises(RuntimeError): + self.resources.inspect(IDENTITY) + + def test_pulled_image_accepts_config_id_or_explicit_index_descriptor(self): + configuration = "sha256:" + "c" * 64 + index = "sha256:" + "d" * 64 + image = "php@" + index + for record in ({"Id": configuration, "Os": "linux", "Architecture": "amd64"}, + {"Id": index, "Os": "linux", "Architecture": "amd64", "Descriptor": { + "digest": index, "mediaType": "application/vnd.oci.image.index.v1+json"}}): + with patch.object(self.resources, "command", side_effect=[result(), result(output=json.dumps([record]))]) as invoke: + self.assertEqual(self.resources.pull(image, configuration), record) + self.assertEqual(invoke.call_args_list[0].args[0], ["pull", "--platform", "linux/amd64", image]) + + def test_index_image_refuses_wrong_digest_type_or_platform(self): + configuration = "sha256:" + "c" * 64 + index = "sha256:" + "d" * 64 + valid = {"Id": index, "Os": "linux", "Architecture": "amd64", "Descriptor": { + "digest": index, "mediaType": "application/vnd.oci.image.index.v1+json"}} + changes = [{"Id": "sha256:" + "0" * 64}, {"Os": "windows"}, {"Architecture": "arm64"}, + {"Descriptor": None}, {"Descriptor": {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.index.v1+json"}}, + {"Id": configuration, "Descriptor": {"digest": "sha256:" + "0" * 64, + "mediaType": "application/vnd.oci.image.index.v1+json"}}, + {"Descriptor": {"digest": index, "mediaType": "application/vnd.oci.image.manifest.v1+json"}}] + for change in changes: + record = valid | change + with self.subTest(change=change), patch.object(self.resources, "command", side_effect=[ + result(), result(output=json.dumps([record]))]), self.assertRaisesRegex(RuntimeError, "configuration differs"): + self.resources.pull("php@" + index, configuration) + + def test_image_tags_and_unselected_repositories_refuse_before_pull(self): + for image in ("php:latest", "php:8.3", "other/image@sha256:" + "d" * 64): + with self.subTest(image=image), patch.object(self.resources, "command") as invoke, self.assertRaises(RuntimeError): + self.resources.pull(image, "sha256:" + "c" * 64) + invoke.assert_not_called() + + def test_foreign_owner_role_and_nonce_are_preserved(self): + for field in (MODULE.OWNER, MODULE.ROLE, MODULE.NONCE): + record = owned_record() + record["Config"]["Labels"][field] = "foreign" + with patch.object(self.resources, "inspect", return_value=record), \ + patch.object(self.resources, "command") as invoke, \ + self.assertRaisesRegex(RuntimeError, "preserve the replacement"): + self.resources.remove(IDENTITY, "server") + invoke.assert_not_called() + + def test_create_captures_stdout_to_held_record_and_workflow_output(self): + captured = [] + self.resources.record_created = lambda role, identity: captured.append((role, identity)) + with patch.object(self.resources, "command", return_value=result(output=IDENTITY + "\n")) as invoke, \ + patch.object(self.resources, "owned", return_value=owned_record()): + self.assertEqual(self.resources.create("server", ["inert-image"]), IDENTITY) + self.assertEqual(captured, [("server", IDENTITY)]) + self.assertEqual(json.loads((self.state / "server.json").read_text()), { + "id": IDENTITY, "owner": "123-1", "role": "server", "nonce": NONCE}) + flags = invoke.call_args.args[0] + self.assertIn("--read-only", flags) + self.assertEqual(flags[flags.index("--memory") + 1], "1g") + self.assertEqual(flags[flags.index("--cpus") + 1], "1") + self.assertEqual(flags[flags.index("--pids-limit") + 1], "256") + with self.assertRaises(RuntimeError): + self.resources.create("server", ["inert-image"]) + + def test_record_path_substitution_does_not_select_or_modify_replacement(self): + original = self.root / "held-state" + self.state.rename(original) + self.state.mkdir(mode=0o700) + replacement = self.state / "server.json" + replacement.write_text('foreign sentinel') + with patch.object(self.resources, "command", return_value=result(output=IDENTITY + "\n")), \ + patch.object(self.resources, "owned", return_value=owned_record()): + self.resources.create("server", ["inert-image"]) + self.assertEqual(replacement.read_text(), 'foreign sentinel') + self.assertEqual(json.loads((original / "server.json").read_text())["id"], IDENTITY) + + def test_preexisting_record_and_symlink_refuse_before_create(self): + path = self.state / "server.json" + target = self.root / "sentinel" + target.write_text('unchanged') + for symlink in (False, True): + if symlink: + path.symlink_to(target) + else: + path.write_text('existing') + with patch.object(self.resources, "command") as invoke, self.assertRaises(OSError): + self.resources.create("server", ["inert-image"]) + invoke.assert_not_called() + path.unlink() + self.assertEqual(target.read_text(), 'unchanged') + + def test_state_replacement_before_directory_open_refuses(self): + original_open = MODULE.os.open + displaced = self.root / "displaced-state" + + def replace_before_open(path, flags, *arguments, **keywords): + if path == self.state: + self.state.rename(displaced) + self.state.mkdir(mode=0o700) + return original_open(path, flags, *arguments, **keywords) + + with patch.object(MODULE.os, "open", side_effect=replace_before_open), \ + self.assertRaisesRegex(RuntimeError, "changed before it was held"): + MODULE.Containers(self.configuration, self.state, "123-1", NONCE) + + def test_nonzero_create_with_full_id_remains_available_for_cleanup(self): + captured = [] + self.resources.record_created = lambda *value: captured.append(value) + with patch.object(self.resources, "command", return_value=result(124, IDENTITY + "\n")), \ + self.assertRaisesRegex(RuntimeError, "after returning"): + self.resources.create("server", ["inert-image"]) + self.assertEqual(captured, [("server", IDENTITY)]) + self.assertEqual(self.resources.identities, {"server": IDENTITY}) + + def test_timed_out_create_still_records_a_complete_stdout_id(self): + captured = [] + self.resources.record_created = lambda *value: captured.append(value) + with patch.object(self.resources, "command", side_effect=MODULE.FixtureTimeout((IDENTITY + "\n").encode())), \ + self.assertRaisesRegex(RuntimeError, "after returning"): + self.resources.create("server", ["inert-image"]) + self.assertEqual(captured, [("server", IDENTITY)]) + + def test_ambiguous_create_stdout_is_never_interpreted_as_a_name_or_id(self): + for output in ("name\n", IDENTITY, IDENTITY + "\nother\n"): + with self.subTest(output=output), tempfile.TemporaryDirectory() as temporary: + state = Path(temporary) + resources = MODULE.Containers(self.configuration, state, "123-1", NONCE) + try: + with patch.object(resources, "command", return_value=result(output=output)), self.assertRaises(RuntimeError): + resources.create("server", ["inert-image"]) + self.assertEqual(resources.identities, {}) + finally: + resources.close() + + def test_cleanup_uses_captured_id_and_observes_exact_absence(self): + absent = result(1, "[]\n", "error: no such object: " + IDENTITY + "\n") + with patch.object(self.resources, "command", side_effect=[ + result(output=json.dumps([owned_record()])), result(), absent]) as invoke: + self.resources.remove(IDENTITY, "server") + self.assertEqual([call.args[0] for call in invoke.call_args_list], [ + ["inspect", IDENTITY], ["rm", "--force", "--volumes", IDENTITY], ["inspect", IDENTITY]]) + + def test_one_cleanup_refusal_does_not_skip_the_other_role(self): + pairs = [("consumer", "c" * 64), ("server", IDENTITY)] + with patch.object(self.resources, "remove", side_effect=[RuntimeError("preserve"), None]) as remove, \ + self.assertRaisesRegex(RuntimeError, "could not be removed"): + self.resources.remove_all(pairs) + self.assertEqual([call.args for call in remove.call_args_list], [(identity, role) for role, identity in pairs]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_command_key_include_closure.py b/tests/ci/test_command_key_include_closure.py new file mode 100644 index 00000000..f394805e --- /dev/null +++ b/tests/ci/test_command_key_include_closure.py @@ -0,0 +1,193 @@ +"""Inert include-tree inventory controls; no package code or runtime loading.""" + +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + + +SOURCE = Path(__file__).resolve().parents[1] / "smoke/command_key_include_closure.py" +SPEC = importlib.util.spec_from_file_location("command_key_include_closure", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +SUPPLY_SPEC = importlib.util.spec_from_file_location("command_key_supply", SOURCE.with_name("verify_command_key_supply.py")) +SUPPLY = importlib.util.module_from_spec(SUPPLY_SPEC) +SUPPLY_SPEC.loader.exec_module(SUPPLY) + + +class IncludeClosureControls(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.consumer, self.fixture, self.output = [self.root / name for name in ("consumer", "fixture", "output")] + for path in (self.consumer, self.fixture, self.output): + path.mkdir(mode=0o700) + (self.consumer / "composer.json").write_text('{}') + (self.consumer / "composer.lock").write_text('{}') + (self.consumer / "vendor/composer").mkdir(parents=True) + (self.consumer / "vendor/autoload.php").write_text('inert autoload bytes') + (self.consumer / "vendor/composer/installed.json").write_text('{}') + (self.consumer / "vendor/composer/nested.php").write_text('inert nested include bytes') + self.sdk = self.consumer / "vendor/durable-workflow/sdk" + (self.sdk / "src").mkdir(parents=True) + (self.sdk / "src/Client.php").write_bytes(b"safe!") + self.approved_sdk = SUPPLY.member_inventory({"src/Client.php": (b"safe!", 0o644)}) + self.approved_digest = MODULE.members_digest(self.approved_sdk) + self.supply = {"sdk_version": "1.2.3", "sdk_reference": "a" * 40, + "sdk_source_url": "https://example.invalid/sdk.git", "sdk_dist_url": "https://example.invalid/sdk.zip", + "sdk_archive_members_sha256": self.approved_digest} + package = {"name": "durable-workflow/sdk", "version": "1.2.3", + "source": {"url": self.supply["sdk_source_url"], "reference": self.supply["sdk_reference"]}, + "dist": {"url": self.supply["sdk_dist_url"], "reference": self.supply["sdk_reference"]}} + (self.consumer / "composer.json").write_text(json.dumps({"require": {"durable-workflow/sdk": "1.2.3"}})) + (self.consumer / "composer.lock").write_text(json.dumps({"packages": [package]})) + (self.consumer / "vendor/composer/installed.json").write_text(json.dumps({"packages": [package | { + "installation-source": "dist", "install-path": "../durable-workflow/sdk"}]})) + self.archive = self.root / "approved.zip" + member = zipfile.ZipInfo("sdk/src/Client.php") + member.external_attr = 0o100644 << 16 + with zipfile.ZipFile(self.archive, "w") as archive: + archive.writestr(member, b"safe!") + (self.fixture / "command-key-client.php").write_text('inert fixture bytes') + (self.fixture / "command-key-supply.json").write_text('{}') + self.approved_fixture = SUPPLY.member_inventory({path.name: (path.read_bytes(), 0o644) for path in self.fixture.iterdir()}) + self.addCleanup(self.unseal) + + def unseal(self): + for path in [self.root, *self.root.rglob("*")]: + if path.is_dir() and not path.is_symlink(): + path.chmod(0o700) + + def stage(self): + return MODULE.stage(self.consumer, self.fixture, self.output, self.approved_sdk, + self.approved_digest, self.approved_fixture) + + def test_complete_nested_include_inventory_and_readonly_copy(self): + result = self.stage() + self.assertEqual(MODULE.inventory(self.output, os.getuid(), sealed=True), result) + member = result["entries"]["consumer/vendor/composer/nested.php"] + self.assertEqual(member["bytes"], len(b'inert nested include bytes')) + self.assertFalse(member["mode"] & 0o222) + self.assertIn("fixture/command-key-client.php", result["entries"]) + + def test_alias_and_hardlink_leaves_refuse(self): + target = self.consumer / "vendor/composer/alias.php" + original = self.consumer / "vendor/autoload.php" + target.symlink_to(original) + with self.assertRaises(ValueError): + self.stage() + target.unlink() + os.link(original, target) + with self.assertRaises(ValueError): + self.stage() + + def test_extra_root_member_refuses(self): + (self.consumer / "unreviewed.php").write_text('inert') + with self.assertRaisesRegex(ValueError, "Unexpected consumer"): + self.stage() + + def test_extra_fixture_and_missing_autoloader_refuse(self): + extra = self.fixture / "unreviewed.php" + extra.write_text('inert') + with self.assertRaisesRegex(ValueError, "Unexpected captured fixture"): + self.stage() + extra.unlink() + (self.consumer / "vendor/autoload.php").unlink() + with self.assertRaisesRegex(ValueError, "complete installed autoloader"): + self.stage() + + def test_repository_metadata_inside_a_dependency_refuses(self): + (self.consumer / "vendor/composer/.git").write_text('inert metadata') + with self.assertRaisesRegex(ValueError, "Repository metadata"): + self.stage() + + def test_nested_byte_change_changes_complete_inventory(self): + before = MODULE.inventory(self.consumer, os.getuid(), sealed=False) + (self.consumer / "vendor/composer/nested.php").write_text('changed inert bytes') + after = MODULE.inventory(self.consumer, os.getuid(), sealed=False) + self.assertNotEqual(before["sha256"], after["sha256"]) + + def test_writable_sealed_member_and_wrong_owner_refuse(self): + self.stage() + member = self.output / "consumer/vendor/autoload.php" + member.chmod(0o600) + owner = os.getuid() + with self.assertRaisesRegex(ValueError, "write permissions"): + MODULE.inventory(self.output, owner, sealed=True) + member.chmod(0o400) + with self.assertRaisesRegex(ValueError, "ownership"): + MODULE.inventory(self.output, owner + 1, sealed=True) + + def test_post_verification_same_size_sdk_replacement_is_not_reapproved(self): + evidence, approved = SUPPLY.verify_with_members(self.consumer, self.archive, self.supply) + self.assertEqual(evidence["archive_members_sha256"], self.approved_digest) + (self.sdk / "src/Client.php").write_bytes(b"evil!") + with self.assertRaisesRegex(ValueError, "approved authority"): + MODULE.stage(self.consumer, self.fixture, self.output, approved, + evidence["archive_members_sha256"], self.approved_fixture) + self.assertEqual(list(self.output.iterdir()), []) + + def test_sdk_replacement_after_inventory_is_checked_through_source_descriptor(self): + original_copy = MODULE.copy_tree + + def replace_then_copy(source, destination, expected): + if source == self.consumer: + (self.sdk / "src/Client.php").write_bytes(b"evil!") + return original_copy(source, destination, expected) + + with patch.object(MODULE, "copy_tree", side_effect=replace_then_copy), \ + self.assertRaisesRegex(ValueError, "changed before copying"): + self.stage() + + def test_captured_fixture_replacement_is_not_reapproved(self): + (self.fixture / "command-key-client.php").write_text('changed fixture bytes') + with self.assertRaisesRegex(ValueError, "approved authority"): + self.stage() + + def test_sealed_sdk_recheck_uses_archive_authority_even_with_new_include_hash(self): + before = self.stage() + leaf = self.output / "consumer/vendor/durable-workflow/sdk/src/Client.php" + leaf.chmod(0o600) + leaf.write_bytes(b"evil!") + leaf.chmod(0o444) + changed = MODULE.inventory(self.output, os.getuid(), sealed=True) + self.assertNotEqual(before["sha256"], changed["sha256"]) + with self.assertRaisesRegex(ValueError, "approved archive member authority"): + MODULE.verify_sealed_sdk(changed, self.approved_digest) + + def test_approved_executable_mode_is_deterministic_after_private_umask(self): + leaf = self.sdk / "src/Client.php" + leaf.chmod(0o600) + self.approved_sdk["src/Client.php"]["mode"] = 0o755 + self.approved_digest = MODULE.members_digest(self.approved_sdk) + result = self.stage() + self.assertEqual(result["entries"]["consumer/vendor/durable-workflow/sdk/src/Client.php"]["mode"], 0o555) + MODULE.verify_sealed_sdk(result, self.approved_digest) + + def test_extra_sdk_empty_directory_and_file_refuse(self): + for directory in (False, True): + extra = self.sdk / "extra" + if directory: + extra.mkdir() + else: + extra.write_bytes(b"inert") + with self.subTest(directory=directory), self.assertRaisesRegex(ValueError, "member set|directory set"): + self.stage() + if directory: + extra.rmdir() + else: + extra.unlink() + + def test_changed_approved_map_does_not_change_the_selected_archive_authority(self): + self.approved_sdk["src/Client.php"]["sha256"] = "0" * 64 + with self.assertRaisesRegex(ValueError, "staging SDK authority"): + self.stage() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_command_key_mount_boundary.py b/tests/ci/test_command_key_mount_boundary.py new file mode 100644 index 00000000..ea508d39 --- /dev/null +++ b/tests/ci/test_command_key_mount_boundary.py @@ -0,0 +1,354 @@ +"""Opt-in mount controls using generated include files, never package code.""" + +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import secrets +import shutil +import stat +import subprocess +import tempfile +import time +import types +import unittest +from unittest.mock import Mock, patch + + +HERE = Path(__file__).resolve().parent +SMOKE = HERE.parent / "smoke" +IMAGE = os.environ.get("SDK_PHP_COMMAND_KEY_MOUNT_IMAGE", "") +OBSERVATIONS = [] +TARGETS = ("consumer/vendor/autoload.php", "consumer/vendor/composer/nested.php", + "fixture/command-key-client.php", "fixture/nested/leaf.php") +ORIGINAL = b"$relative){ + $target='/closure/'.$relative; + $results[]=@file_put_contents($target,"synthetic replacement\n")!==false; + $results[]=@rename(dirname($target).'/candidate-'.$index,$target); +} +echo json_encode($results,JSON_THROW_ON_ERROR),"\n"; +''' + + +def selected_bytes(path): + if not IMAGE: + return path.read_bytes() + source = os.environ.get("SDK_PHP_COMMAND_KEY_BOUNDARY_SOURCE", "") + if re.fullmatch(r"[0-9a-f]{40}", source) is None: + raise RuntimeError("The boundary test needs its exact reviewed Git source") + repository = HERE.parents[1] + raw = subprocess.check_output(["/usr/bin/git", "--no-replace-objects", "-C", str(repository), + "show", source + ":" + path.relative_to(repository).as_posix()], timeout=10) + if path.read_bytes() != raw: + raise RuntimeError("Boundary test inputs differ from the reviewed source") + return raw + + +def load(name): + path = SMOKE / (name + ".py") + module = types.ModuleType(name) + module.__file__ = str(path) + exec(compile(selected_bytes(path), str(path), "exec"), module.__dict__) + return module + + +COHORT = load("command_key_cohort") +CONTAINERS = load("command_key_containers") +CLOSURE = load("command_key_include_closure") +SUPPLY = json.loads(selected_bytes(SMOKE / "command-key-supply.json")) + + +def generated_files(): + files = {} + for index, relative in enumerate(TARGETS): + files[relative] = ORIGINAL + files[(PurePosixPath(relative).parent / ("candidate-" + str(index))).as_posix()] = REPLACEMENT + return files + + +def intended_tree(*, sealed): + entries = {".": {"kind": "directory", "mode": 0o555 if sealed else 0o700}} + for name, raw in generated_files().items(): + for parent in PurePosixPath(name).parents: + entries[parent.as_posix()] = {"kind": "directory", "mode": 0o555 if sealed else 0o700} + entries[name] = {"kind": "file", "mode": 0o444 if sealed else 0o600, + "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()} + digest = hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return {"entries": dict(sorted(entries.items())), "sha256": digest, + "bytes": sum(len(raw) for raw in generated_files().values())} + + +def boundary_arguments(root, *, writable=False): + arguments = ["--pull", "never", *COHORT.php_consumer_arguments(root, SUPPLY)] + if writable: + position = arguments.index("--mount") + 1 + if not arguments[position].endswith(",readonly"): + raise RuntimeError("The countercontrol may remove only the literal read-only mount flag") + arguments[position] = arguments[position].removesuffix(",readonly") + return arguments + + +class ConsumerArguments(unittest.TestCase): + def test_unprivileged_complete_closure_factory_has_no_writable_alias(self): + root = Path("/run/sdk-php-command-keys.abcdef") + with patch.object(COHORT.os, "getuid", return_value=1001), patch.object(COHORT.os, "getgid", return_value=1001): + arguments = boundary_arguments(root) + countercontrol = boundary_arguments(root, writable=True) + self.assertEqual(arguments[:2], ["--pull", "never"]) + self.assertEqual(arguments[arguments.index("--mount") + 1], "type=bind,src=" + str(root) + ",dst=/closure,readonly") + self.assertEqual(arguments[arguments.index("--user") + 1], "1001:1001") + self.assertEqual(arguments[arguments.index("--tmpfs") + 1], "/tmp:mode=1777,size=128m") + self.assertEqual(arguments.count("--mount"), 1) + self.assertEqual([index for index, values in enumerate(zip(arguments, countercontrol)) if values[0] != values[1]], + [arguments.index("--mount") + 1]) + with patch.object(COHORT.os, "getuid", return_value=0), self.assertRaises(RuntimeError): + COHORT.php_consumer_arguments(root, SUPPLY) + + +class GeneratedTreeAuthority(unittest.TestCase): + @contextmanager + def copy_sandbox(self, *, replace_before_copy): + with tempfile.TemporaryDirectory(prefix="sdk-php-copy-authority-") as temporary: + work = Path(temporary) + case = SyntheticMountBoundary("test_root_owned_sealed_closure_refuses_all_replacements_and_keeps_genuine_php") + case.work = work / "work" + case.work.mkdir(mode=0o700) + case.active = set() + surrogate = work / "root-surrogate" + reported = Path("/run/sdk-php-command-keys.ABCDEF") + actual_stat, actual_resolve, actual_inventory = Path.lstat, Path.resolve, CLOSURE.inventory + copied = [] + + def stat_path(path, *arguments, **keywords): + if path == reported: + metadata = actual_stat(surrogate) + return types.SimpleNamespace(st_uid=0, st_mode=metadata.st_mode, + st_dev=metadata.st_dev, st_ino=metadata.st_ino) + return actual_stat(path, *arguments, **keywords) + + def resolve_path(path, *arguments, **keywords): + return reported if path == reported else actual_resolve(path, *arguments, **keywords) + + def inventory(path, owner, *, sealed): + if path == reported: + return actual_inventory(surrogate, os.getuid(), sealed=sealed) + return actual_inventory(path, owner, sealed=sealed) + + def simulated_sudo(arguments, **_): + self.assertEqual(arguments[:2], ["/usr/bin/sudo", "-n"]) + command = arguments[2] + if command == "/usr/bin/mktemp": + surrogate.mkdir(mode=0o700) + return types.SimpleNamespace(stdout=str(reported) + "\n", returncode=0) + if command == "/usr/bin/cp": + staged = case.work / "sealed" + leaf = staged / "fixture/nested/leaf.php" + if replace_before_copy: + leaf.chmod(0o600) + leaf.write_bytes(b" 65536) { + throw new RuntimeException('The command-key fixture packet exceeds its limit.'); +} +$packet = json_decode($input, true, flags: JSON_THROW_ON_ERROR); +if (!is_array($packet) || array_is_list($packet)) { + throw new RuntimeException('The command-key fixture needs an object packet.'); +} +$operation = $packet['operation'] ?? ''; +if (!in_array($operation, ['bootstrap', 'drive', 'signal', 'update', 'read', 'value', 'history', 'describe'], true)) { + throw new RuntimeException('Unknown command-key fixture operation.'); +} +$workflow = $packet['workflow_id'] ?? ''; +if (!is_string($workflow) || preg_match('/\Asdk-php-216-[a-zA-Z0-9_.-]{1,100}\z/', $workflow) !== 1) { + throw new RuntimeException('The fixture workflow identity is outside its owned prefix.'); +} +$namespace = $packet['namespace'] ?? 'default'; +if (!is_string($namespace) || ($namespace !== 'default' + && preg_match('/\Asdk-php-216-[a-zA-Z0-9_.-]{1,100}\z/', $namespace) !== 1)) { + throw new RuntimeException('The fixture namespace is outside its owned prefix.'); +} +$supply = json_decode((string) file_get_contents(__DIR__.'/command-key-supply.json'), true, flags: JSON_THROW_ON_ERROR); +$url = getenv('COMMAND_KEY_RUNTIME_URL'); +if (!is_string($url) || preg_match('~\Ahttp://127\.0\.0\.1:([1-9][0-9]{0,4})\z~', $url, $port) !== 1 + || (int) $port[1] > 65535) { + throw new RuntimeException('The fixture requires a numeric loopback runtime and explicit port.'); +} + +require $argv[1]; +$root = realpath((string) InstalledVersions::getInstallPath('durable-workflow/sdk')); +if ($root === false || InstalledVersions::getPrettyVersion('durable-workflow/sdk') !== $supply['sdk_version'] + || InstalledVersions::getReference('durable-workflow/sdk') !== $supply['sdk_reference']) { + throw new RuntimeException('The installed SDK differs from the selected published supply.'); +} +foreach ([Client::class, Worker::class, SignalFailed::class, UpdateFailed::class] as $class) { + $file = realpath((string) (new ReflectionClass($class))->getFileName()); + if ($file === false || !str_starts_with($file, $root.'/')) { + throw new RuntimeException('Source fallback is forbidden.'); + } +} + +$client = new Client($url, namespace: $namespace, + token: getenv('COMMAND_KEY_TOKEN'), requestTimeoutSeconds: 8, workerRequestTimeoutSeconds: 8); +$info = $client->clusterInfo(); +if ($info->version !== $supply['server_version'] || ($info->raw['implementation'] ?? null) !== 'rust' + || !$info->supportsCommandRequestKeys()) { + throw new RuntimeException('The runtime lacks the selected native command-key contract.'); +} +$identity = ['sdk_version' => $supply['sdk_version'], 'sdk_reference' => $supply['sdk_reference'], + 'server_version' => $info->version, 'php_version' => PHP_VERSION, 'source_fallback' => false]; +$run = $packet['run_id'] ?? null; +$arguments = $packet['arguments'] ?? []; +if (isset($packet['arguments_envelope'])) { + if (array_key_exists('arguments', $packet)) { + throw new RuntimeException('Use one argument representation.'); + } + $arguments = $client->payloadCodec()->decodeEnvelope($packet['arguments_envelope']); +} +if (!is_array($arguments) || !array_is_list($arguments)) { + throw new RuntimeException('Command arguments must be a list.'); +} + +$receiptFields = ['workflow_id', 'run_id', 'command_id', 'signal_id', 'update_id', 'request_id', 'command_sequence', 'command_status', + 'update_status', 'outcome', 'retained', 'intent_verified', 'accepted_at']; +try { + if ($operation === 'bootstrap' || $operation === 'drive') { + $queue = $packet['task_queue'] ?? ''; + if (!is_string($queue) || preg_match('/\Asdk-php-216-[a-zA-Z0-9_.-]{1,100}\z/', $queue) !== 1) { + throw new RuntimeException('The fixture queue is outside its owned prefix.'); + } + $worker = new Worker($client, $queue); + $worker->registerWorkflow('command.keys', static function (WorkflowContext $context, string $mode) use ($worker): mixed { + $worker->requestShutdown(); + $context->waitCondition(static fn (): bool => $context->signals('release') !== []); + if ($mode === 'continue') { + $context->continueAsNew(['open']); + } + return null; + }); + foreach (['signal', 'other'] as $name) { + $worker->declareSignal('command.keys', $name, static fn (mixed $value): mixed => null); + $worker->registerUpdate('command.keys', $name, static function (QueryContext $context, mixed $value) use ($worker): mixed { + $worker->requestShutdown(); + return null; + }); + } + $worker->declareSignal('command.keys', 'release'); + if ($operation === 'bootstrap') { + $mode = $packet['mode'] ?? 'open'; + if (!in_array($mode, ['open', 'close', 'continue'], true)) { + throw new RuntimeException('Unknown workflow fixture mode.'); + } + $client->startWorkflow('command.keys', $workflow, $queue, [$mode], + executionTimeoutSeconds: 300, runTimeoutSeconds: 120); + } + // The runner supplies a finite process timeout even if no task exists. + $worker->run(0); + $result = ['worker_stopped' => true]; + } elseif ($operation === 'signal') { + $receipt = $client->signalWorkflow($workflow, $packet['name'], $arguments, $run, $packet['request_id'] ?? null); + $result = array_intersect_key($receipt, array_flip($receiptFields)); + } elseif ($operation === 'update') { + $receipt = $client->updateWorkflowReceipt($workflow, $packet['name'], $arguments, + $packet['wait_for'] ?? 'accepted', 1, $packet['request_id'] ?? null, $run); + $result = array_intersect_key($receipt, array_flip($receiptFields)); + } elseif ($operation === 'read') { + $receipt = $client->updateOutcomeReceipt($workflow, $packet['update_id'], + waitFor: $packet['wait_for'] ?? 'accepted', waitTimeoutSeconds: 1); + $result = array_intersect_key($receipt, array_flip($receiptFields)); + if (($receipt['update_status'] ?? null) === 'completed') { + $result['completed_null'] = $client->updateOutcome($workflow, $packet['update_id'], waitTimeoutSeconds: 1) === null; + } + } elseif ($operation === 'value') { + $result = ['completed_null' => $client->updateOutcome($workflow, $packet['update_id'], waitTimeoutSeconds: 1) === null]; + } elseif ($operation === 'describe') { + $description = $client->describeWorkflow($workflow, $run); + $result = ['workflow_id' => $description->workflowId, 'run_id' => $description->runId, + 'status' => $description->status]; + } elseif ($operation === 'history') { + $counts = []; + $admissions = []; + foreach ($client->iterateWorkflowHistory($workflow, $run) as $event) { + $type = $event['event_type']; + $counts[$type] = ($counts[$type] ?? 0) + 1; + if (in_array($type, ['SignalReceived', 'UpdateAccepted'], true)) { + $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; + $admissions[] = ['event_type' => $type, + 'command_id' => $payload['workflow_command_id'] ?? $event['workflow_command_id'] ?? null, + 'signal_id' => $payload['signal_id'] ?? null, 'update_id' => $payload['update_id'] ?? null, + 'request_id' => $payload['request_id'] ?? null]; + } + } + $result = ['event_counts' => $counts, 'admissions' => $admissions]; + } else { + throw new RuntimeException('Unknown command-key fixture operation.'); + } + echo json_encode($identity + ['outcome' => 'answered', 'result' => $result], JSON_THROW_ON_ERROR).PHP_EOL; +} catch (SignalFailed|UpdateFailed $error) { + // A lost reply may follow a committed admission; do not label it refused. + echo json_encode($identity + ['outcome' => 'error', 'exception' => $error::class, 'status' => $error->status, + 'reason' => $error->reason(), 'request_id' => $error->requestId(), + 'is_request_id_conflict' => $error->isRequestIdConflict(), + 'conflict' => $error->conflict(), 'retained_command_id' => $error->retainedCommandId(), + 'retained' => $error->details()['retained'] ?? null, + 'intent_verified' => $error->details()['intent_verified'] ?? null, + 'transient_connection_failure' => $error->isTransientConnectionFailure(), + 'transient_upstream_failure' => $error->isTransientUpstreamFailure()], JSON_THROW_ON_ERROR).PHP_EOL; +} catch (UpdateStillPending $error) { + echo json_encode($identity + ['outcome' => 'pending', 'update_id' => $error->updateId, + 'run_id' => $error->runId, 'request_id' => $error->requestId], JSON_THROW_ON_ERROR).PHP_EOL; +} catch (InvalidArgumentException $error) { + echo json_encode($identity + ['outcome' => 'local_invalid_argument', 'exception' => $error::class], + JSON_THROW_ON_ERROR).PHP_EOL; +} diff --git a/tests/smoke/command-key-supply.json b/tests/smoke/command-key-supply.json new file mode 100644 index 00000000..e5cd2b00 --- /dev/null +++ b/tests/smoke/command-key-supply.json @@ -0,0 +1,18 @@ +{ + "sdk_version": "3.18.2", + "sdk_reference": "f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", + "sdk_source_url": "https://github.com/durable-workflow/sdk-php.git", + "sdk_dist_url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/f9483bb2b5b45f591a82d9e5419c5b60a6e967c6", + "server_version": "3.9.2", + "server_source": "5614274d2629d03f92932a07dfee0fb10349ddd9", + "server_index": "sha256:8ffcdb2a36af3129a1987849aafe8715fe0d3bf9a9648467f0b5cb8eeef43ba9", + "server_amd64": "sha256:813c9337c44be7d497efd29e1b463546519e488aa796b5ecf9dfc6f4f10d2128", + "server_config": "sha256:71b39c210e16adae7643f1209386c968e757eb304cfc6d6878b4b8400c3e3da9", + "php_version": "8.3.36", + "php_index": "sha256:232331112d3a7c5b98edeeddc3b85f610172f99352c0635fe6b3e166e47062cb", + "php_amd64": "sha256:b2d2be78de4de803e20d1ae3df91c196e332a40cc851754472be4fab0fac1f03", + "php_config": "sha256:6d664d15c9c1ad38a202a9b91736126c7c804d7669c3b7bfddd7ff6f7fdadba5", + "loss_proxy_source": "117118c83093822469b3824ba9587ff4068ed29f", + "loss_proxy_sha256": "bd1e8ccbbf76f63fc2eafa278a023eb181d405b68d9c30016392e395a1eff725", + "sdk_archive_members_sha256": "c08c3a741f21eff2a42bd9b8e383c88c02a162098d411659eae644497508b7bc" +} diff --git a/tests/smoke/command-keys.md b/tests/smoke/command-keys.md new file mode 100644 index 00000000..f71912bb --- /dev/null +++ b/tests/smoke/command-keys.md @@ -0,0 +1,213 @@ +# Installed PHP command-key adapter + +`command-key-client.php` reads one JSON packet from stdin and performs one +fixture operation through the public PHP SDK API. The selected immutable SDK and Server tuple lives +in `command-key-supply.json`. Every result identifies the installed package +reference, runtime version and PHP version. The adapter bounds input to 64 KiB, +accepts only a numeric loopback origin, and restricts fixture workflow and +queue names to the `sdk-php-216-` prefix. + +Before invocation, the owning acceptance runner verifies the complete registry +archive member digest, Composer lock/source/dist identities and installed SDK file bytes +against that selected supply. It owns the isolated runtime and endpoint, +synthetic credential, namespace provisioning, finite process timeout and exact +cleanup. Mount or capture the reviewed adapter, supply and autoloader inputs +read-only. The adapter's reflection checks reject a repository source fallback; +the runner supplies the independent archive/provenance proof. + +`verify_command_key_supply.py ` +performs the package inspection before PHP loading. It checks the exact root +requirement, absence of repository overrides, lock/source/dist URLs and refs, +dist installation, the approved digest of every archive path/body/mode and every installed SDK file. It +accepts private umasks, normalizes GitHub's DOS nonexecutable entries and +rejects added permissions, missing/extra/changed files or aliases. Its output +is explicitly inspection-only. Exact commit and member identities are the +durable binding because GitHub can regenerate ZIP wrapper bytes; each download's +raw ZIP hash is still retained as an observation. Changed, added or missing +members and changed executable modes refuse. Run its inert controls with +`python3 -I -S -B tests/ci/test_command_key_supply.py`. + +The controller also retains that approved archive member map in memory and +passes it into staging. Each SDK source descriptor is checked against the +approved path/body/mode policy before copying. Expected SDK bytes are never +derived from a later read of the mutable install tree. Staging assigns fixed +read-only modes from the approved modes, checks all members again after the +copy, and rejects missing or extra SDK files and directories. Every subsequent +root-controlled inventory rechecks the sealed SDK against the same digest from +the captured supply before container creation and around each invocation. +The fixture map comes independently from captured Git bytes and is checked +during copying and sealed-root checks too. + +Set `COMMAND_KEY_RUNTIME_URL` to the owned numeric loopback URL and +`COMMAND_KEY_TOKEN` to its synthetic credential. Pass the registry consumer's +`vendor/autoload.php` as the first argument. Packet fields are: + +- `operation`: `bootstrap`, `drive`, `signal`, `update`, `read`, `value`, + `history` or `describe`. +- `workflow_id`, optional `run_id`, and optional `namespace` (`default` or an + owned fixture prefix). +- `task_queue` for `bootstrap`/`drive`, and `mode` (`open`, `close`, `continue`) + for bootstrap. The real Worker waits for `release`, completes with null, or + continues once into an open run. Each processed workflow/update asks that + Worker to shut down. The outer process timeout also bounds an empty queue. +- `name`, optional `request_id`, and either a list of `arguments` or an + official Avro `arguments_envelope` for signal/update calls. `signal` and + `other` are declared; `release` takes no arguments. Both update handlers + return null. Omit a key to test the SDK's generated key and typed error. +- `update_id` and optional `wait_for` for retained reads. `read` returns the + receipt; `value` preserves typed pending versus completed null. + +Results select admission/read identities and flags rather than arbitrary +response fields, headers or payloads. A transport failure is an error with an +unknown admission outcome; it is never described as a refused admission. +History output contains event counts and selected signal/update admission +identities. It excludes argument values and arbitrary event payloads. + +Use the accepted shared committed-response-loss fixture unchanged to prove +response loss. Use the accepted portable SQLite helper and its adjacent exact +Server manifest for synthetic legacy copies. The producer-owned physical seed +and upgrade profile stay separate and must never pass through the synthetic +transform. The modern controller supplies explicit four-way retries, changed +intent/name/run conflicts, pending/null reads, closed/continued retained reads, +Unicode character limits and generated-key recovery after a fully consumed +upstream response is deliberately lost. It compares history admissions for +these cases. Synthetic legacy, physical upgrade, namespace isolation and full +database snapshot proofs remain separate work. No complete shared216 result +follows from the modern controller alone. + +`published-command-keys.yml` is a manual, main-only ordinary GitHub Actions +cell. It uses the qualified stock PHP helper, private Composer state and the +real Composer trust guard. Installation disables scripts, plugins and autoload +generation until archive/installed-file inspection passes. The runner captures +its exact committed source and the unchanged accepted shared loss fixture +before loading them. Source checks and independent review precede execution. + +The proposed workflow has three phases. Staging inspects the installed package +and captures its complete consumer/vendor/fixture include tree without loading +PHP. A root-controlled directory receives the verified copy. Preparation +creates the selected linux/amd64 Server and an idle, unprivileged PHP consumer. +Both have read-only root filesystems, 1-CPU/1-GiB/256-PID caps and bounded tmpfs. +Creation explicitly selects linux/amd64. Classic image stores must report the +selected config digest. Multi-platform stores may report the exact selected +index instead, with its matching index descriptor; the created container must +then report the independently pinned linux/amd64 platform manifest before it +starts. A present manifest descriptor must always agree, even when the config +digest matches. Missing, conflicting or other-platform identities refuse. +The PHP consumer mounts the entire include tree read-only at `/closure`; only +its separate `/tmp` is writable. Cohort execution invokes the adapter inside +that consumer and checks the complete include inventory, mount configuration, +image identity and captured runtime endpoint before and after each operation. + +Each phase loads the controller from exact Git bytes through an independently +fixed literal bootstrap. Preparation captures full IDs from Docker create +stdout, keeps an exclusive private record descriptor open, and publishes the +IDs to workflow outputs before executing package code. The separate literal +always-run cleanup uses those outputs, a fresh empty Docker configuration and +owner/role/nonce labels. It needs no checkout, controller import or state file. +If create loses its returned ID, cleanup reconciles the exact three labels with +a role-specific list limited to two candidates. Multiple candidates, malformed +IDs or conflicting inspection identity/labels refuse. Both roles are attempted +independently, and each role's labels must match no remaining container before +its absence is reported. +It attempts both removals, preserves foreign replacements, and accepts only an +exact missing-object response as proof of absence. Root-stage removal also +requires the original root-owned directory device/inode. Sanitized evidence +and the dependency lock are retained for seven days. + +Run the inert controls with: + +```sh +python3 -I -S -B tests/ci/test_command_key_cohort.py +python3 -I -S -B tests/ci/test_command_key_containers.py +python3 -I -S -B tests/ci/test_command_key_include_closure.py +``` + +They exercise synthetic inspection metadata, captured-ID substitution, +independent cleanup, include inventories and receipts without creating a +runtime or loading PHP. + +`command_key_include_closure.py` inventories every +consumer/vendor/fixture leaf, rejects aliases, hardlinks, repository metadata +and unexpected root members, and copies verified bytes with read-only modes. +Its output includes a complete member/hash inventory. The workflow wiring is +proposed source, with installed execution on hold. Actual nested-file +overwrite/rename controls, a writable-mount countercontrol, full consumer +profile qualification and independent review are still required. File modes +and the inert inventory tests alone provide no isolation verdict. The proposed +official PHP image identities in the supply are raw-registry metadata bindings; +read-only base-profile observations on the PR do not qualify the SDK consumer. +Exact source and runtime qualification receipts belong on the owning PR. + +`tests/ci/test_command_key_mount_boundary.py` proposes two opt-in controls and +an inert argument-factory control. The effectful tests use only generated +include/marker files. One exercises a root-owned sealed tree; the other uses +writable owned files with a read-only mount, then removes only that mount's +read-only qualifier for the negative primitive. The production guard must +refuse the writable mount. The primitive must then show successful marker +overwrite/rename, demonstrating that ordinary file permissions cannot explain +the read-only result. Only the root-owned sealed case includes a generated +constant PHP leaf; the mutable-tree controls execute the literal probe only. +No published SDK, application, repository or host control file is mounted. + +The complete intended tree is constructed from the captured fixture constants +before the privilege transition, including every path, kind, mode, size and +body digest. Both staged and root-owned inventories must equal that retained +authority. A fresh inventory of the mutable staging path cannot redefine it. +The inert regression simulates the privileged copy under an owned temporary +directory, replaces a staged leaf after authority selection and proves refusal +before a mocked container creation. It invokes neither sudo nor Docker. + +The opt-in tests share the proposed consumer argument factory and the exact +selected image/platform and captured-ID lifecycle checks. They create three +containers sequentially, never concurrently, with the existing 1-CPU/1-GiB/ +256-PID limits and host-network setting but no network requests. Image pulling +is disabled. Container absence must be established before owned data or the +root-controlled staging inode can be removed; otherwise those paths are +preserved. Sanitized outcomes include the full IDs and removal results. + +Execution remains review-gated. Set `SDK_PHP_COMMAND_KEY_MOUNT_IMAGE` only to +the exact cached `php@` from the selected supply, and +`SDK_PHP_COMMAND_KEY_BOUNDARY_SOURCE` to the exact reviewed Git commit. Launch +the test from captured Git bytes through a literal bootstrap that verifies an +independently fixed test digest before compile/exec. The helpers and supply are +also captured from that commit. A direct mutable-path launch does not supply +that outer binding. Default CI runs only the inert factory control and skips +the two effectful tests. Preparing this source does not qualify the actual +mount, dependency profile, cleanup-after-state-refusal or installed cohort. + +`tests/ci/test_command_key_cleanup_boundary.py` proposes a separate opt-in +cleanup control. It creates two containers from the selected cached PHP image, +using only the two fixture role labels. One deliberately loses create stdout +and stays unstarted; the other runs a fixed idle PHP command. No native Server, SDK, API or +Worker runs. Both containers have read-only roots, unprivileged users, no +mounts or network, and the existing resource caps. Ownership is flushed before +creation. The missing-ID role is recovered only through label reconciliation; +the test retains only a ground-truth ID hash for comparison, not as cleanup +input. The production guards then refuse an invalid source selection, nonprivate +disposable state and nonempty disposable Docker configuration. + +The control then runs the reviewed literal workflow cleanup from captured Git +bytes with an independently fixed digest. Its environment supplies the held +IDs and labels while deliberately invalidating checkout, state, temporary and +ambient Docker configuration values. The cleanup must use its own fresh Docker +configuration and establish both exact absences. A separate observer checks +the full IDs and both role-label sets again before owned fixture data is removed. +An empty captured-ID set cannot establish cleanup. Cleanup refusal or an +incomplete observer preserves that data. Default CI checks the cleanup source +binding and lost/ambiguous create, duplicate/foreign candidate and independent +role cleanup controls, and skips the +container control. Any actual execution requires exact source/entry review, +`SDK_PHP_COMMAND_KEY_CLEANUP_IMAGE` and `SDK_PHP_COMMAND_KEY_CLEANUP_SOURCE`, +and a Git-blob bootstrap with an independently fixed outer test digest. This +control supplies no dependency, consumer profile or installed acceptance. + +Run the offline input controls against an existing immutable PHP image: + +```sh +SDK_PHP_COMMAND_KEY_IMAGE=sha256: \ + python3 -I -S -B tests/ci/test_command_key_client.py +``` + +These controls use no network or SDK autoload. They verify malformed operations, +foreign fixture identities, non-loopback origins and oversized packets stop +before package loading. They establish no installed command admission result. diff --git a/tests/smoke/command_key_cohort.py b/tests/smoke/command_key_cohort.py new file mode 100644 index 00000000..b95b1517 --- /dev/null +++ b/tests/smoke/command_key_cohort.py @@ -0,0 +1,519 @@ +"""Modern installed PHP command-key cases on one already-owned runtime.""" + +import argparse +import concurrent.futures +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import stat +import subprocess +import threading +import time +import types +import urllib.request + + +HERE = Path(__file__).resolve().parent +INPUTS = ("command-key-client.php", "command-key-supply.json", "verify_command_key_supply.py", + "command_key_cohort.py", "command_key_containers.py", "command_key_include_closure.py") + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def capture_inputs(commit): + require(re.fullmatch("[0-9a-f]{40}", commit or "") is not None, "An exact reviewed runner commit is required") + repository = HERE.parents[1] + result = {} + for name in INPUTS: + path = HERE / name + relative = path.relative_to(repository).as_posix() + original = subprocess.check_output(["git", "--no-replace-objects", "-C", str(repository), + "show", commit + ":" + relative], timeout=10) + require(path.read_bytes() == original, "Runner source differs from the selected commit") + result[name] = original + return result + + +def module_from_bytes(name, raw): + module = types.ModuleType(name) + module.__file__ = str(HERE / (name + ".py")) + exec(compile(raw, module.__file__, "exec"), module.__dict__) + return module + + +def fixture_authority(sources): + return {name: {"mode": 0o644, "bytes": len(sources[name]), "sha256": hashlib.sha256(sources[name]).hexdigest()} + for name in ("command-key-client.php", "command-key-supply.json")} + + +def stage_inventory(root, expected, root_identity, closure, supply, approved_fixture): + require(root.parent == Path("/run") and re.fullmatch(r"sdk-php-command-keys\.[A-Za-z0-9]{6}", root.name), + "The include closure must use its root-controlled staging location") + metadata = root.lstat() + require(root.resolve() == root and stat.S_ISDIR(metadata.st_mode) and metadata.st_uid == 0, + "The include staging root is not root controlled") + require(root_identity == str(metadata.st_dev) + ":" + str(metadata.st_ino), "The include staging inode differs") + result = closure.inventory(root, 0, sealed=True) + closure.verify_sealed_sdk(result, supply["sdk_archive_members_sha256"]) + closure.bind_members(result, "fixture/", approved_fixture, sealed=True) + require(result["sha256"] == expected, "The complete include inventory differs") + return result + + +def selected_image(record, supply, role): + configuration = supply[role + "_config"] + index = supply[role + "_index"] + repository = "durableworkflow/server" if role == "server" else "php" + require(record["Config"]["Image"] == repository + "@" + index, + "The fixture must select its exact public image index") + descriptor = record.get("ImageManifestDescriptor") + if record["Image"] == configuration and descriptor is None: + return + require(record["Image"] in {configuration, index} and isinstance(descriptor, dict), + "The fixture image or selected manifest is missing") + platform = descriptor.get("platform") or {} + require(descriptor.get("digest") == supply[role + "_amd64"] + and descriptor.get("mediaType") in {"application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json"} + and platform.get("os") == "linux" and platform.get("architecture") == "amd64", + "The selected fixture platform manifest differs") + + +def confined(record, supply, role): + selected_image(record, supply, role) + require(record["State"]["Running"], + "The fixture is not running the selected image configuration") + host = record["HostConfig"] + require(host["ReadonlyRootfs"] and not host["Privileged"] and not host.get("Binds") + and host["Init"] and host["CapDrop"] == ["ALL"] and not host.get("CapAdd") + and "no-new-privileges" in host["SecurityOpt"] and not host.get("Devices"), + "The fixture privileges differ") + require(0 < host["Memory"] <= 1024**3 and 0 < host["NanoCpus"] <= 10**9 + and 0 < host["PidsLimit"] <= 256, "The fixture resource caps differ") + + +def mount_destinations(record, tmpfs, required=()): + require(record["HostConfig"]["Tmpfs"] == tmpfs, "The exact bounded tmpfs configuration differs") + destinations = [mount["Destination"] for mount in record["Mounts"]] + # Engine keeps legacy --tmpfs in HostConfig.Tmpfs separately from Mounts. + # Accept that form or the complete reported form, with no extra/duplicate path. + require(len(destinations) == len(set(destinations)) + and set(destinations) in (set(required), set(required) | set(tmpfs)), + "Unexpected writable mount destinations") + + +def runtime_origin(resources, identity, supply): + record = resources.owned(identity, "server") + confined(record, supply, "server") + require(record["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], + "The native source identity differs") + require(record["HostConfig"]["NetworkMode"] == "bridge", "The native network profile differs") + require(all(mount["Type"] == "tmpfs" for mount in record["Mounts"]), "Unexpected native bind or volume") + mount_destinations(record, {"/tmp": "mode=1777,size=64m", + "/app/storage": "uid=1000,gid=1000,mode=0700,size=64m", + "/app/database": "uid=1000,gid=1000,mode=0700,size=32m"}) + require(set(record["NetworkSettings"]["Ports"]) == {"8080/tcp"}, "Unexpected native published ports") + ports = record["NetworkSettings"]["Ports"]["8080/tcp"] + require(isinstance(ports, list) and len(ports) == 1 and ports[0]["HostIp"] == "127.0.0.1", + "The native fixture must have one numeric loopback binding") + port = ports[0]["HostPort"] + require(re.fullmatch(r"[1-9][0-9]{0,4}", port) and int(port) <= 65535, "Invalid fixture port") + return "http://127.0.0.1:" + port + + +def consumer_record(resources, identity, supply, root): + record = resources.owned(identity, "consumer") + confined(record, supply, "php") + host = record["HostConfig"] + require(host["NetworkMode"] == "host" and not record["NetworkSettings"]["Ports"], + "The PHP fixture network profile differs") + require(record["Config"]["User"] == str(os.getuid()) + ":" + str(os.getgid()) and os.getuid() != 0, + "The PHP fixture must use the unprivileged caller identity") + binds = [mount for mount in record["Mounts"] if mount["Type"] == "bind"] + require(len(binds) == 1 and binds[0]["Source"] == str(root) + and binds[0]["Destination"] == "/closure" and binds[0]["RW"] is False, + "The complete include closure must be mounted read-only") + require(all(mount["Type"] == "tmpfs" or mount in binds for mount in record["Mounts"]), + "Unexpected PHP volume or mount") + mount_destinations(record, {"/tmp": "mode=1777,size=128m"}, ("/closure",)) + return record + + +def publish_outputs(values): + output = os.environ.get("GITHUB_OUTPUT") + require(isinstance(output, str) and output, "The trusted workflow output file is required") + descriptor = os.open(output, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW) + try: + metadata = os.fstat(descriptor) + require(stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.getuid() and metadata.st_nlink == 1, + "The workflow output file identity differs") + raw = "".join(key + "=" + value + "\n" for key, value in values.items()).encode() + offset = 0 + while offset < len(raw): + written = os.write(descriptor, raw[offset:]) + require(written > 0, "The workflow outputs could not be written") + offset += written + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def php_consumer_arguments(root, supply): + require(os.getuid() > 0, "The PHP fixture cannot run as root") + return ["--network", "host", "--user", str(os.getuid()) + ":" + str(os.getgid()), + "--workdir", "/closure/consumer", "--tmpfs", "/tmp:mode=1777,size=128m", + "--mount", "type=bind,src=" + str(root) + ",dst=/closure,readonly", "php@" + supply["php_index"], + "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", "sleep(900);"] + + +def prepare_containers(resources, supply, token, root): + try: + server_image = "durableworkflow/server@" + supply["server_index"] + native = resources.pull(server_image, supply["server_config"]) + require(native["Config"]["Labels"].get("org.opencontainers.image.revision") == supply["server_source"], + "The selected native source differs") + server = resources.create("server", ["--network", "bridge", "--tmpfs", "/tmp:mode=1777,size=64m", + "--tmpfs", "/app/storage:uid=1000,gid=1000,mode=0700,size=64m", + "--tmpfs", "/app/database:uid=1000,gid=1000,mode=0700,size=32m", + "--publish", "127.0.0.1::8080", "--env", "DB_CONNECTION=sqlite", + "--env", "DW_AUTH_DRIVER=token", "--env", "DW_AUTH_TOKEN=" + token, server_image]) + selected_image(resources.owned(server, "server"), supply, "server") + resources.start(server, "server") + origin = runtime_origin(resources, server, supply) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + for _ in range(30): + try: + with opener.open(origin + "/api/ready", timeout=2) as response: + if response.status == 200: + break + except OSError: + pass + time.sleep(1) + else: + raise RuntimeError("The selected native fixture did not become ready") + php_image = "php@" + supply["php_index"] + php = resources.pull(php_image, supply["php_config"]) + require("PHP_VERSION=" + supply["php_version"] in php["Config"]["Env"], "The PHP image version differs") + consumer = resources.create("consumer", php_consumer_arguments(root, supply)) + selected_image(resources.owned(consumer, "consumer"), supply, "php") + resources.start(consumer, "consumer") + consumer_record(resources, consumer, supply, root) + probe = resources.command(["exec", consumer, "php", "-c", "/usr/local/etc/php/php.ini-production", "-r", + 'echo json_encode(["version"=>PHP_VERSION,"ini"=>php_ini_loaded_file(),' + '"coverage"=>extension_loaded("xdebug")||extension_loaded("pcov")]);']) + require(probe.returncode == 0, "The PHP profile probe failed") + observed = json.loads(probe.stdout) + require(observed == {"version": supply["php_version"], "ini": "/usr/local/etc/php/php.ini-production", + "coverage": False}, "The PHP production profile differs") + return server, consumer + except BaseException: + resources.remove_all(reversed(list(resources.identities.items()))) + raise + + +class Consumer: + def __init__(self, resources, container, runtime_id, supply, sources, origin, token, root, expected, root_identity, closure): + self.resources, self.container, self.runtime_id = resources, container, runtime_id + self.supply, self.sources, self.origin, self.token = supply, sources, origin, token + self.root, self.expected, self.root_identity, self.closure = root, expected, root_identity, closure + self.approved_fixture = fixture_authority(sources) + self.include_inventory = stage_inventory(root, expected, root_identity, closure, supply, self.approved_fixture) + + def call(self, packet, origin=None): + require(runtime_origin(self.resources, self.runtime_id, self.supply) == self.origin, + "The captured native endpoint changed") + consumer_record(self.resources, self.container, self.supply, self.root) + require(stage_inventory(self.root, self.expected, self.root_identity, self.closure, + self.supply, self.approved_fixture) == self.include_inventory, + "The complete include tree changed before invocation") + for name, raw in self.sources.items(): + require((HERE / name).read_bytes() == raw, "The captured runner source changed") + result = self.resources.command(["exec", "--interactive", "--env", "COMMAND_KEY_RUNTIME_URL=" + (origin or self.origin), + "--env", "COMMAND_KEY_TOKEN=" + self.token, self.container, "php", "-c", + "/usr/local/etc/php/php.ini-production", "/closure/fixture/command-key-client.php", + "/closure/consumer/vendor/autoload.php"], input=json.dumps(packet), timeout=20) + require(result.returncode == 0, "The isolated PHP fixture operation failed") + require(0 < len(result.stdout) <= 1024**2, "The PHP fixture evidence is empty or oversized") + require(stage_inventory(self.root, self.expected, self.root_identity, self.closure, + self.supply, self.approved_fixture) == self.include_inventory, + "The complete include tree changed during invocation") + consumer_record(self.resources, self.container, self.supply, self.root) + response = json.loads(result.stdout) + require(response.get("sdk_version") == self.supply["sdk_version"] + and response.get("sdk_reference") == self.supply["sdk_reference"] + and response.get("server_version") == self.supply["server_version"] + and response.get("php_version") == self.supply["php_version"] + and response.get("source_fallback") is False, "The API operation used another supply") + return response + + def answered(self, packet, origin=None): + response = self.call(packet, origin) + require(response.get("outcome") == "answered", "The API operation was not answered") + return response["result"] + + +def identity(receipt, kind): + fields = ["command_id", "run_id", "request_id"] + if kind == "update": + fields += ["update_id", "accepted_at"] + require(all(isinstance(receipt.get(field), str) and receipt[field] for field in fields), + "The admission identity is incomplete") + require(type(receipt.get("command_sequence")) is int and receipt["command_sequence"] > 0, + "The command sequence is missing") + if kind == "signal": + require(isinstance(receipt.get("signal_id"), str) and receipt["signal_id"], "The signal identity is missing") + fields += ["signal_id"] + return {field: receipt[field] for field in fields + ["command_sequence"]} + + +def retained(receipt, original, kind): + require(identity(receipt, kind) == identity(original, kind), "A retry changed the original admission identity") + require(receipt.get("retained") is True and receipt.get("intent_verified") is True, + "The retry did not retain verified intent") + + +def four_retries(client, packet, original, kind, origin=None): + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + replies = list(pool.map(lambda _: client.answered(packet, origin), range(4))) + for reply in replies: + retained(reply, original, kind) + + +def conflict(client, packet, original): + response = client.call(packet) + require(response.get("outcome") == "error" and response.get("status") == 409 + and response.get("is_request_id_conflict") is True + and response.get("retained_command_id") == original["command_id"] + and response.get("transient_connection_failure") is False + and response.get("transient_upstream_failure") is False, "The changed intent was not a typed conflict") + + +def bootstrap(client, mode, label): + workflow = "sdk-php-216-" + label + "-" + secrets.token_hex(5) + queue = workflow + "-queue" + client.answered({"operation": "bootstrap", "workflow_id": workflow, "task_queue": queue, "mode": mode}) + description = client.answered({"operation": "describe", "workflow_id": workflow}) + require(isinstance(description.get("run_id"), str) and description["run_id"], "The fixture has no original run") + return workflow, queue, description["run_id"] + + +def history(client, workflow, run): + return client.answered({"operation": "history", "workflow_id": workflow, "run_id": run}) + + +def settle(client, workflow, queue, original_run, mode): + client.answered({"operation": "signal", "workflow_id": workflow, "name": "release", + "request_id": "release", "arguments": []}) + for _ in range(6): + client.answered({"operation": "drive", "workflow_id": workflow, "task_queue": queue}) + description = client.answered({"operation": "describe", "workflow_id": workflow}) + if mode == "close" and description.get("status") == "completed": + return description + if mode == "continue" and description.get("run_id") != original_run: + return description + raise RuntimeError("The fixture did not complete its declared transition") + + +def direct_cell(client, mode): + workflow, queue, run = bootstrap(client, mode, mode) + admissions = {} + for kind in ("signal", "update"): + packet = {"operation": kind, "workflow_id": workflow, "name": "signal", "arguments": [1], + "request_id": "explicit-" + kind} + original = client.answered(packet) + identity(original, kind) + require(original["run_id"] == run and original["request_id"] == packet["request_id"] + and original.get("retained") is False and original.get("intent_verified") is True, + "The fresh admission identity or intent marker differs") + four_retries(client, packet, original, kind) + before = history(client, workflow, run) + conflict(client, packet | {"arguments": [True]}, original) + conflict(client, packet | {"name": "other"}, original) + conflict(client, packet | {"run_id": "00000000000000000000000000"}, original) + require(history(client, workflow, run) == before, "A conflict changed durable history") + admissions[kind] = (packet, original) + pending = client.call({"operation": "value", "workflow_id": workflow, + "update_id": admissions["update"][1]["update_id"]}) + require(pending.get("outcome") == "pending", "The queued update did not preserve typed pending") + if mode != "open": + settled = settle(client, workflow, queue, run, mode) + for kind, (packet, original) in admissions.items(): + four_retries(client, packet, original, kind) + value = client.answered({"operation": "value", "workflow_id": workflow, + "update_id": admissions["update"][1]["update_id"]}) + require(value.get("completed_null") is True, "The completed update did not preserve null") + if mode == "continue": + require(settled["run_id"] != run, "The successor reused the old run") + for packet, original in admissions.values(): + conflict(client, packet | {"run_id": settled["run_id"]}, original) + counts = history(client, workflow, run)["event_counts"] + require(counts.get("UpdateAccepted") == 1 and counts.get("SignalReceived") == (1 if mode == "open" else 2), + "Retries admitted an extra durable command") + return {"mode": mode, "four_way_retries": True, "typed_conflicts": True, + "retained_after_transition": mode != "open", "typed_pending": True, + "completed_null": mode != "open", "event_counts": counts, + "workflow_id": workflow, "original_run_id": run, + "admissions": {kind: identity(receipt, kind) for kind, (_, receipt) in admissions.items()}} + + +def unicode_cell(client): + workflow, _, run = bootstrap(client, "open", "unicode") + for kind in ("signal", "update"): + packet = {"operation": kind, "workflow_id": workflow, "name": "signal", "arguments": [1], "request_id": "é" * 255} + original = client.answered(packet) + require(original.get("request_id") == packet["request_id"], "The Unicode key changed") + four_retries(client, packet, original, kind) + before = history(client, workflow, run) + refused = client.call(packet | {"request_id": "é" * 256}) + require(refused.get("outcome") == "local_invalid_argument", "The oversized character key was submitted") + require(history(client, workflow, run) == before, "The local refusal changed history") + return {"unicode_255_characters": True, "unicode_256_local_refusal": True} + + +def loss_cell(client, module, kind): + workflow, _, run = bootstrap(client, "open", "loss-" + kind) + proxy = module.CommittedResponseLoss(client.origin) + thread = threading.Thread(target=proxy.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + path = "/api/workflows/" + workflow + "/" + kind + "/signal" + packet = {"operation": kind, "workflow_id": workflow, "name": "signal", "arguments": [1]} + try: + proxy.arm("POST", path) + response = client.call(packet, proxy.url) + if response.get("outcome") == "error": + require(response.get("transient_connection_failure") is True, "The lost reply was not a typed transport error") + key = response.get("request_id") + else: + require(response.get("outcome") == "answered", "The lost reply has no recoverable identity") + key = response["result"].get("request_id") + require(isinstance(key, str) and re.fullmatch("sdk-php-" + kind + "-[0-9a-f]{32}", key), + "The generated key was not recoverable") + first = history(client, workflow, run) + event_type = "SignalReceived" if kind == "signal" else "UpdateAccepted" + original = [event for event in first["admissions"] if event["event_type"] == event_type] + require(len(original) == 1 and isinstance(original[0].get("command_id"), str), + "The committed original admission identity is unavailable") + packet["request_id"] = key + recovered = client.answered(packet, proxy.url) + require(recovered["command_id"] == original[0]["command_id"] and recovered["run_id"] == run, + "Recovery did not return the committed original command") + require(recovered.get("retained") is True and recovered.get("intent_verified") is True + and recovered.get("request_id") == key, "Recovery did not retain the generated key and intent") + four_retries(client, packet, recovered, kind, proxy.url) + require(history(client, workflow, run) == first, "Response-loss retries changed durable history") + records = [record for record in proxy.records if record["method"] == "POST" and record["path"] == path] + require(6 <= len(records) <= 9 and sum(record["dropped"] for record in records) == 1, + "The response-loss observation is incomplete") + require(all(record["upstream_complete"] and 200 <= record["upstream_status"] < 300 for record in records), + "An upstream response was not completely consumed") + require(len({record["request_body_sha256"] for record in records}) == 1, "A retry changed the generated key or command body") + return {"command_type": kind, "committed_before_loss": True, "original_identity_retained": True, + "four_way_retries": True, "no_second_admission": True, "requests": records, + "workflow_id": workflow, "original_run_id": run, "recovered": identity(recovered, kind)} + finally: + proxy.shutdown() + proxy.server_close() + thread.join(timeout=2) + require(not thread.is_alive(), "The response-loss fixture did not stop") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--phase", choices=("stage", "prepare", "cohort"), required=True) + parser.add_argument("--consumer", type=Path) + parser.add_argument("--archive", type=Path) + parser.add_argument("--proxy-source", type=Path) + parser.add_argument("--stage-root", type=Path, required=True) + parser.add_argument("--include-sha256") + parser.add_argument("--root-identity") + parser.add_argument("--runtime-state", type=Path, required=True) + parser.add_argument("--docker-config", type=Path, required=True) + parser.add_argument("--runtime-owner", required=True) + parser.add_argument("--runner-source", required=True) + parser.add_argument("--server-id") + parser.add_argument("--consumer-id") + parser.add_argument("--nonce") + options = parser.parse_args() + require(re.fullmatch(r"[0-9]+-[0-9]+", options.runtime_owner), "Invalid fixture run owner") + sources = capture_inputs(options.runner_source) + supply = json.loads(sources["command-key-supply.json"]) + approved_fixture = fixture_authority(sources) + closure = module_from_bytes("command_key_include_closure", sources["command_key_include_closure.py"]) + if options.phase == "stage": + require(options.consumer is not None and options.archive is not None, + "The registry consumer and approved archive are required") + verifier = module_from_bytes("verify_command_key_supply", sources["verify_command_key_supply.py"]) + package, approved_sdk = verifier.verify_with_members(options.consumer, options.archive, supply) + fixture = options.stage_root.parent / "captured-fixture" + fixture.mkdir(mode=0o700) + for name in ("command-key-client.php", "command-key-supply.json"): + descriptor = os.open(fixture / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) + with os.fdopen(descriptor, "wb") as output: + output.write(sources[name]) + include = closure.stage(options.consumer, fixture, options.stage_root, approved_sdk, + supply["sdk_archive_members_sha256"], approved_fixture) + print(json.dumps({"include_sha256": include["sha256"], "members": len(include["entries"]), + "bytes": include["bytes"], "package": package, "inspection_only": True})) + return + require(isinstance(options.include_sha256, str) and re.fullmatch(r"[0-9a-f]{64}", options.include_sha256), + "The captured include digest is required") + include = stage_inventory(options.stage_root, options.include_sha256, options.root_identity, + closure, supply, approved_fixture) + containers = module_from_bytes("command_key_containers", sources["command_key_containers.py"]) + nonce = secrets.token_hex(16) if options.phase == "prepare" else options.nonce + created = (lambda role, identity: publish_outputs({role + "_id": identity})) if options.phase == "prepare" else None + resources = containers.Containers(options.docker_config, options.runtime_state, options.runtime_owner, nonce, created) + try: + if options.phase == "prepare": + try: + token = "sdk-php-216-" + secrets.token_hex(24) + publish_outputs({"nonce": nonce, "token": token}) + server, consumer = prepare_containers(resources, supply, token, options.stage_root) + stage_inventory(options.stage_root, options.include_sha256, options.root_identity, + closure, supply, approved_fixture) + # This step ends before any package code executes. GitHub retains + # these outputs independently of the mutable checkout/state. + print(json.dumps({"prepared": True, "package_executed": False, + "server_config": supply["server_config"], "php_config": supply["php_config"]})) + return + except BaseException: + resources.remove_all(reversed(list(resources.identities.items()))) + raise + token = os.environ.get("COMMAND_KEY_TOKEN") + require(isinstance(token, str) and re.fullmatch(r"sdk-php-216-[0-9a-f]{48}", token), + "The prepared synthetic credential is required") + require(options.proxy_source is not None, "The accepted response-loss fixture is required") + proxy_raw = options.proxy_source.read_bytes() + require(hashlib.sha256(proxy_raw).hexdigest() == supply["loss_proxy_sha256"], "The accepted loss fixture differs") + proxy = module_from_bytes("committed_response_loss", proxy_raw) + try: + origin = runtime_origin(resources, options.server_id, supply) + consumer_record(resources, options.consumer_id, supply, options.stage_root) + client = Consumer(resources, options.consumer_id, options.server_id, supply, sources, origin, token, + options.stage_root, options.include_sha256, options.root_identity, closure) + direct = [direct_cell(client, mode) for mode in ("open", "close", "continue")] + unicode = unicode_cell(client) + loss = [loss_cell(client, proxy, kind) for kind in ("signal", "update")] + finally: + resources.remove_all([("consumer", options.consumer_id), ("server", options.server_id)]) + print(json.dumps({"outcome": "pass", "scope": "modern-direct-and-postcommit-loss-only", + "runner_source": options.runner_source, + "runner_inputs": {name: hashlib.sha256(raw).hexdigest() for name, raw in sources.items()}, + "sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], + "include_sha256": include["sha256"], "include_members": len(include["entries"]), + "server_index": supply["server_index"], "server_platform": "linux/amd64", + "server_config": supply["server_config"], "php_index": supply["php_index"], + "php_config": supply["php_config"], "php_version": supply["php_version"], + "direct": direct, "unicode": unicode, "loss": loss, "runtime_removed": True})) + finally: + resources.close() + + +if __name__ == "__main__": + main() diff --git a/tests/smoke/command_key_containers.py b/tests/smoke/command_key_containers.py new file mode 100644 index 00000000..00ef9278 --- /dev/null +++ b/tests/smoke/command_key_containers.py @@ -0,0 +1,182 @@ +"""Two finite command-key fixture roles with captured-ID lifecycle control.""" + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +from types import SimpleNamespace + + +OWNER = "durable-workflow.php-command-keys" +ROLE = "durable-workflow.fixture-role" +NONCE = "durable-workflow.fixture-nonce" +ROLES = {"server", "consumer"} + + +class FixtureTimeout(RuntimeError): + def __init__(self, output): + super().__init__("The bounded fixture command timed out") + self.output = output.decode() if isinstance(output, bytes) else (output or "") + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def private_directory(path): + metadata = path.lstat() + require(path.resolve() == path and stat.S_ISDIR(metadata.st_mode) + and metadata.st_uid == os.getuid() and stat.S_IMODE(metadata.st_mode) == 0o700, + "Fixture control directories must be canonical, private and owned") + return metadata + + +def exact_missing(process, identity): + return (process.returncode == 1 and process.stdout == "[]\n" + and process.stderr in {"Error: No such object: " + identity + "\n", + "error: no such object: " + identity + "\n"}) + + +class Containers: + def __init__(self, configuration, state, owner, nonce, record_created=None): + require(isinstance(owner, str) and re.fullmatch(r"[0-9]+-[0-9]+", owner), "Invalid fixture run owner") + require(isinstance(nonce, str) and re.fullmatch(r"[0-9a-f]{32}", nonce), "Invalid fixture nonce") + private_directory(configuration) + initial = private_directory(state) + self.configuration, self.state, self.owner, self.nonce = configuration, state, owner, nonce + self.state_descriptor = os.open(state, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + metadata = os.fstat(self.state_descriptor) + current = state.lstat() + if ((metadata.st_dev, metadata.st_ino) != (current.st_dev, current.st_ino) + or (metadata.st_dev, metadata.st_ino) != (initial.st_dev, initial.st_ino) + or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700): + os.close(self.state_descriptor) + raise RuntimeError("The fixture state directory changed before it was held") + self.records = {} + self.identities = {} + self.record_created = record_created + + def close(self): + for descriptor in self.records.values(): + os.close(descriptor) + self.records.clear() + if self.state_descriptor is not None: + os.close(self.state_descriptor) + self.state_descriptor = None + + def environment(self): + private_directory(self.configuration) + require(not list(self.configuration.iterdir()), "Docker configuration must remain empty") + return {"PATH": "/usr/bin:/bin", "DOCKER_CONFIG": str(self.configuration), + "DOCKER_HOST": "unix:///var/run/docker.sock"} + + def command(self, arguments, *, timeout=30, input=None): + try: + return subprocess.run(["/usr/bin/docker", *arguments], env=self.environment(), input=input, + capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired as error: + raise FixtureTimeout(error.stdout) from None + + def inspect(self, identity, *, allow_missing=False): + require(isinstance(identity, str) and re.fullmatch(r"[0-9a-f]{64}", identity), + "A full captured container ID is required") + result = self.command(["inspect", identity], timeout=15) + if allow_missing and exact_missing(result, identity): + return None + require(result.returncode == 0, "The captured container cannot be inspected") + rows = json.loads(result.stdout) + require(isinstance(rows, list) and len(rows) == 1 and rows[0]["Id"] == identity, + "The captured container identity differs") + return rows[0] + + def owned(self, identity, role, *, allow_missing=False): + require(role in ROLES, "Unexpected fixture role") + record = self.inspect(identity, allow_missing=allow_missing) + if record is None: + return None + labels = record["Config"].get("Labels") or {} + require((labels.get(OWNER), labels.get(ROLE), labels.get(NONCE)) == (self.owner, role, self.nonce), + "Container ownership changed; preserve the replacement") + return record + + def pull(self, image, configuration): + require(re.fullmatch(r"(?:php|durableworkflow/server)@sha256:[0-9a-f]{64}", image) is not None, + "An exact public fixture image is required") + require(self.command(["pull", "--platform", "linux/amd64", image], timeout=180).returncode == 0, + "The selected public fixture image could not be pulled") + result = self.command(["image", "inspect", image], timeout=15) + require(result.returncode == 0, "The selected fixture image cannot be inspected") + rows = json.loads(result.stdout) + require(isinstance(rows, list) and len(rows) == 1, "The selected image inspection is ambiguous") + selected = rows[0] + descriptor = selected.get("Descriptor") or {} + index = image.split("@", 1)[1] + descriptor_matches = (descriptor.get("digest") == index and descriptor.get("mediaType") in { + "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"}) + index_identity = selected["Id"] == index and descriptor_matches + require((selected["Id"] == configuration or index_identity) + and (not descriptor or descriptor_matches) + and selected["Os"] == "linux" and selected["Architecture"] == "amd64", + "The selected public image configuration differs") + # The created container must independently bind its selected platform + # manifest before start when the engine uses the index as its image ID. + return selected + + def create(self, role, arguments): + require(role in ROLES and role not in self.records, "A fixture role may be created only once") + descriptor = os.open(role + ".json", os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self.state_descriptor) + self.records[role] = descriptor + flags = ["create", "--name", "sdk-php-command-keys-" + self.owner + "-" + self.nonce[:10] + "-" + role, + "--label", OWNER + "=" + self.owner, "--label", ROLE + "=" + role, + "--label", NONCE + "=" + self.nonce, "--platform", "linux/amd64", "--init", "--read-only", + "--cpus", "1", "--memory", "1g", "--pids-limit", "256", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges"] + try: + result = self.command([*flags, *arguments], timeout=30) + except FixtureTimeout as error: + result = SimpleNamespace(returncode=124, stdout=error.output) + require(re.fullmatch(r"[0-9a-f]{64}\n", result.stdout) is not None, + "Container creation did not return one full ID") + identity = result.stdout.rstrip("\n") + self.identities[role] = identity + # Retain the create stdout independently before any later preparation + # operation can fail. No package code runs in this preparation phase. + if self.record_created is not None: + self.record_created(role, identity) + record = {"id": identity, "owner": self.owner, "role": role, "nonce": self.nonce} + raw = (json.dumps(record, sort_keys=True) + "\n").encode() + offset = 0 + while offset < len(raw): + written = os.write(descriptor, raw[offset:]) + require(written > 0, "The held fixture record could not be written") + offset += written + os.fsync(descriptor) + # Keep this descriptor held for the complete preparation phase. + require(result.returncode == 0, "Container creation failed after returning its identity") + self.owned(identity, role) + return identity + + def start(self, identity, role): + self.owned(identity, role) + require(self.command(["start", identity]).returncode == 0, "The captured fixture could not start") + + def remove(self, identity, role): + if self.owned(identity, role, allow_missing=True) is None: + return + require(self.command(["rm", "--force", "--volumes", identity]).returncode == 0, + "The captured fixture could not be removed") + require(self.owned(identity, role, allow_missing=True) is None, + "Exact fixture absence was not established") + + def remove_all(self, pairs): + failed = False + for role, identity in pairs: + try: + self.remove(identity, role) + except Exception: + failed = True + require(not failed, "One or more captured fixtures could not be removed") diff --git a/tests/smoke/command_key_include_closure.py b/tests/smoke/command_key_include_closure.py new file mode 100644 index 00000000..0acffe95 --- /dev/null +++ b/tests/smoke/command_key_include_closure.py @@ -0,0 +1,169 @@ +"""Capture and inventory the complete PHP include tree without loading PHP.""" + +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import stat + + +MAX_FILES = 10000 +MAX_BYTES = 128 * 1024 * 1024 +SDK_PREFIX = "consumer/vendor/durable-workflow/sdk/" + + +def members_digest(members): + return hashlib.sha256(json.dumps(members, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def approved_directories(prefix, approved): + directories = {prefix.rstrip("/")} + for name, expected in approved.items(): + if expected["mode"] not in (0o644, 0o755): + raise ValueError("The approved member mode is unsupported") + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or path.as_posix() != name: + raise ValueError("The approved member path is not canonical") + directories.update(prefix + parent.as_posix() for parent in path.parents if parent.as_posix() != ".") + return directories + + +def bind_members(snapshot, prefix, approved, *, sealed): + files = {name.removeprefix(prefix): entry for name, entry in snapshot["entries"].items() + if name.startswith(prefix) and entry["kind"] == "file"} + if not approved or set(files) != set(approved): + raise ValueError("The staged member set differs from its approved authority") + expected_directories = approved_directories(prefix, approved) + for name, expected in approved.items(): + actual = files[name] + mode_matches = (actual["mode"] == (expected["mode"] & ~0o222) if sealed + else not (actual["mode"] & ~expected["mode"])) + if not mode_matches or actual["bytes"] != expected["bytes"] or actual["sha256"] != expected["sha256"]: + raise ValueError("The staged member differs from its approved authority") + actual_directories = {name for name, entry in snapshot["entries"].items() + if (name == prefix.rstrip("/") or name.startswith(prefix)) and entry["kind"] == "directory"} + if actual_directories != expected_directories: + raise ValueError("The staged directory set differs from its approved authority") + return {prefix + name: {"kind": "file", **member} for name, member in approved.items()} + + +def verify_sealed_sdk(snapshot, approved_digest): + approved = {} + for name, entry in snapshot["entries"].items(): + if name.startswith(SDK_PREFIX) and entry["kind"] == "file": + if entry["mode"] not in (0o444, 0o555): + raise ValueError("The sealed SDK member mode differs") + approved[name.removeprefix(SDK_PREFIX)] = { + "mode": 0o755 if entry["mode"] == 0o555 else 0o644, + "bytes": entry["bytes"], "sha256": entry["sha256"]} + if members_digest(approved) != approved_digest: + raise ValueError("The sealed SDK differs from the approved archive member authority") + bind_members(snapshot, SDK_PREFIX, approved, sealed=True) + + +def inventory(root, owner, *, sealed): + if root.resolve() != root: + raise ValueError("The include root must be canonical") + entries = {} + total = 0 + paths = [root] + for directory, folders, files in os.walk(root, followlinks=False): + paths.extend(Path(directory) / name for name in [*folders, *files]) + if len(paths) > MAX_FILES: + raise ValueError("The include tree exceeds its member bound") + for path in sorted(paths): + if any(part in {".git", ".hg", ".svn"} for part in path.relative_to(root).parts): + raise ValueError("Repository metadata is outside the installed include closure") + metadata = path.lstat() + mode = stat.S_IMODE(metadata.st_mode) + if metadata.st_uid != owner or mode & 0o022 or (sealed and mode & 0o222): + raise ValueError("The include tree ownership or write permissions differ") + if path.is_symlink() or path.resolve() != path: + raise ValueError("The include tree contains an alias") + name = path.relative_to(root).as_posix() + if stat.S_ISDIR(metadata.st_mode): + entries[name] = {"kind": "directory", "mode": mode} + elif stat.S_ISREG(metadata.st_mode) and metadata.st_nlink == 1: + total += metadata.st_size + if total > MAX_BYTES: + raise ValueError("The include tree exceeds its byte bound") + raw = path.read_bytes() + if len(raw) != metadata.st_size: + raise ValueError("The include file changed while capturing it") + entries[name] = {"kind": "file", "mode": mode, "bytes": len(raw), + "sha256": hashlib.sha256(raw).hexdigest()} + else: + raise ValueError("The include tree needs single-link regular leaves") + if len(entries) > MAX_FILES: + raise ValueError("The include tree exceeds its member bound") + digest = hashlib.sha256(json.dumps(entries, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return {"entries": entries, "sha256": digest, "bytes": total} + + +def copy_tree(source, destination, expected): + destination.mkdir(mode=0o700) + for name, member in expected["entries"].items(): + if name == ".": + continue + original, target = source / name, destination / name + if member["kind"] == "directory": + target.mkdir(mode=0o700) + continue + descriptor = os.open(original, os.O_RDONLY | os.O_NOFOLLOW) + try: + metadata = os.fstat(descriptor) + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 + or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) & ~member["mode"]): + raise ValueError("The include source type, owner or mode changed") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + raw = stream.read(MAX_BYTES + 1) + if len(raw) != member["bytes"] or hashlib.sha256(raw).hexdigest() != member["sha256"]: + raise ValueError("The include source changed before copying") + finally: + os.close(descriptor) + output = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(output, "wb") as stream: + stream.write(raw) + target.chmod(0o555 if member["mode"] & 0o111 else 0o444) + for path in sorted(destination.rglob("*"), reverse=True): + if path.is_dir(): + path.chmod(0o555) + destination.chmod(0o555) + + +def stage(consumer, fixture, destination, approved_sdk, approved_digest, approved_fixture): + if members_digest(approved_sdk) != approved_digest: + raise ValueError("The staging SDK authority differs from the approved archive") + if set(path.name for path in consumer.iterdir()) != {"composer.json", "composer.lock", "vendor"}: + raise ValueError("Unexpected consumer root members") + if set(path.name for path in fixture.iterdir()) != {"command-key-client.php", "command-key-supply.json"}: + raise ValueError("Unexpected captured fixture members") + owner = os.getuid() + before = inventory(consumer, owner, sealed=False) + fixture_before = inventory(fixture, owner, sealed=False) + # Expected SDK and fixture leaf bytes come from approved archive/Git data, + # never from a fresh read of the mutable install tree. + consumer_copy = {"entries": dict(before["entries"])} + consumer_copy["entries"].update(bind_members(before, "vendor/durable-workflow/sdk/", approved_sdk, sealed=False)) + fixture_snapshot = {"entries": {"fixture" if name == "." else "fixture/" + name: entry + for name, entry in fixture_before["entries"].items()}} + fixture_copy = {"entries": dict(fixture_before["entries"])} + fixture_copy["entries"].update({name.removeprefix("fixture/"): entry for name, entry in + bind_members(fixture_snapshot, "fixture/", approved_fixture, sealed=False).items()}) + for required in ("vendor/autoload.php", "vendor/composer/installed.json"): + if before["entries"].get(required, {}).get("kind") != "file": + raise ValueError("The complete installed autoloader metadata is required") + metadata = destination.lstat() + if (destination.resolve() != destination or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != owner or stat.S_IMODE(metadata.st_mode) != 0o700 + or list(destination.iterdir())): + raise ValueError("The include destination must be a fresh private directory") + copy_tree(consumer, destination / "consumer", consumer_copy) + copy_tree(fixture, destination / "fixture", fixture_copy) + if inventory(consumer, owner, sealed=False) != before or inventory(fixture, owner, sealed=False) != fixture_before: + raise ValueError("An include source changed during staging") + destination.chmod(0o555) + result = inventory(destination, owner, sealed=True) + verify_sealed_sdk(result, approved_digest) + bind_members(result, "fixture/", approved_fixture, sealed=True) + return result diff --git a/tests/smoke/verify_command_key_supply.py b/tests/smoke/verify_command_key_supply.py new file mode 100644 index 00000000..1014c82e --- /dev/null +++ b/tests/smoke/verify_command_key_supply.py @@ -0,0 +1,159 @@ +"""Verify the selected registry SDK bytes before PHP package loading.""" + +import hashlib +import json +from pathlib import Path, PurePosixPath +import stat +import sys +import zipfile + + +MAX_ARCHIVE_BYTES = 40 * 1024 * 1024 +MAX_PACKAGE_BYTES = 100 * 1024 * 1024 +MAX_FILES = 5000 +PACKAGE = "durable-workflow/sdk" + + +def regular(path): + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ValueError("SDK evidence must be single-link regular files") + return metadata + + +def package_metadata(consumer, supply): + for path in (consumer / "composer.json", consumer / "composer.lock", consumer / "vendor/composer/installed.json"): + regular(path) + if path.resolve() != path: + raise ValueError("Composer metadata must not be an alias") + manifest = json.loads((consumer / "composer.json").read_text()) + if (manifest.get("repositories") or manifest.get("require-dev") + or manifest.get("require") != {PACKAGE: supply["sdk_version"]}): + raise ValueError("The consumer must select only the exact registry SDK") + lock = json.loads((consumer / "composer.lock").read_text()) + installed = json.loads((consumer / "vendor/composer/installed.json").read_text()) + installed = installed["packages"] if isinstance(installed, dict) else installed + selected = [] + for packages in (lock["packages"], installed): + matches = [entry for entry in packages if entry["name"] == PACKAGE] + if len(matches) != 1: + raise ValueError("The selected SDK package must occur exactly once") + entry = matches[0] + if (entry["version"].removeprefix("v") != supply["sdk_version"] + or entry.get("source", {}).get("url") != supply["sdk_source_url"] + or entry.get("source", {}).get("reference") != supply["sdk_reference"] + or entry.get("dist", {}).get("url") != supply["sdk_dist_url"] + or entry.get("dist", {}).get("reference") != supply["sdk_reference"]): + raise ValueError("The SDK lock/source/dist identities differ") + selected.append(entry) + if selected[1].get("installation-source") != "dist": + raise ValueError("The SDK must be a registry dist installation") + + package = consumer / "vendor/durable-workflow/sdk" + if package.resolve() != package or not package.is_dir(): + raise ValueError("The installed SDK root must not be an alias") + if (consumer / "vendor/composer" / selected[1]["install-path"]).resolve() != package: + raise ValueError("Composer's installed SDK path differs") + return package + + +def archive_member(entry): + path = PurePosixPath(entry.filename) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError("The package member is outside its root") + if entry.is_dir(): + return None + name = PurePosixPath(*path.parts[1:]).as_posix() + mode = entry.external_attr >> 16 + # GitHub uses DOS entries without Unix modes for ordinary files, and Unix + # entries for executables. Composer extracts the former as nonexecutables. + if entry.create_system == 0 and mode == 0: + mode = stat.S_IFREG | 0o644 + if not name or name == "." or stat.S_IFMT(mode) not in (0, stat.S_IFREG): + raise ValueError("The package inventory contains an unsupported member") + return name, mode & 0o777 + + +def archive_files(archive, supply): + size = regular(archive).st_size + if not 0 < size <= MAX_ARCHIVE_BYTES: + raise ValueError("The registry archive exceeds its byte bound") + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + + expected = {} + total = 0 + with zipfile.ZipFile(archive) as source: + if len(source.infolist()) > MAX_FILES: + raise ValueError("The package inventory exceeds its bound") + prefixes = {PurePosixPath(entry.filename).parts[0] for entry in source.infolist()} + if len(prefixes) != 1: + raise ValueError("The registry dist must have one package root") + for entry in source.infolist(): + member = archive_member(entry) + if member is None: + continue + name, mode = member + if name in expected: + raise ValueError("The package inventory contains a duplicate member") + total += entry.file_size + if total > MAX_PACKAGE_BYTES: + raise ValueError("The package expands beyond its byte bound") + expected[name] = (source.read(entry), mode) + if member_digest(expected) != supply["sdk_archive_members_sha256"]: + raise ValueError("The registry archive members differ from the approved SDK supply") + return expected, digest, total + + +def member_digest(files): + members = member_inventory(files) + return hashlib.sha256(json.dumps(members, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def member_inventory(files): + return {name: {"mode": mode, "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()} + for name, (raw, mode) in files.items()} + + +def installed_files(package, expected): + actual = set() + for path in package.rglob("*"): + if path.is_symlink(): + raise ValueError("The installed SDK contains an alias") + if path.is_dir(): + continue + name = path.relative_to(package).as_posix() + if name not in expected: + raise ValueError("The installed SDK contains an unapproved file") + metadata = regular(path) + data, mode = expected[name] + # Private umasks may remove archive permissions, but never add them. + if metadata.st_mode & 0o777 & ~mode or metadata.st_mode & 0o022: + raise ValueError("The installed SDK file mode exceeds its archive mode") + if metadata.st_size != len(data) or path.read_bytes() != data: + raise ValueError("The installed SDK bytes differ from the registry archive") + actual.add(name) + if actual != set(expected) or not any(name.startswith("src/") for name in actual): + raise ValueError("The installed SDK inventory is incomplete") + return len(actual) + + +def verify_with_members(consumer, archive, supply): + consumer = Path(consumer) + if consumer.resolve() != consumer or not consumer.is_dir(): + raise ValueError("The consumer must be a canonical directory") + package = package_metadata(consumer, supply) + expected, digest, total = archive_files(Path(archive), supply) + count = installed_files(package, expected) + evidence = {"sdk_version": supply["sdk_version"], "sdk_reference": supply["sdk_reference"], + "archive_sha256": digest, "archive_members_sha256": member_digest(expected), "installed_files": count, + "installed_bytes": total, "source_fallback": False, "inspection_only": True} + return evidence, member_inventory(expected) + + +def verify(consumer, archive, supply): + return verify_with_members(consumer, archive, supply)[0] + + +if __name__ == "__main__": + supply = json.loads(Path(__file__).with_name("command-key-supply.json").read_text()) + print(json.dumps(verify(Path(sys.argv[1]), Path(sys.argv[2]), supply)))