diff --git a/decnet/canary/planter.py b/decnet/canary/planter.py index 47b63f51..6beae78b 100644 --- a/decnet/canary/planter.py +++ b/decnet/canary/planter.py @@ -59,17 +59,22 @@ def _dirname(path: str) -> str: return path[:idx] -async def _run(argv: list[str]) -> tuple[int, str, str]: +async def _run( + argv: list[str], *, stdin_bytes: Optional[bytes] = None, +) -> tuple[int, str, str]: try: proc = await asyncio.create_subprocess_exec( *argv, + stdin=asyncio.subprocess.PIPE if stdin_bytes is not None else None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) except FileNotFoundError as exc: return 127, "", f"argv[0] not found: {exc}" try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT) + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=stdin_bytes), timeout=_TIMEOUT, + ) except asyncio.TimeoutError: try: proc.kill() @@ -83,25 +88,26 @@ async def _run(argv: list[str]) -> tuple[int, str, str]: ) -def _build_plant_command(artifact: CanaryArtifact) -> str: - """Compose the ``sh -c`` script that writes one artifact. +def _build_plant_command(artifact: CanaryArtifact) -> tuple[str, bytes]: + """Compose the ``sh -c`` script + stdin payload for one artifact. - Binary safety: we base64-encode on the host side and ``base64 -d`` - inside the container, so the bytes never touch a shell argv - interpolation point. Both ``base64`` (coreutils) and ``touch -d - @`` are present on every Linux base image we ship, so - there's no per-distro branching. + Binary safety: we base64-encode on the host and stream the result + over stdin to ``base64 -d`` inside the container, so the bytes + never touch the argv (kernel ARG_MAX would reject anything larger + than ~128KB-2MB depending on the host). Both ``base64`` (coreutils) + and ``touch -d @`` are present on every Linux base image + we ship, so there's no per-distro branching. """ - encoded = base64.b64encode(artifact.content).decode("ascii") + encoded = base64.b64encode(artifact.content) mtime = int(time.time() + artifact.mtime_offset) mode_str = oct(artifact.mode)[2:] parts = [ f"mkdir -p {shlex.quote(_dirname(artifact.path))}", - f"printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(artifact.path)}", + f"base64 -d > {shlex.quote(artifact.path)}", f"chmod {mode_str} {shlex.quote(artifact.path)}", f"touch -d @{mtime} {shlex.quote(artifact.path)}", ] - return " && ".join(parts) + return " && ".join(parts), encoded async def _publish( @@ -151,9 +157,11 @@ async def plant( await repo.update_canary_token_state(token_uuid, "failed", err) return False, err - sh_cmd = _build_plant_command(artifact) - argv = [_DOCKER, "exec", _container_for(decky_name), "sh", "-c", sh_cmd] - rc, _stdout, stderr = await _run(argv) + sh_cmd, stdin_payload = _build_plant_command(artifact) + # ``-i`` keeps stdin attached so base64 -d inside the container can + # consume the encoded payload streamed from the host. + argv = [_DOCKER, "exec", "-i", _container_for(decky_name), "sh", "-c", sh_cmd] + rc, _stdout, stderr = await _run(argv, stdin_bytes=stdin_payload) success = rc == 0 error = None if success else (stderr.strip()[:256] or f"rc={rc}") diff --git a/tests/api/canary/test_canary_tokens_api.py b/tests/api/canary/test_canary_tokens_api.py index 32b899a9..9729f128 100644 --- a/tests/api/canary/test_canary_tokens_api.py +++ b/tests/api/canary/test_canary_tokens_api.py @@ -21,7 +21,7 @@ class _FakeProc: self.returncode = rc self._stderr = stderr - async def communicate(self) -> tuple[bytes, bytes]: + async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]: return b"", self._stderr def kill(self) -> None: # pragma: no cover diff --git a/tests/canary/test_deploy_hook.py b/tests/canary/test_deploy_hook.py index 3b0c05d0..71244587 100644 --- a/tests/canary/test_deploy_hook.py +++ b/tests/canary/test_deploy_hook.py @@ -32,7 +32,7 @@ class _FakeProc: self.returncode = rc self._stderr = stderr - async def communicate(self) -> tuple[bytes, bytes]: + async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]: return b"", self._stderr diff --git a/tests/canary/test_planter.py b/tests/canary/test_planter.py index bc436b05..6ecff6a5 100644 --- a/tests/canary/test_planter.py +++ b/tests/canary/test_planter.py @@ -46,12 +46,23 @@ class _FakeProc: def _patch_subprocess(rc: int = 0, stderr: bytes = b""): captured: list[list[str]] = [] + stdin_seen: list[bytes | None] = [] async def _fake(*argv, **kw): captured.append(list(argv)) - return _FakeProc(rc, b"", stderr) + # Capture whatever bytes the planter would stream over stdin — + # the new contract pipes the base64 payload here instead of + # interpolating it into the sh script. + proc = _FakeProc(rc, b"", stderr) + orig = proc.communicate - return patch.object(asyncio, "create_subprocess_exec", _fake), captured + async def communicate(input=None): + stdin_seen.append(input) + return await orig() + proc.communicate = communicate # type: ignore[assignment] + return proc + + return patch.object(asyncio, "create_subprocess_exec", _fake), captured, stdin_seen @pytest_asyncio.fixture @@ -87,7 +98,7 @@ async def test_plant_argv_and_base64_round_trip(repo: SQLiteRepository, fake_bus "generator": "aws_creds", "placement_path": art.path, "callback_token": "slug", "secret_seed": "s", "created_by": "u1", }) - patcher, captured = _patch_subprocess(rc=0) + patcher, captured, stdin_seen = _patch_subprocess(rc=0) with patcher: ok, err = await planter.plant( "web1", art, token_uuid="tok-1", repo=repo, bus=fake_bus, @@ -95,12 +106,15 @@ async def test_plant_argv_and_base64_round_trip(repo: SQLiteRepository, fake_bus assert ok is True and err is None assert len(captured) == 1 argv = captured[0] - assert argv[:3] == ["docker", "exec", "web1-ssh"] - assert argv[3:5] == ["sh", "-c"] - script = argv[5] - # base64-decoded payload appears in the script verbatim. - encoded = base64.b64encode(art.content).decode() - assert encoded in script + # docker exec -i sh -c