Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
src/pal_curl.c
zftpd / src / pal_curl.c
1/*
2 * GNU GPLv3 License — Copyright (c) 2026 SeregonWar
3 * See LICENSE for full text.
4 */
5 
6/* ═══════════════════════════════════════════════════════════════════════════
7 * pal_curl.c — Complete libcurl shim for PS4/PS5
8 *
9 * ── ARCHITECTURE ────────────────────────────────────────────────────────────
10 *
11 * curl_easy_perform() orchestrates the following pipeline:
12 *
13 * curl_easy_perform()
14 * └─ perform_one() ← one HTTP transaction (no redirect logic)
15 * ├─ url_parse()
16 * ├─ net_connect() ← getaddrinfo + non-blocking connect/select
17 * ├─ build_request() ← assemble HTTP/1.1 request headers
18 * ├─ send_all() ← write-loop with EINTR retry
19 * ├─ http_recv_headers() ← accumulate until \r\n\r\n
20 * ├─ parse_status_line()
21 * ├─ parse_header_fields() ← Content-Length / Transfer-Encoding / Location
22 * └─ stream_body_*() ← one of three body readers:
23 * stream_body_known_length() content-length based
24 * stream_body_chunked() chunked/TE decoder
25 * stream_body_until_close() read-until-EOF
26 *
27 * ── KEY DESIGN DECISIONS ────────────────────────────────────────────────────
28 *
29 * getaddrinfo instead of gethostbyname:
30 * gethostbyname() is deprecated, non-reentrant (global h_errno), and
31 * does not support IPv6. getaddrinfo() is the POSIX.1-2008 replacement
32 * and is safe to call concurrently from different threads.
33 *
34 * Non-blocking connect with select() timeout:
35 * The original code stored CURLOPT_CONNECTTIMEOUT but never used it.
36 * We set O_NONBLOCK before connect(), then wait in select() for the
37 * writability event. getsockopt(SO_ERROR) confirms actual success.
38 * The socket is restored to blocking mode before HTTP I/O.
39 *
40 * Separate header and body buffers:
41 * The original code used a single 16 KB buffer for both headers and body,
42 * with an ad-hoc "header_len" counter reset to 0 after headers were found.
43 * This led to incorrect body offset calculations when the server sent
44 * headers and body in the same TCP segment. We now use:
45 * hdr_buf[HDR_BUF_SIZE] — stack-allocated, holds only headers +
46 * the first partial body chunk.
47 * body_buf (heap) — used exclusively for body streaming.
48 *
49 * HTTP/1.1 with chunked Transfer-Encoding:
50 * HTTP/1.0 was used to avoid chunked decoding. Modern servers (even on
51 * local networks) frequently respond with HTTP/1.1 chunked even to
52 * HTTP/1.0 requests. We now send HTTP/1.1 + Connection: close and
53 * implement a proper state-machine chunked decoder (chunked_process()).
54 * Connection: close ensures no keep-alive complexity.
55 *
56 * Overflow-safe arithmetic:
57 * content_length was previously read with strtod(), which loses precision
58 * for files > 2^53 bytes (~8 PB). We use strtoll() instead.
59 * All comparisons between uint32_t offsets and uint64_t file sizes widen
60 * operands explicitly before arithmetic.
61 *
62 * EINTR handling:
63 * All send()/recv() calls are retried on EINTR. A signal (e.g. from
64 * the debugger or a real-time timer) no longer produces spurious I/O errors.
65 *
66 * CURL_WRITEFUNC_PAUSE:
67 * The original code looped forever on PAUSE with only usleep().
68 * We retry up to PAL_PAUSE_MAX_RETRIES times then abort with
69 * CURLE_ABORTED_BY_CALLBACK. This prevents a stuck download from
70 * blocking a thread forever.
71 *
72 * ── MISRA C:2012 DEVIATIONS ─────────────────────────────────────────────────
73 * Rule 15.5 — single exit point: violated in short validation sequences
74 * to avoid deeply-nested if-else ladders.
75 * Rule 21.3 — dynamic memory: malloc/free used only for body_buf
76 * and curl_slist nodes. The ctx itself uses calloc/free.
77 * Rule 13.1 — side effects in initializers: none present.
78 * ═══════════════════════════════════════════════════════════════════════════ */
79 
80#ifndef _FILE_OFFSET_BITS
81# define _FILE_OFFSET_BITS 64
82#endif
83#ifndef _POSIX_C_SOURCE
84# define _POSIX_C_SOURCE 200809L
85#endif
86 
87#include "pal_curl.h"
88#include <stdio.h>
89#include <stdlib.h>
90#include <string.h>
91#include <stdint.h>
92#include <stdarg.h>
93#include <ctype.h>
94#include <errno.h>
95#include <unistd.h>
96#include <fcntl.h>
97#include <time.h>
98#include <strings.h> /* strncasecmp */
99#include <sys/types.h>
100#include <sys/socket.h>
101#include <sys/select.h>
102#include <sys/time.h>
103#include <netinet/in.h>
104#include <netinet/tcp.h>
105#include <arpa/inet.h>
106#include <netdb.h>
107 
108int gettimeofday(struct timeval *tv, void *tz);
109 
110/* ── Compile-time tuning constants ─────────────────────────────────────── */
111 
112#define DEFAULT_UA "zftpd-ps/2.0"
113#define DEFAULT_CONN_MS 30000L /* 30 s default connect timeout */
114#define DEFAULT_TIMEOUT_MS 0L /* 0 = no transfer timeout */
115#define DEFAULT_MAX_REDIRS 10L
116 
117/** Maximum total bytes for HTTP response headers (stack). */
118#define HDR_BUF_SIZE 8192U
119/** HTTP request assembly buffer (stack). */
120#define REQ_BUF_SIZE 8192U
121/** Body streaming chunk (heap-allocated once per perform). */
122#define BODY_BUF_SIZE 32768U
123 
124/** Max retries when write callback returns CURL_WRITEFUNC_PAUSE. */
125#define PAL_PAUSE_MAX_RETRIES 20
126/** Sleep between PAUSE retries (100 ms). */
127#define PAL_PAUSE_DELAY_US 100000U
128 
129/* ── Internal context ──────────────────────────────────────────────────── */
130 
131typedef struct {
132 /* ── Request configuration ──────────────────────────────────── */
133 char url[2048U];
134 char useragent[256U];
135 char range[64U]; /* e.g. "0-4095" */
136 char *postfields; /* caller-owned — not copied */
137 long postfieldsize; /* -1 or 0 → use strlen(postfields) */
138 curl_slist *httpheader; /* caller-owned — not copied */
139 char *errbuf; /* caller-supplied CURL_ERROR_SIZE buffer */
140 
141 /* ── Callbacks ──────────────────────────────────────────────── */
142 curl_write_callback write_cb;
143 void *write_data;
144 curl_xferinfo_callback xferinfo_cb;
145 void *xferinfo_data;
146 
147 /* ── Behavior ───────────────────────────────────────────────── */
148 long follow_location;
149 long max_redirs;
150 long connect_timeout_ms;
151 long timeout_ms;
152 long low_speed_limit; /* bytes/s threshold */
153 long low_speed_time; /* seconds below threshold before abort */
154 int nobody; /* 1 = HEAD */
155 int post; /* 1 = POST */
156 int verbose; /* 1 = print debug to stderr */
157 int noprogress; /* 1 = suppress xferinfo callback */
158 
159 /* ── Response info (populated by perform) ───────────────────── */
160 long response_code;
161 double content_length_download;
162 double speed_download;
163 double size_download;
164 double total_time;
165} pal_curl_ctx_t;
166 
167/* ── HTTP response metadata ─────────────────────────────────────────────── */
168 
169typedef struct {
170 long status_code;
171 int64_t content_length; /* -1 if absent */
172 int chunked; /* Transfer-Encoding: chunked */
173 char location[2048U]; /* Redirect target, if any */
174} http_resp_t;
175 
176/* ── Chunked transfer decoder state ─────────────────────────────────────── */
177 
178typedef enum {
179 CS_SIZE = 0, /* Reading hex chunk-size line */
180 CS_DATA = 1, /* Reading chunk payload */
181 CS_CRLF = 2, /* Consuming \r\n after payload */
182 CS_TRAILER = 3, /* Consuming optional trailer headers */
183 CS_DONE = 4 /* Terminal zero-chunk processed */
184} chunked_state_e;
185 
186typedef struct {
187 chunked_state_e state;
188 char size_buf[24U]; /* Hex digits + optional extensions */
189 size_t size_pos;
190 uint64_t remaining; /* Bytes left in current chunk */
191 int crlf_pos; /* 0 or 1 while consuming post-data \r\n */
192} chunked_ctx_t;
193 
194/* ── Speed guard (CURLOPT_LOW_SPEED_*) ─────────────────────────────────── */
195 
196typedef struct {
197 time_t last_sec; /* wall-clock second of last measurement reset */
198 uint64_t sec_bytes; /* bytes accumulated since last_sec */
199 int slow_count; /* consecutive seconds below threshold */
200} speed_guard_t;
201 
202/* ═══════════════════════════════════════════════════════════════════════════
203 * §1 — String and error-buffer helpers
204 * ═════════════════════════════════════════════════════════════════════════*/
205 
206/** Skip ASCII horizontal whitespace. */
207static const char *skip_ws(const char *s)
208{
209 while (*s == ' ' || *s == '\t') { s++; }
210 return s;
211}
212 
213/**
214 * @brief Case-insensitive test whether a header line starts with prefix.
215 * @return Pointer to the value (after prefix) or NULL.
216 */
217static const char *header_field(const char *line, const char *prefix,
218 size_t prefix_len)
219{
220 if (strncasecmp(line, prefix, prefix_len) != 0) { return NULL; }
221 return line + prefix_len;
222}
223 
224/**
225 * @brief Append a printf-style string into buf at *pos, tracking remaining
226 * space. Returns 0 on success, -1 if the result would be truncated.
227 *
228 * DESIGN RATIONALE:
229 * The original code used bare snprintf() and checked the return value
230 * inline each time, mixing logic with error handling. This helper
231 * centralises the truncation check and keeps build_request() readable.
232 */
233__attribute__((format(printf, 4, 5)))
234static int append_str(char *buf, size_t buf_size, size_t *pos,
235 const char *fmt, ...)
236{
237 if (*pos >= buf_size) { return -1; }
238 
239 va_list ap;
240 va_start(ap, fmt);
241 int n = vsnprintf(buf + *pos, buf_size - *pos, fmt, ap);
242 va_end(ap);
243 
244 if (n < 0 || (size_t)n >= (buf_size - *pos)) { return -1; }
245 *pos += (size_t)n;
246 return 0;
247}
248 
249/**
250 * @brief Write an error message into ctx->errbuf if it was set.
251 * @note Never calls fprintf — all diagnostic output is opt-in via
252 * CURLOPT_VERBOSE to stderr, not via errbuf writes.
253 */
254static void set_errbuf(pal_curl_ctx_t *ctx, const char *msg)
255{
256 if ((ctx == NULL) || (ctx->errbuf == NULL) || (msg == NULL)) { return; }
257 strncpy(ctx->errbuf, msg, (size_t)(CURL_ERROR_SIZE - 1));
258 ctx->errbuf[CURL_ERROR_SIZE - 1] = '\0';
259}
260 
261#define PAL_LOG(ctx, ...) \
262 do { if ((ctx) != NULL && (ctx)->verbose) { fprintf(stderr, "[pal_curl] " __VA_ARGS__); } } while (0)
263 
264/* ═══════════════════════════════════════════════════════════════════════════
265 * §2 — URL parser
266 * ═════════════════════════════════════════════════════════════════════════*/
267 
268/**
269 * @brief Decompose an http:// URL into host, port, and path.
270 *
271 * Handles:
272 * http://host/path
273 * http://host:port/path?query
274 * http://host/path#fragment (fragment stripped)
275 *
276 * https:// → CURLE_UNSUPPORTED_PROTOCOL.
277 * Missing scheme or empty host → CURLE_URL_MALFORMAT.
278 * Port outside [1, 65535] → CURLE_URL_MALFORMAT.
279 *
280 * @param[in] url NUL-terminated input URL.
281 * @param[out] host Destination for hostname (must be ≥ host_size bytes).
282 * @param[in] host_size Size of host buffer.
283 * @param[out] port Decoded TCP port.
284 * @param[out] path Destination for request path (must be ≥ path_size bytes).
285 * @param[in] path_size Size of path buffer.
286 *
287 * @return CURLE_OK on success.
288 *
289 * @note Thread-safety: pure function (no shared state).
290 * @note WCET: O(len(url)).
291 */
292static CURLcode url_parse(const char *url,
293 char *host, size_t host_size,
294 int *port,
295 char *path, size_t path_size)
296{
297 if ((url == NULL) || (host == NULL) || (port == NULL) ||
298 (path == NULL) || (host_size < 2U) || (path_size < 2U)) {
299 return CURLE_URL_MALFORMAT;
300 }
301 
302 const char *p = url;
303 if (strncmp(p, "http://", 7U) == 0) {
304 p += 7U;
305 } else if (strncmp(p, "https://", 8U) == 0) {
306 return CURLE_UNSUPPORTED_PROTOCOL;
307 } else {
308 return CURLE_URL_MALFORMAT;
309 }
310 
311 /* Locate the path separator. */
312 const char *slash = strchr(p, '/');
313 
314 /* Find port separator, only if it precedes slash (or there is no slash). */
315 const char *colon = strchr(p, ':');
316 if ((colon != NULL) && (slash != NULL) && (colon > slash)) {
317 colon = NULL; /* colon belongs to the path, not the authority */
318 }
319 
320 /* Decode host. */
321 const char *host_end = (colon != NULL) ? colon
322 : (slash != NULL) ? slash
323 : (p + strlen(p));
324 size_t host_len = (size_t)(host_end - p);
325 if ((host_len == 0U) || (host_len >= host_size)) {
326 return CURLE_URL_MALFORMAT;
327 }
328 (void)memcpy(host, p, host_len);
329 host[host_len] = '\0';
330 
331 /* Decode port. */
332 if (colon != NULL) {
333 const char *port_end = (slash != NULL) ? slash : (colon + 1U + strlen(colon + 1U));
334 size_t port_len = (size_t)(port_end - (colon + 1U));
335 if (port_len == 0U || port_len >= 6U) {
336 return CURLE_URL_MALFORMAT;
337 }
338 char port_buf[6U];
339 (void)memcpy(port_buf, colon + 1U, port_len);
340 port_buf[port_len] = '\0';
341 char *endp;
342 long lport = strtol(port_buf, &endp, 10);
343 if ((*endp != '\0') || (lport <= 0L) || (lport > 65535L)) {
344 return CURLE_URL_MALFORMAT;
345 }
346 *port = (int)lport;
347 } else {
348 *port = 80;
349 }
350 
351 /* Decode path, stripping fragment. */
352 const char *path_start = (slash != NULL) ? slash : "/";
353 const char *fragment = strchr(path_start, '#');
354 size_t path_len = (fragment != NULL) ? (size_t)(fragment - path_start)
355 : strlen(path_start);
356 if (path_len == 0U) {
357 path_start = "/";
358 path_len = 1U;
359 }
360 if (path_len >= path_size) { return CURLE_URL_MALFORMAT; }
361 (void)memcpy(path, path_start, path_len);
362 path[path_len] = '\0';
363 
364 return CURLE_OK;
365}
366 
367/* ═══════════════════════════════════════════════════════════════════════════
368 * §3 — Network utilities
369 * ═════════════════════════════════════════════════════════════════════════*/
370 
371/**
372 * @brief Wait for a socket to become readable or writable, with timeout.
373 *
374 * @param sock File descriptor.
375 * @param for_write Non-zero to wait for write-readiness, zero for read.
376 * @param timeout_ms Milliseconds to wait. 0 or negative → poll (no wait).
377 *
378 * @return >0 descriptor ready, 0 timeout, <0 error (check errno).
379 *
380 * @note Thread-safety: pure, no shared state.
381 * @note WCET: up to timeout_ms milliseconds.
382 */
383static int socket_wait(int sock, int for_write, long timeout_ms)
384{
385 fd_set fds;
386 FD_ZERO(&fds);
387 FD_SET(sock, &fds);
388 
389 struct timeval tv;
390 tv.tv_sec = timeout_ms / 1000L;
391 tv.tv_usec = (timeout_ms % 1000L) * 1000L;
392 
393 fd_set *rfds = for_write ? NULL : &fds;
394 fd_set *wfds = for_write ? &fds : NULL;
395 
396 return select(sock + 1, rfds, wfds, NULL,
397 (timeout_ms > 0L) ? &tv : NULL);
398}
399 
400/**
401 * @brief Resolve host and connect with an optional timeout.
402 *
403 * Uses getaddrinfo() for IPv4/IPv6 support and tries each returned address
404 * in order until one connects. If timeout_ms > 0, the socket is set to
405 * non-blocking for the connect() call and restored to blocking on success.
406 *
407 * @param host NUL-terminated hostname or IP address.
408 * @param port TCP port [1, 65535].
409 * @param timeout_ms Connect timeout in ms (0 = blocking with no timeout).
410 * @param err_out Receives CURLE_COULDNT_RESOLVE_HOST or
411 * CURLE_COULDNT_CONNECT on failure.
412 *
413 * @return Non-negative socket fd on success, -1 on failure.
414 *
415 * @note Thread-safety: getaddrinfo() is thread-safe on POSIX.
416 * @note WCET: up to timeout_ms ms (DNS resolution not bounded).
417 */
418static int net_connect(const char *host, int port, long timeout_ms,
419 CURLcode *err_out)
420{
421 *err_out = CURLE_COULDNT_CONNECT;
422 
423 char port_str[12U];
424 (void)snprintf(port_str, sizeof(port_str), "%d", port);
425 
426 struct addrinfo hints;
427 (void)memset(&hints, 0, sizeof(hints));
428 hints.ai_family = AF_UNSPEC;
429 hints.ai_socktype = SOCK_STREAM;
430 hints.ai_protocol = IPPROTO_TCP;
431 
432 struct addrinfo *result = NULL;
433 if (getaddrinfo(host, port_str, &hints, &result) != 0) {
434 *err_out = CURLE_COULDNT_RESOLVE_HOST;
435 return -1;
436 }
437 
438 int sock = -1;
439 for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) {
440 sock = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
441 if (sock < 0) { continue; }
442 
443 if (timeout_ms > 0L) {
444 int flags = fcntl(sock, F_GETFL, 0);
445 if (fcntl(sock, F_SETFL, flags | O_NONBLOCK) != 0) {
446 close(sock); sock = -1; continue;
447 }
448 }
449 
450 int r = connect(sock, rp->ai_addr, rp->ai_addrlen);
451 
452 if (r == 0) { break; } /* Immediate success */
453 
454 if ((r < 0) && (errno == EINPROGRESS) && (timeout_ms > 0L)) {
455 int sel = socket_wait(sock, 1, timeout_ms);
456 if (sel > 0) {
457 int so_err = 0;
458 socklen_t sl = (socklen_t)sizeof(so_err);
459 (void)getsockopt(sock, SOL_SOCKET, SO_ERROR, &so_err, &sl);
460 if (so_err == 0) { break; } /* Connected */
461 }
462 }
463 
464 close(sock);
465 sock = -1;
466 }
467 
468 freeaddrinfo(result);
469 
470 if (sock < 0) { return -1; }
471 
472 /* Restore blocking mode. */
473 if (timeout_ms > 0L) {
474 int flags = fcntl(sock, F_GETFL, 0);
475 (void)fcntl(sock, F_SETFL, flags & ~O_NONBLOCK);
476 }
477 
478 /* Disable Nagle algorithm: reduces latency for small HTTP requests. */
479 int one = 1;
480 (void)setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, (socklen_t)sizeof(one));
481 
482 return sock;
483}
484 
485/**
486 * @brief Reliably write all len bytes to sock, retrying on EINTR and
487 * partial writes. Respects timeout_ms between attempts.
488 *
489 * @return 0 on success, -1 on timeout or send error.
490 *
491 * @note Thread-safety: NOT thread-safe.
492 * @note WCET: up to timeout_ms per send attempt.
493 */
494static int send_all(int sock, const void *data, size_t len, long timeout_ms)
495{
496 const char *p = (const char *)data;
497 
498 while (len > 0U) {
499 if (timeout_ms > 0L) {
500 if (socket_wait(sock, 1, timeout_ms) <= 0) { return -1; }
501 }
502 
503 ssize_t n;
504 do { n = send(sock, p, len, 0); } while ((n < 0) && (errno == EINTR));
505 
506 if (n <= 0) { return -1; }
507 p += (size_t)n;
508 len -= (size_t)n;
509 }
510 return 0;
511}
512 
513/**
514 * @brief Read up to len bytes from sock, with optional timeout.
515 * Retries on EINTR.
516 *
517 * @return Bytes read (≥ 0), or -1 on error/timeout.
518 *
519 * @note Thread-safety: NOT thread-safe.
520 * @note WCET: up to timeout_ms ms, plus kernel recv latency.
521 */
522static ssize_t recv_timed(int sock, void *buf, size_t len, long timeout_ms)
523{
524 if (timeout_ms > 0L) {
525 int sel = socket_wait(sock, 0, timeout_ms);
526 if (sel < 0) { return -1; }
527 if (sel == 0) { errno = ETIMEDOUT; return -1; }
528 }
529 
530 ssize_t n;
531 do { n = recv(sock, buf, len, 0); } while ((n < 0) && (errno == EINTR));
532 return n;
533}
534 
535/* ═══════════════════════════════════════════════════════════════════════════
536 * §4 — HTTP request builder
537 * ═════════════════════════════════════════════════════════════════════════*/
538 
539/**
540 * @brief Assemble an HTTP/1.1 request into buf.
541 *
542 * Includes Host, User-Agent, Accept, Connection: close, and optionally:
543 * Range, Content-Type/Content-Length (for POST), and caller-supplied
544 * headers (curl_slist).
545 *
546 * @param ctx Handle context.
547 * @param method "GET", "POST", or "HEAD".
548 * @param host Hostname (for Host: header).
549 * @param port TCP port.
550 * @param path Request path + query string.
551 * @param buf Destination buffer.
552 * @param buf_size Size of buf.
553 *
554 * @return Length of assembled request in bytes, or -1 on truncation.
555 *
556 * @note Thread-safety: pure (no shared state).
557 * @note WCET: O(n) where n is total header string length.
558 */
559static int build_request(const pal_curl_ctx_t *ctx,
560 const char *method, const char *host, int port,
561 const char *path, char *buf, size_t buf_size)
562{
563 size_t pos = 0U;
564 const char *ua = (ctx->useragent[0] != '\0') ? ctx->useragent : DEFAULT_UA;
565 
566 if (append_str(buf, buf_size, &pos,
567 "%s %s HTTP/1.1\r\n"
568 "Host: %s:%d\r\n"
569 "User-Agent: %s\r\n"
570 "Accept: */*\r\n"
571 "Connection: close\r\n",
572 method, path, host, port, ua) != 0) { return -1; }
573 
574 if (ctx->range[0] != '\0') {
575 if (append_str(buf, buf_size, &pos, "Range: bytes=%s\r\n",
576 ctx->range) != 0) { return -1; }
577 }
578 
579 if ((ctx->post != 0) && (ctx->postfields != NULL)) {
580 long plen = (ctx->postfieldsize > 0L) ? ctx->postfieldsize
581 : (long)strlen(ctx->postfields);
582 if (append_str(buf, buf_size, &pos,
583 "Content-Type: application/x-www-form-urlencoded\r\n"
584 "Content-Length: %ld\r\n", plen) != 0) { return -1; }
585 }
586 
587 for (const curl_slist *h = ctx->httpheader; h != NULL; h = h->next) {
588 if (h->data == NULL) { continue; }
589 if (append_str(buf, buf_size, &pos, "%s\r\n", h->data) != 0) {
590 return -1;
591 }
592 }
593 
594 if (append_str(buf, buf_size, &pos, "\r\n") != 0) { return -1; }
595 
596 return (int)pos;
597}
598 
599/* ═══════════════════════════════════════════════════════════════════════════
600 * §5 — HTTP response header reader and parser
601 * ═════════════════════════════════════════════════════════════════════════*/
602 
603/**
604 * @brief Read from sock until the end-of-headers sentinel "\r\n\r\n" is
605 * found or hdr_buf is full.
606 *
607 * On success, hdr_buf[0 .. hdr_len) contains the complete header block
608 * (including the terminal \r\n\r\n), and hdr_buf[hdr_len .. body_prefix_len)
609 * contains any body bytes that arrived with the same TCP segments.
610 *
611 * hdr_buf is NUL-terminated after the last received byte.
612 *
613 * @param sock Connected socket.
614 * @param timeout_ms Per-recv timeout.
615 * @param hdr_buf Caller-supplied buffer (HDR_BUF_SIZE bytes).
616 * @param hdr_buf_size sizeof(hdr_buf).
617 * @param hdr_len_out Receives length of headers incl. \r\n\r\n.
618 * @param body_prefix_out Receives byte count of body data in hdr_buf
619 * after the header end.
620 *
621 * @return CURLE_OK, CURLE_GOT_NOTHING, or CURLE_RECV_ERROR.
622 *
623 * @note Thread-safety: NOT thread-safe.
624 */
625static CURLcode http_recv_headers(int sock, long timeout_ms,
626 char *hdr_buf, size_t hdr_buf_size,
627 size_t *hdr_len_out,
628 size_t *body_prefix_out)
629{
630 size_t pos = 0U;
631 
632 while (pos < hdr_buf_size - 1U) {
633 ssize_t n = recv_timed(sock, hdr_buf + pos,
634 hdr_buf_size - 1U - pos, timeout_ms);
635 if (n < 0) { return CURLE_RECV_ERROR; }
636 if (n == 0) {
637 return (pos == 0U) ? CURLE_GOT_NOTHING : CURLE_RECV_ERROR;
638 }
639 
640 pos += (size_t)n;
641 hdr_buf[pos] = '\0';
642 
643 const char *end = strstr(hdr_buf, "\r\n\r\n");
644 if (end != NULL) {
645 *hdr_len_out = (size_t)(end - hdr_buf) + 4U;
646 *body_prefix_out = pos - *hdr_len_out;
647 return CURLE_OK;
648 }
649 }
650 
651 return CURLE_RECV_ERROR; /* Headers exceeded HDR_BUF_SIZE */
652}
653 
654/**
655 * @brief Parse the HTTP status code from the first line of hdr_buf.
656 *
657 * @param hdr_buf NUL-terminated header buffer.
658 * @param status_out Receives the numeric status code.
659 *
660 * @return 0 on success, -1 if the line is malformed.
661 *
662 * @note Thread-safety: pure.
663 */
664static int parse_status_line(const char *hdr_buf, long *status_out)
665{
666 /* Expect "HTTP/1.x NNN ..." */
667 if (strncmp(hdr_buf, "HTTP/1.", 7U) != 0) { return -1; }
668 
669 const char *sp = strchr(hdr_buf + 7U, ' ');
670 if (sp == NULL) { return -1; }
671 
672 char *endp;
673 long code = strtol(sp + 1, &endp, 10);
674 if ((endp == sp + 1) || (code < 100L) || (code > 999L)) { return -1; }
675 
676 *status_out = code;
677 return 0;
678}
679 
680/**
681 * @brief Extract well-known fields from response headers.
682 *
683 * Parses headers line by line. Each line is temporarily NUL-terminated
684 * then restored, so hdr_buf must be writable.
685 *
686 * Extracted:
687 * Content-Length → info->content_length (int64_t; -1 if absent)
688 * Transfer-Encoding → info->chunked (1 if "chunked")
689 * Location → info->location[]
690 *
691 * @param hdr_buf Writable header buffer (NUL-terminated).
692 * @param hdr_len Byte count of header block, excluding terminal \r\n\r\n.
693 * @param info Output struct (caller must zero-initialise).
694 *
695 * @note Thread-safety: NOT thread-safe (temporarily modifies hdr_buf).
696 * @note WCET: O(hdr_len).
697 */
698static void parse_header_fields(char *hdr_buf, size_t hdr_len,
699 http_resp_t *info)
700{
701 info->content_length = -1;
702 info->chunked = 0;
703 info->location[0] = '\0';
704 
705 /* Skip the status line. */
706 char *p = strstr(hdr_buf, "\r\n");
707 if (p == NULL) { return; }
708 p += 2;
709 
710 char *hdr_end = hdr_buf + hdr_len;
711 
712 while (p < hdr_end) {
713 char *next = strstr(p, "\r\n");
714 size_t line_len = (next != NULL) ? (size_t)(next - p)
715 : (size_t)(hdr_end - p);
716 if (line_len == 0U) { break; }
717 
718 /* Temporarily NUL-terminate this header line. */
719 char saved = p[line_len];
720 p[line_len] = '\0';
721 
722 const char *val;
723 if ((val = header_field(p, "Content-Length:", 15U)) != NULL) {
724 info->content_length = strtoll(skip_ws(val), NULL, 10);
725 } else if ((val = header_field(p, "Transfer-Encoding:", 18U)) != NULL) {
726 if (strncasecmp(skip_ws(val), "chunked", 7U) == 0) {
727 info->chunked = 1;
728 }
729 } else if ((val = header_field(p, "Location:", 9U)) != NULL) {
730 const char *loc = skip_ws(val);
731 strncpy(info->location, loc, sizeof(info->location) - 1U);
732 info->location[sizeof(info->location) - 1U] = '\0';
733 }
734 
735 p[line_len] = saved;
736 if (next == NULL) { break; }
737 p = next + 2;
738 }
739}
740 
741/* ═══════════════════════════════════════════════════════════════════════════
742 * §6 — Chunked Transfer-Encoding decoder
743 * ═════════════════════════════════════════════════════════════════════════*/
744 
745/** Sentinel: chunked_process() sets this to signal the final zero chunk. */
746#define PAL_CHUNKED_DONE 1000
747 
748/**
749 * @brief Streaming state-machine chunked decoder.
750 *
751 * Processes in_len bytes of raw (possibly partial) chunked stream data,
752 * writing decoded payload via write_cb. May be called incrementally:
753 * state persists in cc across calls.
754 *
755 * States:
756 * CS_SIZE Accumulating the hex chunk-size line (+ optional extensions).
757 * CS_DATA Forwarding chunk payload bytes to the write callback.
758 * CS_CRLF Consuming the mandatory \r\n after payload.
759 * CS_TRAILER Consuming optional trailers after the zero-length chunk.
760 * CS_DONE Terminal state; further calls return PAL_CHUNKED_DONE.
761 *
762 * @param cc Persistent decoder state (caller-allocated).
763 * @param in Input buffer of raw chunked data.
764 * @param in_len Byte count in in[].
765 * @param write_cb Write callback (may be NULL).
766 * @param write_data Userdata for write_cb.
767 * @param written_out Incremented with decoded bytes written to callback.
768 *
769 * @return CURLE_OK to continue, PAL_CHUNKED_DONE when complete,
770 * CURLE_WRITE_ERROR or CURLE_RECV_ERROR on errors.
771 *
772 * @note Thread-safety: NOT thread-safe.
773 * @note WCET: O(in_len).
774 */
775static int chunked_process(chunked_ctx_t *cc,
776 const uint8_t *in, size_t in_len,
777 curl_write_callback write_cb, void *write_data,
778 uint64_t *written_out)
779{
780 size_t i = 0U;
781 
782 while (i < in_len) {
783 switch (cc->state) {
784 
785 case CS_SIZE: {
786 char c = (char)in[i++];
787 if (c == '\r') {
788 /* skip: we parse on the \n */
789 } else if (c == '\n') {
790 cc->size_buf[cc->size_pos] = '\0';
791 /* Strip chunk extensions (;params) before the hex number. */
792 char *semi = strchr(cc->size_buf, ';');
793 if (semi != NULL) { *semi = '\0'; }
794 char *endp;
795 cc->remaining = (uint64_t)strtoull(cc->size_buf, &endp, 16);
796 cc->size_pos = 0U;
797 if (endp == cc->size_buf) { return CURLE_RECV_ERROR; }
798 cc->state = (cc->remaining == 0U) ? CS_TRAILER : CS_DATA;
799 } else {
800 if (cc->size_pos >= sizeof(cc->size_buf) - 1U) {
801 return CURLE_RECV_ERROR;
802 }
803 cc->size_buf[cc->size_pos++] = c;
804 }
805 break;
806 }
807 
808 case CS_DATA: {
809 size_t avail = in_len - i;
810 size_t to_write = (avail < (size_t)cc->remaining)
811 ? avail : (size_t)cc->remaining;
812 
813 if ((to_write > 0U) && (write_cb != NULL)) {
814 size_t wrote = write_cb((void *)(uintptr_t)(in + i), 1U, to_write,
815 write_data);
816 if (wrote == (size_t)CURL_WRITEFUNC_PAUSE) {
817 return CURLE_ABORTED_BY_CALLBACK;
818 }
819 if (wrote != to_write) { return CURLE_WRITE_ERROR; }
820 *written_out += to_write;
821 }
822 i += to_write;
823 cc->remaining -= (uint64_t)to_write;
824 
825 if (cc->remaining == 0U) {
826 cc->state = CS_CRLF;
827 cc->crlf_pos = 0;
828 }
829 break;
830 }
831 
832 case CS_CRLF:
833 /*
834 * Consume exactly \r\n following the chunk data.
835 * RFC 7230 §4.1 requires the CRLF to be present.
836 */
837 if ((in[i] == (uint8_t)'\r') && (cc->crlf_pos == 0)) {
838 cc->crlf_pos = 1;
839 } else if ((in[i] == (uint8_t)'\n') && (cc->crlf_pos == 1)) {
840 cc->crlf_pos = 0;
841 cc->state = CS_SIZE;
842 } else {
843 return CURLE_RECV_ERROR;
844 }
845 i++;
846 break;
847 
848 case CS_TRAILER:
849 /*
850 * Trailer headers after "0\r\n". With Connection: close we
851 * will not reuse the connection, so we declare done immediately
852 * rather than parsing trailer key-value pairs.
853 */
854 cc->state = CS_DONE;
855 return PAL_CHUNKED_DONE;
856 
857 case CS_DONE:
858 return PAL_CHUNKED_DONE;
859 
860 default:
861 return CURLE_RECV_ERROR;
862 }
863 }
864 
865 return (cc->state == CS_DONE) ? PAL_CHUNKED_DONE : CURLE_OK;
866}
867 
868/* ═══════════════════════════════════════════════════════════════════════════
869 * §7 — Speed guard
870 * ═════════════════════════════════════════════════════════════════════════*/
871 
872static void speed_guard_init(speed_guard_t *sg, const pal_curl_ctx_t *ctx)
873{
874 sg->last_sec = time(NULL);
875 sg->sec_bytes = 0U;
876 sg->slow_count = 0;
877 (void)ctx;
878}
879 
880/**
881 * @brief Update speed measurement; return 0 if low-speed threshold exceeded.
882 *
883 * @param sg Speed guard state.
884 * @param new_bytes Bytes received in this increment.
885 * @param ctx Handle context (for limit/time thresholds).
886 *
887 * @return 1 if speed is OK or no limit is set, 0 if should abort.
888 *
889 * @note Thread-safety: NOT thread-safe.
890 * @note WCET: O(1).
891 */
892static int speed_guard_update(speed_guard_t *sg, size_t new_bytes,
893 const pal_curl_ctx_t *ctx)
894{
895 sg->sec_bytes += (uint64_t)new_bytes;
896 
897 if (ctx->low_speed_limit <= 0L) { return 1; } /* no limit configured */
898 
899 time_t now = time(NULL);
900 time_t elapsed = now - sg->last_sec;
901 
902 if (elapsed < 1) { return 1; } /* not a full second yet */
903 
904 long speed = (elapsed > 0) ? (long)(sg->sec_bytes / (uint64_t)elapsed) : 0L;
905 
906 if (speed < ctx->low_speed_limit) {
907 sg->slow_count += (int)elapsed;
908 if ((ctx->low_speed_time > 0L) &&
909 (sg->slow_count >= (int)ctx->low_speed_time)) {
910 return 0; /* too slow for too long */
911 }
912 } else {
913 sg->slow_count = 0;
914 }
915 
916 sg->sec_bytes = 0U;
917 sg->last_sec = now;
918 return 1;
919}
920 
921/* ═══════════════════════════════════════════════════════════════════════════
922 * §8 — Write-callback wrapper (PAUSE handling)
923 * ═════════════════════════════════════════════════════════════════════════*/
924 
925/**
926 * @brief Call write_cb with PAUSE retry.
927 *
928 * If the callback returns CURL_WRITEFUNC_PAUSE, sleep PAL_PAUSE_DELAY_US
929 * and retry, up to PAL_PAUSE_MAX_RETRIES times, before aborting.
930 *
931 * DESIGN RATIONALE:
932 * The original code looped forever (while written == 0 || written == PAUSE)
933 * with no exit condition other than success. A misbehaving callback could
934 * block the calling thread indefinitely. The retry limit caps exposure.
935 *
936 * @param cb Write callback (may be NULL — returns CURLE_OK).
937 * @param ptr Data pointer (cast to non-const for callback ABI compat).
938 * @param nmemb Byte count.
939 * @param userdata User data pointer.
940 *
941 * @return CURLE_OK, CURLE_WRITE_ERROR, or CURLE_ABORTED_BY_CALLBACK.
942 *
943 * @note Thread-safety: NOT thread-safe.
944 */
945static CURLcode invoke_write_cb(curl_write_callback cb, const void *ptr,
946 size_t nmemb, void *userdata)
947{
948 if ((cb == NULL) || (nmemb == 0U)) { return CURLE_OK; }
949 
950 for (int attempt = 0; attempt < PAL_PAUSE_MAX_RETRIES; attempt++) {
951 size_t wrote = cb((void *)(uintptr_t)ptr, 1U, nmemb, userdata);
952 if (wrote == nmemb) { return CURLE_OK; }
953 if (wrote == (size_t)CURL_WRITEFUNC_PAUSE) {
954 { struct timespec ts = { 0L, (long)(PAL_PAUSE_DELAY_US) * 1000L }; (void)nanosleep(&ts, NULL); }
955 continue;
956 }
957 return CURLE_WRITE_ERROR;
958 }
959 return CURLE_ABORTED_BY_CALLBACK;
960}
961 
962/* ═══════════════════════════════════════════════════════════════════════════
963 * §9 — Body streaming
964 * ═════════════════════════════════════════════════════════════════════════*/
965 
966/**
967 * @brief Stream a body of known Content-Length bytes.
968 *
969 * First drains any body bytes already in body_prefix (received alongside
970 * the headers), then reads the remainder from sock.
971 *
972 * @param sock Connected socket.
973 * @param body_prefix Pointer into the header buffer after header end.
974 * @param body_prefix_len Bytes already received (may be 0).
975 * @param total Exact Content-Length.
976 * @param ctx Handle context.
977 * @param buf Heap buffer (BODY_BUF_SIZE bytes).
978 * @param buf_size sizeof(buf).
979 * @param bytes_out Incremented with bytes delivered to callback.
980 *
981 * @return CURLE_OK, CURLE_PARTIAL_FILE, CURLE_RECV_ERROR,
982 * CURLE_WRITE_ERROR, CURLE_OPERATION_TIMEDOUT,
983 * or CURLE_ABORTED_BY_CALLBACK.
984 *
985 * @note Thread-safety: NOT thread-safe.
986 */
987static CURLcode stream_body_known_length(int sock,
988 const uint8_t *body_prefix,
989 size_t body_prefix_len,
990 uint64_t total,
991 pal_curl_ctx_t *ctx,
992 uint8_t *buf, size_t buf_size,
993 uint64_t *bytes_out)
994{
995 speed_guard_t sg;
996 speed_guard_init(&sg, ctx);
997 
998 uint64_t remaining = total;
999 
1000 /* Drain prefix */
1001 if (body_prefix_len > 0U) {
1002 size_t to_write = (body_prefix_len > (size_t)remaining)
1003 ? (size_t)remaining : body_prefix_len;
1004 CURLcode rc = invoke_write_cb(ctx->write_cb, body_prefix, to_write,
1005 ctx->write_data);
1006 if (rc != CURLE_OK) { return rc; }
1007 *bytes_out += to_write;
1008 remaining -= (uint64_t)to_write;
1009 }
1010 
1011 while (remaining > 0U) {
1012 size_t want = (remaining < (uint64_t)buf_size)
1013 ? (size_t)remaining : buf_size;
1014 ssize_t n = recv_timed(sock, buf, want, ctx->timeout_ms);
1015 if (n < 0) { return CURLE_RECV_ERROR; }
1016 if (n == 0) { return CURLE_PARTIAL_FILE; }
1017 
1018 CURLcode rc = invoke_write_cb(ctx->write_cb, buf, (size_t)n,
1019 ctx->write_data);
1020 if (rc != CURLE_OK) { return rc; }
1021 *bytes_out += (size_t)n;
1022 remaining -= (uint64_t)n;
1023 
1024 if (!speed_guard_update(&sg, (size_t)n, ctx)) {
1025 return CURLE_OPERATION_TIMEDOUT;
1026 }
1027 if ((ctx->xferinfo_cb != NULL) && (ctx->noprogress == 0)) {
1028 int r = ctx->xferinfo_cb(ctx->xferinfo_data,
1029 (curl_off_t)total,
1030 (curl_off_t)*bytes_out,
1031 0, 0);
1032 if (r != 0) { return CURLE_ABORTED_BY_CALLBACK; }
1033 }
1034 }
1035 return CURLE_OK;
1036}
1037 
1038/**
1039 * @brief Stream a chunked-encoded body.
1040 *
1041 * Passes bytes to the chunked state machine, which decodes and forwards
1042 * payload to write_cb.
1043 *
1044 * @note Thread-safety: NOT thread-safe.
1045 */
1046static CURLcode stream_body_chunked(int sock,
1047 const uint8_t *body_prefix,
1048 size_t body_prefix_len,
1049 pal_curl_ctx_t *ctx,
1050 uint8_t *buf, size_t buf_size,
1051 uint64_t *bytes_out)
1052{
1053 chunked_ctx_t cc;
1054 (void)memset(&cc, 0, sizeof(cc));
1055 cc.state = CS_SIZE;
1056 
1057 speed_guard_t sg;
1058 speed_guard_init(&sg, ctx);
1059 
1060 /* Process any body bytes that arrived with the headers. */
1061 if (body_prefix_len > 0U) {
1062 int r = chunked_process(&cc, body_prefix, body_prefix_len,
1063 ctx->write_cb, ctx->write_data, bytes_out);
1064 if (r == PAL_CHUNKED_DONE) { return CURLE_OK; }
1065 if (r != CURLE_OK) { return (CURLcode)r; }
1066 }
1067 
1068 while (1) {
1069 ssize_t n = recv_timed(sock, buf, buf_size, ctx->timeout_ms);
1070 if (n < 0) { return CURLE_RECV_ERROR; }
1071 if (n == 0) {
1072 /* EOF: acceptable only if we've seen the terminal chunk. */
1073 return ((cc.state == CS_DONE) || (cc.state == CS_TRAILER))
1074 ? CURLE_OK : CURLE_PARTIAL_FILE;
1075 }
1076 
1077 uint64_t prev = *bytes_out;
1078 int r = chunked_process(&cc, buf, (size_t)n,
1079 ctx->write_cb, ctx->write_data, bytes_out);
1080 
1081 if (!speed_guard_update(&sg, (size_t)(*bytes_out - prev), ctx)) {
1082 return CURLE_OPERATION_TIMEDOUT;
1083 }
1084 if (r == PAL_CHUNKED_DONE) { return CURLE_OK; }
1085 if (r != CURLE_OK) { return (CURLcode)r; }
1086 
1087 if ((ctx->xferinfo_cb != NULL) && (ctx->noprogress == 0)) {
1088 int pr = ctx->xferinfo_cb(ctx->xferinfo_data, 0,
1089 (curl_off_t)*bytes_out, 0, 0);
1090 if (pr != 0) { return CURLE_ABORTED_BY_CALLBACK; }
1091 }
1092 }
1093}
1094 
1095/**
1096 * @brief Stream a body with no Content-Length until connection close.
1097 *
1098 * HTTP/1.0-style or HTTP/1.1 with Connection: close and no Content-Length.
1099 *
1100 * @note Thread-safety: NOT thread-safe.
1101 */
1102static CURLcode stream_body_until_close(int sock,
1103 const uint8_t *body_prefix,
1104 size_t body_prefix_len,
1105 pal_curl_ctx_t *ctx,
1106 uint8_t *buf, size_t buf_size,
1107 uint64_t *bytes_out)
1108{
1109 speed_guard_t sg;
1110 speed_guard_init(&sg, ctx);
1111 
1112 if (body_prefix_len > 0U) {
1113 CURLcode rc = invoke_write_cb(ctx->write_cb, body_prefix,
1114 body_prefix_len, ctx->write_data);
1115 if (rc != CURLE_OK) { return rc; }
1116 *bytes_out += body_prefix_len;
1117 }
1118 
1119 while (1) {
1120 ssize_t n = recv_timed(sock, buf, buf_size, ctx->timeout_ms);
1121 if (n < 0) { return CURLE_RECV_ERROR; }
1122 if (n == 0) { break; } /* Clean EOF */
1123 
1124 CURLcode rc = invoke_write_cb(ctx->write_cb, buf, (size_t)n,
1125 ctx->write_data);
1126 if (rc != CURLE_OK) { return rc; }
1127 *bytes_out += (size_t)n;
1128 
1129 if (!speed_guard_update(&sg, (size_t)n, ctx)) {
1130 return CURLE_OPERATION_TIMEDOUT;
1131 }
1132 if ((ctx->xferinfo_cb != NULL) && (ctx->noprogress == 0)) {
1133 int r = ctx->xferinfo_cb(ctx->xferinfo_data, 0,
1134 (curl_off_t)*bytes_out, 0, 0);
1135 if (r != 0) { return CURLE_ABORTED_BY_CALLBACK; }
1136 }
1137 }
1138 return CURLE_OK;
1139}
1140 
1141/* ═══════════════════════════════════════════════════════════════════════════
1142 * §10 — Redirect URL resolver
1143 * ═════════════════════════════════════════════════════════════════════════*/
1144 
1145/**
1146 * @brief Resolve a Location: header value against the current request URL.
1147 *
1148 * If location is an absolute http:// URL it is copied verbatim.
1149 * If it starts with '/' it is treated as an absolute path on the same origin.
1150 * Otherwise it is treated as a relative path on the same origin.
1151 *
1152 * @param base_url The URL of the request that triggered the redirect.
1153 * @param location Location header value.
1154 * @param out Output buffer for the resolved URL.
1155 * @param out_size sizeof(out).
1156 *
1157 * @note Thread-safety: pure (no shared state).
1158 * @note WCET: O(len(base_url) + len(location)).
1159 */
1160static void resolve_redirect(const char *base_url, const char *location,
1161 char *out, size_t out_size)
1162{
1163 /* Absolute URL — use as-is. */
1164 if ((strncmp(location, "http://", 7U) == 0) ||
1165 (strncmp(location, "https://", 8U) == 0)) {
1166 strncpy(out, location, out_size - 1U);
1167 out[out_size - 1U] = '\0';
1168 return;
1169 }
1170 
1171 /* Relative — extract origin from base_url. */
1172 char host[256U];
1173 char dummy[8U];
1174 int port;
1175 if (url_parse(base_url, host, sizeof(host), &port, dummy, sizeof(dummy))
1176 != CURLE_OK) {
1177 strncpy(out, location, out_size - 1U);
1178 out[out_size - 1U] = '\0';
1179 return;
1180 }
1181 
1182 if (location[0] == '/') {
1183 (void)snprintf(out, out_size, "http://%s:%d%s", host, port, location);
1184 } else {
1185 (void)snprintf(out, out_size, "http://%s:%d/%s", host, port, location);
1186 }
1187}
1188 
1189/* ═══════════════════════════════════════════════════════════════════════════
1190 * §11 — Single-request performer
1191 * ═════════════════════════════════════════════════════════════════════════*/
1192 
1193/**
1194 * @brief Execute one HTTP transaction (no redirect following).
1195 *
1196 * On return:
1197 * - *status_out = HTTP status code (e.g. 200, 301, 404)
1198 * - redirect_out = Location: value if 3xx (may be "")
1199 * - *bytes_out = bytes delivered to write_cb
1200 *
1201 * @return CURLE_OK or a specific CURLcode error.
1202 *
1203 * @note Thread-safety: NOT thread-safe.
1204 * @note WCET: Unbounded (network I/O).
1205 */
1206static CURLcode perform_one(pal_curl_ctx_t *ctx, const char *url,
1207 long *status_out,
1208 char *redirect_out, size_t redirect_max,
1209 uint64_t *bytes_out)
1210{
1211 /* ── Parse URL ──────────────────────────────────────────────── */
1212 char host[256U], path[1800U];
1213 int port;
1214 CURLcode rc = url_parse(url, host, sizeof(host), &port, path, sizeof(path));
1215 if (rc != CURLE_OK) {
1216 set_errbuf(ctx, "URL parse failed");
1217 return rc;
1218 }
1219 
1220 /* ── Connect ────────────────────────────────────────────────── */
1221 CURLcode conn_err;
1222 long conn_to = (ctx->connect_timeout_ms > 0L) ? ctx->connect_timeout_ms
1223 : DEFAULT_CONN_MS;
1224 int sock = net_connect(host, port, conn_to, &conn_err);
1225 if (sock < 0) {
1226 set_errbuf(ctx, "Connection failed");
1227 return conn_err;
1228 }
1229 PAL_LOG(ctx, "Connected to %s:%d\n", host, port);
1230 
1231 /* ── Build and send request ─────────────────────────────────── */
1232 char req_buf[REQ_BUF_SIZE];
1233 const char *method = (ctx->nobody != 0) ? "HEAD"
1234 : (ctx->post != 0) ? "POST"
1235 : "GET";
1236 int req_len = build_request(ctx, method, host, port, path,
1237 req_buf, sizeof(req_buf));
1238 if (req_len <= 0) {
1239 close(sock);
1240 set_errbuf(ctx, "Request too large for buffer");
1241 return CURLE_SEND_ERROR;
1242 }
1243 
1244 long send_to = (ctx->timeout_ms > 0L) ? ctx->timeout_ms : conn_to;
1245 if (send_all(sock, req_buf, (size_t)req_len, send_to) != 0) {
1246 close(sock);
1247 set_errbuf(ctx, "Failed to send request headers");
1248 return CURLE_SEND_ERROR;
1249 }
1250 PAL_LOG(ctx, "→ %s %s\n", method, path);
1251 
1252 /* ── Send POST body ─────────────────────────────────────────── */
1253 if ((ctx->post != 0) && (ctx->postfields != NULL)) {
1254 long plen = (ctx->postfieldsize > 0L) ? ctx->postfieldsize
1255 : (long)strlen(ctx->postfields);
1256 if (send_all(sock, ctx->postfields, (size_t)plen, send_to) != 0) {
1257 close(sock);
1258 set_errbuf(ctx, "Failed to send POST body");
1259 return CURLE_SEND_ERROR;
1260 }
1261 }
1262 
1263 /* ── Read response headers ──────────────────────────────────── */
1264 char hdr_buf[HDR_BUF_SIZE];
1265 size_t hdr_len = 0U;
1266 size_t body_pfx_len = 0U;
1267 long recv_to = (ctx->timeout_ms > 0L) ? ctx->timeout_ms : DEFAULT_CONN_MS;
1268 
1269 rc = http_recv_headers(sock, recv_to, hdr_buf, sizeof(hdr_buf),
1270 &hdr_len, &body_pfx_len);
1271 if (rc != CURLE_OK) {
1272 close(sock);
1273 set_errbuf(ctx, "Failed to receive response headers");
1274 return rc;
1275 }
1276 
1277 /* ── Parse status line ──────────────────────────────────────── */
1278 if (parse_status_line(hdr_buf, status_out) != 0) {
1279 close(sock);
1280 set_errbuf(ctx, "Malformed HTTP status line");
1281 return CURLE_RECV_ERROR;
1282 }
1283 PAL_LOG(ctx, "← HTTP %ld\n", *status_out);
1284 
1285 /* ── Parse header fields ─────────────────────────────────────── */
1286 http_resp_t resp;
1287 (void)memset(&resp, 0, sizeof(resp));
1288 /*
1289 * hdr_len includes the terminal \r\n\r\n (4 bytes).
1290 * parse_header_fields() should not see those 4 bytes as a header line.
1291 */
1292 parse_header_fields(hdr_buf, (hdr_len >= 4U) ? (hdr_len - 4U) : 0U,
1293 &resp);
1294 resp.status_code = *status_out;
1295 
1296 ctx->content_length_download = (resp.content_length >= 0)
1297 ? (double)resp.content_length : -1.0;
1298 
1299 /* ── Redirect URL ────────────────────────────────────────────── */
1300 if ((redirect_out != NULL) && (resp.location[0] != '\0')) {
1301 resolve_redirect(url, resp.location, redirect_out, redirect_max);
1302 } else if (redirect_out != NULL) {
1303 redirect_out[0] = '\0';
1304 }
1305 
1306 /* ── HEAD / error: no body to read ─────────────────────────── */
1307 if ((ctx->nobody != 0) || (*status_out >= 400)) {
1308 close(sock);
1309 *bytes_out = 0U;
1310 return CURLE_OK;
1311 }
1312 
1313 /* ── Allocate body streaming buffer ─────────────────────────── */
1314 uint8_t *body_buf = (uint8_t *)malloc(BODY_BUF_SIZE);
1315 if (body_buf == NULL) {
1316 close(sock);
1317 set_errbuf(ctx, "Out of memory for body buffer");
1318 return CURLE_OUT_OF_MEMORY;
1319 }
1320 
1321 const uint8_t *pfx = (const uint8_t *)(hdr_buf + hdr_len);
1322 
1323 /* ── Stream body ─────────────────────────────────────────────── */
1324 if (resp.chunked != 0) {
1325 rc = stream_body_chunked(sock, pfx, body_pfx_len, ctx,
1326 body_buf, BODY_BUF_SIZE, bytes_out);
1327 } else if (resp.content_length >= 0) {
1328 rc = stream_body_known_length(sock, pfx, body_pfx_len,
1329 (uint64_t)resp.content_length,
1330 ctx, body_buf, BODY_BUF_SIZE,
1331 bytes_out);
1332 } else {
1333 rc = stream_body_until_close(sock, pfx, body_pfx_len, ctx,
1334 body_buf, BODY_BUF_SIZE, bytes_out);
1335 }
1336 
1337 free(body_buf);
1338 close(sock);
1339 return rc;
1340}
1341 
1342/* ═══════════════════════════════════════════════════════════════════════════
1343 * §12 — Public API
1344 * ═════════════════════════════════════════════════════════════════════════*/
1345 
1346CURLcode curl_global_init(long flags)
1347{
1348 (void)flags;
1349 return CURLE_OK;
1350}
1351 
1352void curl_global_cleanup(void)
1353{
1354 /* No-op. */
1355}
1356 
1357CURL *curl_easy_init(void)
1358{
1359 pal_curl_ctx_t *ctx = (pal_curl_ctx_t *)calloc(1U, sizeof(pal_curl_ctx_t));
1360 if (ctx == NULL) { return NULL; }
1361 ctx->max_redirs = DEFAULT_MAX_REDIRS;
1362 ctx->connect_timeout_ms = DEFAULT_CONN_MS;
1363 ctx->timeout_ms = DEFAULT_TIMEOUT_MS;
1364 ctx->noprogress = 1;
1365 ctx->content_length_download = -1.0;
1366 return (CURL *)ctx;
1367}
1368 
1369void curl_easy_reset(CURL *handle)
1370{
1371 if (handle == NULL) { return; }
1372 pal_curl_ctx_t *ctx = (pal_curl_ctx_t *)handle;
1373 (void)memset(ctx, 0, sizeof(*ctx));
1374 ctx->max_redirs = DEFAULT_MAX_REDIRS;
1375 ctx->connect_timeout_ms = DEFAULT_CONN_MS;
1376 ctx->timeout_ms = DEFAULT_TIMEOUT_MS;
1377 ctx->noprogress = 1;
1378 ctx->content_length_download = -1.0;
1379}
1380 
1381void curl_easy_cleanup(CURL *handle)
1382{
1383 if (handle == NULL) { return; }
1384 free(handle);
1385}
1386 
1387CURLcode curl_easy_setopt(CURL *handle, CURLoption option, ...)
1388{
1389 if (handle == NULL) { return CURLE_UNKNOWN_OPTION; }
1390 pal_curl_ctx_t *ctx = (pal_curl_ctx_t *)handle;
1391 
1392 va_list ap;
1393 va_start(ap, option);
1394 
1395 CURLcode result = CURLE_OK;
1396 
1397 switch (option) {
1398 
1399 /* ── Object-pointer options ─────────────────────────────────── */
1400 case CURLOPT_URL: {
1401 const char *url = va_arg(ap, const char *);
1402 if (url != NULL) {
1403 strncpy(ctx->url, url, sizeof(ctx->url) - 1U);
1404 ctx->url[sizeof(ctx->url) - 1U] = '\0';
1405 }
1406 break;
1407 }
1408 case CURLOPT_WRITEDATA:
1409 ctx->write_data = va_arg(ap, void *);
1410 break;
1411 case CURLOPT_ERRORBUFFER:
1412 ctx->errbuf = va_arg(ap, char *);
1413 break;
1414 case CURLOPT_POSTFIELDS: {
1415 ctx->postfields = va_arg(ap, char *); /* caller owns */
1416 ctx->post = 1;
1417 break;
1418 }
1419 case CURLOPT_USERAGENT: {
1420 const char *ua = va_arg(ap, const char *);
1421 if (ua != NULL) {
1422 strncpy(ctx->useragent, ua, sizeof(ctx->useragent) - 1U);
1423 ctx->useragent[sizeof(ctx->useragent) - 1U] = '\0';
1424 }
1425 break;
1426 }
1427 case CURLOPT_HTTPHEADER:
1428 ctx->httpheader = va_arg(ap, curl_slist *);
1429 break;
1430 case CURLOPT_RANGE: {
1431 const char *r = va_arg(ap, const char *);
1432 if (r != NULL) {
1433 strncpy(ctx->range, r, sizeof(ctx->range) - 1U);
1434 ctx->range[sizeof(ctx->range) - 1U] = '\0';
1435 } else {
1436 ctx->range[0] = '\0';
1437 }
1438 break;
1439 }
1440 case CURLOPT_XFERINFODATA:
1441 ctx->xferinfo_data = va_arg(ap, void *);
1442 break;
1443 
1444 /* ── Long options ────────────────────────────────────────────── */
1445 case CURLOPT_VERBOSE:
1446 ctx->verbose = (int)va_arg(ap, long);
1447 break;
1448 case CURLOPT_NOPROGRESS:
1449 ctx->noprogress = (int)va_arg(ap, long);
1450 break;
1451 case CURLOPT_NOBODY:
1452 ctx->nobody = (int)va_arg(ap, long);
1453 break;
1454 case CURLOPT_POST:
1455 ctx->post = (int)va_arg(ap, long);
1456 break;
1457 case CURLOPT_FOLLOWLOCATION:
1458 ctx->follow_location = va_arg(ap, long);
1459 break;
1460 case CURLOPT_MAXREDIRS:
1461 ctx->max_redirs = va_arg(ap, long);
1462 break;
1463 case CURLOPT_POSTFIELDSIZE:
1464 ctx->postfieldsize = va_arg(ap, long);
1465 break;
1466 case CURLOPT_TIMEOUT:
1467 ctx->timeout_ms = va_arg(ap, long) * 1000L;
1468 break;
1469 case CURLOPT_TIMEOUT_MS:
1470 ctx->timeout_ms = va_arg(ap, long);
1471 break;
1472 case CURLOPT_CONNECTTIMEOUT:
1473 ctx->connect_timeout_ms = va_arg(ap, long) * 1000L;
1474 break;
1475 case CURLOPT_CONNECTTIMEOUT_MS:
1476 ctx->connect_timeout_ms = va_arg(ap, long);
1477 break;
1478 case CURLOPT_LOW_SPEED_LIMIT:
1479 ctx->low_speed_limit = va_arg(ap, long);
1480 break;
1481 case CURLOPT_LOW_SPEED_TIME:
1482 ctx->low_speed_time = va_arg(ap, long);
1483 break;
1484 case CURLOPT_SSL_VERIFYPEER:
1485 (void)va_arg(ap, long); /* accepted, ignored */
1486 break;
1487 
1488 /* ── Function-pointer options ───────────────────────────────── */
1489 case CURLOPT_WRITEFUNCTION:
1490 ctx->write_cb = va_arg(ap, curl_write_callback);
1491 break;
1492 case CURLOPT_XFERINFOFUNCTION:
1493 ctx->xferinfo_cb = va_arg(ap, curl_xferinfo_callback);
1494 break;
1495 
1496 default:
1497 (void)va_arg(ap, void *); /* consume unknown argument */
1498 result = CURLE_UNKNOWN_OPTION;
1499 break;
1500 }
1501 
1502 va_end(ap);
1503 return result;
1504}
1505 
1506CURLcode curl_easy_perform(CURL *handle)
1507{
1508 if (handle == NULL) { return CURLE_UNKNOWN_OPTION; }
1509 pal_curl_ctx_t *ctx = (pal_curl_ctx_t *)handle;
1510 
1511 if (ctx->url[0] == '\0') {
1512 set_errbuf(ctx, "No URL set");
1513 return CURLE_URL_MALFORMAT;
1514 }
1515 
1516 /* Reset per-transfer response fields. */
1517 ctx->response_code = 0L;
1518 ctx->size_download = 0.0;
1519 ctx->speed_download = 0.0;
1520 ctx->total_time = 0.0;
1521 ctx->content_length_download = -1.0;
1522 
1523 struct timeval tv_start, tv_end;
1524 (void)gettimeofday(&tv_start, NULL);
1525 
1526 char current_url[sizeof(ctx->url)];
1527 strncpy(current_url, ctx->url, sizeof(current_url) - 1U);
1528 current_url[sizeof(current_url) - 1U] = '\0';
1529 
1530 int redirects = 0;
1531 CURLcode rc = CURLE_OK;
1532 
1533 while (1) {
1534 if ((ctx->max_redirs >= 0L) && (redirects > (int)ctx->max_redirs)) {
1535 rc = CURLE_TOO_MANY_REDIRECTS;
1536 break;
1537 }
1538 
1539 long status = 0L;
1540 char redir_url[sizeof(current_url)];
1541 redir_url[0] = '\0';
1542 uint64_t bytes = 0U;
1543 
1544 rc = perform_one(ctx, current_url, &status, redir_url,
1545 sizeof(redir_url), &bytes);
1546 if (rc != CURLE_OK) { break; }
1547 
1548 ctx->response_code = status;
1549 ctx->size_download += (double)bytes;
1550 
1551 /* Follow redirects. */
1552 if ((status >= 300L) && (status < 400L) &&
1553 (ctx->follow_location != 0L) && (redir_url[0] != '\0')) {
1554 strncpy(current_url, redir_url, sizeof(current_url) - 1U);
1555 current_url[sizeof(current_url) - 1U] = '\0';
1556 redirects++;
1557 PAL_LOG(ctx, "Redirect %d → %s\n", redirects, current_url);
1558 continue;
1559 }
1560 
1561 if (status >= 400L) {
1562 set_errbuf(ctx, "HTTP error status");
1563 rc = CURLE_HTTP_RETURNED_ERROR;
1564 }
1565 break;
1566 }
1567 
1568 (void)gettimeofday(&tv_end, NULL);
1569 ctx->total_time = (double)(tv_end.tv_sec - tv_start.tv_sec) +
1570 (double)(tv_end.tv_usec - tv_start.tv_usec) * 1e-6;
1571 if (ctx->total_time > 0.0) {
1572 ctx->speed_download = ctx->size_download / ctx->total_time;
1573 }
1574 
1575 return rc;
1576}
1577 
1578CURLcode curl_easy_getinfo(CURL *handle, CURLINFO info, ...)
1579{
1580 if (handle == NULL) { return CURLE_UNKNOWN_OPTION; }
1581 const pal_curl_ctx_t *ctx = (const pal_curl_ctx_t *)handle;
1582 
1583 va_list ap;
1584 va_start(ap, info);
1585 
1586 CURLcode result = CURLE_OK;
1587 
1588 switch (info) {
1589 case CURLINFO_RESPONSE_CODE: {
1590 long *lp = va_arg(ap, long *);
1591 if (lp != NULL) { *lp = ctx->response_code; }
1592 break;
1593 }
1594 case CURLINFO_CONTENT_LENGTH_DOWNLOAD: {
1595 double *dp = va_arg(ap, double *);
1596 if (dp != NULL) { *dp = ctx->content_length_download; }
1597 break;
1598 }
1599 case CURLINFO_SIZE_DOWNLOAD: {
1600 double *dp = va_arg(ap, double *);
1601 if (dp != NULL) { *dp = ctx->size_download; }
1602 break;
1603 }
1604 case CURLINFO_SPEED_DOWNLOAD: {
1605 double *dp = va_arg(ap, double *);
1606 if (dp != NULL) { *dp = ctx->speed_download; }
1607 break;
1608 }
1609 case CURLINFO_TOTAL_TIME: {
1610 double *dp = va_arg(ap, double *);
1611 if (dp != NULL) { *dp = ctx->total_time; }
1612 break;
1613 }
1614 default:
1615 (void)va_arg(ap, void *);
1616 result = CURLE_UNKNOWN_OPTION;
1617 break;
1618 }
1619 
1620 va_end(ap);
1621 return result;
1622}
1623 
1624const char *curl_easy_strerror(CURLcode code)
1625{
1626 switch (code) {
1627 case CURLE_OK: return "No error";
1628 case CURLE_UNSUPPORTED_PROTOCOL: return "Unsupported protocol";
1629 case CURLE_URL_MALFORMAT: return "URL malformed or missing";
1630 case CURLE_COULDNT_RESOLVE_HOST: return "Could not resolve host";
1631 case CURLE_COULDNT_CONNECT: return "Failed to connect to host";
1632 case CURLE_PARTIAL_FILE: return "Transfer ended prematurely";
1633 case CURLE_HTTP_RETURNED_ERROR: return "HTTP response code indicates error";
1634 case CURLE_WRITE_ERROR: return "Write callback returned error";
1635 case CURLE_OUT_OF_MEMORY: return "Out of memory";
1636 case CURLE_OPERATION_TIMEDOUT: return "Operation timed out";
1637 case CURLE_ABORTED_BY_CALLBACK: return "Aborted by callback";
1638 case CURLE_TOO_MANY_REDIRECTS: return "Too many redirects";
1639 case CURLE_UNKNOWN_OPTION: return "Unknown option";
1640 case CURLE_GOT_NOTHING: return "Server returned no data";
1641 case CURLE_SEND_ERROR: return "Failed sending data to peer";
1642 case CURLE_RECV_ERROR: return "Failure receiving data from peer";
1643 default: return "Unknown error code";
1644 }
1645}
1646 
1647const char *curl_version(void)
1648{
1649 return "pal_curl/2.0 (PS4/PS5 shim; HTTP/1.1; no TLS)";
1650}
1651 
1652/* ═══════════════════════════════════════════════════════════════════════════
1653 * §13 — curl_slist
1654 * ═════════════════════════════════════════════════════════════════════════*/
1655 
1656curl_slist *curl_slist_append(curl_slist *list, const char *data)
1657{
1658 if (data == NULL) { return list; }
1659 
1660 curl_slist *node = (curl_slist *)malloc(sizeof(curl_slist));
1661 if (node == NULL) { return list; } /* allocation failed; return unchanged */
1662 
1663 node->data = strdup(data);
1664 if (node->data == NULL) { free(node); return list; }
1665 node->next = NULL;
1666 
1667 if (list == NULL) { return node; }
1668 
1669 /* Walk to tail. */
1670 curl_slist *tail = list;
1671 while (tail->next != NULL) { tail = tail->next; }
1672 tail->next = node;
1673 return list;
1674}
1675 
1676void curl_slist_free_all(curl_slist *list)
1677{
1678 while (list != NULL) {
1679 curl_slist *next = list->next;
1680 free(list->data);
1681 free(list);
1682 list = next;
1683 }
1684}