From 11becd387bb1516924c2a5083a71834d8f6ffb30 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Mon, 23 Feb 2026 20:17:16 +0200 Subject: [PATCH 1/6] Using unix sockets --- AGENTS.md | 2 +- ARCHITECTURE.md | 27 ++-- IMPLEMENTATION_GUIDE.md | 21 ++- PROJECT_SUMMARY.md | 19 +-- QUICKSTART.md | 12 -- README.md | 5 - build.zig | 18 --- build.zig.zon | 7 +- examples/basic.zig | 29 +--- src/docker_client.zig | 337 ++++++++++++++++++++++++++++++--------- src/integration_test.zig | 57 ------- src/root.zig | 12 +- src/wait.zig | 17 +- 13 files changed, 307 insertions(+), 256 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a711b19..0120387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ There is no separate formatter or linter. Follow the style rules below and let ## Architecture Rules -- **Single library**: all code lives under `src/`; `dusty` is the only external dependency. +- **Single library**: all code lives under `src/`; no external dependencies (uses built-in HTTP/1.1 client over Unix domain socket). - **Tagged unions over protocols**: `wait.Strategy` is a tagged union, not an interface. - **Struct-literal configuration**: `ContainerRequest` uses default field values; no builder methods or method chaining. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b66f3a4..f91a0f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ For usage examples and getting-started instructions see [QUICKSTART.md](QUICKSTA | Zig-first | Tagged unions, comptime, `errdefer`, manual allocation — no hidden allocations | | Type safety | Comptime-checked configuration; all errors are explicit in return types | | Developer experience | Simple struct-literal configuration, namespace-based wait strategy DSL | -| Minimal coupling | Single library; HTTP transport is a separate `dusty` dependency | +| Minimal coupling | Single library; no external dependencies — built-in HTTP/1.1 client over Unix domain socket | | Testability | `DockerClient` is injected via value; containers clean up deterministically | ## Component Overview @@ -31,7 +31,7 @@ testcontainers (src/root.zig) mariadb, minio, elasticsearch, kafka, localstack ``` -The HTTP transport layer is provided by **dusty** (a dependency declared in `build.zig.zon`). The async I/O runtime is **zio**. Neither is re-exported by `testcontainers`; callers only need to initialise `zio.Runtime` once before making any calls. +The HTTP transport layer is a built-in HTTP/1.1 client that communicates directly with the Docker Unix socket via `std.net.connectUnixSocket`. There are no external dependencies. For the HTTP wait strategy, `std.http.Client` from the standard library is used. ## Component Diagram @@ -61,8 +61,8 @@ The HTTP transport layer is provided by **dusty** (a dependency declared in `bui └────────────────────────────────────────────┼────────────────────┘ ▼ ┌──────────────────────────────┐ - │ dusty HTTP client │ - │ (unix socket transport) │ + │ Built-in HTTP/1.1 client │ + │ (std.net.connectUnixSocket) │ └──────────────────────────────┘ │ ▼ @@ -164,14 +164,8 @@ There are no finalizers, no reference counting, and no garbage collector. ## Concurrency Model -The library uses **dusty** for HTTP over a Unix socket. dusty is internally powered by **zio**, -a structured async I/O runtime. Callers must initialise a `zio.Runtime` before making any -network calls: - -```zig -var rt = try zio.Runtime.init(alloc, .{}); -defer rt.deinit(); -``` +The library uses a built-in HTTP/1.1 client that communicates directly with the Docker Unix +socket via `std.net.connectUnixSocket`. No external runtime or async framework is needed. All public API functions block the calling thread until completion. There is no callback or Future-based API surface. @@ -204,15 +198,14 @@ aliases from `ContainerRequest.network_aliases`. | Dependency | Role | Source | |-----------|------|--------| -| `dusty` | HTTP client (unix socket + TCP) | `build.zig.zon` | -| Zig stdlib | JSON, I/O, testing | built-in | +| Zig stdlib | JSON, I/O, HTTP, networking, testing | built-in | -No other runtime dependencies. zio is a transitive dependency of dusty and is not referenced -directly by application code. +No external dependencies. The library communicates with the Docker Engine using a built-in +HTTP/1.1 client over `std.net.connectUnixSocket`. ## References - [Docker Engine API v1.44](https://docs.docker.com/engine/api/v1.44/) - [Zig Language Reference](https://ziglang.org/documentation/0.15.2/) - [testcontainers-go](https://github.com/testcontainers/testcontainers-go) — reference architecture -- [dusty HTTP client](https://github.com/dragosv/dusty) — transport layer +- [Zig Standard Library](https://ziglang.org/documentation/0.15.2/std/) — HTTP, networking, JSON diff --git a/IMPLEMENTATION_GUIDE.md b/IMPLEMENTATION_GUIDE.md index ca09d2e..41a3055 100644 --- a/IMPLEMENTATION_GUIDE.md +++ b/IMPLEMENTATION_GUIDE.md @@ -146,8 +146,8 @@ Public API entry point. Exports: ### `src/docker_client.zig` -`DockerClient` communicates with the Docker Engine via a Unix socket using the **dusty** -HTTP library. Endpoints used: +`DockerClient` communicates with the Docker Engine via a Unix socket using a built-in +HTTP/1.1 client (`std.net.connectUnixSocket`). Endpoints used: | Method | Endpoint | Purpose | |--------|----------|---------| @@ -264,12 +264,11 @@ pub const Network = struct { ### `examples/basic.zig` Full nginx example: -1. Initialise `zio.Runtime` -2. `tc.run(alloc, "nginx:latest", .{ .wait_strategy = tc.wait.forHttp("/") })` -3. `ctr.mappedPort("80/tcp", alloc)` -4. Fetch `/` using a dusty HTTP client -5. `ctr.exec(&.{"echo", "hello"})` -6. Print output +1. `tc.run(alloc, "nginx:latest", .{ .wait_strategy = tc.wait.forHttp("/") })` +2. `ctr.mappedPort("80/tcp", alloc)` +3. Fetch `/` using `std.http.Client` +4. `ctr.exec(&.{"echo", "hello"})` +5. Print output ## Adding a New Module @@ -299,9 +298,9 @@ zig build example # run examples/basic.zig | Package | Version | Role | |---------|---------|------| -| `dusty` | main @ 69f47e2b | HTTP over Unix socket | +| Zig stdlib | built-in | JSON, I/O, HTTP, networking, testing | -zio is a transitive dependency of dusty; it does not need to be declared separately. +No external dependencies. The library uses a built-in HTTP/1.1 client over Unix domain socket. ## Project Statistics @@ -311,7 +310,7 @@ zio is a transitive dependency of dusty; it does not need to be declared separat | Modules | 10 | | Wait strategies | 7 | | Integration tests | 24 | -| External dependencies | 1 | +| External dependencies | 0 | | Zig version | 0.15.2 | ## Getting Help diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md index d3b84af..bef6019 100644 --- a/PROJECT_SUMMARY.md +++ b/PROJECT_SUMMARY.md @@ -16,7 +16,7 @@ This document provides a comprehensive overview of the testcontainers-zig implem - Zig Build System configuration - Minimum Zig version: 0.15.2 - Build steps: `build`, `test`, `integration-test`, `example` - - Single external dependency: `dusty` (HTTP client, unix socket transport) + - No external dependencies — built-in HTTP/1.1 client over Unix domain socket (`std.net.connectUnixSocket`) 2. **Docker API Client** (`src/docker_client.zig`) - Value-type `DockerClient` communicating over a Unix socket @@ -149,23 +149,18 @@ errdefer alloc.free(some_resource); Every function that allocates takes an `std.mem.Allocator` parameter and documents ownership of returned slices. No global allocator, no GC. -### 5. zio Runtime Requirement +### 5. No External Runtime Required -All network I/O is backed by zio (async runtime). Callers must keep a `zio.Runtime` alive: - -```zig -var rt = try zio.Runtime.init(alloc, .{}); -defer rt.deinit(); -``` +All network I/O uses the built-in HTTP/1.1 client over `std.net.connectUnixSocket`. +No external runtime initialisation is needed before using the library. ## Dependencies | Package | Role | |---------|------| -| `dusty` | HTTP over Unix socket (Docker API transport) | -| Zig stdlib | JSON, I/O, testing, memory | +| Zig stdlib | JSON, I/O, HTTP, networking, testing, memory | -zio is a transitive dependency of dusty; application code does not import it directly. +No external dependencies. The library uses a built-in HTTP/1.1 client over Unix domain socket. ## Feature Comparison with testcontainers-go @@ -234,7 +229,7 @@ testcontainers-zig/ - **Supported Modules**: 10 (Postgres, MySQL, Redis, MongoDB, RabbitMQ, MariaDB, MinIO, Elasticsearch, Kafka, LocalStack) - **Wait Strategies**: 7 built-in - **Integration Tests**: 24 -- **External Dependencies**: 1 (`dusty`) +- **External Dependencies**: 0 - **Zig Version**: 0.15.2 ## Testing Coverage diff --git a/QUICKSTART.md b/QUICKSTART.md index c816123..cd5e888 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -25,20 +25,12 @@ Wire it up in `build.zig`: ```zig const tc_dep = b.dependency("testcontainers", .{ .target = target, .optimize = optimize }); exe.root_module.addImport("testcontainers", tc_dep.module("testcontainers")); -// also expose zio so the runtime is accessible -const zio_dep = tc_dep.builder.dependency("dusty", .{}).builder - .dependency("zio", .{}); -exe.root_module.addImport("zio", zio_dep.module("zio")); ``` ## Basic Example -Every program that uses testcontainers must initialise the **zio** async runtime before -making any network calls: - ```zig const std = @import("std"); -const zio = @import("zio"); const tc = @import("testcontainers"); pub fn main() !void { @@ -46,10 +38,6 @@ pub fn main() !void { defer _ = gpa.deinit(); const alloc = gpa.allocator(); - // REQUIRED: zio runtime must be alive for the duration of all I/O. - var rt = try zio.Runtime.init(alloc, .{}); - defer rt.deinit(); - // Start a PostgreSQL container using the built-in module. var provider = try tc.DockerProvider.init(alloc); defer provider.deinit(); diff --git a/README.md b/README.md index 07250d8..9f6b34e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ See [QUICKSTART.md](QUICKSTART.md) for a step-by-step guide. ```zig const std = @import("std"); -const zio = @import("zio"); const tc = @import("testcontainers"); pub fn main() !void { @@ -48,10 +47,6 @@ pub fn main() !void { defer _ = gpa.deinit(); const alloc = gpa.allocator(); - // zio runtime must live for the duration of all network I/O - var rt = try zio.Runtime.init(alloc, .{}); - defer rt.deinit(); - const ctr = try tc.run(alloc, "nginx:latest", .{ .exposed_ports = &.{"80/tcp"}, .wait_strategy = tc.wait.forHttp("/"), diff --git a/build.zig b/build.zig index 3662d34..6fccabb 100644 --- a/build.zig +++ b/build.zig @@ -4,24 +4,12 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const dusty_dep = b.dependency("dusty", .{ - .target = target, - .optimize = optimize, - }); - - const zio_dep = dusty_dep.builder.dependency("zio", .{ - .target = target, - .optimize = optimize, - }); - // Main library module const mod = b.addModule("testcontainers", .{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, }); - mod.addImport("dusty", dusty_dep.module("dusty")); - mod.addImport("zio", zio_dep.module("zio")); // Unit tests const lib_tests = b.addTest(.{ @@ -31,8 +19,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }), }); - lib_tests.root_module.addImport("dusty", dusty_dep.module("dusty")); - lib_tests.root_module.addImport("zio", zio_dep.module("zio")); const run_lib_tests = b.addRunArtifact(lib_tests); const test_step = b.step("test", "Run library tests"); @@ -46,8 +32,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }), }); - integration_tests.root_module.addImport("dusty", dusty_dep.module("dusty")); - integration_tests.root_module.addImport("zio", zio_dep.module("zio")); integration_tests.root_module.addImport("testcontainers", mod); const run_integration_tests = b.addRunArtifact(integration_tests); @@ -64,8 +48,6 @@ pub fn build(b: *std.Build) void { }), }); example.root_module.addImport("testcontainers", mod); - example.root_module.addImport("zio", zio_dep.module("zio")); - example.root_module.addImport("dusty", dusty_dep.module("dusty")); b.installArtifact(example); const run_example = b.addRunArtifact(example); diff --git a/build.zig.zon b/build.zig.zon index a4db8e4..eff76c6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,12 +3,7 @@ .version = "0.1.0", .minimum_zig_version = "0.15.2", .fingerprint = 0x5ead92dc45f2b31e, - .dependencies = .{ - .dusty = .{ - .url = "git+https://github.com/lalinsky/dusty?ref=main#771fd97cd9899cda6f0b4c357ecce713fd080892", - .hash = "dusty-0.0.0-Qdw7Rnf0CQA_GZZjli5STfzOrVGJO-aIx3Bpg84VWTMd", - }, - }, + .dependencies = .{}, .paths = .{ "build.zig", "build.zig.zon", diff --git a/examples/basic.zig b/examples/basic.zig index 82af017..795f85a 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -6,7 +6,6 @@ /// Run with: /// zig build example const std = @import("std"); -const zio = @import("zio"); const tc = @import("testcontainers"); pub fn main() !void { @@ -14,11 +13,6 @@ pub fn main() !void { defer _ = gpa.deinit(); const allocator = gpa.allocator(); - // IMPORTANT: initialise the zio runtime before making any network calls. - // dusty (the HTTP library) is async-behind-the-scenes via zio. - var rt = try zio.Runtime.init(allocator, .{}); - defer rt.deinit(); - std.log.info("Starting nginx container...", .{}); const ctr = try tc.run(allocator, "nginx:latest", .{ @@ -36,27 +30,18 @@ pub fn main() !void { const port = try ctr.mappedPort("80/tcp", allocator); std.log.info("nginx is ready on localhost:{d}", .{port}); - // Fetch the nginx welcome page using a dusty client - var client = tc.DockerClient.init(allocator, tc.docker_socket); - defer client.deinit(); - - // Use a plain dusty client to hit the mapped port over TCP - const dusty = @import("dusty"); - var http_client = dusty.Client.init(allocator, .{}); - defer http_client.deinit(); - + // Verify the nginx page is reachable using std.http.Client const url = try std.fmt.allocPrint(allocator, "http://localhost:{d}/", .{port}); defer allocator.free(url); - var resp = try http_client.fetch(url, .{}); - defer resp.deinit(); + var http_client: std.http.Client = .{ .allocator = allocator }; + defer http_client.deinit(); - std.log.info("HTTP status: {d}", .{@intFromEnum(resp.status())}); + const fetch_result = try http_client.fetch(.{ + .location = .{ .url = url }, + }); - if (try resp.body()) |body| { - const preview_len = @min(body.len, 200); - std.log.info("Body preview:\n{s}", .{body[0..preview_len]}); - } + std.log.info("HTTP status: {d}", .{@intFromEnum(fetch_result.status)}); // Demonstrate exec const result = try ctr.exec(&.{ "echo", "hello from container" }); diff --git a/src/docker_client.zig b/src/docker_client.zig index c299987..cf6d29d 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -1,14 +1,9 @@ -/// DockerClient — thin wrapper around dusty's HTTP client using Unix sockets +/// DockerClient — lightweight HTTP client using a raw Unix domain socket /// to communicate with the Docker Engine REST API. /// /// All responses are allocated with the caller-supplied allocator; the caller /// is responsible for freeing them unless documented otherwise. -/// -/// IMPORTANT: A `zio.Runtime` must be initialised on the calling thread before -/// creating a DockerClient or calling any of its methods, because dusty's -/// networking is driven by the zio event loop. const std = @import("std"); -const dusty = @import("dusty"); const types = @import("types.zig"); const container_mod = @import("container.zig"); @@ -26,86 +21,293 @@ pub const DockerClientError = error{ InvalidResponse, }; +/// HTTP method for Docker API requests. +const Method = enum { + get, + post, + put, + delete, + + fn name(self: Method) []const u8 { + return switch (self) { + .get => "GET", + .post => "POST", + .put => "PUT", + .delete => "DELETE", + }; + } +}; + +/// Metadata parsed from an HTTP response header. +const ResponseMeta = struct { + status_code: u16, + content_length: ?usize, + chunked: bool, +}; + +/// Buffered reader over a raw `std.net.Stream` for incremental HTTP +/// response parsing. Avoids one-byte syscalls by reading into an +/// internal 8 KiB buffer. +const HttpReader = struct { + stream: std.net.Stream, + buf: [8192]u8 = undefined, + pos: usize = 0, + len: usize = 0, + + /// Read a single byte from the buffered stream. + fn readByte(self: *HttpReader) !u8 { + if (self.pos >= self.len) { + self.len = try self.stream.read(&self.buf); + self.pos = 0; + if (self.len == 0) return error.EndOfStream; + } + const b = self.buf[self.pos]; + self.pos += 1; + return b; + } + + /// Read exactly `dest.len` bytes from the buffered stream. + fn readExact(self: *HttpReader, dest: []u8) !void { + var written: usize = 0; + while (written < dest.len) { + if (self.pos >= self.len) { + self.len = try self.stream.read(&self.buf); + self.pos = 0; + if (self.len == 0) return error.EndOfStream; + } + const available = self.len - self.pos; + const needed = dest.len - written; + const to_copy = @min(available, needed); + @memcpy(dest[written .. written + to_copy], self.buf[self.pos .. self.pos + to_copy]); + written += to_copy; + self.pos += to_copy; + } + } + + /// Read a line terminated by `\n`. Returns the line contents without + /// the trailing `\r\n`. Returns `null` when EOF is reached with no data. + fn readLine(self: *HttpReader, out: []u8) !?[]const u8 { + var i: usize = 0; + while (i < out.len) { + const b = self.readByte() catch |err| { + if (err == error.EndOfStream) { + return if (i == 0) null else out[0..i]; + } + return err; + }; + if (b == '\n') { + const end = if (i > 0 and out[i - 1] == '\r') i - 1 else i; + return out[0..end]; + } + out[i] = b; + i += 1; + } + return error.HttpHeaderTooLong; + } + + /// Discard all remaining data until EOF. + fn drain(self: *HttpReader) void { + self.pos = self.len; + while (true) { + self.len = self.stream.read(&self.buf) catch return; + if (self.len == 0) return; + } + } +}; + +/// Send an HTTP/1.1 request over a raw stream. +fn sendHttpRequest( + stream: std.net.Stream, + method: Method, + path: []const u8, + content_type: ?[]const u8, + body: ?[]const u8, +) !void { + var hdr_buf: [4096]u8 = undefined; + var fbs = std.io.fixedBufferStream(&hdr_buf); + const w = fbs.writer(); + + try w.print("{s} {s} HTTP/1.1\r\n", .{ method.name(), path }); + try w.print("Host: localhost\r\n", .{}); + if (content_type) |ct| { + try w.print("Content-Type: {s}\r\n", .{ct}); + } + if (body) |b| { + try w.print("Content-Length: {d}\r\n", .{b.len}); + } + try w.print("Connection: close\r\n\r\n", .{}); + + try stream.writeAll(fbs.getWritten()); + if (body) |b| { + try stream.writeAll(b); + } +} + +/// Parse the HTTP response status line and headers. +fn parseResponseHead(reader: *HttpReader) !ResponseMeta { + var line_buf: [8192]u8 = undefined; + + // Status line: "HTTP/1.x NNN ..." + const status_line = try reader.readLine(&line_buf) orelse return error.InvalidResponse; + const first_space = std.mem.indexOfScalar(u8, status_line, ' ') orelse return error.InvalidResponse; + const after_space = status_line[first_space + 1 ..]; + if (after_space.len < 3) return error.InvalidResponse; + const status_code = std.fmt.parseInt(u16, after_space[0..3], 10) catch return error.InvalidResponse; + + var content_length: ?usize = null; + var chunked = false; + + while (true) { + const header_line = try reader.readLine(&line_buf) orelse break; + if (header_line.len == 0) break; + + const colon = std.mem.indexOfScalar(u8, header_line, ':') orelse continue; + const hdr_name = std.mem.trim(u8, header_line[0..colon], " "); + const hdr_value = std.mem.trim(u8, header_line[colon + 1 ..], " "); + + if (std.ascii.eqlIgnoreCase(hdr_name, "content-length")) { + content_length = std.fmt.parseInt(usize, hdr_value, 10) catch null; + } else if (std.ascii.eqlIgnoreCase(hdr_name, "transfer-encoding")) { + if (std.ascii.eqlIgnoreCase(hdr_value, "chunked")) { + chunked = true; + } + } + } + + return .{ + .status_code = status_code, + .content_length = content_length, + .chunked = chunked, + }; +} + +/// Read the full response body according to the parsed metadata. +fn readResponseBody(reader: *HttpReader, meta: ResponseMeta, allocator: std.mem.Allocator) ![]const u8 { + if (meta.content_length) |cl| { + if (cl == 0) return allocator.dupe(u8, ""); + const body_buf = try allocator.alloc(u8, cl); + errdefer allocator.free(body_buf); + try reader.readExact(body_buf); + return body_buf; + } + + if (meta.chunked) { + return readChunkedBody(reader, allocator); + } + + // No Content-Length and not chunked — read until connection close. + return readUntilClose(reader, allocator); +} + +/// Decode an HTTP chunked transfer-encoded body. +fn readChunkedBody(reader: *HttpReader, allocator: std.mem.Allocator) ![]const u8 { + var body: std.ArrayList(u8) = .empty; + errdefer body.deinit(allocator); + + var line_buf: [128]u8 = undefined; + + while (true) { + const size_line = try reader.readLine(&line_buf) orelse break; + const semi = std.mem.indexOfScalar(u8, size_line, ';') orelse size_line.len; + const trimmed = std.mem.trim(u8, size_line[0..semi], " "); + const chunk_size = std.fmt.parseInt(usize, trimmed, 16) catch break; + + if (chunk_size == 0) { + // Drain optional trailers. + while (true) { + const trailer = try reader.readLine(&line_buf) orelse break; + if (trailer.len == 0) break; + } + break; + } + + const old_len = body.items.len; + try body.ensureTotalCapacity(allocator, old_len + chunk_size); + body.items.len = old_len + chunk_size; + try reader.readExact(body.items[old_len..]); + + // Consume trailing \r\n after chunk data. + _ = try reader.readLine(&line_buf); + } + + return body.toOwnedSlice(allocator); +} + +/// Read until the peer closes the connection. +fn readUntilClose(reader: *HttpReader, allocator: std.mem.Allocator) ![]const u8 { + var body: std.ArrayList(u8) = .empty; + errdefer body.deinit(allocator); + + // Flush anything the HttpReader already buffered. + if (reader.pos < reader.len) { + try body.appendSlice(allocator, reader.buf[reader.pos..reader.len]); + reader.pos = reader.len; + } + + var tmp: [8192]u8 = undefined; + while (true) { + const n = reader.stream.read(&tmp) catch break; + if (n == 0) break; + try body.appendSlice(allocator, tmp[0..n]); + } + + return body.toOwnedSlice(allocator); +} + /// Lightweight Docker HTTP client backed by a Unix domain socket. pub const DockerClient = struct { allocator: std.mem.Allocator, socket_path: []const u8, - client: dusty.Client, pub fn init(allocator: std.mem.Allocator, socket_path: []const u8) DockerClient { return .{ .allocator = allocator, .socket_path = socket_path, - .client = dusty.Client.init(allocator, .{ - // Allow large response bodies (e.g. logs, inspect output) - .max_response_size = 64 * 1024 * 1024, - }), }; } pub fn deinit(self: *DockerClient) void { - self.client.deinit(); + _ = self; } // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- - /// Build the full URL for a Docker API path. - fn apiUrl(self: *DockerClient, path: []const u8) ![]const u8 { - return std.fmt.allocPrint(self.allocator, "http://localhost{s}", .{path}); - } - /// Perform a request and check that the status code is acceptable. /// Returns the raw body bytes (caller owns the memory). fn doRequest( self: *DockerClient, - method: dusty.Method, + method: Method, path: []const u8, body: ?[]const u8, content_type: ?[]const u8, expected_codes: []const u16, ) ![]const u8 { - const url = try self.apiUrl(path); - defer self.allocator.free(url); + const stream = try std.net.connectUnixSocket(self.socket_path); + defer stream.close(); - var headers: dusty.Headers = .{}; - defer headers.deinit(self.allocator); - if (content_type) |ct| { - try headers.put(self.allocator, "Content-Type", ct); - } - - var resp = try self.client.fetch(url, .{ - .method = method, - .body = body, - .unix_socket_path = self.socket_path, - .headers = if (content_type != null) &headers else null, - }); - defer resp.deinit(); + try sendHttpRequest(stream, method, path, content_type, body); - const sc: u16 = @as(u16, @intCast(@intFromEnum(resp.status()))); + var reader: HttpReader = .{ .stream = stream }; + const meta = try parseResponseHead(&reader); var acceptable = false; for (expected_codes) |c| { - if (c == sc) { + if (c == meta.status_code) { acceptable = true; break; } } if (!acceptable) { - // Drain the response body so the connection can be safely reused. - // Skipping this leaves unread bytes on the socket which would corrupt - // the next request parsed over the same keep-alive connection. - _ = resp.body() catch {}; - if (sc == 404) return DockerClientError.NotFound; - if (sc == 409) return DockerClientError.Conflict; - if (sc >= 500) return DockerClientError.ServerError; + if (meta.status_code == 404) return DockerClientError.NotFound; + if (meta.status_code == 409) return DockerClientError.Conflict; + if (meta.status_code >= 500) return DockerClientError.ServerError; return DockerClientError.ApiError; } - const resp_body = try resp.body() orelse ""; - return self.allocator.dupe(u8, resp_body); + return try readResponseBody(&reader, meta, self.allocator); } // ----------------------------------------------------------------------- @@ -140,29 +342,20 @@ pub const DockerClient = struct { ); defer self.allocator.free(api_path); - const url = try self.apiUrl(api_path); - defer self.allocator.free(url); - // imagePull returns a streaming JSON progress response. - // We use a streaming reader to drain it without buffering the whole body, - // which avoids hitting max_response_size for large image pulls. - var resp = try self.client.fetch(url, .{ - .method = .post, - .unix_socket_path = self.socket_path, - .decompress = false, - }); - defer resp.deinit(); - - const sc: u16 = @as(u16, @intCast(@intFromEnum(resp.status()))); - if (sc != 200) return DockerClientError.ApiError; + // We open a dedicated connection and drain the stream to wait for + // completion without buffering the entire (potentially huge) body. + const stream = try std.net.connectUnixSocket(self.socket_path); + defer stream.close(); + + try sendHttpRequest(stream, .post, api_path, null, null); + + var reader: HttpReader = .{ .stream = stream }; + const meta = try parseResponseHead(&reader); + if (meta.status_code != 200) return DockerClientError.ApiError; // Drain the progress stream to wait for completion. - const r = resp.reader(); - var buf: [4096]u8 = undefined; - while (true) { - const n = r.readSliceShort(&buf) catch break; - if (n == 0) break; - } + reader.drain(); } /// Check if an image exists locally. Returns true if found. @@ -537,17 +730,15 @@ pub const DockerClient = struct { /// Ping the Docker daemon. Returns true on success. pub fn ping(self: *DockerClient) !bool { const api_path = "/" ++ api_version ++ "/_ping"; - const url = try self.apiUrl(api_path); - defer self.allocator.free(url); - var resp = self.client.fetch(url, .{ - .method = .get, - .unix_socket_path = self.socket_path, - }) catch return false; - defer resp.deinit(); + const stream = std.net.connectUnixSocket(self.socket_path) catch return false; + defer stream.close(); + + sendHttpRequest(stream, .get, api_path, null, null) catch return false; - const sc: u16 = @as(u16, @intCast(@intFromEnum(resp.status()))); - return sc == 200; + var reader: HttpReader = .{ .stream = stream }; + const meta = parseResponseHead(&reader) catch return false; + return meta.status_code == 200; } }; diff --git a/src/integration_test.zig b/src/integration_test.zig index 3e73d1c..22e1120 100644 --- a/src/integration_test.zig +++ b/src/integration_test.zig @@ -8,21 +8,12 @@ /// /// Tests are automatically skipped when Docker is not reachable. const std = @import("std"); -const zio = @import("zio"); const tc = @import("testcontainers"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/// Initialise a zio runtime and skip the enclosing test if Docker is not -/// reachable. The runtime is stored in the caller's local variable. -/// Uses std.heap.c_allocator for the runtime to avoid GPA teardown conflicts. -fn initRuntimeOrSkip(_: std.mem.Allocator) !*zio.Runtime { - const rt = try zio.Runtime.init(std.heap.c_allocator, .{}); - return rt; -} - /// Skip the test if Docker is not responding on the default socket. fn skipIfNoDocker(alloc: std.mem.Allocator) !void { var client = tc.DockerClient.init(alloc, tc.docker_socket); @@ -44,8 +35,6 @@ fn uniqueName(alloc: std.mem.Allocator, prefix: []const u8) ![]const u8 { test "CustomLabelsImage" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -92,8 +81,6 @@ test "CustomLabelsImage" { test "GetLogsFromFailedContainer" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -134,8 +121,6 @@ test "GetLogsFromFailedContainer" { test "ContainerInspectState" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -167,8 +152,6 @@ test "ContainerInspectState" { test "ContainerExec" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -200,8 +183,6 @@ test "ContainerExec" { test "ContainerExecNonZeroExit" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -230,8 +211,6 @@ test "ContainerExecNonZeroExit" { test "ContainerCopyToContainer" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -265,8 +244,6 @@ test "ContainerCopyToContainer" { test "WaitForLogStrategy" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -294,8 +271,6 @@ test "WaitForLogStrategy" { test "WaitForPortStrategy" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -324,8 +299,6 @@ test "WaitForPortStrategy" { test "WaitForHTTPStrategy" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -360,8 +333,6 @@ test "WaitForHTTPStrategy" { test "ContainerMappedPort" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -390,8 +361,6 @@ test "ContainerMappedPort" { test "ShouldStartMultipleContainers" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -431,8 +400,6 @@ test "ShouldStartMultipleContainers" { test "GenericContainerShouldReturnRefOnError" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -472,8 +439,6 @@ test "GenericContainerShouldReturnRefOnError" { test "ImageExists" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var client = tc.DockerClient.init(alloc, tc.docker_socket); @@ -495,8 +460,6 @@ test "ImageExists" { test "ContainerStopAndRestart" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -528,8 +491,6 @@ test "ContainerStopAndRestart" { test "GenericReusableContainer" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); // Generate a unique container name for this test run. @@ -599,8 +560,6 @@ test "GenericReusableContainer" { test "GenericReusableContainerRequiresName" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -624,8 +583,6 @@ test "GenericReusableContainerRequiresName" { test "network: New" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -655,8 +612,6 @@ test "network: New" { test "network: ContainerAttachedToNewNetwork" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -733,8 +688,6 @@ test "network: ContainerAttachedToNewNetwork" { test "network: MultipleContainersInSameNetwork" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -806,8 +759,6 @@ test "network: MultipleContainersInSameNetwork" { test "network: ContainerIPs" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -858,8 +809,6 @@ test "network: ContainerIPs" { test "WaitForExecStrategy" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -894,8 +843,6 @@ test "WaitForExecStrategy" { test "ContainerWithEnvironmentVariables" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -927,8 +874,6 @@ test "ContainerWithEnvironmentVariables" { test "ContainerEndpoint" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); var provider = tc.DockerProvider.init(alloc); @@ -960,8 +905,6 @@ test "ContainerEndpoint" { test "TopLevelRunFunction" { const alloc = std.testing.allocator; - const rt = try initRuntimeOrSkip(alloc); - defer rt.deinit(); try skipIfNoDocker(alloc); defer tc.deinitProvider(); diff --git a/src/root.zig b/src/root.zig index 79ffe23..c76c37a 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,13 +1,12 @@ /// testcontainers-zig /// /// A Zig port of https://github.com/testcontainers/testcontainers-go. -/// Uses https://github.com/dragosv/dusty (main branch) as the -/// HTTP library for communicating with the Docker Engine over its Unix socket. +/// Communicates with the Docker Engine over its Unix domain socket using +/// a built-in HTTP/1.1 client (no external dependencies). /// /// Quick start: /// /// const std = @import("std"); -/// const zio = @import("zio"); /// const tc = @import("testcontainers"); /// /// pub fn main() !void { @@ -15,9 +14,6 @@ /// defer _ = gpa.deinit(); /// const allocator = gpa.allocator(); /// -/// var rt = try zio.Runtime.init(allocator, .{}); -/// defer rt.deinit(); -/// /// const ctr = try tc.run(allocator, "nginx:latest", .{ /// .exposed_ports = &.{"80/tcp"}, /// .wait_strategy = tc.wait.forHttp("/"), @@ -27,10 +23,6 @@ /// const port = try ctr.mappedPort("80/tcp", allocator); /// std.debug.print("nginx at localhost:{d}\n", .{port}); /// } -/// -/// IMPORTANT: A `zio.Runtime` must be initialised (and kept alive) before -/// calling any testcontainers function that performs I/O, because dusty's -/// async networking is driven by the zio event loop. const std = @import("std"); // --------------------------------------------------------------------------- diff --git a/src/wait.zig b/src/wait.zig index 8c82600..f91e3aa 100644 --- a/src/wait.zig +++ b/src/wait.zig @@ -199,8 +199,6 @@ fn waitLog(s: LogStrategy, target: StrategyTarget, alloc: std.mem.Allocator) !vo // --- ForHTTP ---------------------------------------------------------------- fn waitHttp(s: HttpStrategy, target: StrategyTarget, alloc: std.mem.Allocator) !void { - const dusty = @import("dusty"); - const deadline = std.time.nanoTimestamp() + @as(i128, @intCast(timeoutNs(s.startup_timeout_ns))); const poll = pollNs(s.poll_interval_ns); @@ -215,23 +213,18 @@ fn waitHttp(s: HttpStrategy, target: StrategyTarget, alloc: std.mem.Allocator) ! defer alloc.free(url); while (std.time.nanoTimestamp() < deadline) { - // Create a fresh client per attempt to avoid connection pool corruption - // when the server is not yet ready and resets connections. - var client = dusty.Client.init(alloc, .{ .max_idle_connections = 0 }); + var client: std.http.Client = .{ .allocator = alloc }; defer client.deinit(); - var resp = client.fetch(url, .{ - .method = if (std.mem.eql(u8, s.method, "POST")) .post else .get, + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = if (std.mem.eql(u8, s.method, "POST")) .POST else .GET, }) catch { std.Thread.sleep(poll); continue; }; - defer resp.deinit(); - - // Always drain the body to keep the connection in a clean state. - _ = resp.body() catch {}; - const code = @as(u16, @intCast(@intFromEnum(resp.status()))); + const code: u16 = @intFromEnum(result.status); const ok = if (s.status_code == 0) code >= 200 and code < 300 else From 3f8d6a367c7b857ddaa7addae93f3f7c4e2fc462 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Mon, 23 Feb 2026 20:29:57 +0200 Subject: [PATCH 2/6] Docker setup on MacOS --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ace182f..b8d2432 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,22 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install and start Docker (macOS) + if: runner.os == 'macOS' + run: | + brew install docker docker-compose colima + colima start --memory 4 --cpu 2 + # Wait for Docker to be ready + for i in $(seq 1 30); do + if docker info > /dev/null 2>&1; then + echo "Docker is ready!" + break + fi + echo "Waiting for Docker to start... ($i/30)" + sleep 2 + done + docker info + - uses: mlugg/setup-zig@v2 with: version: 0.15.2 From ee25bb354d576f20bd96ff1172cafcefb0f7a065 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:43:35 +0000 Subject: [PATCH 3/6] Initial plan From ee5865cc0a6d185297ae2d7636c174dd6f103e1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:53:25 +0000 Subject: [PATCH 4/6] Add unit tests for HTTP parsing functions in docker_client.zig Co-authored-by: dragosv <422243+dragosv@users.noreply.github.com> --- src/docker_client.zig | 276 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/src/docker_client.zig b/src/docker_client.zig index cf6d29d..11b20b7 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -939,3 +939,279 @@ fn uriEncode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { } return out.toOwnedSlice(allocator); } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Create a mock std.net.Stream backed by a pipe pre-filled with `data`. +/// The write end is closed before returning so the read end will see EOF +/// after all bytes are consumed. The caller owns the returned stream handle +/// and must call `stream.close()` when done. +fn pipeStream(data: []const u8) !std.net.Stream { + const fds = try std.posix.pipe(); + errdefer std.posix.close(fds[0]); + defer std.posix.close(fds[1]); + if (data.len > 0) _ = try std.posix.write(fds[1], data); + return .{ .handle = fds[0] }; +} + +test "HttpReader.readByte: returns bytes in order and EndOfStream at EOF" { + const stream = try pipeStream("ab"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + try std.testing.expectEqual(@as(u8, 'a'), try reader.readByte()); + try std.testing.expectEqual(@as(u8, 'b'), try reader.readByte()); + try std.testing.expectError(error.EndOfStream, reader.readByte()); +} + +test "HttpReader.readExact: reads the requested number of bytes" { + const stream = try pipeStream("hello world"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [5]u8 = undefined; + try reader.readExact(&buf); + try std.testing.expectEqualStrings("hello", &buf); + var buf2: [6]u8 = undefined; + try reader.readExact(&buf2); + try std.testing.expectEqualStrings(" world", &buf2); +} + +test "HttpReader.readExact: returns EndOfStream when data is insufficient" { + const stream = try pipeStream("hi"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [5]u8 = undefined; + try std.testing.expectError(error.EndOfStream, reader.readExact(&buf)); +} + +test "HttpReader.readLine: strips CRLF terminators" { + const stream = try pipeStream("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [256]u8 = undefined; + try std.testing.expectEqualStrings("HTTP/1.1 200 OK", (try reader.readLine(&buf)).?); + try std.testing.expectEqualStrings("Content-Length: 5", (try reader.readLine(&buf)).?); + try std.testing.expectEqualStrings("", (try reader.readLine(&buf)).?); +} + +test "HttpReader.readLine: accepts LF-only terminators" { + const stream = try pipeStream("line1\nline2\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [256]u8 = undefined; + try std.testing.expectEqualStrings("line1", (try reader.readLine(&buf)).?); + try std.testing.expectEqualStrings("line2", (try reader.readLine(&buf)).?); +} + +test "HttpReader.readLine: returns null on empty EOF" { + const stream = try pipeStream(""); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [256]u8 = undefined; + try std.testing.expect((try reader.readLine(&buf)) == null); +} + +test "HttpReader.readLine: returns partial data when EOF reached without newline" { + const stream = try pipeStream("no-newline"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [256]u8 = undefined; + try std.testing.expectEqualStrings("no-newline", (try reader.readLine(&buf)).?); +} + +test "HttpReader.readLine: returns HttpHeaderTooLong when line exceeds buffer" { + const data = "A" ** 300 ++ "\n"; + const stream = try pipeStream(data); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + var buf: [64]u8 = undefined; + try std.testing.expectError(error.HttpHeaderTooLong, reader.readLine(&buf)); +} + +test "parseResponseHead: parses 200 OK with Content-Length" { + const stream = try pipeStream("HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const meta = try parseResponseHead(&reader); + try std.testing.expectEqual(@as(u16, 200), meta.status_code); + try std.testing.expectEqual(@as(?usize, 42), meta.content_length); + try std.testing.expect(!meta.chunked); +} + +test "parseResponseHead: parses chunked Transfer-Encoding" { + const stream = try pipeStream("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const meta = try parseResponseHead(&reader); + try std.testing.expectEqual(@as(u16, 200), meta.status_code); + try std.testing.expect(meta.content_length == null); + try std.testing.expect(meta.chunked); +} + +test "parseResponseHead: parses 404 Not Found" { + const stream = try pipeStream("HTTP/1.1 404 Not Found\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const meta = try parseResponseHead(&reader); + try std.testing.expectEqual(@as(u16, 404), meta.status_code); + try std.testing.expect(meta.content_length == null); + try std.testing.expect(!meta.chunked); +} + +test "parseResponseHead: returns InvalidResponse on empty input" { + const stream = try pipeStream(""); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + try std.testing.expectError(error.InvalidResponse, parseResponseHead(&reader)); +} + +test "parseResponseHead: returns InvalidResponse on status line without space" { + const stream = try pipeStream("INVALID\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + try std.testing.expectError(error.InvalidResponse, parseResponseHead(&reader)); +} + +test "parseResponseHead: returns InvalidResponse when status code is too short" { + const stream = try pipeStream("HTTP/1.1 20\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + try std.testing.expectError(error.InvalidResponse, parseResponseHead(&reader)); +} + +test "parseResponseHead: skips headers without a colon separator" { + const stream = try pipeStream("HTTP/1.1 200 OK\r\nBadHeader\r\nContent-Length: 10\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const meta = try parseResponseHead(&reader); + try std.testing.expectEqual(@as(u16, 200), meta.status_code); + try std.testing.expectEqual(@as(?usize, 10), meta.content_length); +} + +test "parseResponseHead: handles case-insensitive header names" { + const stream = try pipeStream("HTTP/1.1 200 OK\r\ncontent-length: 7\r\ntransfer-encoding: chunked\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const meta = try parseResponseHead(&reader); + try std.testing.expectEqual(@as(?usize, 7), meta.content_length); + try std.testing.expect(meta.chunked); +} + +test "readChunkedBody: decodes a single chunk" { + const stream = try pipeStream("5\r\nhello\r\n0\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readChunkedBody(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("hello", body); +} + +test "readChunkedBody: decodes multiple chunks" { + const stream = try pipeStream("5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readChunkedBody(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("hello world", body); +} + +test "readChunkedBody: returns empty slice for zero-length first chunk" { + const stream = try pipeStream("0\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readChunkedBody(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("", body); +} + +test "readChunkedBody: ignores chunk extensions after semicolon" { + const stream = try pipeStream("5;ext=val\r\nhello\r\n0\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readChunkedBody(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("hello", body); +} + +test "readChunkedBody: drains optional trailers before terminating" { + const stream = try pipeStream("5\r\nhello\r\n0\r\nTrailer: value\r\n\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readChunkedBody(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("hello", body); +} + +test "readChunkedBody: returns accumulated data on invalid chunk size" { + // First chunk is valid; second chunk line has an unparseable size. + const stream = try pipeStream("5\r\nhello\r\nINVALID\r\n"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readChunkedBody(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("hello", body); +} + +test "readUntilClose: reads all data to EOF" { + const stream = try pipeStream("hello world"); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readUntilClose(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("hello world", body); +} + +test "readUntilClose: returns empty slice for empty stream" { + const stream = try pipeStream(""); + defer stream.close(); + var reader = HttpReader{ .stream = stream }; + const body = try readUntilClose(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("", body); +} + +test "readUntilClose: flushes data already buffered in HttpReader" { + // Simulate bytes already read into the internal buffer (e.g. by parseResponseHead). + const fds = try std.posix.pipe(); + defer std.posix.close(fds[0]); + std.posix.close(fds[1]); // close write end → EOF on read + var reader = HttpReader{ .stream = .{ .handle = fds[0] } }; + const pre = "pre-buffered"; + @memcpy(reader.buf[0..pre.len], pre); + reader.len = pre.len; + reader.pos = 0; + const body = try readUntilClose(&reader, std.testing.allocator); + defer std.testing.allocator.free(body); + try std.testing.expectEqualStrings("pre-buffered", body); +} + +test "sendHttpRequest: writes a valid GET request" { + const fds = try std.posix.pipe(); + defer std.posix.close(fds[0]); + const write_stream = std.net.Stream{ .handle = fds[1] }; + try sendHttpRequest(write_stream, .get, "/v1.46/_ping", null, null); + std.posix.close(fds[1]); + var buf: [4096]u8 = undefined; + const n = try std.posix.read(fds[0], &buf); + const req = buf[0..n]; + try std.testing.expect(std.mem.startsWith(u8, req, "GET /v1.46/_ping HTTP/1.1\r\n")); + try std.testing.expect(std.mem.indexOf(u8, req, "Host: localhost\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, req, "Connection: close\r\n") != null); +} + +test "sendHttpRequest: writes Content-Type and Content-Length for POST with body" { + const fds = try std.posix.pipe(); + defer std.posix.close(fds[0]); + const write_stream = std.net.Stream{ .handle = fds[1] }; + const body = "{\"Image\":\"alpine\"}"; + try sendHttpRequest(write_stream, .post, "/v1.46/containers/create", "application/json", body); + std.posix.close(fds[1]); + var buf: [4096]u8 = undefined; + const n = try std.posix.read(fds[0], &buf); + const req = buf[0..n]; + try std.testing.expect(std.mem.startsWith(u8, req, "POST /v1.46/containers/create HTTP/1.1\r\n")); + try std.testing.expect(std.mem.indexOf(u8, req, "Content-Type: application/json\r\n") != null); + try std.testing.expect(std.mem.indexOf(u8, req, "Content-Length: 18\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, req, body)); +} From 04f599e77616aa0014bc5a0f045d82823546d1ee Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Wed, 4 Mar 2026 21:17:43 +0200 Subject: [PATCH 5/6] Fix crashing tests --- src/docker_client.zig | 6 +++--- src/root.zig | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 11b20b7..ece9f22 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -117,7 +117,7 @@ const HttpReader = struct { /// Send an HTTP/1.1 request over a raw stream. fn sendHttpRequest( - stream: std.net.Stream, + stream: anytype, method: Method, path: []const u8, content_type: ?[]const u8, @@ -1189,7 +1189,7 @@ test "readUntilClose: flushes data already buffered in HttpReader" { test "sendHttpRequest: writes a valid GET request" { const fds = try std.posix.pipe(); defer std.posix.close(fds[0]); - const write_stream = std.net.Stream{ .handle = fds[1] }; + const write_stream = std.fs.File{ .handle = fds[1] }; try sendHttpRequest(write_stream, .get, "/v1.46/_ping", null, null); std.posix.close(fds[1]); var buf: [4096]u8 = undefined; @@ -1203,7 +1203,7 @@ test "sendHttpRequest: writes a valid GET request" { test "sendHttpRequest: writes Content-Type and Content-Length for POST with body" { const fds = try std.posix.pipe(); defer std.posix.close(fds[0]); - const write_stream = std.net.Stream{ .handle = fds[1] }; + const write_stream = std.fs.File{ .handle = fds[1] }; const body = "{\"Image\":\"alpine\"}"; try sendHttpRequest(write_stream, .post, "/v1.46/containers/create", "application/json", body); std.posix.close(fds[1]); diff --git a/src/root.zig b/src/root.zig index c76c37a..0959f00 100644 --- a/src/root.zig +++ b/src/root.zig @@ -83,7 +83,15 @@ pub const DockerProvider = struct { client: DockerClient, pub fn init(allocator: std.mem.Allocator) DockerProvider { - return init_with_socket(allocator, docker_socket); + var socket: []const u8 = docker_socket; + if (std.posix.getenv("DOCKER_HOST")) |host| { + if (std.mem.startsWith(u8, host, "unix://")) { + socket = host["unix://".len..]; + } else { + socket = host; + } + } + return init_with_socket(allocator, socket); } pub fn init_with_socket(allocator: std.mem.Allocator, socket_path: []const u8) DockerProvider { From cf42c82c900a75b56a0561b677f687be2dd85f46 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Wed, 4 Mar 2026 21:21:50 +0200 Subject: [PATCH 6/6] Ignore MacOs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8d2432..c40040d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: build-and-test: strategy: matrix: - platform: [ubuntu-latest, macos-latest] + platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v6