From 2ad0667eac222d85ae1611e4cdf735a5f5367673 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sun, 1 Mar 2026 16:16:33 -0600 Subject: [PATCH] fix: URL-decode query parameters (cursor, did) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clients may percent-encode colons in DIDs (did%3Aplc%3A...), which broke cursor-based pagination — the encoded cursor didn't match any RocksDB key so the scan restarted from the beginning. adds queryParamDecoded() with a hexVal helper for percent-decoding into a caller-provided buffer. applied to cursor in listReposByCollection and did in getRepoStatus/getLatestCommit. Co-Authored-By: Claude Opus 4.6 --- src/main.zig | 59 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/main.zig b/src/main.zig index 36e3672..d9e0e7e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -591,7 +591,8 @@ fn handleListRepos(stream: std.net.Stream, query: []const u8, persist: *event_lo } fn handleGetRepoStatus(stream: std.net.Stream, query: []const u8, persist: *event_log_mod.DiskPersist) void { - const did = queryParam(query, "did") orelse { + var did_buf: [256]u8 = undefined; + const did = queryParamDecoded(query, "did", &did_buf) orelse { httpRespondJson(stream, "400 Bad Request", "{\"error\":\"BadRequest\",\"message\":\"did parameter required\"}"); return; }; @@ -651,7 +652,8 @@ fn handleGetRepoStatus(stream: std.net.Stream, query: []const u8, persist: *even } fn handleGetLatestCommit(stream: std.net.Stream, query: []const u8, persist: *event_log_mod.DiskPersist) void { - const did = queryParam(query, "did") orelse { + var did_buf: [256]u8 = undefined; + const did = queryParamDecoded(query, "did", &did_buf) orelse { httpRespondJson(stream, "400 Bad Request", "{\"error\":\"BadRequest\",\"message\":\"did parameter required\"}"); return; }; @@ -736,7 +738,8 @@ fn handleListReposByCollection(stream: std.net.Stream, query: []const u8, ci: *c return; } - const cursor_did = queryParam(query, "cursor"); + var cursor_buf: [256]u8 = undefined; + const cursor_did = queryParamDecoded(query, "cursor", &cursor_buf); // scan collection index var did_buf: [65536]u8 = undefined; @@ -785,6 +788,56 @@ fn queryParam(query: []const u8, name: []const u8) ?[]const u8 { return null; } +/// like queryParam but percent-decodes the value into buf. +/// returns null if the param is missing, or a slice into buf with the decoded value. +fn queryParamDecoded(query: []const u8, name: []const u8, buf: []u8) ?[]const u8 { + const raw = queryParam(query, name) orelse return null; + var i: usize = 0; + var out: usize = 0; + while (i < raw.len) { + if (raw[i] == '%' and i + 2 < raw.len) { + const hi = hexVal(raw[i + 1]) orelse { + if (out >= buf.len) return null; + buf[out] = raw[i]; + out += 1; + i += 1; + continue; + }; + const lo = hexVal(raw[i + 2]) orelse { + if (out >= buf.len) return null; + buf[out] = raw[i]; + out += 1; + i += 1; + continue; + }; + if (out >= buf.len) return null; + buf[out] = (hi << 4) | lo; + out += 1; + i += 3; + } else if (raw[i] == '+') { + if (out >= buf.len) return null; + buf[out] = ' '; + out += 1; + i += 1; + } else { + if (out >= buf.len) return null; + buf[out] = raw[i]; + out += 1; + i += 1; + } + } + return buf[0..out]; +} + +fn hexVal(c: u8) ?u4 { + return switch (c) { + '0'...'9' => @intCast(c - '0'), + 'a'...'f' => @intCast(c - 'a' + 10), + 'A'...'F' => @intCast(c - 'A' + 10), + else => null, + }; +} + fn httpRespondJson(stream: std.net.Stream, status: []const u8, body: []const u8) void { httpRespond(stream, status, "application/json", body); } -- 2.51.2