Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/api/account.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const chatgpt_http = @import("http.zig");
const registry = @import("../registry/root.zig");

pub const default_account_endpoint = "https://chatgpt.com/backend-api/accounts";

Expand Down Expand Up @@ -104,5 +105,5 @@ fn parseAccountNameAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !
else => return null,
};
if (raw.len == 0) return null;
return try allocator.dupe(u8, raw);
return try registry.sanitizeDisplayAlloc(allocator, raw);
}
8 changes: 6 additions & 2 deletions src/api/http_executable.zig
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ pub fn resolveExecutablePathEntryForLaunchAlloc(
return null;
}
fn accessPath(path: []const u8) bool {
// A PATH entry that exists but cannot be launched (e.g. a data file named
// `curl`) must not shadow later, usable candidates. Windows has no execute
// permission bit, so existence stays the check there.
const options: std.Io.Dir.AccessOptions = if (builtin.target.os.tag == .windows) .{} else .{ .execute = true };
if (std.Io.Dir.path.isAbsolute(path)) {
std.Io.Dir.accessAbsolute(app_runtime.io(), path, .{}) catch return false;
std.Io.Dir.accessAbsolute(app_runtime.io(), path, options) catch return false;
return true;
}

std.Io.Dir.cwd().access(app_runtime.io(), path, .{}) catch return false;
std.Io.Dir.cwd().access(app_runtime.io(), path, options) catch return false;
return true;
}

Expand Down
53 changes: 40 additions & 13 deletions src/api/http_proxy.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

const builtin = @import("builtin");
const std = @import("std");
const app_runtime = @import("../core/runtime.zig");
const env = @import("http_env.zig");

const no_system_proxy = struct {
Expand Down Expand Up @@ -66,9 +67,9 @@ const no_proxy_env_keys = [_][]const u8{ "NO_PROXY", "no_proxy" };
/// A proxy entry selected for one scheme.
pub const Picked = struct {
hostport: []const u8,
/// `curl` needs an explicit scheme to treat an entry as SOCKS; `socks5h`
/// keeps DNS resolution on the proxy side.
socks: bool,
/// `curl` needs an explicit scheme prefix to treat an entry as SOCKS; an
/// empty prefix means the default HTTP-proxy scheme.
scheme: []const u8 = "",
};

/// Resolve the proxy to use for `endpoint` in this process.
Expand Down Expand Up @@ -108,11 +109,13 @@ pub fn decide(

var url: std.ArrayList(u8) = .empty;
errdefer url.deinit(allocator);
if (picked.socks) try url.appendSlice(allocator, "socks5h://");
try url.appendSlice(allocator, picked.scheme);
try url.appendSlice(allocator, picked.hostport);

const url_owned = try url.toOwnedSlice(allocator);
errdefer allocator.free(url_owned);
return Proxy{
.url = try url.toOwnedSlice(allocator),
.url = url_owned,
.noproxy = try noproxyValue(allocator, env_map, system.bypass),
};
}
Expand All @@ -138,7 +141,17 @@ fn noproxyValue(
pub fn pickProxyServer(scheme: Scheme, raw: []const u8) ?Picked {
const trimmed = std.mem.trim(u8, raw, " \t");
if (trimmed.len == 0) return null;
if (std.mem.findScalar(u8, trimmed, '=') == null) return .{ .hostport = trimmed, .socks = false };
if (std.mem.findScalar(u8, trimmed, '=') == null) {
// WinHTTP reports a PAC failover list as a bare `host:port;host:port`
// string; only the first entry can be handed to curl, and WinHTTP has
// already ordered them by preference.
var it = std.mem.splitScalar(u8, trimmed, ';');
while (it.next()) |entry| {
const item = std.mem.trim(u8, entry, " \t");
if (item.len != 0) return .{ .hostport = item };
}
return null;
}

const wanted = switch (scheme) {
.https => "https",
Expand All @@ -155,10 +168,10 @@ pub fn pickProxyServer(scheme: Scheme, raw: []const u8) ?Picked {
if (value.len == 0) continue;
const picked = Picked{
.hostport = value,
.socks = std.ascii.eqlIgnoreCase(key, "socks") or std.ascii.eqlIgnoreCase(key, "socks5"),
.scheme = socksSchemeForKey(key) orelse "",
};
if (std.ascii.eqlIgnoreCase(key, wanted)) return picked;
if (picked.socks) {
if (picked.scheme.len != 0) {
if (socks_fallback == null) socks_fallback = picked;
} else if (fallback == null and
(std.ascii.eqlIgnoreCase(key, "https") or std.ascii.eqlIgnoreCase(key, "http")))
Expand All @@ -169,6 +182,18 @@ pub fn pickProxyServer(scheme: Scheme, raw: []const u8) ?Picked {
return fallback orelse socks_fallback;
}

/// WinINET's legacy `socks` entry was SOCKS4, but every modern consumer of the
/// shared format (the macOS `SOCKSProxy` key included) speaks SOCKS5, so it
/// maps to `socks5h`; explicit `socks4`/`socks5`/`socks5h` keys are honoured
/// literally. `socks5h` keeps DNS resolution on the proxy side.
fn socksSchemeForKey(key: []const u8) ?[]const u8 {
if (std.ascii.eqlIgnoreCase(key, "socks4")) return "socks4://";
if (std.ascii.eqlIgnoreCase(key, "socks") or
std.ascii.eqlIgnoreCase(key, "socks5") or
std.ascii.eqlIgnoreCase(key, "socks5h")) return "socks5h://";
return null;
}

/// Translate a platform bypass list into curl's comma separated `noproxy` list.
/// The WinINET `<local>` shorthand becomes `localhost`, duplicates are dropped,
/// and empty input yields `null`.
Expand All @@ -185,6 +210,9 @@ pub fn translateBypass(allocator: std.mem.Allocator, raw: []const u8) !?[]u8 {
if (std.ascii.eqlIgnoreCase(part, "<local>") or std.ascii.eqlIgnoreCase(part, "<loopback>")) {
part = "localhost";
}
// `curl`'s noproxy list matches by domain suffix and does not read the
// WinINET `*.` wildcard prefix, so `*.internal` becomes `internal`.
if (std.mem.startsWith(u8, part, "*.")) part = part[2..];
if (containsEntry(seen.items, part)) continue;
if (out.items.len != 0) try out.append(allocator, ',');
try out.appendSlice(allocator, part);
Expand Down Expand Up @@ -215,7 +243,7 @@ fn envValue(env_map: *const std.process.Environ.Map, key: []const u8) ?[]const u
return value;
}

var system_proxy_mutex: std.atomic.Mutex = .unlocked;
var system_proxy_mutex: std.Io.Mutex = .init;
var system_proxy_cached: ?*const SystemProxy = null;

/// Platform proxy settings, read once per process. Every endpoint codex-auth
Expand All @@ -226,10 +254,9 @@ var system_proxy_cached: ?*const SystemProxy = null;
pub fn systemProxy(endpoint: []const u8) SystemProxy {
if (@atomicLoad(?*const SystemProxy, &system_proxy_cached, .acquire)) |cached| return cached.*;

while (!system_proxy_mutex.tryLock()) {
std.Thread.yield() catch {};
}
defer system_proxy_mutex.unlock();
const io = app_runtime.io();
system_proxy_mutex.lockUncancelable(io);
defer system_proxy_mutex.unlock(io);
if (@atomicLoad(?*const SystemProxy, &system_proxy_cached, .acquire)) |cached| return cached.*;

// The cached struct and the server/bypass slices inside it live for the
Expand Down
26 changes: 23 additions & 3 deletions src/api/http_proxy_scutil.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,36 @@ pub const Values = struct {
};

/// Fields are kept as slices into the input text, which is valid for the whole
/// parse. `scutil` emits far fewer keys than this, so the fixed table avoids an
/// allocator round trip per line.
const max_fields = 12;
/// parse. Only the keys `appendProtocol` reads are stored, so the fixed table
/// stays small and unrelated keys can never crowd the wanted ones out.
const max_fields = 9;

const wanted_keys = [_][]const u8{
"HTTPEnable", "HTTPProxy", "HTTPPort",
"HTTPSEnable", "HTTPSProxy", "HTTPSPort",
"SOCKSEnable", "SOCKSProxy", "SOCKSPort",
};

const Fields = struct {
keys: [max_fields][]const u8 = @splat(""),
values: [max_fields][]const u8 = @splat(""),
count: usize = 0,

fn put(self: *Fields, key: []const u8, value: []const u8) void {
for (wanted_keys) |wanted| {
if (std.mem.eql(u8, key, wanted)) break;
} else return;
// A repeated key replaces its earlier value, matching "last wins" plist
// semantics and keeping the table bounded even for hostile input.
if (self.get(key) != null) {
for (self.keys[0..self.count], self.values[0..self.count]) |*found, *stored| {
if (std.mem.eql(u8, found.*, key)) {
stored.* = value;
return;
}
}
unreachable;
}
if (self.count == max_fields) return;
self.keys[self.count] = key;
self.values[self.count] = value;
Expand Down
13 changes: 3 additions & 10 deletions src/api/me.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const http = @import("http.zig");
const registry = @import("../registry/root.zig");

pub const default_me_endpoint = "https://api.openai.com/v1/me";

Expand Down Expand Up @@ -52,9 +53,9 @@ pub fn parseMeResponse(allocator: std.mem.Allocator, body: []const u8) !MeResult

const owned_user_id = try allocator.dupe(u8, id);
errdefer allocator.free(owned_user_id);
const owned_email = try normalizeEmailAlloc(allocator, email);
const owned_email = try registry.normalizeEmailAlloc(allocator, email);
errdefer allocator.free(owned_email);
const owned_name = if (name) |value| try allocator.dupe(u8, value) else null;
const owned_name = if (name) |value| try registry.sanitizeDisplayAlloc(allocator, value) else null;
errdefer if (owned_name) |value| allocator.free(value);

return .{
Expand All @@ -71,11 +72,3 @@ fn nonEmptyStringField(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 {
else => null,
};
}

fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 {
var buf = try allocator.alloc(u8, email.len);
for (email, 0..) |ch, i| {
buf[i] = std.ascii.toLower(ch);
}
return buf;
}
8 changes: 1 addition & 7 deletions src/auth/auth.zig
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,7 @@ const CpaAuthJson = struct {
last_refresh: ?[]const u8,
};

fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 {
var buf = try allocator.alloc(u8, email.len);
for (email, 0..) |ch, i| {
buf[i] = std.ascii.toLower(ch);
}
return buf;
}
const normalizeEmailAlloc = registry.normalizeEmailAlloc;

fn recordKeyAlloc(
allocator: std.mem.Allocator,
Expand Down
9 changes: 7 additions & 2 deletions src/cli/live_view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,15 @@ pub fn selectAccountWithLiveUpdates(
},
.enter => {
if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| {
return try dupSelectedAccountKeyForDisplayedAccount(allocator, rows, borrowed.reg, displayed_idx);
if (try dupSelectedAccountKeyForDisplayedAccount(allocator, rows, borrowed.reg, displayed_idx)) |account_key| return account_key;
// The numbered row is not selectable (for
// example an errored account): keep the
// view open instead of treating it as quit.
continue;
}
if (selected_idx) |idx| return try dupSelectedAccountKey(allocator, rows, borrowed.reg, idx);
return null;
// Nothing selected to confirm; ignore.
continue;
},
.quit => return null,
.backspace => {
Expand Down
17 changes: 16 additions & 1 deletion src/registry/account_ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,13 @@ pub fn deleteRemovedAccountBackups(
};
defer dir.close(app_runtime.io());

// Collect matching entries first: deleting while the directory iterator is
// live can skip siblings depending on the filesystem.
var stale_names: std.ArrayList([]u8) = .empty;
defer {
for (stale_names.items) |name| allocator.free(name);
stale_names.deinit(allocator);
}
var it = dir.iterate();
while (try it.next(app_runtime.io())) |entry| {
if (entry.kind != .file and entry.kind != .sym_link) continue;
Expand All @@ -381,7 +388,15 @@ pub fn deleteRemovedAccountBackups(
const record_key = info.record_key orelse continue;
if (!isRemovedAccountKey(reg, removed, record_key)) continue;

dir.deleteFile(app_runtime.io(), entry.name) catch {};
const owned_name = try allocator.dupe(u8, entry.name);
stale_names.append(allocator, owned_name) catch |err| {
allocator.free(owned_name);
return err;
};
}

for (stale_names.items) |name| {
dir.deleteFile(app_runtime.io(), name) catch {};
}
}

Expand Down
30 changes: 25 additions & 5 deletions src/registry/clean.zig
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ pub fn isAllowedCurrentSnapshot(reg: *const Registry, entry_name: []const u8) bo
pub fn isAllowedAccountsEntry(reg: *const Registry, entry_name: []const u8) bool {
if (std.mem.eql(u8, entry_name, "registry.json")) return true;
if (std.mem.eql(u8, entry_name, "backups")) return true;
if (std.mem.eql(u8, entry_name, common.default_export_dir_name)) return true;
if (std.mem.eql(u8, entry_name, common.codext_cache_dir_name)) return true;
return isAllowedCurrentSnapshot(reg, entry_name);
}

Expand Down Expand Up @@ -225,19 +227,37 @@ pub fn cleanAccountsBackupsWithLoader(allocator: std.mem.Allocator, codex_home:
var reg = try load_registry(allocator, codex_home);
defer reg.deinit(allocator);

var stale_snapshot_files_removed: usize = 0;
// Collect the stale entries first: deleting while the directory iterator is
// live can skip siblings depending on the filesystem.
var stale_names: std.ArrayList(struct { name: []u8, kind: std.Io.File.Kind }) = .empty;
defer {
for (stale_names.items) |item| allocator.free(item.name);
stale_names.deinit(allocator);
}
var accounts_dir = try std.Io.Dir.cwd().openDir(app_runtime.io(), dir, .{ .iterate = true });
defer accounts_dir.close(app_runtime.io());
var it = accounts_dir.iterate();
while (try it.next(app_runtime.io())) |entry| {
switch (entry.kind) {
.file, .sym_link, .directory => {},
else => continue,
}
if (isAllowedAccountsEntry(&reg, entry.name)) {
continue;
}
const owned_name = try allocator.dupe(u8, entry.name);
stale_names.append(allocator, .{ .name = owned_name, .kind = entry.kind }) catch |err| {
allocator.free(owned_name);
return err;
};
}

switch (entry.kind) {
.file, .sym_link => try accounts_dir.deleteFile(app_runtime.io(), entry.name),
.directory => try accounts_dir.deleteTree(app_runtime.io(), entry.name),
else => continue,
var stale_snapshot_files_removed: usize = 0;
for (stale_names.items) |item| {
switch (item.kind) {
.file, .sym_link => try accounts_dir.deleteFile(app_runtime.io(), item.name),
.directory => try accounts_dir.deleteTree(app_runtime.io(), item.name),
else => unreachable,
}
stale_snapshot_files_removed += 1;
}
Expand Down
Loading
Loading