diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 83d924292..97305f231 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -17,8 +17,8 @@ # explicit model update and countercontrol review before gaining coverage. QUALIFICATION_CLEANUP_SOURCES = { "scripts/ci/native_qualification.py": "65916e73b7f0c6431c612f411e27f9429a2c37dbab287d1f17b840dee0f8d185", - "scripts/ci/native_alias_evidence.py": "b39aa4c96b7b5642b4fbb800dad0d02f0a393f7638a95220dc0c439a939ad200", - "scripts/ci/native_alias_transport.py": "053af14689c7c69d6620860390801fc900a331126f32a4f68fac6eb0e6593fef", + "scripts/ci/native_alias_evidence.py": "f5af7af8fa8eeeda1b205dfd9316d6d44277f814e850bc8356d85f1cac1c43c3", + "scripts/ci/native_alias_transport.py": "16e495ed6874f7d62f29f0f4dc11aaf9e1715bd7046823f48d3443b3ed370c74", } diff --git a/scripts/ci/native-release-verify-publication.sh b/scripts/ci/native-release-verify-publication.sh index 53e9f8136..60e3c0e41 100644 --- a/scripts/ci/native-release-verify-publication.sh +++ b/scripts/ci/native-release-verify-publication.sh @@ -45,7 +45,7 @@ expected="$workflow workflow_dispatch completed success $tag $commit $run_attemp # Both exact tags name one index. digest() { local subject="$1" - "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$subject" || fail "cannot read $subject." + "$docker_bin" buildx imagetools inspect --format '{{printf "%s" .Manifest.Digest}}' "$subject" || fail "cannot read $subject." } index=$(digest "$GHCR_IMAGE:$version") [[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "exact version tag has no index digest." diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py index f3c8f73e7..8aac2c503 100644 --- a/scripts/ci/native_alias_evidence.py +++ b/scripts/ci/native_alias_evidence.py @@ -33,9 +33,12 @@ RUNNER_PATHS = { ".github/workflows/native-image-parity.yml", ".github/workflows/published-compose.yml", ".github/workflows/native-promotion-contracts.yml", - "scripts/ci/native_alias_evidence.py", "scripts/ci/native_qualification.py", SUBJECT_PATH, + "scripts/ci/native_alias_evidence.py", "scripts/ci/native_alias_transport.py", + "scripts/ci/native_qualification.py", SUBJECT_PATH, + "scripts/ci/native-release-verify-publication.sh", "scripts/ci/check-docker-workflow-isolation.py", "tests/Unit/Support/native_alias_evidence_test.py", "tests/Unit/Support/native_qualification_test.py", + "tests/Feature/TaskQueueAdmissionTest.php", "README.md", } MANIFEST_LIMIT = 8192 diff --git a/scripts/ci/native_alias_transport.py b/scripts/ci/native_alias_transport.py index 2519aaf90..adc8d1b00 100644 --- a/scripts/ci/native_alias_transport.py +++ b/scripts/ci/native_alias_transport.py @@ -114,9 +114,16 @@ def __init__(self, commands: Commands | None = None): self.commands = commands if commands is not None else Commands() def api(self, path: str) -> Any: + # GitHub comparisons use a three-dot separator, not a traversal + # segment. Only the immutable-source and protected-main reads used by + # the evidence predicates may contain it; every other path keeps the + # original dot/traversal refusal. + comparison = type(path) is str and re.fullmatch( + rf"repos/{REPOSITORY}/compare/[0-9a-f]{{40}}\.\.\.(?:" + r"[0-9a-f]{40}\?per_page=100|main(?:\?per_page=1)?)", path) is not None require(type(path) is str and path.startswith(f"repos/{REPOSITORY}/") and re.fullmatch(r"[A-Za-z0-9_./?=&-]+", path) is not None - and ".." not in path, "provider_api_path") + and (".." not in path or comparison), "provider_api_path") return strict_json(self.commands.run( ["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", path]), JSON_LIMIT) diff --git a/tests/Feature/TaskQueueAdmissionTest.php b/tests/Feature/TaskQueueAdmissionTest.php index 83dedcc44..d4c07740a 100644 --- a/tests/Feature/TaskQueueAdmissionTest.php +++ b/tests/Feature/TaskQueueAdmissionTest.php @@ -367,6 +367,7 @@ public function test_workflow_task_polls_respect_namespace_active_lease_caps_acr public function test_workflow_task_polls_respect_namespace_dispatch_rate_caps_across_queues(): void { + $this->freezeTime(); Queue::fake(); config([ @@ -427,6 +428,7 @@ public function test_workflow_task_polls_respect_namespace_dispatch_rate_caps_ac public function test_workflow_task_polls_respect_budget_group_dispatch_rate_caps_across_queues(): void { + $this->freezeTime(); Queue::fake(); config([ @@ -498,6 +500,26 @@ public function test_workflow_task_polls_respect_budget_group_dispatch_rate_caps ->assertJsonPath('admission.workflow_tasks.server_remaining_budget_group_dispatch_capacity', 0) ->assertJsonPath('admission.workflow_tasks.server_lock_required', true) ->assertJsonPath('admission.workflow_tasks.server_lock_supported', true); + + // The counter is keyed by minute. Keep the same-minute checks above + // fixed, then exercise the next bucket explicitly. + $this->travel(1)->minutes(); + + // The worker heartbeat has its own shorter freshness window. + $this->withHeaders($this->workerHeaders()) + ->postJson('/api/worker/heartbeat', [ + 'worker_id' => 'php-workflow-budget-group-b', + ]) + ->assertOk() + ->assertJsonPath('acknowledged', true); + + $this->withHeaders($this->apiHeaders()) + ->getJson('/api/task-queues/external-workflows-b') + ->assertOk() + ->assertJsonPath('admission.workflow_tasks.status', 'accepting') + ->assertJsonPath('admission.workflow_tasks.active_worker_count', 1) + ->assertJsonPath('admission.workflow_tasks.server_budget_group_dispatch_count_this_minute', 0) + ->assertJsonPath('admission.workflow_tasks.server_remaining_budget_group_dispatch_capacity', 1); } public function test_activity_task_polls_respect_queue_specific_active_lease_caps(): void @@ -584,6 +606,7 @@ public function test_activity_task_polls_respect_queue_specific_active_lease_cap public function test_activity_task_polls_respect_queue_specific_dispatch_rate_caps(): void { + $this->freezeTime(); Queue::fake(); config([ diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index d3428c9f6..c69da01dc 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -650,6 +650,110 @@ def run(self, argv, limit=E.JSON_LIMIT, env=None, accepted=(0,)): class TransportTests(unittest.TestCase): + def test_publication_verifier_uses_a_digest_template_buildx_actually_evaluates(self): + # Buildx 0.30 treats templates beginning with {{.Manifest as its + # human-readable manifest printer. Model that actual CLI behavior at + # the command boundary, without a registry, image or provider write. + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + gh = root / "gh" + gh.write_text(textwrap.dedent('''\ + #!/usr/bin/env python3 + import os,sys + args=sys.argv[1:] + if args[:2]==['attestation','verify']: + print('https://github.com/durable-workflow/server/actions/runs/41/attempts/1') + elif args[:1]==['api']: + path=args[1] + if '/git/ref/tags/' in path: print('commit '+os.environ['SOURCE']) + elif '/compare/' in path: print('ahead') + elif '/actions/runs/41' in path: + print('.github/workflows/native-release.yml workflow_dispatch completed success 3.2.0 '+os.environ['SOURCE']+' 1') + else: raise SystemExit(74) + else: raise SystemExit(74) + ''')) + docker = root / "docker" + docker.write_text(textwrap.dedent('''\ + #!/usr/bin/env python3 + import os,sys + args=sys.argv[1:] + if len(args)!=6 or args[:4]!=['buildx','imagetools','inspect','--format']: + raise SystemExit(74) + if args[5] not in ['ghcr.io/durable-workflow/server:3.2.0','durableworkflow/server:3.2.0']: + raise SystemExit(74) + if args[4].startswith('{{.Manifest'): + print('Name: fixture') + print('Digest: '+os.environ['INDEX']) + elif args[4]=='{{printf "%s" .Manifest.Digest}}': print(os.environ['INDEX']) + else: raise SystemExit(74) + ''')) + gh.chmod(0o700) + docker.chmod(0o700) + environment = {'PATH':os.environ['PATH'], 'SOURCE':SOURCE, 'INDEX':INDEX, + 'GH':str(gh), 'DOCKER':str(docker), 'GITHUB_REPOSITORY':E.REPOSITORY, + 'GHCR_IMAGE':E.GHCR_IMAGE, 'DOCKERHUB_IMAGE':E.DOCKERHUB_IMAGE, + 'RELEASE_TAG':'3.2.0', 'RELEASE_RUN_ID':'41', 'RELEASE_RUN_ATTEMPT':'1'} + result = subprocess.run(['bash',str(ROOT / 'scripts/ci/native-release-verify-publication.sh')], + env=environment, capture_output=True, text=True, timeout=5, check=False) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(json.loads(result.stdout), {'tag':'3.2.0','source_commit':SOURCE, + 'index_digest':INDEX,'run_id':41,'run_attempt':1}) + + def test_git_comparisons_only_allow_the_exact_source_and_main_read_shapes(self): + commands = ReadCommands(b"{}") + provider = T.GitHubRegistryProvider(commands) + prefix = "repos/" + E.REPOSITORY + "/compare/" + allowed = [prefix + SOURCE + "..." + RUNNER + "?per_page=100", + prefix + RUNNER + "...main?per_page=1", prefix + SOURCE + "...main"] + for path in allowed: + self.assertEqual(provider.api(path), {}) + self.assertEqual([call[0] for call in commands.calls], + [["gh", "api", "--hostname", "github.com", "--method", "GET", path] + for path in allowed]) + refused = [prefix + SOURCE + suffix for suffix in ( + ".." + RUNNER, "...." + RUNNER, "...main/../secrets", "...main?per_page=2", + "...main?per_page=100", "...main&per_page=1", "...main?per_page=1&page=2", + "..." + RUNNER, "..." + RUNNER + "?per_page=1", "...foreign?per_page=1")] + refused += [prefix + "main..." + RUNNER + "?per_page=100", + prefix + "A" * 40 + "...main", prefix + SOURCE + "...%2e%2e", + "repos/foreign/server/compare/" + SOURCE + "...main", + "repos/" + E.REPOSITORY + "/contents/../secrets"] + for path in refused: + with self.subTest(path=path), self.assertRaisesRegex(E.Refusal, "provider_api_path"): + provider.api(path) + self.assertEqual(len(commands.calls), len(allowed), "refused paths never reach the command transport") + + def test_runner_ancestry_uses_the_actual_provider_path_guard(self): + fixture = RunnerProvider() + + class ApiCommands: + def __init__(self): + self.paths = [] + + def run(self, argv, limit=E.JSON_LIMIT, env=None, accepted=(0,)): + self_test.assertEqual(argv[:6], ["gh", "api", "--hostname", "github.com", "--method", "GET"]) + self_test.assertEqual(len(argv), 7) + self.paths.append(argv[6]) + return E.canonical(fixture.api(argv[6])) + + self_test = self + commands = ApiCommands() + provider = T.GitHubRegistryProvider(commands) + selected = fixture.selected + self.assertEqual(E.runner_subject(provider, selected["runner"], selected["release"]), fixture.binding) + self.assertEqual(commands.paths, [ + f"repos/{E.REPOSITORY}/contents/{E.SUBJECT_PATH}?ref={RUNNER}", + f"repos/{E.REPOSITORY}/compare/{SOURCE}...{RUNNER}?per_page=100", + f"repos/{E.REPOSITORY}/compare/{RUNNER}...main?per_page=1", + ]) + fixture.on_main = False + with self.assertRaisesRegex(E.Refusal, "qualification_runner_not_on_main"): + E.runner_subject(provider, selected["runner"], selected["release"]) + fixture.on_main = True + fixture.diff_files.append({"filename": "rust/src/runtime/store.rs", "status": "modified"}) + with self.assertRaisesRegex(E.Refusal, "qualification_runner_frozen_source"): + E.runner_subject(provider, selected["runner"], selected["release"]) + def test_unprivileged_plan_requires_exact_tag_and_accepted_main_source(self): value = {"release": {"tag": "3.2.0", "source_commit": SOURCE}} context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"}