const std = @import("std"); const zat = @import("zat"); pub const HostedHandleError = error{ InvalidHandle, DisallowedTld, ReservedHandle, UnsupportedDomain, InvalidHostedHandle, }; pub fn normalize(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { const normalized = try allocator.alloc(u8, raw.len); errdefer allocator.free(normalized); _ = std.ascii.lowerString(normalized, raw); if (zat.Handle.parse(normalized) == null) return HostedHandleError.InvalidHandle; try validateTld(normalized); return normalized; } pub fn isHosted(handle: []const u8, raw_domains: []const u8) bool { var domains = std.mem.splitScalar(u8, raw_domains, ','); while (domains.next()) |raw_domain| { const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); if (domain.len == 0 or domain[0] != '.') continue; if (handle.len > domain.len and std.mem.endsWith(u8, handle, domain)) return true; } return false; } pub fn isHostedAuthority(handle: []const u8, raw_domains: []const u8) bool { if (isHosted(handle, raw_domains)) return true; var domains = std.mem.splitScalar(u8, raw_domains, ','); while (domains.next()) |raw_domain| { const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); if (domain.len > 1 and domain[0] == '.' and std.mem.eql(u8, handle, domain[1..])) return true; } return false; } pub fn validateHosted(handle: []const u8, raw_domains: []const u8) HostedHandleError!void { var domains = std.mem.splitScalar(u8, raw_domains, ','); while (domains.next()) |raw_domain| { const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); if (domain.len == 0 or domain[0] != '.') continue; if (handle.len <= domain.len or !std.mem.endsWith(u8, handle, domain)) continue; const name = handle[0 .. handle.len - domain.len]; if (name.len < 3 or name.len > 18 or std.mem.indexOfScalar(u8, name, '.') != null) { return HostedHandleError.InvalidHostedHandle; } if (isReserved(name)) return HostedHandleError.ReservedHandle; return; } return HostedHandleError.UnsupportedDomain; } fn validateTld(handle: []const u8) HostedHandleError!void { const disallowed = [_][]const u8{ ".alt", ".arpa", ".example", ".internal", ".invalid", ".local", ".localhost", ".onion", }; for (disallowed) |suffix| { if (std.mem.endsWith(u8, handle, suffix)) return HostedHandleError.DisallowedTld; } } fn isReserved(name: []const u8) bool { const reserved = [_][]const u8{ "account", "accounts", "admin", "api", "at", "atp", "atproto", "auth", "bsky", "bluesky", "did", "help", "handle", "lex", "lexicon", "mail", "mod", "moderation", "nsid", "oauth", "pds", "plc", "repo", "root", "status", "support", "system", "tid", "webmaster", "www", "xrpc", }; for (reserved) |value| { if (std.mem.eql(u8, name, value)) return true; } return false; } pub fn validateDomains(raw_domains: []const u8) error{InvalidHandleDomain}!void { var count: usize = 0; var domains = std.mem.splitScalar(u8, raw_domains, ','); while (domains.next()) |raw_domain| { const domain = std.mem.trim(u8, raw_domain, &std.ascii.whitespace); if (domain.len < 2 or domain[0] != '.') return error.InvalidHandleDomain; var example_buf: [zat.Handle.max_length]u8 = undefined; const example = std.fmt.bufPrint(&example_buf, "abc{s}", .{domain}) catch return error.InvalidHandleDomain; if (zat.Handle.parse(example) == null) return error.InvalidHandleDomain; count += 1; } if (count == 0) return error.InvalidHandleDomain; } test "hosted handles require one bounded account label" { try validateHosted("alice.pds.example.com", ".pds.example.com"); try std.testing.expectError(error.InvalidHostedHandle, validateHosted("a.pds.example.com", ".pds.example.com")); try std.testing.expectError(error.InvalidHostedHandle, validateHosted("nested.alice.pds.example.com", ".pds.example.com")); try std.testing.expectError(error.ReservedHandle, validateHosted("admin.pds.example.com", ".pds.example.com")); try std.testing.expectError(error.UnsupportedDomain, validateHosted("alice.example.com", ".pds.example.com")); } test "normalization lowercases without accepting surrounding whitespace" { const normalized = try normalize(std.testing.allocator, "Alice.Example.COM"); defer std.testing.allocator.free(normalized); try std.testing.expectEqualStrings("alice.example.com", normalized); try std.testing.expectError(error.InvalidHandle, normalize(std.testing.allocator, " alice.example.com ")); try std.testing.expectError(error.DisallowedTld, normalize(std.testing.allocator, "alice.localhost")); } test "hosted authority includes the configured base domain" { try std.testing.expect(isHostedAuthority("alice.pds.example.com", ".pds.example.com")); try std.testing.expect(isHostedAuthority("pds.example.com", ".pds.example.com")); try std.testing.expect(!isHostedAuthority("alice.example.com", ".pds.example.com")); } test "handle domains are leading-dot suffixes" { try validateDomains(".pds.example.com,.test.example"); try std.testing.expectError(error.InvalidHandleDomain, validateDomains("pds.example.com")); try std.testing.expectError(error.InvalidHandleDomain, validateDomains(".example.com,example.com")); try std.testing.expectError(error.InvalidHandleDomain, validateDomains("")); }