Skip to content

Commit a812cf6

Browse files
authored
refactor(logs,landscape): unified log targets, injection-safe profile writes (#56)
Implements plans/refactor/05-logs-landscape-hardening.md: - One stream/collect implementation instead of six: frozen target dataclasses (StageTarget, ServerTarget, ReplicaTarget) select the StreamOperation; WorkspaceLogManager.open/stream/collect take a LogTarget. The six legacy methods (stream_server, stream_replica, collect_server, collect_replica, open_*_stream) and the positional stream(stage, step) form remain as deprecated shims that warn and delegate (tested via pytest.warns) - save_profile no longer builds a shell heredoc from user content: YAML is transported base64-encoded (printf '%s' '<b64>' | base64 -d), so content containing PROFILE_EOF, , backticks, or quotes is inert; get/delete_profile filenames are single-quoted. Adversarial round-trip tests cover terminator/substitution/quote/CRLF/unicode payloads - LogStream takes the APIHttpClient and uses its new public stream() passthrough and stream_timeout property (configured connect timeout, unbounded read) instead of reaching into _get_client() with a hardcoded Timeout(5.0); SSE terminator event names are a module constant - LogEntry.kind is a LogKind enum (I/E) with str fallback for unknown values (left_to_right union) No breaking changes: all legacy method names keep working with a DeprecationWarning.
1 parent f401ab3 commit a812cf6

7 files changed

Lines changed: 446 additions & 129 deletions

File tree

src/codesphere/http_client.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
2+
from contextlib import AbstractAsyncContextManager
23
from types import TracebackType
3-
from typing import Any
4+
from typing import Any, cast
45

56
import httpx
67
from pydantic import SecretStr
@@ -38,6 +39,24 @@ def _get_client(self) -> httpx.AsyncClient:
3839
)
3940
return self._client
4041

42+
@property
43+
def timeout(self) -> httpx.Timeout:
44+
"""The configured request timeouts."""
45+
return self._timeout_config
46+
47+
@property
48+
def stream_timeout(self) -> httpx.Timeout:
49+
"""Timeouts for long-lived streams: configured connect, unbounded read."""
50+
# httpx.Timeout attributes are untyped upstream; connect is float | None
51+
connect = cast("float | None", self._timeout_config.connect)
52+
return httpx.Timeout(connect, read=None)
53+
54+
def stream(
55+
self, method: str, endpoint: str, **kwargs: Any
56+
) -> AbstractAsyncContextManager[httpx.Response]:
57+
"""Open a streaming request (e.g. SSE) on the underlying client."""
58+
return self._get_client().stream(method, endpoint, **kwargs)
59+
4160
async def open(self) -> None:
4261
if not self._client:
4362
self._client = httpx.AsyncClient(**self._client_config)

src/codesphere/resources/workspace/landscape/resources.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import base64
45
import logging
56
import re
67
from typing import TYPE_CHECKING
@@ -75,16 +76,18 @@ async def save_profile(self, name: str, config: ProfileConfig | str) -> None:
7576
yaml_content = config.to_yaml() if isinstance(config, ProfileConfig) else config
7677

7778
body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n"
78-
await self._run_command(
79-
f"cat > {filename} << 'PROFILE_EOF'\n{body}PROFILE_EOF\n"
80-
)
79+
# Transport the content base64-encoded: the base64 alphabet is inert
80+
# inside single quotes, so arbitrary YAML cannot break out of the
81+
# shell command (the filename is regex-validated above).
82+
encoded = base64.b64encode(body.encode("utf-8")).decode("ascii")
83+
await self._run_command(f"printf '%s' '{encoded}' | base64 -d > '{filename}'")
8184

8285
async def get_profile(self, name: str) -> str:
83-
result = await self._run_command(f"cat {_profile_filename(name)}")
86+
result = await self._run_command(f"cat '{_profile_filename(name)}'")
8487
return result.output
8588

8689
async def delete_profile(self, name: str) -> None:
87-
await self._run_command(f"rm -f {_profile_filename(name)}")
90+
await self._run_command(f"rm -f '{_profile_filename(name)}'")
8891

8992
async def deploy(self, profile: str | None = None) -> None:
9093
if profile is not None:
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1-
from .resources import LogStream, WorkspaceLogManager
2-
from .schemas import LogEntry, LogProblem, LogStage
1+
from .resources import (
2+
LogStream,
3+
LogTarget,
4+
ReplicaTarget,
5+
ServerTarget,
6+
StageTarget,
7+
WorkspaceLogManager,
8+
)
9+
from .schemas import LogEntry, LogKind, LogProblem, LogStage
310

411
__all__ = [
512
"LogEntry",
13+
"LogKind",
614
"LogProblem",
715
"LogStage",
816
"LogStream",
17+
"LogTarget",
18+
"ReplicaTarget",
19+
"ServerTarget",
20+
"StageTarget",
921
"WorkspaceLogManager",
1022
]

0 commit comments

Comments
 (0)