From 11becd387bb1516924c2a5083a71834d8f6ffb30 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Mon, 23 Feb 2026 20:17:16 +0200 Subject: [PATCH 01/25] 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 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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 From c3caf1b6fe661cb9f223af86937be2148c9cccfe Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:17:40 +0200 Subject: [PATCH 07/25] Update src/wait.zig Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/wait.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wait.zig b/src/wait.zig index f91e3aa..c762034 100644 --- a/src/wait.zig +++ b/src/wait.zig @@ -223,6 +223,7 @@ fn waitHttp(s: HttpStrategy, target: StrategyTarget, alloc: std.mem.Allocator) ! std.Thread.sleep(poll); continue; }; + defer result.deinit(alloc); const code: u16 = @intFromEnum(result.status); const ok = if (s.status_code == 0) From fcd7e5e3f07c80ae3dd46f4f9adb36bc5363ee70 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:17:53 +0200 Subject: [PATCH 08/25] Update examples/basic.zig Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/basic.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/basic.zig b/examples/basic.zig index 795f85a..7c87b98 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -40,6 +40,7 @@ pub fn main() !void { const fetch_result = try http_client.fetch(.{ .location = .{ .url = url }, }); + defer fetch_result.deinit(allocator); std.log.info("HTTP status: {d}", .{@intFromEnum(fetch_result.status)}); From 578fa41f7b8afbeaa8582447d1b882990c3bf92e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:18:16 +0000 Subject: [PATCH 09/25] Initial plan From 09d80b73138e8df471ab60657267329c6ef0ad2a Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:18:22 +0200 Subject: [PATCH 10/25] Update src/docker_client.zig Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/docker_client.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index ece9f22..493edf3 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -210,7 +210,7 @@ fn readChunkedBody(reader: *HttpReader, allocator: std.mem.Allocator) ![]const u 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; + const chunk_size = std.fmt.parseInt(usize, trimmed, 16) catch return error.InvalidResponse; if (chunk_size == 0) { // Drain optional trailers. From c3da803439476502b58f532eec33db443c88c6a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:18:59 +0000 Subject: [PATCH 11/25] Initial plan From 923e3ebff9ecfd5778b769088dc833d056e1b8c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:19:07 +0000 Subject: [PATCH 12/25] Initial plan From e4876ac2ca59b50c8d93c1bb517f9df77c95d64a Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:19:17 +0200 Subject: [PATCH 13/25] Update src/docker_client.zig Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/docker_client.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 493edf3..11475ba 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -204,7 +204,8 @@ fn readChunkedBody(reader: *HttpReader, allocator: std.mem.Allocator) ![]const u var body: std.ArrayList(u8) = .empty; errdefer body.deinit(allocator); - var line_buf: [128]u8 = undefined; + // Use a reasonably large buffer to accommodate chunk extensions and trailer lines. + var line_buf: [4096]u8 = undefined; while (true) { const size_line = try reader.readLine(&line_buf) orelse break; From 06e40681e27320da4c635c9b5a6ac06653a757b2 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:19:59 +0200 Subject: [PATCH 14/25] Update src/docker_client.zig Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/docker_client.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 11475ba..6e62aec 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -183,6 +183,9 @@ fn parseResponseHead(reader: *HttpReader) !ResponseMeta { /// Read the full response body according to the parsed metadata. fn readResponseBody(reader: *HttpReader, meta: ResponseMeta, allocator: std.mem.Allocator) ![]const u8 { + if (meta.chunked) { + return readChunkedBody(reader, allocator); + } if (meta.content_length) |cl| { if (cl == 0) return allocator.dupe(u8, ""); const body_buf = try allocator.alloc(u8, cl); @@ -191,13 +194,10 @@ fn readResponseBody(reader: *HttpReader, meta: ResponseMeta, allocator: std.mem. 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 { From 1fa712f16e34e33944777105bb5078fefe148772 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:20:26 +0200 Subject: [PATCH 15/25] Update src/docker_client.zig Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/docker_client.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 6e62aec..81b3ade 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -208,7 +208,7 @@ fn readChunkedBody(reader: *HttpReader, allocator: std.mem.Allocator) ![]const u var line_buf: [4096]u8 = undefined; while (true) { - const size_line = try reader.readLine(&line_buf) orelse break; + const size_line = try reader.readLine(&line_buf) orelse return error.InvalidResponse; 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 return error.InvalidResponse; From 01bf92c4f40ecc1d90b813eecc255015dc972a54 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 08:20:55 +0200 Subject: [PATCH 16/25] Update src/wait.zig Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/wait.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wait.zig b/src/wait.zig index c762034..cc4524a 100644 --- a/src/wait.zig +++ b/src/wait.zig @@ -218,7 +218,11 @@ fn waitHttp(s: HttpStrategy, target: StrategyTarget, alloc: std.mem.Allocator) ! const result = client.fetch(.{ .location = .{ .url = url }, - .method = if (std.mem.eql(u8, s.method, "POST")) .POST else .GET, + .method = if (std.mem.eql(u8, s.method, "POST")) .POST + else if (std.mem.eql(u8, s.method, "HEAD")) .HEAD + else if (std.mem.eql(u8, s.method, "PUT")) .PUT + else if (std.mem.eql(u8, s.method, "DELETE")) .DELETE + else .GET, }) catch { std.Thread.sleep(poll); continue; From 3e5c2866ebb1d1edbf6786a5da867bb5627f1400 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:20:58 +0000 Subject: [PATCH 17/25] =?UTF-8?q?Fix=20HTTP=20spec=20violation:=20check=20?= =?UTF-8?q?chunked=20before=20content=5Flength=20per=20RFC=207230=20=C2=A7?= =?UTF-8?q?3.3.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dragosv <422243+dragosv@users.noreply.github.com> --- src/docker_client.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 493edf3..bfca19d 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -182,7 +182,13 @@ fn parseResponseHead(reader: *HttpReader) !ResponseMeta { } /// Read the full response body according to the parsed metadata. +/// Per RFC 7230 §3.3.3, Transfer-Encoding takes precedence over Content-Length +/// when both are present. fn readResponseBody(reader: *HttpReader, meta: ResponseMeta, allocator: std.mem.Allocator) ![]const u8 { + if (meta.chunked) { + return readChunkedBody(reader, allocator); + } + if (meta.content_length) |cl| { if (cl == 0) return allocator.dupe(u8, ""); const body_buf = try allocator.alloc(u8, cl); @@ -191,10 +197,6 @@ fn readResponseBody(reader: *HttpReader, meta: ResponseMeta, allocator: std.mem. return body_buf; } - if (meta.chunked) { - return readChunkedBody(reader, allocator); - } - // No Content-Length and not chunked — read until connection close. return readUntilClose(reader, allocator); } From 7d5cc99ab2f895b12779bbe5ad27cfca885819e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:21:04 +0000 Subject: [PATCH 18/25] Replace fixed 4096-byte buffer with ArrayList in sendHttpRequest Co-authored-by: dragosv <422243+dragosv@users.noreply.github.com> --- src/docker_client.zig | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index ece9f22..736bfc5 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -117,15 +117,16 @@ const HttpReader = struct { /// Send an HTTP/1.1 request over a raw stream. fn sendHttpRequest( + allocator: std.mem.Allocator, stream: anytype, 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(); + var hdr_buf = std.ArrayList(u8).init(allocator); + defer hdr_buf.deinit(); + const w = hdr_buf.writer(); try w.print("{s} {s} HTTP/1.1\r\n", .{ method.name(), path }); try w.print("Host: localhost\r\n", .{}); @@ -137,7 +138,7 @@ fn sendHttpRequest( } try w.print("Connection: close\r\n\r\n", .{}); - try stream.writeAll(fbs.getWritten()); + try stream.writeAll(hdr_buf.items); if (body) |b| { try stream.writeAll(b); } @@ -287,7 +288,7 @@ pub const DockerClient = struct { const stream = try std.net.connectUnixSocket(self.socket_path); defer stream.close(); - try sendHttpRequest(stream, method, path, content_type, body); + try sendHttpRequest(self.allocator, stream, method, path, content_type, body); var reader: HttpReader = .{ .stream = stream }; const meta = try parseResponseHead(&reader); @@ -348,7 +349,7 @@ pub const DockerClient = struct { const stream = try std.net.connectUnixSocket(self.socket_path); defer stream.close(); - try sendHttpRequest(stream, .post, api_path, null, null); + try sendHttpRequest(self.allocator, stream, .post, api_path, null, null); var reader: HttpReader = .{ .stream = stream }; const meta = try parseResponseHead(&reader); @@ -734,7 +735,7 @@ pub const DockerClient = struct { const stream = std.net.connectUnixSocket(self.socket_path) catch return false; defer stream.close(); - sendHttpRequest(stream, .get, api_path, null, null) catch return false; + sendHttpRequest(self.allocator, stream, .get, api_path, null, null) catch return false; var reader: HttpReader = .{ .stream = stream }; const meta = parseResponseHead(&reader) catch return false; @@ -1190,7 +1191,7 @@ test "sendHttpRequest: writes a valid GET request" { const fds = try std.posix.pipe(); defer std.posix.close(fds[0]); const write_stream = std.fs.File{ .handle = fds[1] }; - try sendHttpRequest(write_stream, .get, "/v1.46/_ping", null, null); + try sendHttpRequest(std.testing.allocator, 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); @@ -1205,7 +1206,7 @@ test "sendHttpRequest: writes Content-Type and Content-Length for POST with body defer std.posix.close(fds[0]); 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); + try sendHttpRequest(std.testing.allocator, 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); From 9eb74ac0c574c7c8754c3aa8616e774465d79a2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:28:21 +0000 Subject: [PATCH 19/25] Parse Docker pull stream for embedded errors in imagePull Co-authored-by: dragosv <422243+dragosv@users.noreply.github.com> --- src/docker_client.zig | 90 ++++++++++++++++++++++++++++++++++++++++--- src/wait.zig | 1 - 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 493edf3..cf415f3 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -19,6 +19,9 @@ pub const DockerClientError = error{ Conflict, ServerError, InvalidResponse, + /// The Docker API returned a 200 OK but embedded an error object in the + /// streaming JSON response (e.g. image not found, authentication failure). + ImagePullFailed, }; /// HTTP method for Docker API requests. @@ -254,6 +257,39 @@ fn readUntilClose(reader: *HttpReader, allocator: std.mem.Allocator) ![]const u8 return body.toOwnedSlice(allocator); } +/// Scan a newline-delimited JSON pull-progress stream for embedded error objects. +/// +/// Docker's image-pull endpoint returns HTTP 200 OK even on failure; pull +/// errors such as "image not found" or "authentication required" are reported +/// as `{"error":"..."}` JSON objects somewhere in the streaming body. This +/// helper iterates every line of `body`, attempts to parse lines that look +/// like JSON objects, and returns `ImagePullFailed` as soon as any such line +/// contains an `"error"` key. Malformed lines are silently skipped. +/// The Docker error message, when present, is written to the log at `.debug` +/// level so callers can diagnose pull failures. +fn checkPullStreamErrors(allocator: std.mem.Allocator, body: []const u8) !void { + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r"); + if (trimmed.len == 0 or trimmed[0] != '{') continue; + const parsed = std.json.parseFromSlice( + std.json.Value, + allocator, + trimmed, + .{}, + ) catch continue; + defer parsed.deinit(); + if (parsed.value == .object) { + if (parsed.value.object.get("error")) |err_val| { + if (err_val == .string) { + std.log.debug("docker image pull failed: {s}", .{err_val.string}); + } + return DockerClientError.ImagePullFailed; + } + } + } +} + /// Lightweight Docker HTTP client backed by a Unix domain socket. pub const DockerClient = struct { allocator: std.mem.Allocator, @@ -354,8 +390,13 @@ pub const DockerClient = struct { const meta = try parseResponseHead(&reader); if (meta.status_code != 200) return DockerClientError.ApiError; - // Drain the progress stream to wait for completion. - reader.drain(); + // Docker returns 200 OK even for pull failures; errors are embedded as + // {"error":"..."} JSON objects in the newline-delimited progress stream. + // Read the full stream and scan each line for an error field. + const pull_body = try readResponseBody(&reader, meta, self.allocator); + defer self.allocator.free(pull_body); + + try checkPullStreamErrors(self.allocator, pull_body); } /// Check if an image exists locally. Returns true if found. @@ -1143,14 +1184,12 @@ test "readChunkedBody: drains optional trailers before terminating" { try std.testing.expectEqualStrings("hello", body); } -test "readChunkedBody: returns accumulated data on invalid chunk size" { +test "readChunkedBody: returns InvalidResponse 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); + try std.testing.expectError(error.InvalidResponse, readChunkedBody(&reader, std.testing.allocator)); } test "readUntilClose: reads all data to EOF" { @@ -1215,3 +1254,42 @@ test "sendHttpRequest: writes Content-Type and Content-Length for POST with body 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)); } + +test "checkPullStreamErrors: returns ImagePullFailed when error key is present" { + const body = + \\{"status":"Pulling from library/nonexistent"} + \\{"errorDetail":{"message":"manifest for nonexistent:latest not found"},"error":"manifest for nonexistent:latest not found"} + \\ + ; + try std.testing.expectError( + DockerClientError.ImagePullFailed, + checkPullStreamErrors(std.testing.allocator, body), + ); +} + +test "checkPullStreamErrors: succeeds when stream contains no error objects" { + const body = + \\{"status":"Pulling from library/alpine","id":"latest"} + \\{"status":"Pull complete","progressDetail":{},"id":"sha256:abc"} + \\{"status":"Status: Downloaded newer image for alpine:latest"} + \\ + ; + try checkPullStreamErrors(std.testing.allocator, body); +} + +test "checkPullStreamErrors: succeeds on empty stream" { + try checkPullStreamErrors(std.testing.allocator, ""); +} + +test "checkPullStreamErrors: skips non-JSON and malformed lines" { + const body = "not json\n \n{malformed\n{\"status\":\"ok\"}\n"; + try checkPullStreamErrors(std.testing.allocator, body); +} + +test "checkPullStreamErrors: detects error in CRLF-terminated stream" { + const body = "{\"status\":\"Pulling\"}\r\n{\"error\":\"pull access denied\"}\r\n"; + try std.testing.expectError( + DockerClientError.ImagePullFailed, + checkPullStreamErrors(std.testing.allocator, body), + ); +} diff --git a/src/wait.zig b/src/wait.zig index c762034..f91e3aa 100644 --- a/src/wait.zig +++ b/src/wait.zig @@ -223,7 +223,6 @@ fn waitHttp(s: HttpStrategy, target: StrategyTarget, alloc: std.mem.Allocator) ! std.Thread.sleep(poll); continue; }; - defer result.deinit(alloc); const code: u16 = @intFromEnum(result.status); const ok = if (s.status_code == 0) From 4e22390eae38e591b9bbcadcfb757de8aa0eb8d8 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 15:27:45 +0200 Subject: [PATCH 20/25] Fix build --- src/docker_client.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index b222fb9..f42b58f 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -204,7 +204,6 @@ fn readResponseBody(reader: *HttpReader, meta: ResponseMeta, allocator: std.mem. // 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 { From edad26cd10d6210c2fe127d9b2f3acad313d98f7 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 16:08:23 +0200 Subject: [PATCH 21/25] Fix build --- .github/workflows/ci.yml | 18 +----------------- src/docker_client.zig | 2 +- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40040d..8c338fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,22 +19,6 @@ 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 @@ -42,7 +26,7 @@ jobs: - name: Check zig version run: zig version - - name: Build all examples + - name: Build all run: zig build - name: Run tests diff --git a/src/docker_client.zig b/src/docker_client.zig index f42b58f..31c0468 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -127,7 +127,7 @@ fn sendHttpRequest( content_type: ?[]const u8, body: ?[]const u8, ) !void { - var hdr_buf = std.ArrayList(u8).init(allocator); + var hdr_buf = std.std.ArrayList(u8).initCapacity(allocator, 0); defer hdr_buf.deinit(); const w = hdr_buf.writer(); From 43e27d3451874d7338d5b1cf1e11257761093ae1 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 16:42:40 +0200 Subject: [PATCH 22/25] Fix build --- src/docker_client.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 31c0468..274c2b1 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -127,7 +127,7 @@ fn sendHttpRequest( content_type: ?[]const u8, body: ?[]const u8, ) !void { - var hdr_buf = std.std.ArrayList(u8).initCapacity(allocator, 0); + var hdr_buf = std.ArrayList(u8).initCapacity(allocator, 0); defer hdr_buf.deinit(); const w = hdr_buf.writer(); From 4fe26c84dc69d4251c41ccba0a41cc142c876037 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 19:15:52 +0200 Subject: [PATCH 23/25] fixes --- examples/basic.zig | 1 - src/docker_client.zig | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/basic.zig b/examples/basic.zig index 7c87b98..795f85a 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -40,7 +40,6 @@ pub fn main() !void { const fetch_result = try http_client.fetch(.{ .location = .{ .url = url }, }); - defer fetch_result.deinit(allocator); std.log.info("HTTP status: {d}", .{@intFromEnum(fetch_result.status)}); diff --git a/src/docker_client.zig b/src/docker_client.zig index 274c2b1..76e1691 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -127,9 +127,9 @@ fn sendHttpRequest( content_type: ?[]const u8, body: ?[]const u8, ) !void { - var hdr_buf = std.ArrayList(u8).initCapacity(allocator, 0); - defer hdr_buf.deinit(); - const w = hdr_buf.writer(); + var hdr_buf = try std.ArrayList(u8).initCapacity(allocator, 0); + defer hdr_buf.deinit(allocator); + const w = hdr_buf.writer(allocator); try w.print("{s} {s} HTTP/1.1\r\n", .{ method.name(), path }); try w.print("Host: localhost\r\n", .{}); From 9674e44dc750baab8fabf2a7a36c897c2fd75ab5 Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 20:32:45 +0200 Subject: [PATCH 24/25] Update src/root.zig Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/root.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/root.zig b/src/root.zig index 0959f00..adcc949 100644 --- a/src/root.zig +++ b/src/root.zig @@ -87,10 +87,14 @@ pub const DockerProvider = struct { if (std.posix.getenv("DOCKER_HOST")) |host| { if (std.mem.startsWith(u8, host, "unix://")) { socket = host["unix://".len..]; + } else if (std.mem.indexOf(u8, host, "://") != null) { + // Unsupported transport for this Unix-socket-only client. + socket = docker_socket; } else { socket = host; } } + } return init_with_socket(allocator, socket); } From 0988231e3473712ffcecb44f5947488203c4f13f Mon Sep 17 00:00:00 2001 From: Dragos Varovici Date: Thu, 5 Mar 2026 20:42:14 +0200 Subject: [PATCH 25/25] Fixes for imagePull logic --- src/docker_client.zig | 33 +++++++++++++++++++++++++++++---- src/root.zig | 1 - 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/docker_client.zig b/src/docker_client.zig index 76e1691..29ac45c 100644 --- a/src/docker_client.zig +++ b/src/docker_client.zig @@ -396,11 +396,36 @@ pub const DockerClient = struct { // Docker returns 200 OK even for pull failures; errors are embedded as // {"error":"..."} JSON objects in the newline-delimited progress stream. - // Read the full stream and scan each line for an error field. - const pull_body = try readResponseBody(&reader, meta, self.allocator); - defer self.allocator.free(pull_body); + // We read the stream incrementally to avoid allocating the whole body. + if (meta.chunked) { + var line_buf: [4096]u8 = undefined; + while (true) { + const size_line = try reader.readLine(&line_buf) orelse return error.InvalidResponse; + 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 return error.InvalidResponse; + + if (chunk_size == 0) { + while (true) { + const trailer = try reader.readLine(&line_buf) orelse break; + if (trailer.len == 0) break; + } + break; + } + + const chunk = try self.allocator.alloc(u8, chunk_size); + defer self.allocator.free(chunk); + try reader.readExact(chunk); + + _ = try reader.readLine(&line_buf); // trailing \r\n - try checkPullStreamErrors(self.allocator, pull_body); + try checkPullStreamErrors(self.allocator, chunk); + } + } else { + const pull_body = try readResponseBody(&reader, meta, self.allocator); + defer self.allocator.free(pull_body); + try checkPullStreamErrors(self.allocator, pull_body); + } } /// Check if an image exists locally. Returns true if found. diff --git a/src/root.zig b/src/root.zig index adcc949..dc02b73 100644 --- a/src/root.zig +++ b/src/root.zig @@ -94,7 +94,6 @@ pub const DockerProvider = struct { socket = host; } } - } return init_with_socket(allocator, socket); }