From 93ddcefc714c84106a1af9c59dab8ef4ec962f0d Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Sat, 29 Aug 2026 21:16:48 -0700 Subject: [PATCH] fix(cloudflare-pages): stage Wrangler module output Use Wrangler's official outdir output for Pages Functions and enforce authenticated module provenance before staging an advanced-mode worker tree. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WhrjTokoEF6Zax8EASdGsv --- README.md | 2 +- docs/dagger-modules.md | 7 +- .../.dagger/src/cloudflare_pages/main.py | 146 +++++++++++- .../.dagger/tests/test_deploy_contract.py | 225 ++++++++++++++++-- 4 files changed, 346 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index a332ca7..dd0dbc6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The shared modules are: artifact envelopes, envelope verification, and exact-current-`main` GitHub evidence; - `cloudflare-pages`: fail-closed Pages preflight, one pinned Wrangler direct upload, deployment/live convergence bound to the created deployment ID, and an opt-in compiler for - authenticated Pages Functions sources. + authenticated Pages Functions sources that stage validated advanced-mode module trees. - `python-package`: frozen dependency audit, non-root pure-Python wheel and sdist build, metadata-derived tag verification, and a Foundation envelope for a separate source-free official PyPA publisher job. The module never publishes to a registry. diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index f7e2985..653d8bb 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -263,8 +263,11 @@ compiles with pinned Wrangler 4.103.0 from fixed `/project/functions`; and rejec build failures, or a pre-existing `dist/_worker.js` or `dist/_routes.json`. Wrangler emits esbuild metadata into private scratch space; the provider rejects any resolved input outside authenticated `dist` and `functions`, its private generated-route scratch directory, and the one fixed Wrangler -template plus its exact pinned router input. It stages only the derived `_worker.js` and -`_routes.json` into authenticated `dist`, then performs the same single direct upload and +template plus its exact pinned router input. Wrangler emits directory-mode module output; the +provider requires a bounded `_worker.js/index.js`, rejects multipart upload serialization and any +module path that escapes the generated tree, and requires every auxiliary module's content to match +an authenticated `dist` or `functions` input. It stages only that `_worker.js` module directory plus +`_routes.json` into authenticated `dist`. It then performs the same single direct upload and deployment-ID convergence used for static sites. Static mode retains its exact arguments and ordering. diff --git a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py index 304f962..3685b06 100644 --- a/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py +++ b/modules/cloudflare-pages/.dagger/src/cloudflare_pages/main.py @@ -5,6 +5,7 @@ import asyncio import json import posixpath +from collections.abc import Sequence from dataclasses import dataclass from pathlib import PurePosixPath from typing import Final, Literal @@ -69,16 +70,24 @@ "--skip-caching", ) WRANGLER_FUNCTIONS_REQUIRED_FLAGS: Final = ( - "--outfile", + "--outdir", "--output-routes-path", "--project-directory", "--build-output-directory", "--metafile", ) FUNCTIONS_METADATA_NAME: Final = "_build-metadata.json" -FUNCTIONS_STAGED_ENTRIES: Final = frozenset({"_routes.json", "_worker.js"}) -FUNCTIONS_DERIVED_ENTRIES: Final = FUNCTIONS_STAGED_ENTRIES | {FUNCTIONS_METADATA_NAME} +FUNCTIONS_WORKER_NAME: Final = "_worker.js" +FUNCTIONS_ENTRYPOINT_NAME: Final = "index.js" +FUNCTIONS_CONFLICT_ENTRIES: Final = frozenset( + {"_routes.json", "_routes.json/", "_worker.js", "_worker.js/"} +) +FUNCTIONS_DERIVED_ENTRIES: Final = frozenset( + {FUNCTIONS_METADATA_NAME, "_routes.json", "_worker.js/"} +) FUNCTIONS_METADATA_BYTES: Final = 1_048_576 +FUNCTIONS_ENTRYPOINT_BYTES: Final = 67_108_864 +FUNCTIONS_DIGEST_BATCH_SIZE: Final = 32 FUNCTIONS_AUTHENTICATED_ROOTS: Final = ( PurePosixPath("/project/dist"), PurePosixPath("/project/functions"), @@ -419,7 +428,7 @@ async def _require_pages_functions_source(verified: dagger.Directory, target: Pa functions = await verified.directory("functions").entries() except dagger.QueryError: raise CloudflarePolicyError("Pages Functions roots could not be read") from None - conflicts = FUNCTIONS_STAGED_ENTRIES.intersection(static) + conflicts = FUNCTIONS_CONFLICT_ENTRIES.intersection(static) if conflicts: name = sorted(conflicts)[0] raise CloudflarePolicyError(f"{name} conflicts with Pages functions delivery") @@ -446,8 +455,9 @@ async def _compiled_pages_artifact( raise CloudflarePolicyError("Pages Functions build failed") from None if frozenset(entries) != FUNCTIONS_DERIVED_ENTRIES: raise CloudflarePolicyError("Pages Functions derived outputs differ") - await _require_closed_functions_build(derived) - staged = source.directory(deploy_root).with_file("_worker.js", derived.file("_worker.js")) + await _require_closed_functions_build(source, derived) + worker = derived.directory(FUNCTIONS_WORKER_NAME) + staged = source.directory(deploy_root).with_directory(FUNCTIONS_WORKER_NAME, worker) staged = staged.with_file("_routes.json", derived.file("_routes.json")) await staged.digest() return staged @@ -475,10 +485,130 @@ def _empty_directory() -> dagger.Directory: return dag.directory() -async def _require_closed_functions_build(derived: dagger.Directory) -> None: +async def _require_closed_functions_build( + source: dagger.Directory, derived: dagger.Directory +) -> None: metadata = await _functions_build_metadata(derived) inputs = tuple(_resolved_functions_input(value) for value in metadata.inputs) _require_closed_functions_inputs(inputs) + await _require_worker_modules(source, derived) + + +async def _require_worker_modules(source: dagger.Directory, derived: dagger.Directory) -> None: + worker = derived.directory(FUNCTIONS_WORKER_NAME) + try: + entries = await asyncio.wait_for(worker.entries(), WRANGLER_FUNCTIONS_SECONDS) + paths = await asyncio.wait_for(worker.glob("**"), WRANGLER_FUNCTIONS_SECONDS) + except (TimeoutError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions module output differs") from None + _require_worker_inventory(tuple(entries), tuple(paths)) + await _require_worker_entrypoint(worker.file(FUNCTIONS_ENTRYPOINT_NAME)) + await _require_worker_provenance(source, worker, tuple(paths)) + + +async def _require_worker_provenance( + source: dagger.Directory, worker: dagger.Directory, paths: tuple[str, ...] +) -> None: + modules = _auxiliary_worker_paths(paths) + if not modules: + return + allowed = await _authenticated_source_digests(source) + emitted = await _file_digests(worker, modules) + if not set(emitted).issubset(allowed): + raise CloudflarePolicyError("Pages Functions module provenance differs") + + +def _auxiliary_worker_paths(paths: tuple[str, ...]) -> tuple[str, ...]: + return tuple( + path for path in paths if path != FUNCTIONS_ENTRYPOINT_NAME and not path.endswith("/") + ) + + +async def _authenticated_source_digests(source: dagger.Directory) -> frozenset[str]: + paths = await _authenticated_source_paths(source) + return frozenset(await _file_digests(source, paths)) + + +async def _authenticated_source_paths(source: dagger.Directory) -> tuple[str, ...]: + patterns = ("dist/**", "functions/**") + try: + groups = await asyncio.wait_for( + asyncio.gather(*(source.glob(pattern) for pattern in patterns)), + WRANGLER_FUNCTIONS_SECONDS, + ) + except (TimeoutError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions module provenance differs") from None + return _regular_source_paths(groups) + + +def _regular_source_paths(groups: Sequence[Sequence[str]]) -> tuple[str, ...]: + return tuple(path for group in groups for path in group if not path.endswith("/")) + + +async def _file_digests(directory: dagger.Directory, paths: tuple[str, ...]) -> tuple[str, ...]: + try: + values = await asyncio.wait_for( + _batched_file_digests(directory, paths), + WRANGLER_FUNCTIONS_SECONDS, + ) + except (TimeoutError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions module provenance differs") from None + return values + + +async def _batched_file_digests( + directory: dagger.Directory, paths: tuple[str, ...] +) -> tuple[str, ...]: + values: list[str] = [] + for start in range(0, len(paths), FUNCTIONS_DIGEST_BATCH_SIZE): + batch = paths[start : start + FUNCTIONS_DIGEST_BATCH_SIZE] + digests = await asyncio.gather( + *(directory.file(path).digest(exclude_metadata=True) for path in batch) + ) + values.extend(digests) + return tuple(values) + + +def _require_worker_inventory(entries: tuple[str, ...], paths: tuple[str, ...]) -> None: + _require_worker_entrypoint_path(entries, paths) + _require_closed_worker_paths(paths) + + +def _require_worker_entrypoint_path(entries: tuple[str, ...], paths: tuple[str, ...]) -> None: + if FUNCTIONS_ENTRYPOINT_NAME not in entries or FUNCTIONS_ENTRYPOINT_NAME not in paths: + raise CloudflarePolicyError("Pages Functions module output differs") + + +def _require_closed_worker_paths(paths: tuple[str, ...]) -> None: + if not paths or not all(_closed_worker_path(value) for value in paths): + raise CloudflarePolicyError("Pages Functions module output differs") + + +def _closed_worker_path(value: str) -> bool: + path = PurePosixPath(value) + normalized = path.as_posix() + return bool(value) and not path.is_absolute() and ".." not in path.parts and normalized == value + + +async def _require_worker_entrypoint(entrypoint: dagger.File) -> None: + try: + size = await asyncio.wait_for(entrypoint.size(), WRANGLER_FUNCTIONS_SECONDS) + if not 0 < size <= FUNCTIONS_ENTRYPOINT_BYTES: + raise CloudflarePolicyError("Pages Functions module output differs") + value = await asyncio.wait_for(entrypoint.contents(), WRANGLER_FUNCTIONS_SECONDS) + except (TimeoutError, dagger.QueryError): + raise CloudflarePolicyError("Pages Functions module output differs") from None + if _serialized_multipart(value): + raise CloudflarePolicyError("Pages Functions module output differs") + + +def _serialized_multipart(value: str) -> bool: + lines = value.splitlines() + return ( + len(lines) > 1 + and lines[0].startswith("--") + and lines[1].casefold().startswith("content-disposition: form-data") + ) def _require_closed_functions_inputs(inputs: tuple[PurePosixPath, ...]) -> None: @@ -563,7 +693,7 @@ def functions_build_args() -> list[str]: "functions", "build", "functions", - "--outfile=/derived/_worker.js", + "--outdir=/derived/_worker.js", "--output-routes-path=/derived/_routes.json", "--project-directory=/project", "--build-output-directory=/project/dist", diff --git a/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py b/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py index 13c0769..69db062 100644 --- a/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py +++ b/modules/cloudflare-pages/.dagger/tests/test_deploy_contract.py @@ -4,6 +4,7 @@ import ast import asyncio +import hashlib import json import shutil import subprocess @@ -121,7 +122,7 @@ def do_PATCH(self) -> None: import dagger from dagger import dag, function, object_type from cloudflare_pages.api import CloudflarePolicyError, deploy_verified_artifact -from cloudflare_pages.main import (CurlPagesOperations, WRANGLER_OUTPUT_PATH, _jq_binary, +from cloudflare_pages.main import (CurlPagesOperations, NODE_IMAGE, WRANGLER_OUTPUT_PATH, _jq_binary, _prepare_deploy_artifact, _uncached, _verify_envelope, _wrangler_script, wrangler_deploy_args) from cloudflare_pages.models import AttemptIdentity, CreatedDeployment, GitHubEvidence, PagesTarget @@ -135,11 +136,24 @@ def do_PATCH(self) -> None: set -eu [ "$1 $2 $3" = "pages deploy /artifact" ] entries=$(find /artifact -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) -if [ -f /artifact/_worker.js ]; then +if [ -e /artifact/_worker.js ]; then + [ -d /artifact/_worker.js ] [ "$entries" = "_routes.json _worker.js index.html" ] - grep -q hello-from-functions /artifact/_worker.js + [ "$(find /artifact/_worker.js -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)" = "index.js" ] + ! grep -q '^------formdata-' /artifact/_worker.js/index.js + ! grep -q 'Content-Disposition: form-data' /artifact/_worker.js/index.js + grep -q hello-from-functions /artifact/_worker.js/index.js + node --input-type=module --check < /artifact/_worker.js/index.js + node --input-type=module -e ' + const fs = await import("node:fs"); + const source = fs.readFileSync("/artifact/_worker.js/index.js").toString("base64"); + const worker = (await import(`data:text/javascript;base64,${source}`)).default; + const response = await worker.fetch(new Request("https://example.com/api/hello"), + {ASSETS: {fetch: () => new Response("asset")}}, {waitUntil: () => undefined}); + if (await response.text() !== "hello-from-functions") process.exit(1); + ' else [ "$entries" = "index.html" ] [ "$(cat /artifact/index.html)" = "verified artifact" ] @@ -158,7 +172,7 @@ async def upload(self, artifact: dagger.Directory, source_sha: str) -> CreatedDe await self._request("GET", "/__mock/upload") return created def _upload_container(self, artifact: dagger.Directory, source_sha: str) -> dagger.Container: - base = dag.container(platform=dagger.Platform("linux/amd64")).from_(PYTHON_IMAGE) + base = dag.container(platform=dagger.Platform("linux/amd64")).from_(NODE_IMAGE) base = base.with_new_file("/usr/local/bin/wrangler", FAKE_WRANGLER, permissions=0o755) base = base.with_mounted_directory("/artifact", artifact, read_only=True) base = base.with_mounted_temp("/run/provider-output") @@ -202,10 +216,26 @@ async def reject_functions_escape(source: dagger.Directory, target: PagesTarget, code = f'import pkg from "{specifier}"; export const onRequest=()=>new Response(pkg.name)' escaped = source.with_new_file("functions/api/hello.js", code) try: await _prepare_deploy_artifact(escaped, target) - except CloudflarePolicyError as error: - assert "escaped authenticated roots" in str(error); return + except CloudflarePolicyError: return raise ValueError("outside import reached provider transport") +async def reject_external_wasm(source: dagger.Directory, target: PagesTarget) -> None: + specifier = "../../../usr/local/lib/node_modules/wrangler/node_modules/blake3-wasm/dist/wasm/nodejs/blake3_js_bg.wasm" + await reject_functions_escape(source, target, specifier) + +async def accept_executable_auxiliary(source: dagger.Directory, target: PagesTarget) -> None: + code = 'import value from "../data.txt"; export const onRequest=()=>new Response(value)' + value = source.with_new_file("functions/data.txt", "authenticated auxiliary", permissions=0o755) + value = value.with_new_file("functions/api/hello.js", code) + prepared = await _prepare_deploy_artifact(value, target) + worker = prepared.directory("_worker.js") + paths = await worker.glob("*.txt") + if len(paths) != 1: raise ValueError("authenticated auxiliary module was not staged") + emitted = worker.file(paths[0]); original = value.file("functions/data.txt") + if await emitted.digest() == await original.digest(): raise ValueError("mode fixture did not differ") + if await emitted.digest(exclude_metadata=True) != await original.digest(exclude_metadata=True): + raise ValueError("authenticated auxiliary content differed") + async def functions_contract(token: dagger.Secret, account: dagger.Secret, mock: dagger.Service, cert: dagger.File) -> None: target = PagesTarget("hseshadr/edge-reco", "edge-reco", "main", "edge-reco.com", "dist", pages_functions=True) @@ -217,6 +247,8 @@ async def functions_contract(token: dagger.Secret, account: dagger.Secret, else: raise ValueError("missing bare import reached provider transport") await reject_functions_escape(source, target, "/usr/local/lib/node_modules/wrangler/package.json") await reject_functions_escape(source, target, "../../../usr/local/lib/node_modules/wrangler/package.json") + await accept_executable_auxiliary(source, target) + await reject_external_wasm(source, target) operations = MockOperations(token, account, target, mock, cert) envelope = dag.foundation().envelope(source, "hseshadr/edge-reco@" + SHA, "b" * 40 + ":44", ["dist", "functions"]) @@ -247,7 +279,7 @@ async def contract(self) -> str: await functions_contract(token, account, mock, fixture_files().file("ca.pem")) tampered = envelope.with_new_file("artifact/dist/index.html", "tampered") try: await _verify_envelope(tampered, "hseshadr/edge-reco@" + SHA, "b" * 40 + ":44", ["dist"]) - except dagger.QueryError: return "provider order, functions route, missing import, and tamper rejection passed" + except dagger.QueryError: return "provider order, runnable module tree, multipart, escape, conflict, and tamper rejection passed" raise ValueError("tampered envelope was accepted") """ @@ -493,6 +525,39 @@ async def contents(self) -> str: async def size(self) -> int: return len(self.contents_value.encode()) + async def digest(self, *, exclude_metadata: bool | None = False) -> str: + assert exclude_metadata is True + value = hashlib.sha256(self.contents_value.encode()).hexdigest() + return f"sha256:{value}" + + +@dataclass +class DigestConcurrency: + active: int = 0 + maximum: int = 0 + + +@dataclass(frozen=True) +class ConcurrentDigestFile: + state: DigestConcurrency + + async def digest(self, *, exclude_metadata: bool | None = False) -> str: + assert exclude_metadata is True + self.state.active += 1 + self.state.maximum = max(self.state.maximum, self.state.active) + await asyncio.sleep(0) + self.state.active -= 1 + return "sha256:fixture" + + +@dataclass(frozen=True) +class ConcurrentDigestDirectory: + state: DigestConcurrency + + def file(self, path: str) -> ConcurrentDigestFile: + assert path + return ConcurrentDigestFile(self.state) + @dataclass class OversizedMetadataFile: @@ -522,16 +587,20 @@ class FakeDirectory: contents_by_path: dict[str, str] = field(default_factory=dict) digested: bool = False added_files: list[tuple[str, object]] = field(default_factory=list) + added_directory_values: list[tuple[str, object]] = field(default_factory=list) filters: list[tuple[str, ...]] = field(default_factory=list) added_directories: list[str] = field(default_factory=list) + glob_by_path: dict[str, tuple[str, ...]] = field(default_factory=dict) def directory(self, path: str) -> FakeDirectory: selected = f"{self.selected}/{path}".strip("/") return FakeDirectory( - selected, - self.entries_by_path, - self.contents_by_path, + selected=selected, + entries_by_path=self.entries_by_path, + contents_by_path=self.contents_by_path, added_files=self.added_files, + added_directory_values=self.added_directory_values, + glob_by_path=self.glob_by_path, ) def file(self, path: str) -> object: @@ -544,6 +613,10 @@ def with_file(self, path: str, value: object) -> FakeDirectory: self.added_files.append((path, value)) return self + def with_directory(self, path: str, value: object) -> FakeDirectory: + self.added_directory_values.append((path, value)) + return self + def filter(self, *, exclude: list[str]) -> FakeDirectory: self.filters.append(tuple(exclude)) return self @@ -555,6 +628,10 @@ def with_new_directory(self, path: str) -> FakeDirectory: async def entries(self) -> list[str]: return list(self.entries_by_path.get(self.selected, ())) + async def glob(self, pattern: str) -> list[str]: + key = self.selected if self.selected else pattern + return list(self.glob_by_path.get(key, ())) + async def digest(self) -> str: self.digested = True return "sha256:fixture" @@ -566,7 +643,7 @@ class FakeFunctionsContainer: events: list[tuple[str, object]] = field(default_factory=list) derived: FakeDirectory = field( - default_factory=lambda: FakeDirectory(entries_by_path={"": ("_routes.json", "_worker.js")}) + default_factory=lambda: FakeDirectory(entries_by_path={"": ("_routes.json", "_worker.js/")}) ) def with_mounted_directory(self, path: str, value: object, *, read_only: bool = False) -> Self: @@ -606,11 +683,33 @@ async def entries(self) -> list[str]: def _derived_with_metadata(*inputs: str) -> FakeDirectory: metadata = json.dumps({"inputs": {path: {"bytes": 1} for path in inputs}, "outputs": {}}) return FakeDirectory( - entries_by_path={"": ("_build-metadata.json", "_routes.json", "_worker.js")}, - contents_by_path={"_build-metadata.json": metadata}, + entries_by_path={ + "": ("_build-metadata.json", "_routes.json", "_worker.js/"), + "_worker.js": ("index.js",), + }, + contents_by_path={ + "_build-metadata.json": metadata, + "_worker.js/index.js": "export default {fetch() { return new Response('ok') }};", + }, + glob_by_path={"_worker.js": ("index.js",)}, + ) + + +def _source_with_auxiliary(value: str) -> FakeDirectory: + return FakeDirectory( + contents_by_path={"functions/api/module.wasm": value}, + glob_by_path={"functions/**": ("functions/api/module.wasm",)}, ) +def _derived_with_auxiliary(value: str) -> FakeDirectory: + derived = _derived_with_metadata("api/hello.js") + derived.entries_by_path["_worker.js"] = ("index.js", "module.wasm") + derived.glob_by_path["_worker.js"] = ("index.js", "module.wasm") + derived.contents_by_path["_worker.js/module.wasm"] = value + return derived + + @dataclass(frozen=True) class RecordingCurlOperations(CurlPagesOperations): """Curl adapter double that records the exact method, suffix, and body.""" @@ -1535,14 +1634,14 @@ def test_should_remove_consumer_packages_and_configs_from_compiler_input() -> No assert source.added_directories == [".wrangler/tmp"] -def test_should_use_fixed_pinned_functions_build_without_dependency_inputs() -> None: +def test_should_request_module_directory_output_from_pinned_functions_compiler() -> None: assert main_module.functions_build_args() == [ "wrangler", "pages", "functions", "build", "functions", - "--outfile=/derived/_worker.js", + "--outdir=/derived/_worker.js", "--output-routes-path=/derived/_routes.json", "--project-directory=/project", "--build-output-directory=/project/dist", @@ -1566,10 +1665,49 @@ async def test_should_stage_only_compiled_worker_and_routes() -> None: ) assert cast(FakeDirectory, staged).selected == "dist" - assert cast(FakeDirectory, staged).added_files == [ - ("_worker.js", ("", "_worker.js")), - ("_routes.json", ("", "_routes.json")), + assert cast(FakeDirectory, staged).added_directory_values == [ + ("_worker.js", derived.directory("_worker.js")) ] + assert cast(FakeDirectory, staged).added_files == [("_routes.json", ("", "_routes.json"))] + + +@pytest.mark.asyncio +async def test_should_reject_serialized_multipart_worker_before_staging() -> None: + derived = _derived_with_metadata("api/hello.js") + derived.contents_by_path["_worker.js/index.js"] = ( + "------formdata-undici-fixed\r\nContent-Disposition: form-data; name=metadata" + ) + container = FakeFunctionsContainer(derived=derived) + + with pytest.raises(CloudflarePolicyError, match="module output differs"): + await main_module._compiled_pages_artifact( + cast(dagger.Directory, FakeDirectory()), cast(dagger.Container, container), "dist" + ) + + +@pytest.mark.asyncio +async def test_should_reject_worker_module_path_escape_before_staging() -> None: + derived = _derived_with_metadata("api/hello.js") + derived.glob_by_path["_worker.js"] = ("index.js", "../outside.js") + container = FakeFunctionsContainer(derived=derived) + + with pytest.raises(CloudflarePolicyError, match="module output differs"): + await main_module._compiled_pages_artifact( + cast(dagger.Directory, FakeDirectory()), cast(dagger.Container, container), "dist" + ) + + +@pytest.mark.asyncio +async def test_should_reject_missing_worker_entrypoint_before_staging() -> None: + derived = _derived_with_metadata("api/hello.js") + derived.entries_by_path["_worker.js"] = ("foreign.js",) + derived.glob_by_path["_worker.js"] = ("foreign.js",) + container = FakeFunctionsContainer(derived=derived) + + with pytest.raises(CloudflarePolicyError, match="module output differs"): + await main_module._compiled_pages_artifact( + cast(dagger.Directory, FakeDirectory()), cast(dagger.Container, container), "dist" + ) @pytest.mark.asyncio @@ -1590,7 +1728,9 @@ async def test_should_reject_resolved_inputs_outside_authenticated_roots(outside derived = _derived_with_metadata("api/hello.js", outside) with pytest.raises(CloudflarePolicyError, match="escaped authenticated roots"): - await main_module._require_closed_functions_build(cast(dagger.Directory, derived)) + await main_module._require_closed_functions_build( + cast(dagger.Directory, FakeDirectory()), cast(dagger.Directory, derived) + ) @pytest.mark.asyncio @@ -1603,7 +1743,41 @@ async def test_should_accept_only_authenticated_and_fixed_compiler_inputs() -> N "../../usr/local/lib/node_modules/wrangler/templates/pages-template-worker.ts", ) - await main_module._require_closed_functions_build(cast(dagger.Directory, derived)) + await main_module._require_closed_functions_build( + cast(dagger.Directory, FakeDirectory()), cast(dagger.Directory, derived) + ) + + +@pytest.mark.asyncio +async def test_should_accept_auxiliary_worker_with_authenticated_content() -> None: + source = _source_with_auxiliary("authenticated wasm") + derived = _derived_with_auxiliary("authenticated wasm") + + await main_module._require_closed_functions_build( + cast(dagger.Directory, source), cast(dagger.Directory, derived) + ) + + +@pytest.mark.asyncio +async def test_should_reject_auxiliary_worker_without_authenticated_content() -> None: + source = _source_with_auxiliary("authenticated wasm") + derived = _derived_with_auxiliary("toolchain wasm") + + with pytest.raises(CloudflarePolicyError, match="module provenance differs"): + await main_module._require_closed_functions_build( + cast(dagger.Directory, source), cast(dagger.Directory, derived) + ) + + +@pytest.mark.asyncio +async def test_should_bound_terminal_content_digest_queries() -> None: + state = DigestConcurrency() + directory = ConcurrentDigestDirectory(state) + paths = tuple(f"asset-{index}.wasm" for index in range(65)) + + await main_module._file_digests(cast(dagger.Directory, directory), paths) + + assert 1 < state.maximum <= 32 @pytest.mark.asyncio @@ -1614,7 +1788,9 @@ async def test_should_reject_unlisted_pinned_image_input() -> None: ) with pytest.raises(CloudflarePolicyError, match="escaped authenticated roots"): - await main_module._require_closed_functions_build(cast(dagger.Directory, derived)) + await main_module._require_closed_functions_build( + cast(dagger.Directory, FakeDirectory()), cast(dagger.Directory, derived) + ) @pytest.mark.asyncio @@ -1746,7 +1922,7 @@ async def provider(*_: object) -> main_module.ProviderContext: @pytest.mark.asyncio -@pytest.mark.parametrize("conflict", ("_worker.js", "_routes.json")) +@pytest.mark.parametrize("conflict", ("_worker.js", "_worker.js/", "_routes.json", "_routes.json/")) async def test_should_reject_derived_conflict_before_green_main( monkeypatch: pytest.MonkeyPatch, conflict: str, @@ -1945,6 +2121,9 @@ def test_real_fixture_should_cover_closed_two_root_functions_transaction() -> No "artifact/dist/index.html", "artifact/functions/api/hello.js", "/usr/local/lib/node_modules/wrangler/package.json", + "[ -d /artifact/_worker.js ]", + "Content-Disposition: form-data", + "node --input-type=module --check", "deploy_verified_artifact(", 'events.count("upload") == 2', ) @@ -2041,6 +2220,6 @@ def test_should_run_real_dagger_mock_provider_contract(tmp_path: Path) -> None: # Then _require_success(result) assert ( - "provider order, functions route, missing import, and tamper rejection passed" + "provider order, runnable module tree, multipart, escape, conflict, and tamper rejection passed" in result.stdout )