atproto utils for zig zat.dev
atproto sdk zig
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340//! AT-URI Parser//!//! at-uris identify repositories and records in the atproto network.//! format: at://<authority>[/<collection>[/<rkey>]]//!//! validation rules://! - max 8KB length//! - no trailing slashes//! - authority is either a DID or handle//! - collection (if present) must be a valid NSID//! - rkey (if present) must be a valid record key//!//! see: https://atproto.com/specs/at-uri-scheme
const std = @import("std");const Did = @import("did.zig").Did;const Handle = @import("handle.zig").Handle;const Nsid = @import("nsid.zig").Nsid;const Rkey = @import("rkey.zig").Rkey;
pub const AtUri = struct { /// the full uri string (borrowed, not owned) raw: []const u8,
/// offset where authority ends (after "at://") authority_end: usize,
/// offset where collection ends (0 if no collection) collection_end: usize,
pub const max_length = 8 * 1024; const prefix = "at://";
/// parse an at-uri. returns null if invalid. pub fn parse(s: []const u8) ?AtUri { // length check if (s.len < prefix.len or s.len > max_length) return null;
// must start with "at://" if (!std.mem.startsWith(u8, s, prefix)) return null;
// reject forbidden characters anywhere after prefix for (s) |c| { if (c == ' ' or c == '#' or c == '?') return null; }
// no trailing slash if (s[s.len - 1] == '/') return null;
const after_prefix = s[prefix.len..]; if (after_prefix.len == 0) return null; // empty authority
// find first slash (end of authority) const authority_end_rel = std.mem.indexOfScalar(u8, after_prefix, '/');
const auth_str = after_prefix[0 .. authority_end_rel orelse after_prefix.len]; if (auth_str.len == 0) return null; // AT URI authorities are not generic URI authorities; do not accept // percent-encoded DID or handle spellings. if (std.mem.indexOfScalar(u8, auth_str, '%') != null) return null;
// authority must be a valid DID or handle if (Did.parse(auth_str) == null and Handle.parse(auth_str) == null) return null;
if (authority_end_rel) |ae| { const after_authority = after_prefix[ae + 1 ..]; if (after_authority.len == 0) return null; // trailing slash after authority
// find second slash (end of collection) const collection_end_rel = std.mem.indexOfScalar(u8, after_authority, '/');
const coll_str = after_authority[0 .. collection_end_rel orelse after_authority.len]; if (coll_str.len == 0) return null; // empty collection
// collection must be a valid NSID if (Nsid.parse(coll_str) == null) return null;
if (collection_end_rel) |ce| { const rkey_str = after_authority[ce + 1 ..]; if (rkey_str.len == 0) return null; // trailing slash after collection
// rkey must be a valid record key if (Rkey.parse(rkey_str) == null) return null;
return .{ .raw = s, .authority_end = prefix.len + ae, .collection_end = prefix.len + ae + 1 + ce, }; } else { // uri with authority + collection only return .{ .raw = s, .authority_end = prefix.len + ae, .collection_end = s.len, }; } } else { // authority only return .{ .raw = s, .authority_end = s.len, .collection_end = 0, }; } }
/// the authority portion (DID or handle) pub fn authority(self: AtUri) []const u8 { return self.raw[prefix.len..self.authority_end]; }
/// the collection portion, or null if not present pub fn collection(self: AtUri) ?[]const u8 { if (self.collection_end == 0) return null; return self.raw[self.authority_end + 1 .. self.collection_end]; }
/// the rkey portion, or null if not present pub fn rkey(self: AtUri) ?[]const u8 { if (self.collection_end == 0) return null; if (self.collection_end >= self.raw.len) return null; const r = self.raw[self.collection_end + 1 ..]; if (r.len == 0) return null; return r; }
/// check if this uri has a collection component pub fn hasCollection(self: AtUri) bool { return self.collection_end != 0; }
/// check if this uri has an rkey component pub fn hasRkey(self: AtUri) bool { return self.rkey() != null; }
/// format a new at-uri into the provided buffer. /// returns the slice of the buffer used, or null if buffer too small. pub fn format( buf: []u8, authority_str: []const u8, collection_str: ?[]const u8, rkey_str: ?[]const u8, ) ?[]const u8 { if (std.mem.indexOfScalar(u8, authority_str, '%') != null) return null; if (Did.parse(authority_str) == null and Handle.parse(authority_str) == null) return null; if (collection_str == null and rkey_str != null) return null; if (collection_str) |c| { if (Nsid.parse(c) == null) return null; } if (rkey_str) |r| { if (Rkey.parse(r) == null) return null; }
var total_len = prefix.len + authority_str.len; if (collection_str) |c| { total_len += 1 + c.len; if (rkey_str) |r| { total_len += 1 + r.len; } }
if (buf.len < total_len) return null;
var pos: usize = 0;
@memcpy(buf[pos..][0..prefix.len], prefix); pos += prefix.len;
@memcpy(buf[pos..][0..authority_str.len], authority_str); pos += authority_str.len;
if (collection_str) |c| { buf[pos] = '/'; pos += 1; @memcpy(buf[pos..][0..c.len], c); pos += c.len;
if (rkey_str) |r| { buf[pos] = '/'; pos += 1; @memcpy(buf[pos..][0..r.len], r); pos += r.len; } }
return buf[0..pos]; }};
// === tests from atproto.com/specs/at-uri-scheme ===
test "valid: full uri with did:plc" { const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jxtb5w2hkt2m") orelse return error.InvalidUri; try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority()); try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?); try std.testing.expectEqualStrings("3jxtb5w2hkt2m", uri.rkey().?);}
test "valid: full uri with did:web" { const uri = AtUri.parse("at://did:web:example.com/app.bsky.actor.profile/self") orelse return error.InvalidUri; try std.testing.expectEqualStrings("did:web:example.com", uri.authority()); try std.testing.expectEqualStrings("app.bsky.actor.profile", uri.collection().?); try std.testing.expectEqualStrings("self", uri.rkey().?);}
test "valid: full uri with handle" { const uri = AtUri.parse("at://alice.bsky.social/app.bsky.feed.post/abc123") orelse return error.InvalidUri; try std.testing.expectEqualStrings("alice.bsky.social", uri.authority()); try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?); try std.testing.expectEqualStrings("abc123", uri.rkey().?);}
test "valid: generic DID authority" { const uri = AtUri.parse("at://did:abc:123/io.nsid.someFunc/record-key") orelse return error.InvalidUri; try std.testing.expectEqualStrings("did:abc:123", uri.authority()); try std.testing.expectEqualStrings("io.nsid.someFunc", uri.collection().?); try std.testing.expectEqualStrings("record-key", uri.rkey().?);}
test "valid: permissive record-key punctuation from current spec" { const valid = [_][]const u8{ "at://did:abc:123/io.nsid.someFunc/self.", "at://did:abc:123/io.nsid.someFunc/lang:", "at://did:abc:123/io.nsid.someFunc/:", "at://did:abc:123/io.nsid.someFunc/-", "at://did:abc:123/io.nsid.someFunc/_", "at://did:abc:123/io.nsid.someFunc/~", "at://did:abc:123/io.nsid.someFunc/...", };
for (valid) |s| { try std.testing.expect(AtUri.parse(s) != null); }}
test "valid: authority only" { const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur") orelse return error.InvalidUri; try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority()); try std.testing.expect(uri.collection() == null); try std.testing.expect(uri.rkey() == null); try std.testing.expect(!uri.hasCollection()); try std.testing.expect(!uri.hasRkey());}
test "valid: authority and collection only" { const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post") orelse return error.InvalidUri; try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority()); try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?); try std.testing.expect(uri.rkey() == null); try std.testing.expect(uri.hasCollection()); try std.testing.expect(!uri.hasRkey());}
test "invalid: missing prefix" { try std.testing.expect(AtUri.parse("did:plc:xyz/app.bsky.feed.post/abc") == null); try std.testing.expect(AtUri.parse("http://did:plc:xyz/collection/rkey") == null);}
test "invalid: empty authority" { try std.testing.expect(AtUri.parse("at://") == null); try std.testing.expect(AtUri.parse("at:///collection/rkey") == null);}
test "invalid: trailing slash" { try std.testing.expect(AtUri.parse("at://did:plc:xyz/") == null); try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/") == null); try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/rkey/") == null);}
test "invalid: empty collection" { try std.testing.expect(AtUri.parse("at://did:plc:xyz//rkey") == null);}
test "invalid: empty rkey" { try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/") == null);}
test "invalid: query fragment and extra path segments" { try std.testing.expect(AtUri.parse("at://did:plc:xyz/app.bsky.feed.post/abc?query") == null); try std.testing.expect(AtUri.parse("at://did:plc:xyz/app.bsky.feed.post/abc#frag") == null); try std.testing.expect(AtUri.parse("at://did:plc:xyz/app.bsky.feed.post/abc/extra") == null); try std.testing.expect(AtUri.parse("at://did:plc:xyz/app.bsky.feed.post//") == null);}
test "invalid: percent-encoded authority" { try std.testing.expect(AtUri.parse("at://did:web:localhost%3A1234/app.bsky.feed.post/abc") == null); try std.testing.expect(AtUri.parse("at://did:method:val%BB") == null); try std.testing.expect(AtUri.parse("at://did%3Aplc%3Amy_did") == null); try std.testing.expect(AtUri.parse("at://did%3Aplc%3Amy_did/com.atproto.feed.post/record") == null); try std.testing.expect(AtUri.parse("at://user%2Ebsky%2Esocial") == null);}
test "invalid: proposed triple-slash form is not current syntax" { try std.testing.expect(AtUri.parse("at:///did:plc:xyz/app.bsky.feed.post/abc") == null);}
test "invalid: record-key reserved characters" { const invalid = [_][]const u8{ "at://did:plc:xyz/app.bsky.feed.post/%23", "at://did:plc:xyz/app.bsky.feed.post/$", "at://did:plc:xyz/app.bsky.feed.post/@", "at://did:plc:xyz/app.bsky.feed.post/!", "at://did:plc:xyz/app.bsky.feed.post/*", "at://did:plc:xyz/app.bsky.feed.post/.", "at://did:plc:xyz/app.bsky.feed.post/..", };
for (invalid) |s| { try std.testing.expect(AtUri.parse(s) == null); }}
test "format: full uri" { var buf: [256]u8 = undefined; const result = AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", "abc123") orelse return error.BufferTooSmall; try std.testing.expectEqualStrings("at://did:plc:xyz/app.bsky.feed.post/abc123", result);}
test "format: authority only" { var buf: [256]u8 = undefined; const result = AtUri.format(&buf, "did:plc:xyz", null, null) orelse return error.BufferTooSmall; try std.testing.expectEqualStrings("at://did:plc:xyz", result);}
test "format: authority and collection" { var buf: [256]u8 = undefined; const result = AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", null) orelse return error.BufferTooSmall; try std.testing.expectEqualStrings("at://did:plc:xyz/app.bsky.feed.post", result);}
test "format: rejects invalid components" { var buf: [256]u8 = undefined; try std.testing.expect(AtUri.format(&buf, "did:web:localhost%3A1234", "app.bsky.feed.post", "abc") == null); try std.testing.expect(AtUri.format(&buf, "did:plc:xyz", null, "abc") == null); try std.testing.expect(AtUri.format(&buf, "did:plc:xyz", "short", "abc") == null); try std.testing.expect(AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", "abc/def") == null);}