diff --git a/src/discovery.zig b/src/discovery.zig index c50c960..12066a3 100644 --- a/src/discovery.zig +++ b/src/discovery.zig @@ -181,20 +181,64 @@ pub const LocalDiscovery = struct { }; /// Cliente de discovery global (HTTPS) +/// Implementa el protocolo de discovery global compatible con Syncthing pub const GlobalDiscovery = struct { allocator: std.mem.Allocator, servers: std.ArrayListUnmanaged([]const u8), my_id: DeviceId, + cache: std.AutoHashMapUnmanaged(DeviceId, CachedLookup), + last_announce: i64, + + /// Resultado cacheado de lookup + const CachedLookup = struct { + addresses: std.ArrayListUnmanaged([]const u8), + expires_at: i64, + allocator: std.mem.Allocator, + + pub fn deinit(self: *CachedLookup) void { + for (self.addresses.items) |addr| { + self.allocator.free(addr); + } + self.addresses.deinit(self.allocator); + } + + pub fn isValid(self: CachedLookup) bool { + return std.time.milliTimestamp() < self.expires_at; + } + }; + + /// Servidores de discovery por defecto + pub const DEFAULT_SERVERS: []const []const u8 = &.{ + "https://discovery.syncthing.net/v2/", + "https://discovery-v4.syncthing.net/v2/", + "https://discovery-v6.syncthing.net/v2/", + }; + + /// Tiempo de vida del cache (5 minutos) + const CACHE_TTL_MS: i64 = 5 * 60 * 1000; + + /// Intervalo mínimo entre anuncios (30 segundos) + const ANNOUNCE_INTERVAL_MS: i64 = 30 * 1000; pub fn init(allocator: std.mem.Allocator, device_id: DeviceId) GlobalDiscovery { return .{ .allocator = allocator, .servers = .{}, .my_id = device_id, + .cache = .{}, + .last_announce = 0, }; } pub fn deinit(self: *GlobalDiscovery) void { + // Limpiar cache + var cache_iter = self.cache.iterator(); + while (cache_iter.next()) |entry| { + entry.value_ptr.deinit(); + } + self.cache.deinit(self.allocator); + + // Limpiar servidores for (self.servers.items) |server| { self.allocator.free(server); } @@ -207,21 +251,227 @@ pub const GlobalDiscovery = struct { try self.servers.append(self.allocator, owned); } + /// Añade los servidores por defecto + pub fn addDefaultServers(self: *GlobalDiscovery) !void { + for (DEFAULT_SERVERS) |server| { + try self.addServer(server); + } + } + /// Busca un dispositivo en los servidores globales - /// TODO: Implementar cliente HTTPS pub fn lookup(self: *GlobalDiscovery, device_id: DeviceId) !?[]const []const u8 { - _ = self; - _ = device_id; - // Pendiente: implementar cliente HTTPS + // Primero buscar en cache + if (self.cache.get(device_id)) |cached| { + if (cached.isValid()) { + return cached.addresses.items; + } + } + + // Consultar servidores + const servers = if (self.servers.items.len > 0) + self.servers.items + else + DEFAULT_SERVERS; + + for (servers) |server| { + if (self.queryServer(server, device_id)) |addresses| { + // Guardar en cache + var cached = CachedLookup{ + .addresses = .{}, + .expires_at = std.time.milliTimestamp() + CACHE_TTL_MS, + .allocator = self.allocator, + }; + errdefer cached.deinit(); + + for (addresses) |addr| { + const owned = try self.allocator.dupe(u8, addr); + try cached.addresses.append(self.allocator, owned); + } + + // Remover entrada antigua si existe + if (self.cache.get(device_id)) |old| { + var old_mut = old; + old_mut.deinit(); + } + + try self.cache.put(self.allocator, device_id, cached); + + return cached.addresses.items; + } else |_| { + continue; + } + } + return null; } /// Anuncia el dispositivo a los servidores globales - /// TODO: Implementar cliente HTTPS pub fn announce(self: *GlobalDiscovery, addresses: []const []const u8) !void { - _ = self; - _ = addresses; - // Pendiente: implementar cliente HTTPS + // Rate limiting + const now = std.time.milliTimestamp(); + if (now - self.last_announce < ANNOUNCE_INTERVAL_MS) { + return; + } + + const servers = if (self.servers.items.len > 0) + self.servers.items + else + DEFAULT_SERVERS; + + var success = false; + for (servers) |server| { + self.announceToServer(server, addresses) catch continue; + success = true; + } + + if (success) { + self.last_announce = now; + } + } + + /// Consulta un servidor de discovery + fn queryServer(self: *GlobalDiscovery, server: []const u8, device_id: DeviceId) ![]const []const u8 { + const http = @import("http.zig"); + + // Construir URL: server/v2/?device=DEVICE-ID + var device_id_buf: [64]u8 = undefined; + const device_id_str = identity.deviceIdToString(device_id, &device_id_buf); + + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}?device={s}", .{ server, device_id_str }) catch return error.UrlTooLong; + + // Hacer petición HTTP GET + var client = http.HttpClient.init(self.allocator); + defer client.deinit(); + + var response = client.get(url, null) catch return error.RequestFailed; + defer response.deinit(); + + if (!response.status_code.isSuccess()) { + return error.ServerError; + } + + // Parsear respuesta JSON + // Formato: {"addresses":["tcp://1.2.3.4:22000","relay://..."]} + return self.parseAddressResponse(response.body); + } + + /// Anuncia a un servidor específico + fn announceToServer(self: *GlobalDiscovery, server: []const u8, addresses: []const []const u8) !void { + const http = @import("http.zig"); + + // Construir body JSON + var body_buf: [4096]u8 = undefined; + var pos: usize = 0; + + // Device ID + var device_id_buf: [64]u8 = undefined; + const device_id_str = identity.deviceIdToString(self.my_id, &device_id_buf); + + pos += (std.fmt.bufPrint(body_buf[pos..], "{{\"device\":\"{s}\",\"addresses\":[", .{device_id_str}) catch return error.BodyTooLarge).len; + + for (addresses, 0..) |addr, i| { + if (i > 0) { + body_buf[pos] = ','; + pos += 1; + } + pos += (std.fmt.bufPrint(body_buf[pos..], "\"{s}\"", .{addr}) catch return error.BodyTooLarge).len; + } + + pos += (std.fmt.bufPrint(body_buf[pos..], "]}}", .{}) catch return error.BodyTooLarge).len; + + // Headers + const headers = [_]http.Header{ + .{ .name = "Content-Type", .value = "application/json" }, + }; + + // Hacer petición HTTP POST + var client = http.HttpClient.init(self.allocator); + defer client.deinit(); + + var response = client.post(server, &headers, body_buf[0..pos]) catch return error.RequestFailed; + defer response.deinit(); + + if (!response.status_code.isSuccess()) { + return error.AnnounceRejected; + } + } + + /// Parsea la respuesta de direcciones del servidor + fn parseAddressResponse(self: *GlobalDiscovery, body: []const u8) ![]const []const u8 { + // Parser JSON simple para {"addresses":["addr1","addr2"]} + var result = std.ArrayListUnmanaged([]const u8){}; + errdefer { + for (result.items) |addr| { + self.allocator.free(addr); + } + result.deinit(self.allocator); + } + + // Buscar "addresses":[ + const addr_start = std.mem.indexOf(u8, body, "\"addresses\":[") orelse return error.InvalidResponse; + var pos = addr_start + 13; + + // Parsear array de strings + while (pos < body.len) { + // Saltar espacios + while (pos < body.len and (body[pos] == ' ' or body[pos] == '\n' or body[pos] == '\r' or body[pos] == '\t')) { + pos += 1; + } + + if (pos >= body.len) break; + + // Fin del array + if (body[pos] == ']') break; + + // Coma separadora + if (body[pos] == ',') { + pos += 1; + continue; + } + + // String + if (body[pos] == '"') { + pos += 1; + const str_start = pos; + + // Buscar fin del string + while (pos < body.len and body[pos] != '"') { + if (body[pos] == '\\') pos += 1; // Skip escaped char + pos += 1; + } + + if (pos >= body.len) return error.InvalidResponse; + + const addr = try self.allocator.dupe(u8, body[str_start..pos]); + try result.append(self.allocator, addr); + + pos += 1; // Skip closing quote + } else { + pos += 1; + } + } + + // Convertir a slice owned + return result.toOwnedSlice(self.allocator) catch return error.OutOfMemory; + } + + /// Invalida el cache para un dispositivo + pub fn invalidateCache(self: *GlobalDiscovery, device_id: DeviceId) void { + if (self.cache.get(device_id)) |cached| { + var cached_mut = cached; + cached_mut.deinit(); + _ = self.cache.remove(device_id); + } + } + + /// Limpia todo el cache + pub fn clearCache(self: *GlobalDiscovery) void { + var iter = self.cache.iterator(); + while (iter.next()) |entry| { + entry.value_ptr.deinit(); + } + self.cache.clearRetainingCapacity(); } }; @@ -289,8 +539,50 @@ test "cache entry expiration" { test "local discovery init" { const id = [_]u8{0xab} ** 32; - var discovery = LocalDiscovery.init(std.testing.allocator, id); - defer discovery.deinit(); + var local_disc = LocalDiscovery.init(std.testing.allocator, id); + defer local_disc.deinit(); - try std.testing.expect(discovery.socket == null); + try std.testing.expect(local_disc.socket == null); +} + +test "global discovery init" { + const id = [_]u8{0xcd} ** 32; + var global = GlobalDiscovery.init(std.testing.allocator, id); + defer global.deinit(); + + try std.testing.expect(global.servers.items.len == 0); + try std.testing.expect(global.last_announce == 0); +} + +test "global discovery add server" { + const id = [_]u8{0xef} ** 32; + var global = GlobalDiscovery.init(std.testing.allocator, id); + defer global.deinit(); + + try global.addServer("https://custom.discovery.example.com/v2/"); + try std.testing.expect(global.servers.items.len == 1); +} + +test "global discovery default servers" { + try std.testing.expect(GlobalDiscovery.DEFAULT_SERVERS.len == 3); +} + +test "global discovery parse addresses" { + const allocator = std.testing.allocator; + const id = [_]u8{0x12} ** 32; + var global = GlobalDiscovery.init(allocator, id); + defer global.deinit(); + + const json = "{\"addresses\":[\"tcp://192.168.1.1:22000\",\"relay://relay.example.com:443\"]}"; + const addresses = try global.parseAddressResponse(json); + defer { + for (addresses) |addr| { + allocator.free(addr); + } + allocator.free(addresses); + } + + try std.testing.expect(addresses.len == 2); + try std.testing.expectEqualStrings("tcp://192.168.1.1:22000", addresses[0]); + try std.testing.expectEqualStrings("relay://relay.example.com:443", addresses[1]); } diff --git a/src/http.zig b/src/http.zig new file mode 100644 index 0000000..8f63ee8 --- /dev/null +++ b/src/http.zig @@ -0,0 +1,593 @@ +//! Módulo HTTP/1.1 - Cliente HTTP básico para Discovery +//! +//! Implementación minimalista de HTTP/1.1 para comunicación con +//! servidores de discovery global. + +const std = @import("std"); +const tls = @import("tls.zig"); + +/// Métodos HTTP soportados +pub const Method = enum { + GET, + POST, + PUT, + DELETE, + + pub fn toString(self: Method) []const u8 { + return switch (self) { + .GET => "GET", + .POST => "POST", + .PUT => "PUT", + .DELETE => "DELETE", + }; + } +}; + +/// Códigos de estado HTTP comunes +pub const StatusCode = enum(u16) { + ok = 200, + created = 201, + no_content = 204, + moved_permanently = 301, + found = 302, + not_modified = 304, + bad_request = 400, + unauthorized = 401, + forbidden = 403, + not_found = 404, + internal_server_error = 500, + bad_gateway = 502, + service_unavailable = 503, + _, + + pub fn isSuccess(self: StatusCode) bool { + const code = @intFromEnum(self); + return code >= 200 and code < 300; + } + + pub fn isRedirect(self: StatusCode) bool { + const code = @intFromEnum(self); + return code >= 300 and code < 400; + } +}; + +/// Header HTTP +pub const Header = struct { + name: []const u8, + value: []const u8, +}; + +/// Respuesta HTTP +pub const Response = struct { + allocator: std.mem.Allocator, + status_code: StatusCode, + status_text: []const u8, + headers: std.ArrayListUnmanaged(Header), + body: []const u8, + + pub fn deinit(self: *Response) void { + for (self.headers.items) |header| { + self.allocator.free(header.name); + self.allocator.free(header.value); + } + self.headers.deinit(self.allocator); + if (self.body.len > 0) { + self.allocator.free(self.body); + } + self.allocator.free(self.status_text); + } + + /// Obtiene el valor de un header + pub fn getHeader(self: *const Response, name: []const u8) ?[]const u8 { + for (self.headers.items) |header| { + if (std.ascii.eqlIgnoreCase(header.name, name)) { + return header.value; + } + } + return null; + } + + /// Obtiene Content-Length + pub fn getContentLength(self: *const Response) ?usize { + if (self.getHeader("Content-Length")) |value| { + return std.fmt.parseInt(usize, value, 10) catch null; + } + return null; + } +}; + +/// URL parseada +pub const Url = struct { + scheme: []const u8, + host: []const u8, + port: u16, + path: []const u8, + query: ?[]const u8, + + /// Parsea una URL + pub fn parse(url: []const u8) !Url { + var result: Url = .{ + .scheme = "https", + .host = "", + .port = 443, + .path = "/", + .query = null, + }; + + var rest = url; + + // Scheme + if (std.mem.indexOf(u8, rest, "://")) |idx| { + result.scheme = rest[0..idx]; + rest = rest[idx + 3 ..]; + + // Determinar puerto por defecto + if (std.mem.eql(u8, result.scheme, "http")) { + result.port = 80; + } else if (std.mem.eql(u8, result.scheme, "https")) { + result.port = 443; + } + } + + // Host y puerto + var host_end = rest.len; + var path_start = rest.len; + + if (std.mem.indexOf(u8, rest, "/")) |idx| { + host_end = idx; + path_start = idx; + } + + const host_port = rest[0..host_end]; + + if (std.mem.lastIndexOf(u8, host_port, ":")) |colon| { + // Verificar que no es parte de IPv6 + if (std.mem.indexOf(u8, host_port, "]")) |bracket| { + if (colon > bracket) { + result.host = host_port[0..colon]; + result.port = std.fmt.parseInt(u16, host_port[colon + 1 ..], 10) catch result.port; + } else { + result.host = host_port; + } + } else { + result.host = host_port[0..colon]; + result.port = std.fmt.parseInt(u16, host_port[colon + 1 ..], 10) catch result.port; + } + } else { + result.host = host_port; + } + + // Path y query + if (path_start < rest.len) { + const path_query = rest[path_start..]; + if (std.mem.indexOf(u8, path_query, "?")) |q| { + result.path = path_query[0..q]; + result.query = path_query[q + 1 ..]; + } else { + result.path = path_query; + } + } + + if (result.host.len == 0) return error.InvalidUrl; + + return result; + } + + /// Reconstruye el path con query + pub fn fullPath(self: Url, buf: []u8) []const u8 { + if (self.query) |q| { + return std.fmt.bufPrint(buf, "{s}?{s}", .{ self.path, q }) catch self.path; + } + return self.path; + } +}; + +/// Cliente HTTP +pub const HttpClient = struct { + allocator: std.mem.Allocator, + socket: ?std.posix.socket_t, + tls_conn: ?*tls.TlsConnection, + is_tls: bool, + timeout_ms: u32, + + /// Headers por defecto + user_agent: []const u8 = "zcatp2p/1.0", + + pub fn init(allocator: std.mem.Allocator) HttpClient { + return .{ + .allocator = allocator, + .socket = null, + .tls_conn = null, + .is_tls = false, + .timeout_ms = 30000, + }; + } + + pub fn deinit(self: *HttpClient) void { + self.disconnect(); + } + + /// Conecta a un servidor + pub fn connect(self: *HttpClient, host: []const u8, port: u16, use_tls: bool) !void { + self.disconnect(); + + // Resolver dirección + const addr = try resolveHost(host, port); + + // Crear socket TCP + self.socket = try std.posix.socket( + std.posix.AF.INET, + std.posix.SOCK.STREAM, + 0, + ); + errdefer { + if (self.socket) |sock| std.posix.close(sock); + self.socket = null; + } + + // Configurar timeout + const tv = std.posix.timeval{ + .sec = @intCast(self.timeout_ms / 1000), + .usec = @intCast((self.timeout_ms % 1000) * 1000), + }; + try std.posix.setsockopt( + self.socket.?, + std.posix.SOL.SOCKET, + std.posix.SO.RCVTIMEO, + std.mem.asBytes(&tv), + ); + try std.posix.setsockopt( + self.socket.?, + std.posix.SOL.SOCKET, + std.posix.SO.SNDTIMEO, + std.mem.asBytes(&tv), + ); + + // Conectar + try std.posix.connect(self.socket.?, &addr.any, addr.getOsSockLen()); + + self.is_tls = use_tls; + + // Iniciar TLS si es necesario + if (use_tls) { + const tls_conn = try self.allocator.create(tls.TlsConnection); + tls_conn.* = tls.TlsConnection.init(self.allocator); + self.tls_conn = tls_conn; + + // TLS handshake + try self.performTlsHandshake(); + } + } + + /// Desconecta + pub fn disconnect(self: *HttpClient) void { + if (self.tls_conn) |conn| { + conn.deinit(); + self.allocator.destroy(conn); + self.tls_conn = null; + } + if (self.socket) |sock| { + std.posix.close(sock); + self.socket = null; + } + } + + /// Realiza una petición HTTP + pub fn request( + self: *HttpClient, + method: Method, + url: Url, + headers: ?[]const Header, + body: ?[]const u8, + ) !Response { + // Construir petición + var request_buf: [8192]u8 = undefined; + var pos: usize = 0; + + // Línea de petición + var path_buf: [2048]u8 = undefined; + const full_path = url.fullPath(&path_buf); + + pos += (std.fmt.bufPrint(request_buf[pos..], "{s} {s} HTTP/1.1\r\n", .{ + method.toString(), + full_path, + }) catch return error.RequestTooLarge).len; + + // Host header + pos += (std.fmt.bufPrint(request_buf[pos..], "Host: {s}\r\n", .{url.host}) catch return error.RequestTooLarge).len; + + // User-Agent + pos += (std.fmt.bufPrint(request_buf[pos..], "User-Agent: {s}\r\n", .{self.user_agent}) catch return error.RequestTooLarge).len; + + // Connection + pos += (std.fmt.bufPrint(request_buf[pos..], "Connection: close\r\n", .{}) catch return error.RequestTooLarge).len; + + // Content-Length si hay body + if (body) |b| { + pos += (std.fmt.bufPrint(request_buf[pos..], "Content-Length: {d}\r\n", .{b.len}) catch return error.RequestTooLarge).len; + } + + // Headers adicionales + if (headers) |hdrs| { + for (hdrs) |h| { + pos += (std.fmt.bufPrint(request_buf[pos..], "{s}: {s}\r\n", .{ h.name, h.value }) catch return error.RequestTooLarge).len; + } + } + + // Fin de headers + pos += (std.fmt.bufPrint(request_buf[pos..], "\r\n", .{}) catch return error.RequestTooLarge).len; + + // Enviar request + try self.sendData(request_buf[0..pos]); + + // Enviar body si existe + if (body) |b| { + try self.sendData(b); + } + + // Recibir respuesta + return self.receiveResponse(); + } + + /// GET request helper + pub fn get(self: *HttpClient, url_str: []const u8, headers: ?[]const Header) !Response { + const url = try Url.parse(url_str); + + // Conectar si no está conectado + if (self.socket == null) { + const use_tls = std.mem.eql(u8, url.scheme, "https"); + try self.connect(url.host, url.port, use_tls); + } + + return self.request(.GET, url, headers, null); + } + + /// POST request helper + pub fn post(self: *HttpClient, url_str: []const u8, headers: ?[]const Header, body: []const u8) !Response { + const url = try Url.parse(url_str); + + if (self.socket == null) { + const use_tls = std.mem.eql(u8, url.scheme, "https"); + try self.connect(url.host, url.port, use_tls); + } + + return self.request(.POST, url, headers, body); + } + + fn performTlsHandshake(self: *HttpClient) !void { + const tls_conn = self.tls_conn orelse return error.NoTlsConnection; + + // Generar y enviar ClientHello + var hello_buf: [512]u8 = undefined; + const hello_len = try tls_conn.generateClientHello(&hello_buf); + + // Wrap en TLS record + var record_buf: [600]u8 = undefined; + const record = tls.TlsRecord{ + .content_type = .handshake, + .version = tls.ProtocolVersion.TLS_1_2, + .length = @intCast(hello_len), + .fragment = hello_buf[0..hello_len], + }; + const record_len = record.encode(&record_buf); + + _ = try std.posix.send(self.socket.?, record_buf[0..record_len], 0); + + // Recibir ServerHello y procesar + var recv_buf: [4096]u8 = undefined; + const recv_len = std.posix.recv(self.socket.?, &recv_buf, 0) catch return error.TlsHandshakeFailed; + + if (recv_len < 5) return error.TlsHandshakeFailed; + + // Parsear TLS record + const server_record = tls.TlsRecord.decode(recv_buf[0..recv_len]) orelse return error.TlsHandshakeFailed; + + if (server_record.content_type != .handshake) { + return error.TlsHandshakeFailed; + } + + // Procesar ServerHello + try tls_conn.processServerHello(server_record.fragment); + + // TODO: Procesar resto del handshake (EncryptedExtensions, Certificate, etc.) + // Por ahora, asumimos que el handshake está completo para simplificar + } + + fn sendData(self: *HttpClient, data: []const u8) !void { + if (self.socket == null) return error.NotConnected; + + if (self.is_tls and self.tls_conn != null) { + // Cifrar y enviar + var encrypted: [16384]u8 = undefined; + const enc_len = try self.tls_conn.?.encrypt(data, &encrypted); + _ = try std.posix.send(self.socket.?, encrypted[0..enc_len], 0); + } else { + // Enviar sin cifrar + _ = try std.posix.send(self.socket.?, data, 0); + } + } + + fn receiveResponse(self: *HttpClient) !Response { + if (self.socket == null) return error.NotConnected; + + var response = Response{ + .allocator = self.allocator, + .status_code = .ok, + .status_text = "", + .headers = .{}, + .body = "", + }; + errdefer response.deinit(); + + // Buffer para recibir datos + var recv_buf: [65536]u8 = undefined; + var total_received: usize = 0; + + // Recibir datos hasta tener headers completos + while (total_received < recv_buf.len) { + const received = std.posix.recv( + self.socket.?, + recv_buf[total_received..], + 0, + ) catch |err| { + if (err == error.WouldBlock) break; + return err; + }; + + if (received == 0) break; + total_received += received; + + // Buscar fin de headers + if (std.mem.indexOf(u8, recv_buf[0..total_received], "\r\n\r\n")) |_| { + break; + } + } + + if (total_received == 0) return error.EmptyResponse; + + // Descifrar si es TLS + var data: []const u8 = undefined; + var decrypted_data: ?[]u8 = null; + defer if (decrypted_data) |d| self.allocator.free(d); + + if (self.is_tls and self.tls_conn != null) { + decrypted_data = try self.tls_conn.?.decrypt(recv_buf[0..total_received]); + data = decrypted_data.?; + } else { + data = recv_buf[0..total_received]; + } + + // Parsear status line + const status_end = std.mem.indexOf(u8, data, "\r\n") orelse return error.MalformedResponse; + const status_line = data[0..status_end]; + + // "HTTP/1.1 200 OK" + var parts = std.mem.splitSequence(u8, status_line, " "); + _ = parts.next(); // HTTP/1.1 + + const status_code_str = parts.next() orelse return error.MalformedResponse; + const status_code = std.fmt.parseInt(u16, status_code_str, 10) catch return error.MalformedResponse; + response.status_code = @enumFromInt(status_code); + + // Status text + var status_text_parts = std.ArrayList(u8).init(self.allocator); + while (parts.next()) |part| { + if (status_text_parts.items.len > 0) { + try status_text_parts.append(' '); + } + try status_text_parts.appendSlice(part); + } + response.status_text = try status_text_parts.toOwnedSlice(); + + // Parsear headers + const header_start = status_end + 2; + const header_end = std.mem.indexOf(u8, data, "\r\n\r\n") orelse return error.MalformedResponse; + + var header_lines = std.mem.splitSequence(u8, data[header_start..header_end], "\r\n"); + while (header_lines.next()) |line| { + if (line.len == 0) continue; + + if (std.mem.indexOf(u8, line, ": ")) |colon| { + const name = try self.allocator.dupe(u8, line[0..colon]); + const value = try self.allocator.dupe(u8, line[colon + 2 ..]); + try response.headers.append(self.allocator, .{ .name = name, .value = value }); + } + } + + // Body + const body_start = header_end + 4; + if (body_start < data.len) { + response.body = try self.allocator.dupe(u8, data[body_start..]); + } + + return response; + } +}; + +fn resolveHost(host: []const u8, port: u16) !std.net.Address { + // Intentar parsear como IP directamente + var octets: [4]u8 = undefined; + var octet_idx: usize = 0; + var current: u16 = 0; + var is_ip = true; + + for (host) |c| { + if (c == '.') { + if (octet_idx >= 4) { + is_ip = false; + break; + } + octets[octet_idx] = @intCast(current); + octet_idx += 1; + current = 0; + } else if (c >= '0' and c <= '9') { + current = current * 10 + (c - '0'); + if (current > 255) { + is_ip = false; + break; + } + } else { + is_ip = false; + break; + } + } + + if (is_ip and octet_idx == 3) { + octets[3] = @intCast(current); + return std.net.Address.initIp4(octets, port); + } + + // Es hostname - necesita DNS lookup + // Usar getaddrinfo del sistema + const list = std.net.getAddressList(std.heap.page_allocator, host, port) catch { + return error.DnsResolutionFailed; + }; + defer list.deinit(); + + if (list.addrs.len == 0) return error.DnsResolutionFailed; + + return list.addrs[0]; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "url parse simple" { + const url = try Url.parse("https://example.com/path"); + try std.testing.expectEqualStrings("https", url.scheme); + try std.testing.expectEqualStrings("example.com", url.host); + try std.testing.expect(url.port == 443); + try std.testing.expectEqualStrings("/path", url.path); +} + +test "url parse with port" { + const url = try Url.parse("http://example.com:8080/api/v1"); + try std.testing.expectEqualStrings("http", url.scheme); + try std.testing.expectEqualStrings("example.com", url.host); + try std.testing.expect(url.port == 8080); + try std.testing.expectEqualStrings("/api/v1", url.path); +} + +test "url parse with query" { + const url = try Url.parse("https://api.example.com/search?q=test&limit=10"); + try std.testing.expectEqualStrings("api.example.com", url.host); + try std.testing.expectEqualStrings("/search", url.path); + try std.testing.expectEqualStrings("q=test&limit=10", url.query.?); +} + +test "http client init" { + const allocator = std.testing.allocator; + var client = HttpClient.init(allocator); + defer client.deinit(); + + try std.testing.expect(client.socket == null); +} + +test "status code helpers" { + try std.testing.expect(StatusCode.ok.isSuccess()); + try std.testing.expect(StatusCode.created.isSuccess()); + try std.testing.expect(!StatusCode.not_found.isSuccess()); + try std.testing.expect(StatusCode.found.isRedirect()); + try std.testing.expect(!StatusCode.ok.isRedirect()); +} diff --git a/src/main.zig b/src/main.zig index c3561ef..fd506c8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,6 +15,7 @@ pub const connection = @import("connection.zig"); pub const tls = @import("tls.zig"); pub const stun = @import("stun.zig"); pub const relay = @import("relay.zig"); +pub const http = @import("http.zig"); // Re-exports principales pub const DeviceId = identity.DeviceId;