Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,11 @@ jobs:
# the runner's python3), security and load tests. Feature-gated, so the
# main test step above does not build it into bashkit.
- name: Run CPython tests
run: cargo test -p bashkit --features cpython --lib --test integration -- cpython
run: cargo test -p bashkit --features cpython,http_client --lib --test integration -- cpython

# Same suites on the opt-in native-code build (`cpython-native`).
- name: Run CPython tests (native code)
run: cargo test -p bashkit --features cpython-native --lib --test integration -- cpython
run: cargo test -p bashkit --features cpython-native,http_client --lib --test integration -- cpython

- name: Run realfs tests
run: cargo test --features realfs -p bashkit --test realfs_tests -p bashkit-cli
Expand Down Expand Up @@ -568,6 +568,7 @@ jobs:
run: |
cargo +nightly fuzz build
cargo +nightly fuzz build --features cpython cpython_fuzz
cargo +nightly fuzz build --features cpython cpython_http_fuzz

# Gate job for branch protection — name must stay "Check"
check:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [parser_fuzz, lexer_fuzz, analyze_fuzz, arithmetic_fuzz, glob_fuzz, cpython_fuzz]
target: [parser_fuzz, lexer_fuzz, analyze_fuzz, arithmetic_fuzz, glob_fuzz, cpython_fuzz, cpython_http_fuzz]

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -61,7 +61,7 @@ jobs:
exit 1
fi
features=()
if [ "${{ matrix.target }}" = cpython_fuzz ]; then features=(--features cpython); fi
case "${{ matrix.target }}" in cpython_*) features=(--features cpython) ;; esac
cargo +nightly fuzz run "${features[@]}" ${{ matrix.target }} -- \
-max_total_time="$DURATION_INPUT" \
-print_final_stats=1
Expand Down
4 changes: 2 additions & 2 deletions crates/bashkit-cpython-wasm/artifacts/MANIFEST
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ wasi_sdk=24
wasmtime_wizer=49.0.1
zlib=1.3.1
sqlite=sqlite-amalgamation-3500400
6f4c6d7b93992426af97884dfb603c2ef9ddda9ad20e51e2b79e84c18eb1e140 python.wasm.gz
672dd03d9e31bee0d92c14f10f772b97c5daedbc34b9f14d5ebc21b086b77daf python314.zip
8cbd501c5b6e0f73d986be9b5bf48f4f0f28d40ef83774e666ffbb854758b0a8 python.wasm.gz
abf5df19ec01bb180672e94b816711fc03dfd92e109b94fc10e5399523bb464c python314.zip
Binary file modified crates/bashkit-cpython-wasm/artifacts/python.wasm.gz
Binary file not shown.
Binary file modified crates/bashkit-cpython-wasm/artifacts/python314.zip
Binary file not shown.
215 changes: 215 additions & 0 deletions crates/bashkit-cpython-wasm/guest/_bashkit_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""HTTP for the bashkit CPython guest: stdlib adapters over `_bashkit.http`.

`_bashkit.http` hands one request to the host, which runs it through
bashkit's egress pipeline (allowlist, SSRF checks, credential injection,
signing, the embedder's transport, size caps). The guest has no sockets and
no `ssl`; TLS is the host's.

Decisions:
- http.client keeps its own request and response code; only the socket is
replaced. A connection gets a bridge socket that collects the bytes
http.client writes, sends the request through the host once it is
complete, and serves the reply as an HTTP/1.1 byte stream that the real
`HTTPResponse` parses. Status line, headers, chunked uploads, keep-alive
and HEAD behave as in CPython, and host errors surface from `request()`
so urllib wraps them in `URLError` like a failed connect.
- `HTTPSConnection` is defined although `ssl` is missing (context arguments
are accepted and ignored), so urllib.request registers its https handler.
- Proxies and CONNECT tunnels are not used: the bridge sends to the tunnel
target directly, and egress policy is the embedder's.
- Applied by a footer appended to `http/client.py` at build time, so nothing
runs for scripts that never import http.client.
"""

import io
import sys

import _bashkit

_DEFAULT_PORTS = {"http": 80, "https": 443}


class _NoTLSContext:
"""Stand-in for `ssl.SSLContext`: the host verifies TLS itself."""

check_hostname = True
verify_mode = 2 # ssl.CERT_REQUIRED
post_handshake_auth = None

def __getattr__(self, name):
# load_verify_locations, set_alpn_protocols, load_cert_chain, ...
return lambda *args, **kwargs: None


def _parse_request(data):
"""Split one complete request off `data`.

Returns (method, target, headers, body, rest) or None if incomplete.
"""
end = data.find(b"\r\n\r\n")
if end < 0:
return None
head = bytes(data[:end]).decode("latin-1").split("\r\n")
method, target, _ = head[0].split(" ", 2)
headers = []
length = 0
chunked = False
for line in head[1:]:
name, _, value = line.partition(":")
value = value.strip()
lower = name.strip().lower()
if lower == "content-length":
length = int(value)
elif lower == "transfer-encoding" and "chunked" in value.lower():
chunked = True
headers.append((name.strip(), value))
pos = end + 4
if not chunked:
if len(data) - pos < length:
return None
return method, target, headers, bytes(data[pos : pos + length]), pos + length
body = bytearray()
while True:
eol = data.find(b"\r\n", pos)
if eol < 0:
return None
size = int(bytes(data[pos:eol]).split(b";", 1)[0], 16)
pos = eol + 2
if size == 0:
# Skip trailers up to the blank line.
while True:
eol = data.find(b"\r\n", pos)
if eol < 0:
return None
blank = eol == pos
pos = eol + 2
if blank:
return method, target, headers, bytes(body), pos
if len(data) - pos < size + 2:
return None
body += data[pos : pos + size]
pos += size + 2


def _status_line(status):
from http import HTTPStatus

try:
reason = HTTPStatus(status).phrase
except ValueError:
reason = ""
return f"HTTP/1.1 {status} {reason}\r\n"


class _BridgeSocket:
"""What http.client sees as its socket."""

def __init__(self, scheme, host, port, timeout):
if ":" in host and not host.startswith("["):
host = f"[{host}]"
if port is None or port == _DEFAULT_PORTS[scheme]:
self._base = f"{scheme}://{host}"
else:
self._base = f"{scheme}://{host}:{port}"
self._timeout = timeout if isinstance(timeout, (int, float)) else None
self._out = bytearray()
self._replies = []

def sendall(self, data):
self._out += data
while True:
parsed = _parse_request(self._out)
if parsed is None:
return
method, target, headers, body, used = parsed
del self._out[:used]
self._replies.append(self._exchange(method, target, headers, body))

def send(self, data):
self.sendall(data)
return len(data)

def _exchange(self, method, target, headers, body):
if target.startswith(("http://", "https://")):
url = target # absolute form (proxy-style request)
else:
url = self._base + target
headers = [
(k, v) for k, v in headers if k.lower() not in ("host", "transfer-encoding")
]
status, reply_headers, reply_body = _bashkit.http(
method, url, headers, body, self._timeout
)
lines = [_status_line(status)]
for name, value in reply_headers:
lower = name.lower()
if lower == "transfer-encoding":
continue
if lower == "content-length" and method != "HEAD":
continue
lines.append(f"{name}: {value}\r\n")
if method != "HEAD":
lines.append(f"Content-Length: {len(reply_body)}\r\n")
lines.append("\r\n")
return "".join(lines).encode("utf-8") + reply_body

def makefile(self, mode="r", *args, **kwargs):
if "w" in mode:
raise OSError("bridge socket is read-only")
if not self._replies:
raise ConnectionError("no HTTP request was sent")
return io.BytesIO(self._replies.pop(0))

def settimeout(self, timeout):
self._timeout = timeout

def setsockopt(self, *args):
pass

def close(self):
self._out.clear()
self._replies.clear()


def patch_http_client(ns):
"""Route `http.client` connections through the host. `ns` is the
module's globals (called from the footer of http/client.py)."""
base = ns["HTTPConnection"]

def connect(self):
if self._tunnel_host:
host, port = self._tunnel_host, self._tunnel_port
else:
host, port = self.host, self.port
sys.audit("http.client.connect", self, host, port)
self.sock = _BridgeSocket(self._bashkit_scheme, host, port, self.timeout)

base.connect = connect
base._bashkit_scheme = "http"

if "HTTPSConnection" not in ns:
socket = ns["socket"]

class HTTPSConnection(base):
"HTTPS connection; TLS is terminated by the bashkit host."

default_port = ns["HTTPS_PORT"]
_bashkit_scheme = "https"

def __init__(
self,
host,
port=None,
*,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
source_address=None,
context=None,
blocksize=8192,
):
super().__init__(host, port, timeout, source_address, blocksize=blocksize)
self._context = context

HTTPSConnection.__module__ = "http.client"
ns["HTTPSConnection"] = HTTPSConnection
ns["__all__"].append("HTTPSConnection")
ns["_create_https_context"] = lambda http_version: _NoTLSContext()
Loading
Loading