diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index d969a7e6..38149eb9 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -37,7 +37,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) { errorChain(systemInit()); errorChain(inputInit()); errorChain(assetInit()); - // errorChain(saveInit()); + errorChain(saveInit()); errorChain(localeManagerInit()); errorChain(displayInit()); errorChain(uiInit()); @@ -88,7 +88,7 @@ errorret_t engineDispose(void) { errorChain(uiDispose()); consoleDispose(); errorChain(displayDispose()); - // errorChain(saveDispose()); + errorChain(saveDispose()); errorChain(assetDispose()); errorOk(); diff --git a/src/dusk/network/CMakeLists.txt b/src/dusk/network/CMakeLists.txt index 6926e0b1..84d07b66 100644 --- a/src/dusk/network/CMakeLists.txt +++ b/src/dusk/network/CMakeLists.txt @@ -8,3 +8,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} network.c networkinfo.c ) + +# Subdirs +add_subdirectory(http) diff --git a/src/dusk/network/http/CMakeLists.txt b/src/dusk/network/http/CMakeLists.txt new file mode 100644 index 00000000..89b92396 --- /dev/null +++ b/src/dusk/network/http/CMakeLists.txt @@ -0,0 +1,14 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + networkhttpheader.c + networkhttpurl.c + networkhttprequest.c + networkhttpprocess.c + networkhttpthread.c + networkhttp.c +) diff --git a/src/dusk/network/http/networkhttp.c b/src/dusk/network/http/networkhttp.c new file mode 100644 index 00000000..2cf4ef96 --- /dev/null +++ b/src/dusk/network/http/networkhttp.c @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networkhttp.h" +#include "networkhttprequest.h" +#include "networkhttpthread.h" + +networkhttp_t NETWORK_HTTP; + +errorret_t networkHttpInit(void) { + networkHttpRequestPoolInit(); + + threadInit(&NETWORK_HTTP.thread, networkHttpThreadRun); + threadStart(&NETWORK_HTTP.thread); + + errorOk(); +} + +errorret_t networkHttpUpdate(void) { + for(uint32_t i = 0; i < NETWORK_HTTP_REQUEST_COUNT_MAX; i++) { + networkhttprequest_t *request = &NETWORK_HTTP_REQUESTS[i]; + + threadMutexLock(&request->mutex); + const networkhttprequeststate_t state = request->state; + threadMutexUnlock(&request->mutex); + + if(state == NETWORK_HTTP_REQUEST_STATE_DONE) { + eventInvoke(&request->onComplete, request); + } else if(state == NETWORK_HTTP_REQUEST_STATE_ERROR) { + eventInvoke(&request->onError, request); + } else { + continue; + } + + networkHttpRequestReset(request); + } + + errorOk(); +} + +errorret_t networkHttpDispose(void) { + threadStop(&NETWORK_HTTP.thread); + errorOk(); +} diff --git a/src/dusk/network/http/networkhttp.h b/src/dusk/network/http/networkhttp.h new file mode 100644 index 00000000..2bb3e418 --- /dev/null +++ b/src/dusk/network/http/networkhttp.h @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "thread/thread.h" + +typedef struct { + thread_t thread; +} networkhttp_t; + +extern networkhttp_t NETWORK_HTTP; + +/** + * Initializes the HTTP client: prepares the request pool and starts + * the background worker thread. Called once during engine startup. + * + * @return Any error that occurs. + */ +errorret_t networkHttpInit(void); + +/** + * Dispatches any requests that finished on the background thread since + * the last call: fires onComplete/onError on the main thread, frees + * the response, and returns the slot to the pool. Call once per frame. + * + * @return Any error that occurs. + */ +errorret_t networkHttpUpdate(void); + +/** + * Stops the background worker thread and disposes of the HTTP client. + * + * @return Any error that occurs. + */ +errorret_t networkHttpDispose(void); diff --git a/src/dusk/network/http/networkhttpheader.c b/src/dusk/network/http/networkhttpheader.c new file mode 100644 index 00000000..1aa786fb --- /dev/null +++ b/src/dusk/network/http/networkhttpheader.c @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networkhttpheader.h" +#include "util/memory.h" +#include "util/string.h" + +void networkHttpHeaderListClear(networkhttpheaderlist_t *list) { + memoryZero(list, sizeof(networkhttpheaderlist_t)); +} + +bool_t networkHttpHeaderIsReserved(const char_t *name) { + return + stringCompareInsensitive(name, "Host") == 0 || + stringCompareInsensitive(name, "Connection") == 0 || + stringCompareInsensitive(name, "Content-Length") == 0; +} + +void networkHttpHeaderListAdd( + networkhttpheaderlist_t *list, + const char_t *name, + const char_t *value, + const bool_t skipReserved +) { + if(list->count >= NETWORK_HTTP_HEADER_COUNT_MAX) return; + if(skipReserved && networkHttpHeaderIsReserved(name)) return; + + networkhttpheader_t *header = &list->headers[list->count]; + stringCopy(header->name, name, NETWORK_HTTP_HEADER_NAME_MAX - 1); + stringCopy(header->value, value, NETWORK_HTTP_HEADER_VALUE_MAX - 1); + list->count++; +} + +const networkhttpheader_t * networkHttpHeaderListFind( + const networkhttpheaderlist_t *list, + const char_t *name +) { + for(uint32_t i = 0; i < list->count; i++) { + if(stringCompareInsensitive(list->headers[i].name, name) == 0) { + return &list->headers[i]; + } + } + + return NULL; +} diff --git a/src/dusk/network/http/networkhttpheader.h b/src/dusk/network/http/networkhttpheader.h new file mode 100644 index 00000000..b418b518 --- /dev/null +++ b/src/dusk/network/http/networkhttpheader.h @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "dusk.h" + +#define NETWORK_HTTP_HEADER_COUNT_MAX 16 +#define NETWORK_HTTP_HEADER_NAME_MAX 64 +#define NETWORK_HTTP_HEADER_VALUE_MAX 256 + +typedef struct { + char_t name[NETWORK_HTTP_HEADER_NAME_MAX]; + char_t value[NETWORK_HTTP_HEADER_VALUE_MAX]; +} networkhttpheader_t; + +typedef struct { + networkhttpheader_t headers[NETWORK_HTTP_HEADER_COUNT_MAX]; + uint32_t count; +} networkhttpheaderlist_t; + +/** + * Clears a header list, removing all entries. + * + * @param list The header list to clear. + */ +void networkHttpHeaderListClear(networkhttpheaderlist_t *list); + +/** + * Determines whether a header name is reserved, meaning it is computed + * internally by the request writer and must not be set by the caller. + * This is Host, Connection and Content-Length. + * + * @param name The header name to check, compared case-insensitively. + * @return true if the header name is reserved. + */ +bool_t networkHttpHeaderIsReserved(const char_t *name); + +/** + * Appends a header to the list, silently doing nothing if the list is + * already full or if skipReserved is true and the name is reserved (see + * networkHttpHeaderIsReserved). + * + * @param list The header list to append to. + * @param name The header name. + * @param value The header value. + * @param skipReserved If true, reserved header names are ignored. + */ +void networkHttpHeaderListAdd( + networkhttpheaderlist_t *list, + const char_t *name, + const char_t *value, + const bool_t skipReserved +); + +/** + * Finds the first header in the list matching the given name, compared + * case-insensitively. + * + * @param list The header list to search. + * @param name The header name to search for. + * @return Pointer to the matching header, or NULL if not found. + */ +const networkhttpheader_t * networkHttpHeaderListFind( + const networkhttpheaderlist_t *list, + const char_t *name +); diff --git a/src/dusk/network/http/networkhttpprocess.c b/src/dusk/network/http/networkhttpprocess.c new file mode 100644 index 00000000..b7f9b597 --- /dev/null +++ b/src/dusk/network/http/networkhttpprocess.c @@ -0,0 +1,346 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networkhttpprocess.h" +#include "util/memory.h" +#include "util/string.h" +#include "assert/assert.h" + +const char_t * networkHttpProcessMethodName( + const networkhttpmethod_t method +) { + switch(method) { + case NETWORK_HTTP_METHOD_POST: return "POST"; + case NETWORK_HTTP_METHOD_PUT: return "PUT"; + case NETWORK_HTTP_METHOD_GET: + default: + return "GET"; + } +} + +errorret_t networkHttpProcessBuildHead( + const networkhttprequest_t *request, + const networkhttpurl_t *target, + char_t *dest, + const size_t destSize, + size_t *outLength +) { + size_t cursor = 0; + + errorChain(networkHttpUrlAppend( + dest, destSize, &cursor, networkHttpProcessMethodName(request->method) + )); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, " ")); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, target->path)); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, " HTTP/1.1\r\n")); + + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, "Host: ")); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, target->host)); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, "\r\n")); + + errorChain(networkHttpUrlAppend( + dest, destSize, &cursor, "Connection: close\r\n" + )); + + for(uint32_t i = 0; i < request->requestHeaders.count; i++) { + const networkhttpheader_t *header = &request->requestHeaders.headers[i]; + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, header->name)); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, ": ")); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, header->value)); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, "\r\n")); + } + + if(request->bodyLength > 0) { + char_t lengthStr[24]; + stringFormat(lengthStr, sizeof(lengthStr) - 1, "%zu", request->bodyLength); + + errorChain(networkHttpUrlAppend( + dest, destSize, &cursor, "Content-Length: " + )); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, lengthStr)); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, "\r\n")); + } + + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, "\r\n")); + + *outLength = cursor; + errorOk(); +} + +errorret_t networkHttpProcessSendAll( + networksocketplatform_t *sock, + const uint8_t *data, + const size_t length +) { + size_t sentTotal = 0; + while(sentTotal < length) { + size_t sent = 0; + errorChain(networkSocketPlatformSend( + sock, data + sentTotal, length - sentTotal, &sent + )); + if(sent == 0) errorThrow("Connection closed while sending data"); + sentTotal += sent; + } + + errorOk(); +} + +errorret_t networkHttpProcessReceiveHeaders( + networksocketplatform_t *sock, + char_t *headerBuf, + const size_t headerBufSize, + uint8_t *leftover, + const size_t leftoverBufSize, + size_t *outLeftoverLength +) { + size_t headerLength = 0; + headerBuf[0] = '\0'; + + uint8_t chunk[NETWORK_HTTP_PROCESS_READ_CHUNK_SIZE]; + + for(;;) { + size_t received = 0; + errorChain(networkSocketPlatformReceive( + sock, chunk, sizeof(chunk), &received + )); + if(received == 0) { + errorThrow("Connection closed before response headers completed"); + } + + if(headerLength + received >= headerBufSize) { + errorThrow("Response headers too large"); + } + + memoryCopy(headerBuf + headerLength, chunk, received); + headerLength += received; + headerBuf[headerLength] = '\0'; + + char_t *terminator = strstr(headerBuf, "\r\n\r\n"); + if(terminator == NULL) continue; + + const size_t terminatorOffset = (size_t)(terminator - headerBuf); + const size_t bodyStart = terminatorOffset + 4; + const size_t extra = headerLength - bodyStart; + + if(extra > leftoverBufSize) errorThrow("Too much body data buffered"); + if(extra > 0) memoryCopy(leftover, headerBuf + bodyStart, extra); + *outLeftoverLength = extra; + + // Keep the trailing "\r\n" of the last header line, drop the blank + // line so callers can split on "\r\n" without a special case. + headerBuf[terminatorOffset + 2] = '\0'; + break; + } + + errorOk(); +} + +errorret_t networkHttpProcessParseStatusLine( + const char_t *line, + uint16_t *outStatus +) { + const char_t *space = strchr(line, ' '); + if(space == NULL) errorThrow("Malformed status line: %s", line); + + char_t statusStr[4]; + if(strlen(space + 1) < 3) errorThrow("Malformed status line: %s", line); + memoryCopy(statusStr, space + 1, 3); + statusStr[3] = '\0'; + + if(!stringToU16(statusStr, outStatus)) { + errorThrow("Malformed status code: %s", line); + } + + errorOk(); +} + +void networkHttpProcessParseHeaderLine( + const char_t *line, + networkhttpheaderlist_t *outHeaders +) { + const char_t *colon = strchr(line, ':'); + if(colon == NULL) return; + + const size_t nameLength = (size_t)(colon - line); + if(nameLength == 0 || nameLength >= NETWORK_HTTP_HEADER_NAME_MAX) return; + + char_t name[NETWORK_HTTP_HEADER_NAME_MAX]; + memoryCopy(name, line, nameLength); + name[nameLength] = '\0'; + + const char_t *valueStart = colon + 1; + while(*valueStart == ' ') valueStart++; + + const size_t valueLength = strlen(valueStart); + if(valueLength >= NETWORK_HTTP_HEADER_VALUE_MAX) return; + + char_t value[NETWORK_HTTP_HEADER_VALUE_MAX]; + memoryCopy(value, valueStart, valueLength + 1); + + networkHttpHeaderListAdd(outHeaders, name, value, false); +} + +errorret_t networkHttpProcessParseHeaders( + char_t *headerBuf, + uint16_t *outStatus, + networkhttpheaderlist_t *outHeaders +) { + char_t *cursor = headerBuf; + bool_t first = true; + + for(;;) { + char_t *lineEnd = strstr(cursor, "\r\n"); + const bool_t isLast = lineEnd == NULL; + if(lineEnd != NULL) *lineEnd = '\0'; + + if(first) { + errorChain(networkHttpProcessParseStatusLine(cursor, outStatus)); + first = false; + } else if(cursor[0] != '\0') { + networkHttpProcessParseHeaderLine(cursor, outHeaders); + } + + if(isLast) break; + cursor = lineEnd + 2; + } + + errorOk(); +} + +errorret_t networkHttpProcessReadBody( + networksocketplatform_t *sock, + const networkhttpheaderlist_t *headers, + const uint8_t *leftover, + const size_t leftoverLength, + uint8_t **outBody, + size_t *outBodyLength +) { + const networkhttpheader_t *contentLength = + networkHttpHeaderListFind(headers, "Content-Length"); + + if(contentLength != NULL) { + int64_t expected = 0; + if(!stringToI64(contentLength->value, &expected) || expected < 0) { + errorThrow("Malformed Content-Length header: %s", contentLength->value); + } + + if(expected == 0) { + *outBody = NULL; + *outBodyLength = 0; + errorOk(); + } + + uint8_t *body = memoryAllocate((size_t)expected); + const size_t initialCopy = + leftoverLength < (size_t)expected ? leftoverLength : (size_t)expected; + if(initialCopy > 0) memoryCopy(body, leftover, initialCopy); + size_t received = initialCopy; + + while(received < (size_t)expected) { + size_t got = 0; + errorret_t ret = networkSocketPlatformReceive( + sock, body + received, (size_t)expected - received, &got + ); + if(errorIsNotOk(ret)) { + memoryFree(body); + return errorChainImpl(ret, __FILE__, __func__, __LINE__); + } + if(got == 0) { + memoryFree(body); + errorThrow("Connection closed before full body was received"); + } + received += got; + } + + *outBody = body; + *outBodyLength = (size_t)expected; + errorOk(); + } + + // No Content-Length: read until the connection closes. + size_t capacity = NETWORK_HTTP_PROCESS_BODY_INITIAL_SIZE; + if(leftoverLength > capacity) capacity = leftoverLength; + + uint8_t *body = memoryAllocate(capacity); + size_t length = 0; + if(leftoverLength > 0) { + memoryCopy(body, leftover, leftoverLength); + length = leftoverLength; + } + + for(;;) { + if(length == capacity) { + const size_t newCapacity = capacity * 2; + memoryResize((void **)&body, capacity, newCapacity); + capacity = newCapacity; + } + + size_t got = 0; + errorret_t ret = networkSocketPlatformReceive( + sock, body + length, capacity - length, &got + ); + if(errorIsNotOk(ret)) { + memoryFree(body); + return errorChainImpl(ret, __FILE__, __func__, __LINE__); + } + if(got == 0) break; + length += got; + } + + *outBody = body; + *outBodyLength = length; + errorOk(); +} + +errorret_t networkHttpProcessAttempt( + const networkhttprequest_t *request, + const networkhttpurl_t *target, + networkhttpresponse_t *outResponse +) { + memoryZero(outResponse, sizeof(networkhttpresponse_t)); + + networksocketplatform_t sock; + errorChain(networkSocketPlatformConnect(&sock, target->host, target->port)); + + char_t head[NETWORK_HTTP_PROCESS_HEAD_BUF_SIZE]; + size_t headLength = 0; + networkHttpProcessErrorChain(&sock, networkHttpProcessBuildHead( + request, target, head, NETWORK_HTTP_PROCESS_HEAD_BUF_SIZE, &headLength + )); + + networkHttpProcessErrorChain(&sock, networkHttpProcessSendAll( + &sock, (const uint8_t *)head, headLength + )); + + if(request->bodyLength > 0) { + networkHttpProcessErrorChain(&sock, networkHttpProcessSendAll( + &sock, request->body, request->bodyLength + )); + } + + char_t headerBuf[NETWORK_HTTP_PROCESS_HEADER_BUF_SIZE]; + uint8_t leftover[NETWORK_HTTP_PROCESS_READ_CHUNK_SIZE]; + size_t leftoverLength = 0; + + networkHttpProcessErrorChain(&sock, networkHttpProcessReceiveHeaders( + &sock, headerBuf, NETWORK_HTTP_PROCESS_HEADER_BUF_SIZE, + leftover, NETWORK_HTTP_PROCESS_READ_CHUNK_SIZE, &leftoverLength + )); + + networkHttpProcessErrorChain(&sock, networkHttpProcessParseHeaders( + headerBuf, &outResponse->status, &outResponse->headers + )); + + errorret_t bodyRet = networkHttpProcessReadBody( + &sock, &outResponse->headers, leftover, leftoverLength, + &outResponse->body, &outResponse->bodyLength + ); + networkSocketPlatformClose(&sock); + errorChain(bodyRet); + + errorOk(); +} diff --git a/src/dusk/network/http/networkhttpprocess.h b/src/dusk/network/http/networkhttpprocess.h new file mode 100644 index 00000000..d0519b38 --- /dev/null +++ b/src/dusk/network/http/networkhttpprocess.h @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "network/http/networkhttprequest.h" +#include "network/http/networkhttpurl.h" +#include "network/networksocketplatform.h" + +#define NETWORK_HTTP_PROCESS_HEAD_BUF_SIZE 4096 +#define NETWORK_HTTP_PROCESS_HEADER_BUF_SIZE 8192 +#define NETWORK_HTTP_PROCESS_READ_CHUNK_SIZE 2048 +#define NETWORK_HTTP_PROCESS_BODY_INITIAL_SIZE 4096 + +/** + * Shorthand to chain an error, closing sock first if expr failed. Used + * for every step of an attempt after the socket has connected, since + * failing partway through must not leak the socket. + */ +#define networkHttpProcessErrorChain(sock, _expr) { \ + errorret_t _nhpErr = (_expr); \ + if(errorIsNotOk(_nhpErr)) { \ + networkSocketPlatformClose(sock); \ + return errorChainImpl(_nhpErr, __FILE__, __func__, __LINE__); \ + } \ +} + +/** + * Returns the wire method name ("GET", "POST", "PUT") for method. + * + * @param method The method to name. + * @return A static string naming the method. + */ +const char_t * networkHttpProcessMethodName( + const networkhttpmethod_t method +); + +/** + * Builds the request line, headers and blank line terminator (but not + * the body) for request, targeting target, into dest. + * + * @param request The request being sent. + * @param target The resolved host/port/path for this attempt. + * @param dest The destination buffer. + * @param destSize The size of dest, including the null terminator. + * @param outLength The number of bytes written is written here. + * @return An error if the result would not fit in dest. + */ +errorret_t networkHttpProcessBuildHead( + const networkhttprequest_t *request, + const networkhttpurl_t *target, + char_t *dest, + const size_t destSize, + size_t *outLength +); + +/** + * Sends length bytes of data over sock, looping until all of it has + * been written. + * + * @param sock The connected socket. + * @param data The data to send. + * @param length The number of bytes in data. + * @return An error if the send failed or the connection closed early. + */ +errorret_t networkHttpProcessSendAll( + networksocketplatform_t *sock, + const uint8_t *data, + const size_t length +); + +/** + * Reads from sock until the blank line ("\r\n\r\n") terminating the + * response headers has been seen, writing the status line and header + * lines (without the blank line) into headerBuf as a null-terminated + * string. Any body bytes read past the terminator in the same receive + * are written to leftover. + * + * @param sock The connected socket. + * @param headerBuf The destination buffer for the header text. + * @param headerBufSize The size of headerBuf, including the null + * terminator. + * @param leftover Destination buffer for any body bytes read early. + * @param leftoverBufSize The size of leftover. + * @param outLeftoverLength The number of bytes written to leftover is + * written here. + * @return An error if the headers could not be read, were malformed, + * or did not fit within the given buffers. + */ +errorret_t networkHttpProcessReceiveHeaders( + networksocketplatform_t *sock, + char_t *headerBuf, + const size_t headerBufSize, + uint8_t *leftover, + const size_t leftoverBufSize, + size_t *outLeftoverLength +); + +/** + * Parses a "HTTP/1.1 " status line. + * + * @param line The status line, null-terminated. + * @param outStatus The parsed status code is written here. + * @return An error if the status line is malformed. + */ +errorret_t networkHttpProcessParseStatusLine( + const char_t *line, + uint16_t *outStatus +); + +/** + * Parses a single "Name: Value" response header line and appends it to + * outHeaders. Silently does nothing if the line has no colon or the + * name/value are too long to fit a networkhttpheader_t. + * + * @param line The header line, null-terminated. + * @param outHeaders The header list to append to. + */ +void networkHttpProcessParseHeaderLine( + const char_t *line, + networkhttpheaderlist_t *outHeaders +); + +/** + * Parses the status line and every header line out of headerBuf (as + * produced by networkHttpProcessReceiveHeaders). Mutates headerBuf in + * place to split it into lines. + * + * @param headerBuf The header text to parse. + * @param outStatus The parsed status code is written here. + * @param outHeaders The header list to populate. + * @return An error if the status line is malformed. + */ +errorret_t networkHttpProcessParseHeaders( + char_t *headerBuf, + uint16_t *outStatus, + networkhttpheaderlist_t *outHeaders +); + +/** + * Reads the response body from sock. If headers contains a + * Content-Length, reads exactly that many bytes; otherwise reads until + * the connection closes. Either way, leftover/leftoverLength (body + * bytes already read while looking for the header terminator) are + * included at the start of the result. + * + * @param sock The connected socket. + * @param headers The parsed response headers. + * @param leftover Body bytes already read past the header terminator. + * @param leftoverLength The number of bytes in leftover. + * @param outBody A memoryAllocate'd buffer holding the body is written + * here, or NULL if the body is empty. Owned by the caller. + * @param outBodyLength The number of bytes in *outBody is written here. + * @return An error if the body could not be fully read. + */ +errorret_t networkHttpProcessReadBody( + networksocketplatform_t *sock, + const networkhttpheaderlist_t *headers, + const uint8_t *leftover, + const size_t leftoverLength, + uint8_t **outBody, + size_t *outBodyLength +); + +/** + * Performs a single blocking HTTP exchange: connects to target, writes + * the request head + body, and reads back one parsed response. Does + * not follow redirects -- that is the caller's responsibility. + * + * @param request The request to send (method, headers, body). + * @param target The resolved host/port/path to connect to for this + * attempt (may differ from the request's own URL if this is a + * redirected attempt). + * @param outResponse The parsed response is written here on success. + * @return An error if the exchange could not be completed. + */ +errorret_t networkHttpProcessAttempt( + const networkhttprequest_t *request, + const networkhttpurl_t *target, + networkhttpresponse_t *outResponse +); diff --git a/src/dusk/network/http/networkhttprequest.c b/src/dusk/network/http/networkhttprequest.c new file mode 100644 index 00000000..5abb9d92 --- /dev/null +++ b/src/dusk/network/http/networkhttprequest.c @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networkhttprequest.h" +#include "util/memory.h" +#include "assert/assert.h" + +networkhttprequest_t NETWORK_HTTP_REQUESTS[NETWORK_HTTP_REQUEST_COUNT_MAX]; + +void networkHttpRequestPoolInit(void) { + for(uint32_t i = 0; i < NETWORK_HTTP_REQUEST_COUNT_MAX; i++) { + networkhttprequest_t *request = &NETWORK_HTTP_REQUESTS[i]; + + memoryZero(request, sizeof(networkhttprequest_t)); + threadMutexInit(&request->mutex); + eventInit( + &request->onComplete, request->onCompleteCallbacks, + request->onCompleteUsers, NETWORK_HTTP_REQUEST_EVENT_MAX + ); + eventInit( + &request->onError, request->onErrorCallbacks, + request->onErrorUsers, NETWORK_HTTP_REQUEST_EVENT_MAX + ); + request->state = NETWORK_HTTP_REQUEST_STATE_FREE; + } +} + +networkhttprequest_t * networkHttpRequestClaim(void) { + for(uint32_t i = 0; i < NETWORK_HTTP_REQUEST_COUNT_MAX; i++) { + networkhttprequest_t *request = &NETWORK_HTTP_REQUESTS[i]; + + threadMutexLock(&request->mutex); + if(request->state != NETWORK_HTTP_REQUEST_STATE_FREE) { + threadMutexUnlock(&request->mutex); + continue; + } + threadMutexUnlock(&request->mutex); + return request; + } + + assertUnreachable("No available HTTP request slots."); + return NULL; +} + +void networkHttpRequestReset(networkhttprequest_t *request) { + assertNotNull(request, "request must not be NULL"); + + threadMutexLock(&request->mutex); + + if(request->body != NULL) memoryFree(request->body); + request->body = NULL; + request->bodyLength = 0; + + if(request->response.body != NULL) memoryFree(request->response.body); + memoryZero(&request->response, sizeof(networkhttpresponse_t)); + + networkHttpHeaderListClear(&request->requestHeaders); + + eventInit( + &request->onComplete, request->onCompleteCallbacks, + request->onCompleteUsers, NETWORK_HTTP_REQUEST_EVENT_MAX + ); + eventInit( + &request->onError, request->onErrorCallbacks, + request->onErrorUsers, NETWORK_HTTP_REQUEST_EVENT_MAX + ); + + request->state = NETWORK_HTTP_REQUEST_STATE_FREE; + + threadMutexUnlock(&request->mutex); +} + +errorret_t networkHttpRequest( + const networkhttpmethod_t method, + const char_t *url, + const networkhttpheader_t *headers, + const uint32_t headerCount, + const networkhttpheader_t *queryParams, + const uint32_t queryParamCount, + const uint8_t *body, + const size_t bodyLength, + const eventcallback_t onComplete, + const eventcallback_t onError, + void *user +) { + assertNotNull(url, "url must not be NULL"); + + networkhttprequest_t *request = networkHttpRequestClaim(); + request->method = method; + + errorChain(networkHttpUrlBuild( + request->url, NETWORK_HTTP_URL_MAX, url, queryParams, queryParamCount + )); + + networkHttpHeaderListClear(&request->requestHeaders); + for(uint32_t i = 0; i < headerCount; i++) { + networkHttpHeaderListAdd( + &request->requestHeaders, headers[i].name, headers[i].value, true + ); + } + + request->bodyLength = bodyLength; + if(bodyLength > 0) { + assertNotNull(body, "body must not be NULL when bodyLength > 0"); + request->body = memoryAllocate(bodyLength); + memoryCopy(request->body, body, bodyLength); + } + + if(onComplete != NULL) eventSubscribe(&request->onComplete, onComplete, user); + if(onError != NULL) eventSubscribe(&request->onError, onError, user); + + threadMutexLock(&request->mutex); + request->state = NETWORK_HTTP_REQUEST_STATE_PENDING; + threadMutexUnlock(&request->mutex); + + errorOk(); +} diff --git a/src/dusk/network/http/networkhttprequest.h b/src/dusk/network/http/networkhttprequest.h new file mode 100644 index 00000000..d90ca497 --- /dev/null +++ b/src/dusk/network/http/networkhttprequest.h @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "event/event.h" +#include "thread/threadmutex.h" +#include "network/http/networkhttpheader.h" +#include "network/http/networkhttpurl.h" + +#define NETWORK_HTTP_REQUEST_COUNT_MAX 4 +#define NETWORK_HTTP_REQUEST_EVENT_MAX 1 + +typedef enum { + NETWORK_HTTP_METHOD_GET, + NETWORK_HTTP_METHOD_POST, + NETWORK_HTTP_METHOD_PUT +} networkhttpmethod_t; + +typedef enum { + NETWORK_HTTP_REQUEST_STATE_FREE, + NETWORK_HTTP_REQUEST_STATE_PENDING, + NETWORK_HTTP_REQUEST_STATE_ACTIVE, + NETWORK_HTTP_REQUEST_STATE_DONE, + NETWORK_HTTP_REQUEST_STATE_ERROR +} networkhttprequeststate_t; + +typedef struct { + uint16_t status; + networkhttpheaderlist_t headers; + + // Owned, memoryAllocate'd. Only valid until the onComplete/onError + // event has finished firing, at which point it is freed. + uint8_t *body; + size_t bodyLength; +} networkhttpresponse_t; + +typedef struct { + threadmutex_t mutex; + networkhttprequeststate_t state; + + networkhttpmethod_t method; + + // Fully built, including any query params, by networkHttpRequest. + char_t url[NETWORK_HTTP_URL_MAX]; + networkhttpheaderlist_t requestHeaders; + + // Owned copy, memoryAllocate'd. + uint8_t *body; + size_t bodyLength; + + /** Fired on the main thread once a response has been received. */ + event_t onComplete; + eventcallback_t onCompleteCallbacks[NETWORK_HTTP_REQUEST_EVENT_MAX]; + void *onCompleteUsers[NETWORK_HTTP_REQUEST_EVENT_MAX]; + + /** Fired on the main thread if the request could not be completed. */ + event_t onError; + eventcallback_t onErrorCallbacks[NETWORK_HTTP_REQUEST_EVENT_MAX]; + void *onErrorUsers[NETWORK_HTTP_REQUEST_EVENT_MAX]; + + networkhttpresponse_t response; +} networkhttprequest_t; + +extern networkhttprequest_t + NETWORK_HTTP_REQUESTS[NETWORK_HTTP_REQUEST_COUNT_MAX]; + +/** + * Initializes the request pool: prepares every slot's mutex and events + * and marks them all FREE. Called once by networkHttpInit. + */ +void networkHttpRequestPoolInit(void); + +/** + * Finds and returns a FREE slot in the request pool. The slot is not + * marked as in-use by this call; the caller must populate it and then + * set its state to PENDING. + * + * @return A free request slot. + */ +networkhttprequest_t * networkHttpRequestClaim(void); + +/** + * Resets a request slot back to FREE, freeing any owned buffers + * (request body, response body) and clearing its event subscribers. + * + * @param request The request slot to reset. + */ +void networkHttpRequestReset(networkhttprequest_t *request); + +/** + * Sends an HTTP request asynchronously. The request runs on a + * background thread; onComplete or onError is invoked on the main + * thread (from networkHttpUpdate) once it finishes. 301 redirects are + * followed automatically. + * + * @param method The HTTP method to use. + * @param url The "http://host[:port]/path" URL to request. Must not + * use any scheme other than http. + * @param headers Request headers to send, may be NULL if headerCount + * is 0. The Host, Connection and Content-Length headers are + * reserved and computed internally; any of those passed here + * are ignored. + * @param headerCount Number of entries in headers. + * @param queryParams Query string parameters to append to url, may be + * NULL if queryParamCount is 0. + * @param queryParamCount Number of entries in queryParams. + * @param body Request body to send for POST/PUT, may be NULL if + * bodyLength is 0. Ignored for GET. + * @param bodyLength Number of bytes in body. + * @param onComplete Invoked with (networkhttprequest_t *, user) once a + * response has been received, whatever its status code. May be + * NULL to ignore. + * @param onError Invoked with (networkhttprequest_t *, user) if the + * request could not be completed (DNS/connect/send/receive + * failure, or a malformed response). May be NULL to ignore. + * @param user Arbitrary pointer forwarded to onComplete/onError. + * @return An error if the request could not be queued (e.g. a + * malformed url). + */ +errorret_t networkHttpRequest( + const networkhttpmethod_t method, + const char_t *url, + const networkhttpheader_t *headers, + const uint32_t headerCount, + const networkhttpheader_t *queryParams, + const uint32_t queryParamCount, + const uint8_t *body, + const size_t bodyLength, + const eventcallback_t onComplete, + const eventcallback_t onError, + void *user +); diff --git a/src/dusk/network/http/networkhttpthread.c b/src/dusk/network/http/networkhttpthread.c new file mode 100644 index 00000000..3d8b8d1d --- /dev/null +++ b/src/dusk/network/http/networkhttpthread.c @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networkhttpthread.h" +#include "networkhttpprocess.h" +#include "util/memory.h" +#include "assert/assert.h" +#include + +void networkHttpThreadRun(thread_t *thread) { + assertNotMainThread("networkHttpThreadRun must not run on the main thread."); + + while(!threadShouldStop(thread)) { + bool_t didWork = false; + + for(uint32_t i = 0; i < NETWORK_HTTP_REQUEST_COUNT_MAX; i++) { + networkhttprequest_t *request = &NETWORK_HTTP_REQUESTS[i]; + + threadMutexLock(&request->mutex); + if(request->state != NETWORK_HTTP_REQUEST_STATE_PENDING) { + threadMutexUnlock(&request->mutex); + continue; + } + request->state = NETWORK_HTTP_REQUEST_STATE_ACTIVE; + threadMutexUnlock(&request->mutex); + + didWork = true; + networkHttpThreadProcessRequest(request); + } + + if(threadShouldStop(thread)) break; + if(!didWork) usleep(1000); + } +} + +void networkHttpThreadProcessRequest(networkhttprequest_t *request) { + networkhttpurl_t target; + errorret_t parseRet = networkHttpUrlParse(request->url, &target); + + networkhttprequeststate_t finalState = NETWORK_HTTP_REQUEST_STATE_DONE; + + if(errorIsNotOk(parseRet)) { + errorCatch(errorPrint(parseRet)); + finalState = NETWORK_HTTP_REQUEST_STATE_ERROR; + } else { + for(uint32_t redirect = 0; ; redirect++) { + errorret_t ret = networkHttpProcessAttempt( + request, &target, &request->response + ); + + if(errorIsNotOk(ret)) { + errorCatch(errorPrint(ret)); + finalState = NETWORK_HTTP_REQUEST_STATE_ERROR; + break; + } + + const bool_t isRedirect = request->response.status == 301; + const networkhttpheader_t *location = isRedirect ? + networkHttpHeaderListFind(&request->response.headers, "Location") : + NULL; + + if(location == NULL || redirect >= NETWORK_HTTP_REDIRECT_COUNT_MAX) { + break; + } + + networkhttpurl_t nextTarget; + errorret_t nextRet = networkHttpUrlParse(location->value, &nextTarget); + if(errorIsNotOk(nextRet)) { + // Not an absolute URL we can follow -- treat the 301 itself as + // the final response instead of failing the whole request. + errorCatch(errorPrint(nextRet)); + break; + } + + // Free the intermediate response body before following further. + if(request->response.body != NULL) memoryFree(request->response.body); + target = nextTarget; + } + } + + threadMutexLock(&request->mutex); + request->state = finalState; + threadMutexUnlock(&request->mutex); +} diff --git a/src/dusk/network/http/networkhttpthread.h b/src/dusk/network/http/networkhttpthread.h new file mode 100644 index 00000000..3c8f2b09 --- /dev/null +++ b/src/dusk/network/http/networkhttpthread.h @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "thread/thread.h" +#include "network/http/networkhttprequest.h" + +#define NETWORK_HTTP_REDIRECT_COUNT_MAX 5 + +/** + * The background worker thread body: repeatedly scans the request + * pool for PENDING slots, processes each to completion, and marks it + * DONE or ERROR. Idles briefly when there is nothing to do. Runs + * until the thread is stopped. + * + * @param thread The thread runner. + */ +void networkHttpThreadRun(thread_t *thread); + +/** + * Processes a single ACTIVE request slot to completion, following up + * to NETWORK_HTTP_REDIRECT_COUNT_MAX HTTP 301 redirects, and leaves it + * in the DONE or ERROR state. Only called from the background thread. + * + * @param request The request slot to process. + */ +void networkHttpThreadProcessRequest(networkhttprequest_t *request); diff --git a/src/dusk/network/http/networkhttpurl.c b/src/dusk/network/http/networkhttpurl.c new file mode 100644 index 00000000..35b4fbf8 --- /dev/null +++ b/src/dusk/network/http/networkhttpurl.c @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networkhttpurl.h" +#include "util/memory.h" +#include "util/string.h" +#include "assert/assert.h" + +errorret_t networkHttpUrlParse(const char_t *url, networkhttpurl_t *out) { + assertNotNull(url, "url must not be NULL"); + assertNotNull(out, "out must not be NULL"); + + if(strlen(url) <= 7 || strncasecmp(url, "http://", 7) != 0) { + errorThrow("Unsupported URL scheme (only http:// is supported): %s", url); + } + + const char_t *rest = url + 7; + + const size_t hostLen = strcspn(rest, ":/"); + if(hostLen == 0 || hostLen >= NETWORK_HTTP_HOST_MAX) { + errorThrow("Invalid host in URL: %s", url); + } + memoryCopy(out->host, rest, hostLen); + out->host[hostLen] = '\0'; + rest += hostLen; + + if(*rest == ':') { + rest++; + + const size_t portLen = strcspn(rest, "/"); + char_t portStr[8]; + if(portLen == 0 || portLen >= sizeof(portStr)) { + errorThrow("Invalid port in URL: %s", url); + } + memoryCopy(portStr, rest, portLen); + portStr[portLen] = '\0'; + + if(!stringToU16(portStr, &out->port)) { + errorThrow("Invalid port in URL: %s", url); + } + rest += portLen; + } else { + out->port = NETWORK_HTTP_PORT_DEFAULT; + } + + const char_t *path = *rest == '\0' ? "/" : rest; + const size_t pathLen = strlen(path); + if(pathLen >= NETWORK_HTTP_PATH_MAX) { + errorThrow("Path too long in URL: %s", url); + } + memoryCopy(out->path, path, pathLen + 1); + + errorOk(); +} + +errorret_t networkHttpUrlBuild( + char_t *dest, + const size_t destSize, + const char_t *baseUrl, + const networkhttpheader_t *queryParams, + const uint32_t queryParamCount +) { + assertNotNull(dest, "dest must not be NULL"); + assertNotNull(baseUrl, "baseUrl must not be NULL"); + + const size_t baseLen = strlen(baseUrl); + if(baseLen >= destSize) errorThrow("URL too long: %s", baseUrl); + memoryCopy(dest, baseUrl, baseLen + 1); + + size_t cursor = baseLen; + bool_t hasQuery = stringIncludesString(baseUrl, "?"); + + for(uint32_t i = 0; i < queryParamCount; i++) { + errorChain(networkHttpUrlAppend( + dest, destSize, &cursor, hasQuery ? "&" : "?" + )); + hasQuery = true; + + errorChain(networkHttpUrlEncodeComponent( + dest, destSize, &cursor, queryParams[i].name + )); + errorChain(networkHttpUrlAppend(dest, destSize, &cursor, "=")); + errorChain(networkHttpUrlEncodeComponent( + dest, destSize, &cursor, queryParams[i].value + )); + } + + errorOk(); +} + +errorret_t networkHttpUrlAppend( + char_t *dest, + const size_t destSize, + size_t *cursor, + const char_t *str +) { + const size_t len = strlen(str); + if(*cursor + len >= destSize) errorThrow("URL buffer too small"); + + memoryCopy(dest + *cursor, str, len + 1); + *cursor += len; + errorOk(); +} + +errorret_t networkHttpUrlEncodeComponent( + char_t *dest, + const size_t destSize, + size_t *cursor, + const char_t *src +) { + const char_t *hex = "0123456789ABCDEF"; + + for(const char_t *p = src; *p != '\0'; p++) { + const uint8_t c = (uint8_t)*p; + const bool_t unreserved = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~'; + + if(unreserved) { + if(*cursor + 1 >= destSize) errorThrow("URL buffer too small"); + dest[(*cursor)++] = (char_t)c; + } else { + if(*cursor + 3 >= destSize) errorThrow("URL buffer too small"); + dest[(*cursor)++] = '%'; + dest[(*cursor)++] = hex[(c >> 4) & 0xF]; + dest[(*cursor)++] = hex[c & 0xF]; + } + } + + dest[*cursor] = '\0'; + errorOk(); +} diff --git a/src/dusk/network/http/networkhttpurl.h b/src/dusk/network/http/networkhttpurl.h new file mode 100644 index 00000000..e7820dfd --- /dev/null +++ b/src/dusk/network/http/networkhttpurl.h @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "network/http/networkhttpheader.h" + +#define NETWORK_HTTP_URL_MAX 1024 +#define NETWORK_HTTP_HOST_MAX 256 +#define NETWORK_HTTP_PATH_MAX 768 +#define NETWORK_HTTP_PORT_DEFAULT 80 + +typedef struct { + char_t host[NETWORK_HTTP_HOST_MAX]; + uint16_t port; + + // Includes the leading slash and, if present, the query string. + char_t path[NETWORK_HTTP_PATH_MAX]; +} networkhttpurl_t; + +/** + * Parses a "http://host[:port]/path[?query]" URL. Any other scheme is + * rejected, since this client does not support TLS. + * + * @param url The URL to parse. + * @param out The parsed URL is written here. + * @return An error if the URL is malformed or uses an unsupported scheme. + */ +errorret_t networkHttpUrlParse(const char_t *url, networkhttpurl_t *out); + +/** + * Copies baseUrl into dest, then appends each queryParam as a + * percent-encoded "key=value" pair, joined with "&" (or "?" for the + * first one, unless baseUrl already contains a "?"). + * + * @param dest The destination buffer. + * @param destSize The size of dest, including the null terminator. + * @param baseUrl The URL to copy before appending query params. + * @param queryParams Array of query parameters to append, may be NULL if + * queryParamCount is 0. + * @param queryParamCount Number of entries in queryParams. + * @return An error if the result would not fit in dest. + */ +errorret_t networkHttpUrlBuild( + char_t *dest, + const size_t destSize, + const char_t *baseUrl, + const networkhttpheader_t *queryParams, + const uint32_t queryParamCount +); + +/** + * Appends a raw (not percent-encoded) string to dest at *cursor, + * advancing *cursor and keeping dest null-terminated. + * + * @param dest The destination buffer. + * @param destSize The size of dest, including the null terminator. + * @param cursor The current write offset into dest, updated in place. + * @param str The string to append. + * @return An error if the result would not fit in dest. + */ +errorret_t networkHttpUrlAppend( + char_t *dest, + const size_t destSize, + size_t *cursor, + const char_t *str +); + +/** + * Percent-encodes src and appends it to dest at *cursor, advancing + * *cursor and keeping dest null-terminated. Unreserved characters + * (letters, digits, "-", "_", ".", "~") are copied as-is. + * + * @param dest The destination buffer. + * @param destSize The size of dest, including the null terminator. + * @param cursor The current write offset into dest, updated in place. + * @param src The string to percent-encode and append. + * @return An error if the result would not fit in dest. + */ +errorret_t networkHttpUrlEncodeComponent( + char_t *dest, + const size_t destSize, + size_t *cursor, + const char_t *src +); diff --git a/src/dusk/network/network.c b/src/dusk/network/network.c index 9ebd57fd..010c3dbf 100644 --- a/src/dusk/network/network.c +++ b/src/dusk/network/network.c @@ -6,6 +6,7 @@ */ #include "network.h" +#include "network/http/networkhttp.h" #include "util/memory.h" #include "assert/assert.h" #include "log/log.h" @@ -18,11 +19,13 @@ errorret_t networkInit() { NETWORK.errorState.code = ERROR_OK; NETWORK.onDisconnect = NULL; - return networkPlatformInit(); + errorChain(networkPlatformInit()); + return networkHttpInit(); } errorret_t networkUpdate() { errorChain(networkPlatformUpdate()); + errorChain(networkHttpUpdate()); if(NETWORK.state == NETWORK_STATE_CONNECTED && !networkIsConnected()) { NETWORK.state = NETWORK_STATE_DISCONNECTED; @@ -112,5 +115,5 @@ errorret_t networkDispose() { } errorChain(networkPlatformDispose()); - errorOk(); + return networkHttpDispose(); } diff --git a/src/duskdolphin/network/CMakeLists.txt b/src/duskdolphin/network/CMakeLists.txt index e514c3b9..0ca9b5c5 100644 --- a/src/duskdolphin/network/CMakeLists.txt +++ b/src/duskdolphin/network/CMakeLists.txt @@ -6,4 +6,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC networkdolphin.c + networksocketdolphin.c ) diff --git a/src/duskdolphin/network/networksocketdolphin.c b/src/duskdolphin/network/networksocketdolphin.c new file mode 100644 index 00000000..125e1a83 --- /dev/null +++ b/src/duskdolphin/network/networksocketdolphin.c @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networksocketdolphin.h" +#include "util/memory.h" +#include "assert/assert.h" + +#ifdef DUSK_WII + #include +#endif + +errorret_t networkSocketDolphinConnect( + networksocketdolphin_t *sock, + const char_t *host, + const uint16_t port +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(host, "host must not be NULL"); + + #ifdef DUSK_WII + struct hostent *entry = net_gethostbyname(host); + if(entry == NULL || entry->h_addr_list[0] == NULL) { + errorThrow("Failed to resolve host %s", host); + } + + const s32 fd = net_socket(AF_INET, SOCK_STREAM, 0); + if(fd < 0) errorThrow("Failed to create socket: %d", (int_t)fd); + + struct sockaddr_in serverAddr; + memoryZero(&serverAddr, sizeof(serverAddr)); + serverAddr.sin_family = AF_INET; + serverAddr.sin_port = htons(port); + memoryCopy( + &serverAddr.sin_addr, entry->h_addr_list[0], sizeof(serverAddr.sin_addr) + ); + + const s32 ret = net_connect(fd, (void *)&serverAddr, sizeof(serverAddr)); + if(ret < 0) { + net_close(fd); + errorThrow("Failed to connect to %s:%u: %d", host, port, (int_t)ret); + } + + sock->fd = fd; + errorOk(); + #else + errorThrow( + "Networking is not supported on this platform (%s:%u)", host, port + ); + #endif +} + +errorret_t networkSocketDolphinSend( + networksocketdolphin_t *sock, + const uint8_t *data, + const size_t length, + size_t *outSent +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(data, "data must not be NULL"); + assertNotNull(outSent, "outSent must not be NULL"); + + #ifdef DUSK_WII + const s32 sent = net_send(sock->fd, data, (s32)length, 0); + if(sent < 0) errorThrow("Failed to send data: %d", (int_t)sent); + + *outSent = (size_t)sent; + errorOk(); + #else + errorThrow("Networking is not supported on this platform"); + #endif +} + +errorret_t networkSocketDolphinReceive( + networksocketdolphin_t *sock, + uint8_t *buffer, + const size_t bufferSize, + size_t *outReceived +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(buffer, "buffer must not be NULL"); + assertNotNull(outReceived, "outReceived must not be NULL"); + + #ifdef DUSK_WII + const s32 received = net_recv(sock->fd, buffer, (s32)bufferSize, 0); + if(received < 0) errorThrow("Failed to receive data: %d", (int_t)received); + + *outReceived = (size_t)received; + errorOk(); + #else + errorThrow("Networking is not supported on this platform"); + #endif +} + +void networkSocketDolphinClose(networksocketdolphin_t *sock) { + assertNotNull(sock, "sock must not be NULL"); + + #ifdef DUSK_WII + if(sock->fd >= 0) net_close(sock->fd); + #endif + sock->fd = -1; +} diff --git a/src/duskdolphin/network/networksocketdolphin.h b/src/duskdolphin/network/networksocketdolphin.h new file mode 100644 index 00000000..b59dab78 --- /dev/null +++ b/src/duskdolphin/network/networksocketdolphin.h @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" + +typedef struct { + int_t fd; +} networksocketdolphin_t; + +/** + * Resolves host and opens a blocking TCP connection to host:port. + * Always fails under plain DUSK_GAMECUBE -- retail GameCube units have + * no network hardware. + * + * @param sock The socket structure to initialize. + * @param host The hostname or IP address to connect to. + * @param port The port to connect to. + * @return An error if resolution or connection failed. + */ +errorret_t networkSocketDolphinConnect( + networksocketdolphin_t *sock, + const char_t *host, + const uint16_t port +); + +/** + * Sends data over the socket. May write fewer bytes than requested; the + * caller is responsible for looping until all data is sent. + * + * @param sock The connected socket. + * @param data The data to send. + * @param length The number of bytes in data. + * @param outSent The number of bytes actually sent is written here. + * @return An error if the send failed. + */ +errorret_t networkSocketDolphinSend( + networksocketdolphin_t *sock, + const uint8_t *data, + const size_t length, + size_t *outSent +); + +/** + * Receives data from the socket. + * + * @param sock The connected socket. + * @param buffer The buffer to receive into. + * @param bufferSize The size of buffer. + * @param outReceived The number of bytes actually received is written + * here; 0 means the peer closed the connection. + * @return An error if the receive failed. + */ +errorret_t networkSocketDolphinReceive( + networksocketdolphin_t *sock, + uint8_t *buffer, + const size_t bufferSize, + size_t *outReceived +); + +/** + * Closes the socket. + * + * @param sock The socket to close. + */ +void networkSocketDolphinClose(networksocketdolphin_t *sock); diff --git a/src/duskdolphin/network/networksocketplatform.h b/src/duskdolphin/network/networksocketplatform.h new file mode 100644 index 00000000..5d24d9f7 --- /dev/null +++ b/src/duskdolphin/network/networksocketplatform.h @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "networksocketdolphin.h" + +typedef networksocketdolphin_t networksocketplatform_t; + +#define networkSocketPlatformConnect networkSocketDolphinConnect +#define networkSocketPlatformSend networkSocketDolphinSend +#define networkSocketPlatformReceive networkSocketDolphinReceive +#define networkSocketPlatformClose networkSocketDolphinClose diff --git a/src/dusklinux/network/CMakeLists.txt b/src/dusklinux/network/CMakeLists.txt index c1322dc3..ca2770f2 100644 --- a/src/dusklinux/network/CMakeLists.txt +++ b/src/dusklinux/network/CMakeLists.txt @@ -6,4 +6,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC networklinux.c + networksocketlinux.c ) diff --git a/src/dusklinux/network/networksocketlinux.c b/src/dusklinux/network/networksocketlinux.c new file mode 100644 index 00000000..8784dd4f --- /dev/null +++ b/src/dusklinux/network/networksocketlinux.c @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networksocketlinux.h" +#include "util/memory.h" +#include "util/string.h" +#include "assert/assert.h" +#include +#include +#include +#include + +errorret_t networkSocketLinuxConnect( + networksocketlinux_t *sock, + const char_t *host, + const uint16_t port +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(host, "host must not be NULL"); + + char_t portStr[8]; + stringFormat(portStr, sizeof(portStr) - 1, "%u", port); + + struct addrinfo hints; + memoryZero(&hints, sizeof(hints)); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo *result = NULL; + const int_t gaiRet = getaddrinfo(host, portStr, &hints, &result); + if(gaiRet != 0) { + errorThrow("Failed to resolve host %s: %s", host, gai_strerror(gaiRet)); + } + + int_t fd = -1; + for(struct addrinfo *addr = result; addr != NULL; addr = addr->ai_next) { + fd = socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol); + if(fd < 0) continue; + + if(connect(fd, addr->ai_addr, addr->ai_addrlen) == 0) break; + + close(fd); + fd = -1; + } + + freeaddrinfo(result); + + if(fd < 0) errorThrow("Failed to connect to %s:%u", host, port); + + struct timeval timeout; + timeout.tv_sec = NETWORK_SOCKET_LINUX_TIMEOUT_SECONDS; + timeout.tv_usec = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + sock->fd = fd; + errorOk(); +} + +errorret_t networkSocketLinuxSend( + networksocketlinux_t *sock, + const uint8_t *data, + const size_t length, + size_t *outSent +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(data, "data must not be NULL"); + assertNotNull(outSent, "outSent must not be NULL"); + + const ssize_t sent = send(sock->fd, data, length, 0); + if(sent < 0) errorThrow("Failed to send data: %s", strerror(errno)); + + *outSent = (size_t)sent; + errorOk(); +} + +errorret_t networkSocketLinuxReceive( + networksocketlinux_t *sock, + uint8_t *buffer, + const size_t bufferSize, + size_t *outReceived +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(buffer, "buffer must not be NULL"); + assertNotNull(outReceived, "outReceived must not be NULL"); + + const ssize_t received = recv(sock->fd, buffer, bufferSize, 0); + if(received < 0) errorThrow("Failed to receive data: %s", strerror(errno)); + + *outReceived = (size_t)received; + errorOk(); +} + +void networkSocketLinuxClose(networksocketlinux_t *sock) { + assertNotNull(sock, "sock must not be NULL"); + if(sock->fd >= 0) close(sock->fd); + sock->fd = -1; +} diff --git a/src/dusklinux/network/networksocketlinux.h b/src/dusklinux/network/networksocketlinux.h new file mode 100644 index 00000000..de55912c --- /dev/null +++ b/src/dusklinux/network/networksocketlinux.h @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" + +#define NETWORK_SOCKET_LINUX_TIMEOUT_SECONDS 10 + +typedef struct { + int_t fd; +} networksocketlinux_t; + +/** + * Resolves host and opens a blocking TCP connection to host:port. + * + * @param sock The socket structure to initialize. + * @param host The hostname or IP address to connect to. + * @param port The port to connect to. + * @return An error if resolution or connection failed. + */ +errorret_t networkSocketLinuxConnect( + networksocketlinux_t *sock, + const char_t *host, + const uint16_t port +); + +/** + * Sends data over the socket. May write fewer bytes than requested; the + * caller is responsible for looping until all data is sent. + * + * @param sock The connected socket. + * @param data The data to send. + * @param length The number of bytes in data. + * @param outSent The number of bytes actually sent is written here. + * @return An error if the send failed. + */ +errorret_t networkSocketLinuxSend( + networksocketlinux_t *sock, + const uint8_t *data, + const size_t length, + size_t *outSent +); + +/** + * Receives data from the socket. + * + * @param sock The connected socket. + * @param buffer The buffer to receive into. + * @param bufferSize The size of buffer. + * @param outReceived The number of bytes actually received is written + * here; 0 means the peer closed the connection. + * @return An error if the receive failed. + */ +errorret_t networkSocketLinuxReceive( + networksocketlinux_t *sock, + uint8_t *buffer, + const size_t bufferSize, + size_t *outReceived +); + +/** + * Closes the socket. + * + * @param sock The socket to close. + */ +void networkSocketLinuxClose(networksocketlinux_t *sock); diff --git a/src/dusklinux/network/networksocketplatform.h b/src/dusklinux/network/networksocketplatform.h new file mode 100644 index 00000000..7dde7ab3 --- /dev/null +++ b/src/dusklinux/network/networksocketplatform.h @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "networksocketlinux.h" + +typedef networksocketlinux_t networksocketplatform_t; + +#define networkSocketPlatformConnect networkSocketLinuxConnect +#define networkSocketPlatformSend networkSocketLinuxSend +#define networkSocketPlatformReceive networkSocketLinuxReceive +#define networkSocketPlatformClose networkSocketLinuxClose diff --git a/src/duskpsp/network/CMakeLists.txt b/src/duskpsp/network/CMakeLists.txt index c302baf6..8ee7721e 100644 --- a/src/duskpsp/network/CMakeLists.txt +++ b/src/duskpsp/network/CMakeLists.txt @@ -6,4 +6,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC networkpsp.c + networksocketpsp.c ) diff --git a/src/duskpsp/network/networksocketplatform.h b/src/duskpsp/network/networksocketplatform.h new file mode 100644 index 00000000..8b3c1559 --- /dev/null +++ b/src/duskpsp/network/networksocketplatform.h @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "networksocketpsp.h" + +typedef networksocketpsp_t networksocketplatform_t; + +#define networkSocketPlatformConnect networkSocketPSPConnect +#define networkSocketPlatformSend networkSocketPSPSend +#define networkSocketPlatformReceive networkSocketPSPReceive +#define networkSocketPlatformClose networkSocketPSPClose diff --git a/src/duskpsp/network/networksocketpsp.c b/src/duskpsp/network/networksocketpsp.c new file mode 100644 index 00000000..a74f5e08 --- /dev/null +++ b/src/duskpsp/network/networksocketpsp.c @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "networksocketpsp.h" +#include "util/memory.h" +#include "assert/assert.h" +#include +#include +#include + +errorret_t networkSocketPSPConnect( + networksocketpsp_t *sock, + const char_t *host, + const uint16_t port +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(host, "host must not be NULL"); + + int_t rid = -1; + uint8_t resolverBuf[NETWORK_SOCKET_PSP_RESOLVER_BUF_SIZE]; + int_t ret = sceNetResolverCreate( + &rid, resolverBuf, sizeof(resolverBuf) + ); + if(ret < 0) errorThrow("Failed to create resolver: 0x%08X", ret); + + struct in_addr addr; + ret = sceNetResolverStartNtoA( + rid, host, &addr, + NETWORK_SOCKET_PSP_RESOLVER_TIMEOUT_SECONDS, + NETWORK_SOCKET_PSP_RESOLVER_RETRY_COUNT + ); + sceNetResolverDelete(rid); + if(ret < 0) errorThrow("Failed to resolve host %s: 0x%08X", host, ret); + + const int_t fd = sceNetInetSocket(AF_INET, SOCK_STREAM, 0); + if(fd < 0) errorThrow("Failed to create socket: 0x%08X", fd); + + struct sockaddr_in serverAddr; + memoryZero(&serverAddr, sizeof(serverAddr)); + serverAddr.sin_family = AF_INET; + serverAddr.sin_port = htons(port); + serverAddr.sin_addr = addr; + + ret = sceNetInetConnect( + fd, (struct sockaddr *)&serverAddr, sizeof(serverAddr) + ); + if(ret < 0) { + sceNetInetClose(fd); + errorThrow("Failed to connect to %s:%u: 0x%08X", host, port, ret); + } + + struct SceNetInetTimeval timeout; + timeout.tv_sec = NETWORK_SOCKET_PSP_TIMEOUT_SECONDS; + timeout.tv_usec = 0; + sceNetInetSetsockopt( + fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout) + ); + sceNetInetSetsockopt( + fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout) + ); + + sock->fd = fd; + errorOk(); +} + +errorret_t networkSocketPSPSend( + networksocketpsp_t *sock, + const uint8_t *data, + const size_t length, + size_t *outSent +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(data, "data must not be NULL"); + assertNotNull(outSent, "outSent must not be NULL"); + + const int_t sent = sceNetInetSend(sock->fd, data, length, 0); + if(sent < 0) errorThrow("Failed to send data: 0x%08X", sceNetInetGetErrno()); + + *outSent = (size_t)sent; + errorOk(); +} + +errorret_t networkSocketPSPReceive( + networksocketpsp_t *sock, + uint8_t *buffer, + const size_t bufferSize, + size_t *outReceived +) { + assertNotNull(sock, "sock must not be NULL"); + assertNotNull(buffer, "buffer must not be NULL"); + assertNotNull(outReceived, "outReceived must not be NULL"); + + const int_t received = sceNetInetRecv(sock->fd, buffer, bufferSize, 0); + if(received < 0) { + errorThrow("Failed to receive data: 0x%08X", sceNetInetGetErrno()); + } + + *outReceived = (size_t)received; + errorOk(); +} + +void networkSocketPSPClose(networksocketpsp_t *sock) { + assertNotNull(sock, "sock must not be NULL"); + if(sock->fd >= 0) sceNetInetClose(sock->fd); + sock->fd = -1; +} diff --git a/src/duskpsp/network/networksocketpsp.h b/src/duskpsp/network/networksocketpsp.h new file mode 100644 index 00000000..a6f54b6a --- /dev/null +++ b/src/duskpsp/network/networksocketpsp.h @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" + +#define NETWORK_SOCKET_PSP_TIMEOUT_SECONDS 10 +#define NETWORK_SOCKET_PSP_RESOLVER_BUF_SIZE 1024 +#define NETWORK_SOCKET_PSP_RESOLVER_TIMEOUT_SECONDS 5 +#define NETWORK_SOCKET_PSP_RESOLVER_RETRY_COUNT 3 + +typedef struct { + int_t fd; +} networksocketpsp_t; + +/** + * Resolves host and opens a blocking TCP connection to host:port. + * + * @param sock The socket structure to initialize. + * @param host The hostname or IP address to connect to. + * @param port The port to connect to. + * @return An error if resolution or connection failed. + */ +errorret_t networkSocketPSPConnect( + networksocketpsp_t *sock, + const char_t *host, + const uint16_t port +); + +/** + * Sends data over the socket. May write fewer bytes than requested; the + * caller is responsible for looping until all data is sent. + * + * @param sock The connected socket. + * @param data The data to send. + * @param length The number of bytes in data. + * @param outSent The number of bytes actually sent is written here. + * @return An error if the send failed. + */ +errorret_t networkSocketPSPSend( + networksocketpsp_t *sock, + const uint8_t *data, + const size_t length, + size_t *outSent +); + +/** + * Receives data from the socket. + * + * @param sock The connected socket. + * @param buffer The buffer to receive into. + * @param bufferSize The size of buffer. + * @param outReceived The number of bytes actually received is written + * here; 0 means the peer closed the connection. + * @return An error if the receive failed. + */ +errorret_t networkSocketPSPReceive( + networksocketpsp_t *sock, + uint8_t *buffer, + const size_t bufferSize, + size_t *outReceived +); + +/** + * Closes the socket. + * + * @param sock The socket to close. + */ +void networkSocketPSPClose(networksocketpsp_t *sock); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 226d9346..1eedb721 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -6,6 +6,7 @@ add_subdirectory(assert) add_subdirectory(asset) add_subdirectory(error) +add_subdirectory(network) add_subdirectory(thread) add_subdirectory(display) # add_subdirectory(rpg) diff --git a/test/network/CMakeLists.txt b/test/network/CMakeLists.txt new file mode 100644 index 00000000..e138f72b --- /dev/null +++ b/test/network/CMakeLists.txt @@ -0,0 +1,6 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +add_subdirectory(http) diff --git a/test/network/http/CMakeLists.txt b/test/network/http/CMakeLists.txt new file mode 100644 index 00000000..ded87e32 --- /dev/null +++ b/test/network/http/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +dusktest(test_networkhttp.c) diff --git a/test/network/http/test_networkhttp.c b/test/network/http/test_networkhttp.c new file mode 100644 index 00000000..04f5c069 --- /dev/null +++ b/test/network/http/test_networkhttp.c @@ -0,0 +1,450 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "network/http/networkhttp.h" +#include "network/http/networkhttprequest.h" +#include "util/memory.h" +#include "util/string.h" +#include +#include +#include + +// ============================================================ +// Fake single-connection HTTP server, driven on a background thread_t +// ============================================================ + +typedef struct { + int_t listenFd; + uint16_t port; + thread_t thread; + + const uint8_t *responseData; + size_t responseLength; + + uint8_t receivedData[8192]; + size_t receivedLength; +} fakeserver_t; + +static void fakeServerRun(thread_t *thread) { + fakeserver_t *server = (fakeserver_t *)thread->data; + + struct sockaddr_in clientAddr; + socklen_t clientAddrLen = sizeof(clientAddr); + const int_t clientFd = accept( + server->listenFd, (struct sockaddr *)&clientAddr, &clientAddrLen + ); + if(clientFd < 0) return; + + struct timeval timeout; + timeout.tv_sec = 0; + timeout.tv_usec = 300000; + setsockopt(clientFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + server->receivedLength = 0; + for(;;) { + if(server->receivedLength >= sizeof(server->receivedData) - 1) break; + + const ssize_t got = recv( + clientFd, + server->receivedData + server->receivedLength, + sizeof(server->receivedData) - 1 - server->receivedLength, + 0 + ); + if(got <= 0) break; + server->receivedLength += (size_t)got; + } + + send(clientFd, server->responseData, server->responseLength, 0); + close(clientFd); + close(server->listenFd); + server->listenFd = -1; +} + +static void fakeServerStart( + fakeserver_t *server, + const uint8_t *responseData, + const size_t responseLength +) { + memoryZero(server, sizeof(fakeserver_t)); + server->responseData = responseData; + server->responseLength = responseLength; + + server->listenFd = socket(AF_INET, SOCK_STREAM, 0); + assert_true(server->listenFd >= 0); + + struct sockaddr_in addr; + memoryZero(&addr, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + assert_int_equal( + bind(server->listenFd, (struct sockaddr *)&addr, sizeof(addr)), 0 + ); + + socklen_t addrLen = sizeof(addr); + assert_int_equal( + getsockname(server->listenFd, (struct sockaddr *)&addr, &addrLen), 0 + ); + server->port = ntohs(addr.sin_port); + + assert_int_equal(listen(server->listenFd, 1), 0); + + threadInit(&server->thread, fakeServerRun); + server->thread.data = server; + threadStart(&server->thread); +} + +static void fakeServerStop(fakeserver_t *server) { + threadStop(&server->thread); + if(server->listenFd >= 0) close(server->listenFd); +} + +// ============================================================ +// networkHttpRequest completion capture +// ============================================================ + +typedef struct { + bool_t completed; + bool_t errored; + uint16_t status; + uint8_t body[4096]; + size_t bodyLength; +} capturedresponse_t; + +static void captureOnComplete(void *params, void *user) { + const networkhttprequest_t *request = (const networkhttprequest_t *)params; + capturedresponse_t *captured = (capturedresponse_t *)user; + + captured->status = request->response.status; + captured->bodyLength = request->response.bodyLength; + if(request->response.bodyLength > 0) { + memoryCopy( + captured->body, request->response.body, request->response.bodyLength + ); + } + captured->completed = true; +} + +static void captureOnError(void *params, void *user) { + capturedresponse_t *captured = (capturedresponse_t *)user; + captured->errored = true; + captured->completed = true; +} + +static void waitForCompletion(capturedresponse_t *captured) { + for(int32_t i = 0; i < 5000 && !captured->completed; i++) { + networkHttpUpdate(); + usleep(1000); + } + assert_true(captured->completed); +} + +// ============================================================ +// Per-test setup / teardown +// ============================================================ + +static int http_setup(void **state) { + networkHttpInit(); + return 0; +} + +static int http_teardown(void **state) { + networkHttpDispose(); + return 0; +} + +// ============================================================ +// Tests +// ============================================================ + +static void test_get_request_headers_and_body(void **state) { + const char_t *responseText = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: 5\r\n" + "\r\n" + "hello"; + + fakeserver_t server; + fakeServerStart( + &server, (const uint8_t *)responseText, strlen(responseText) + ); + + char_t url[128]; + stringFormat(url, sizeof(url) - 1, "http://127.0.0.1:%u/foo", server.port); + + const networkhttpheader_t headers[1] = { + { .name = "X-Test", .value = "abc" } + }; + + capturedresponse_t captured; + memoryZero(&captured, sizeof(captured)); + + const errorret_t ret = networkHttpRequest( + NETWORK_HTTP_METHOD_GET, url, + headers, 1, + NULL, 0, + NULL, 0, + captureOnComplete, captureOnError, &captured + ); + assert_true(errorIsOk(ret)); + + waitForCompletion(&captured); + fakeServerStop(&server); + + assert_false(captured.errored); + assert_int_equal(captured.status, 200); + assert_int_equal(captured.bodyLength, 5); + assert_memory_equal(captured.body, "hello", 5); + + server.receivedData[server.receivedLength] = '\0'; + const char_t *received = (const char_t *)server.receivedData; + assert_non_null(strstr(received, "GET /foo HTTP/1.1")); + assert_non_null(strstr(received, "X-Test: abc")); + assert_non_null(strstr(received, "Connection: close")); + assert_non_null(strstr(received, "Host: 127.0.0.1")); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_query_params_are_encoded_and_appended(void **state) { + const char_t *responseText = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + + fakeserver_t server; + fakeServerStart( + &server, (const uint8_t *)responseText, strlen(responseText) + ); + + char_t url[128]; + stringFormat( + url, sizeof(url) - 1, "http://127.0.0.1:%u/search", server.port + ); + + const networkhttpheader_t query[2] = { + { .name = "q", .value = "hello world" }, + { .name = "page", .value = "2" } + }; + + capturedresponse_t captured; + memoryZero(&captured, sizeof(captured)); + + const errorret_t ret = networkHttpRequest( + NETWORK_HTTP_METHOD_GET, url, + NULL, 0, + query, 2, + NULL, 0, + captureOnComplete, captureOnError, &captured + ); + assert_true(errorIsOk(ret)); + + waitForCompletion(&captured); + fakeServerStop(&server); + + assert_false(captured.errored); + + server.receivedData[server.receivedLength] = '\0'; + const char_t *received = (const char_t *)server.receivedData; + assert_non_null( + strstr(received, "GET /search?q=hello%20world&page=2 HTTP/1.1") + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_post_sends_body_with_content_length(void **state) { + const char_t *responseText = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"; + + fakeserver_t server; + fakeServerStart( + &server, (const uint8_t *)responseText, strlen(responseText) + ); + + char_t url[128]; + stringFormat( + url, sizeof(url) - 1, "http://127.0.0.1:%u/submit", server.port + ); + + const char_t *body = "{\"a\":1}"; + + capturedresponse_t captured; + memoryZero(&captured, sizeof(captured)); + + const errorret_t ret = networkHttpRequest( + NETWORK_HTTP_METHOD_POST, url, + NULL, 0, + NULL, 0, + (const uint8_t *)body, strlen(body), + captureOnComplete, captureOnError, &captured + ); + assert_true(errorIsOk(ret)); + + waitForCompletion(&captured); + fakeServerStop(&server); + + assert_false(captured.errored); + assert_int_equal(captured.status, 200); + assert_memory_equal(captured.body, "OK", 2); + + server.receivedData[server.receivedLength] = '\0'; + const char_t *received = (const char_t *)server.receivedData; + assert_non_null(strstr(received, "POST /submit HTTP/1.1")); + assert_non_null(strstr(received, "Content-Length: 7")); + assert_non_null(strstr(received, "{\"a\":1}")); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_put_method_is_used(void **state) { + const char_t *responseText = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + + fakeserver_t server; + fakeServerStart( + &server, (const uint8_t *)responseText, strlen(responseText) + ); + + char_t url[128]; + stringFormat(url, sizeof(url) - 1, "http://127.0.0.1:%u/item/1", server.port); + + capturedresponse_t captured; + memoryZero(&captured, sizeof(captured)); + + const errorret_t ret = networkHttpRequest( + NETWORK_HTTP_METHOD_PUT, url, + NULL, 0, NULL, 0, NULL, 0, + captureOnComplete, captureOnError, &captured + ); + assert_true(errorIsOk(ret)); + + waitForCompletion(&captured); + fakeServerStop(&server); + + assert_false(captured.errored); + + server.receivedData[server.receivedLength] = '\0'; + assert_non_null( + strstr((const char_t *)server.receivedData, "PUT /item/1 HTTP/1.1") + ); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_301_redirect_is_followed_automatically(void **state) { + const char_t *finalResponseText = + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"; + + fakeserver_t target; + fakeServerStart( + &target, (const uint8_t *)finalResponseText, strlen(finalResponseText) + ); + + char_t targetUrl[128]; + stringFormat( + targetUrl, sizeof(targetUrl) - 1, + "http://127.0.0.1:%u/target", target.port + ); + + char_t redirectResponseText[256]; + stringFormat( + redirectResponseText, sizeof(redirectResponseText) - 1, + "HTTP/1.1 301 Moved Permanently\r\n" + "Location: %s\r\n" + "Content-Length: 0\r\n" + "\r\n", + targetUrl + ); + + fakeserver_t initial; + fakeServerStart( + &initial, + (const uint8_t *)redirectResponseText, + strlen(redirectResponseText) + ); + + char_t url[128]; + stringFormat(url, sizeof(url) - 1, "http://127.0.0.1:%u/old", initial.port); + + capturedresponse_t captured; + memoryZero(&captured, sizeof(captured)); + + const errorret_t ret = networkHttpRequest( + NETWORK_HTTP_METHOD_GET, url, + NULL, 0, NULL, 0, NULL, 0, + captureOnComplete, captureOnError, &captured + ); + assert_true(errorIsOk(ret)); + + waitForCompletion(&captured); + fakeServerStop(&initial); + fakeServerStop(&target); + + assert_false(captured.errored); + assert_int_equal(captured.status, 200); + assert_memory_equal(captured.body, "OK", 2); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_connection_refused_triggers_onError(void **state) { + const int_t fd = socket(AF_INET, SOCK_STREAM, 0); + assert_true(fd >= 0); + + struct sockaddr_in addr; + memoryZero(&addr, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + assert_int_equal(bind(fd, (struct sockaddr *)&addr, sizeof(addr)), 0); + + socklen_t addrLen = sizeof(addr); + assert_int_equal(getsockname(fd, (struct sockaddr *)&addr, &addrLen), 0); + const uint16_t port = ntohs(addr.sin_port); + close(fd); // Nothing is listening on this port now. + + char_t url[64]; + stringFormat(url, sizeof(url) - 1, "http://127.0.0.1:%u/", port); + + capturedresponse_t captured; + memoryZero(&captured, sizeof(captured)); + + const errorret_t ret = networkHttpRequest( + NETWORK_HTTP_METHOD_GET, url, + NULL, 0, NULL, 0, NULL, 0, + captureOnComplete, captureOnError, &captured + ); + assert_true(errorIsOk(ret)); + + waitForCompletion(&captured); + + assert_true(captured.errored); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(void) { + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_get_request_headers_and_body, http_setup, http_teardown + ), + cmocka_unit_test_setup_teardown( + test_query_params_are_encoded_and_appended, http_setup, http_teardown + ), + cmocka_unit_test_setup_teardown( + test_post_sends_body_with_content_length, http_setup, http_teardown + ), + cmocka_unit_test_setup_teardown( + test_put_method_is_used, http_setup, http_teardown + ), + cmocka_unit_test_setup_teardown( + test_301_redirect_is_followed_automatically, http_setup, http_teardown + ), + cmocka_unit_test_setup_teardown( + test_connection_refused_triggers_onError, http_setup, http_teardown + ), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +}