fast-cli/src/lib/fast.zig
mikkelam 3570f5d5b7 Initial commit: Fast CLI - Blazing fast internet speed tester
- Zig CLI tool for testing internet speed via Fast.com
- Cross-platform binaries for Linux, macOS, ARM64
- Real-time progress, latency measurement, upload testing
- Zero runtime dependencies, 1.3 MiB binary
2025-06-19 00:04:14 +02:00

246 lines
8.5 KiB
Zig
Raw Blame History

const std = @import("std");
const http = @import("std").http;
const print = std.debug.print;
const testing = std.testing;
const log = std.log.scoped(.fast_api);
const mvzr = @import("mvzr");
const FastError = error{
HttpRequestFailed,
ScriptNotFound,
TokenNotFound,
JsonParseError,
};
const Location = struct {
city: []const u8,
country: []const u8,
};
const Client = struct {
ip: []const u8,
asn: []const u8,
isp: []const u8,
location: Location,
};
const Target = struct {
name: []const u8,
url: []const u8,
location: Location,
};
const FastResponse = struct {
client: Client,
targets: []Target,
};
pub const Fast = struct {
client: http.Client,
arena: std.heap.ArenaAllocator,
use_https: bool,
pub fn init(allocator: std.mem.Allocator, use_https: bool) Fast {
const arena = std.heap.ArenaAllocator.init(allocator);
return Fast{
.client = http.Client{ .allocator = allocator },
.arena = arena,
.use_https = use_https,
};
}
pub fn deinit(self: *Fast) void {
self.client.deinit();
self.arena.deinit();
}
fn get_http_protocol(self: Fast) []const u8 {
return if (self.use_https) "https" else "http";
}
pub fn get_urls(self: *Fast, url_count: u64) ![]const []const u8 {
const allocator = self.arena.allocator();
const token = try self.get_token(allocator);
log.debug("Found token: {s}", .{token});
const url = try std.fmt.allocPrint(allocator, "{s}://api.fast.com/netflix/speedtest/v2?https={}&token={s}&urlCount={d}", .{ self.get_http_protocol(), self.use_https, token, url_count });
log.debug("Getting download URLs from: {s}", .{url});
const json_data = try self.get_page(allocator, url);
var result = try Fast.parse_response_urls(json_data.items, allocator);
return result.toOwnedSlice();
}
/// Sanitizes JSON data by replacing invalid UTF-8 bytes that cause parseFromSlice to fail.
///
/// Fast.com API returns city names with corrupted UTF-8 encoding:
/// - "København" becomes "K<>benhavn" in the HTTP response
/// - The "<22>" character contains invalid UTF-8 bytes (e.g., 0xF8)
/// - These bytes are not valid UTF-8 replacement characters (0xEF 0xBF 0xBD)
/// - std.json.parseFromSlice fails with error.SyntaxError on invalid UTF-8
///
/// This function replaces invalid UTF-8 bytes with spaces to make the JSON parseable.
fn sanitize_json(json_data: []const u8, allocator: std.mem.Allocator) ![]u8 {
var sanitized = try allocator.dupe(u8, json_data);
// Replace invalid UTF-8 bytes with spaces
for (sanitized, 0..) |byte, i| {
if (byte > 127) {
// Replace any byte > 127 that's not part of a valid UTF-8 sequence
// This includes:
// - 0xF8 (248) and other invalid start bytes
// - Orphaned continuation bytes (128-191)
// - Any other problematic high bytes
sanitized[i] = ' ';
}
}
return sanitized;
}
fn parse_response_urls(json_data: []const u8, result_allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
var result = std.ArrayList([]const u8).init(result_allocator);
const sanitized_json = try sanitize_json(json_data, result_allocator);
defer result_allocator.free(sanitized_json);
const parsed = std.json.parseFromSlice(FastResponse, result_allocator, sanitized_json, .{
.ignore_unknown_fields = true,
}) catch |err| {
log.err("JSON parse error: {}", .{err});
return error.JsonParseError;
};
defer parsed.deinit();
const response = parsed.value;
for (response.targets) |target| {
const url_copy = try result_allocator.dupe(u8, target.url);
try result.append(url_copy);
}
return result;
}
fn extract_script_name(html_content: []const u8) ![]const u8 {
const script_re = mvzr.compile("app-[a-zA-Z0-9]+\\.js").?;
const script_match: mvzr.Match = script_re.match(html_content) orelse
return error.ScriptNotFound;
return html_content[script_match.start..script_match.end];
}
fn extract_token(script_content: []const u8, allocator: std.mem.Allocator) ![]const u8 {
const token_re = mvzr.compile("token:\"[a-zA-Z0-9]*\"").?;
const token_match = token_re.match(script_content) orelse
return error.TokenNotFound;
const token_with_prefix = script_content[token_match.start..token_match.end];
return allocator.dupe(u8, token_with_prefix[7 .. token_with_prefix.len - 1]);
}
/// This function searches for the token in the javascript returned by the fast.com public website
fn get_token(self: *Fast, allocator: std.mem.Allocator) ![]const u8 {
const base_url = try std.fmt.allocPrint(allocator, "{s}://fast.com", .{self.get_http_protocol()});
const fast_body = try self.get_page(allocator, base_url);
const script_name = try extract_script_name(fast_body.items);
const script_url = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ base_url, script_name });
// print("getting fast api token from {s}\n", .{script_url});
const resp_body = try self.get_page(allocator, script_url);
return extract_token(resp_body.items, allocator);
}
fn get_page(self: *Fast, allocator: std.mem.Allocator, url: []const u8) !std.ArrayList(u8) {
_ = allocator;
var response_body = std.ArrayList(u8).init(self.arena.allocator());
const response: http.Client.FetchResult = try self.client.fetch(.{
.method = .GET,
.location = .{ .url = url },
.response_storage = .{ .dynamic = &response_body },
});
log.debug("HTTP response status: {} for URL: {s}", .{ response.status, url });
if (response.status != .ok) {
log.err("HTTP request failed with status code {}", .{response.status});
return error.HttpRequestFailed;
}
return response_body;
}
};
test "parse_response_urls_v2" {
const response =
\\{"client":{"ip":"87.52.107.67","asn":"3292","isp":"YouSee","location":{"city":"Kobenhavn","country":"DK"}},"targets":[{"name":"https://example.com/0","url":"https://example.com/0","location":{"city":"Test","country":"DK"}},{"name":"https://example.com/1","url":"https://example.com/1","location":{"city":"Test","country":"DK"}}]}
;
const allocator = testing.allocator;
const urls = try Fast.parse_response_urls(response, allocator);
defer {
for (urls.items) |url| {
allocator.free(url);
}
urls.deinit();
}
try testing.expect(urls.items.len == 2);
try testing.expect(std.mem.eql(u8, urls.items[0], "https://example.com/0"));
try testing.expect(std.mem.eql(u8, urls.items[1], "https://example.com/1"));
}
test "sanitize_json_removes_invalid_utf8" {
// Test that sanitize_json replaces invalid UTF-8 bytes like 0xF8 (248) with spaces
const problematic_json = [_]u8{
'{', '"', 'c', 'i', 't', 'y', '"', ':', '"', 'K',
0xF8, // Invalid UTF-8 byte (248) - reproduces Fast.com API issue
'b',
'e',
'n',
'h',
'a',
'v',
'n',
'"',
'}',
};
const allocator = testing.allocator;
const sanitized = try Fast.sanitize_json(&problematic_json, allocator);
defer allocator.free(sanitized);
// Verify that the 0xF8 byte was replaced with a space
var found_space = false;
for (sanitized) |byte| {
if (byte == ' ') {
found_space = true;
}
// Should not contain any bytes > 127 after sanitization
try testing.expect(byte <= 127);
}
try testing.expect(found_space); // Should have replaced invalid byte with space
}
test "extract_script_name" {
const html =
\\<html><head><script src="app-1234abcd.js"></script></head></html>
;
const script_name = try Fast.extract_script_name(html);
try testing.expect(std.mem.eql(u8, script_name, "app-1234abcd.js"));
}
test "extract_token" {
const script_content =
\\var config = {token:"abcdef123456", other: "value"};
;
const allocator = testing.allocator;
const token = try Fast.extract_token(script_content, allocator);
defer allocator.free(token);
try testing.expect(std.mem.eql(u8, token, "abcdef123456"));
}