A library for ATProtocol identities.

Compare changes

Choose any two refs to compare.

+543 -627
Cargo.lock
··· 3 version = 4 4 5 [[package]] 6 - name = "addr2line" 7 - version = "0.24.2" 8 - source = "registry+https://github.com/rust-lang/crates.io-index" 9 - checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 - dependencies = [ 11 - "gimli", 12 - ] 13 - 14 - [[package]] 15 - name = "adler2" 16 - version = "2.0.0" 17 - source = "registry+https://github.com/rust-lang/crates.io-index" 18 - checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 - 20 - [[package]] 21 name = "aho-corasick" 22 - version = "1.1.3" 23 source = "registry+https://github.com/rust-lang/crates.io-index" 24 - checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 dependencies = [ 26 "memchr", 27 ] ··· 34 35 [[package]] 36 name = "anstream" 37 - version = "0.6.19" 38 source = "registry+https://github.com/rust-lang/crates.io-index" 39 - checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" 40 dependencies = [ 41 "anstyle", 42 "anstyle-parse", ··· 49 50 [[package]] 51 name = "anstyle" 52 - version = "1.0.11" 53 source = "registry+https://github.com/rust-lang/crates.io-index" 54 - checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 55 56 [[package]] 57 name = "anstyle-parse" ··· 64 65 [[package]] 66 name = "anstyle-query" 67 - version = "1.1.3" 68 source = "registry+https://github.com/rust-lang/crates.io-index" 69 - checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" 70 dependencies = [ 71 - "windows-sys 0.59.0", 72 ] 73 74 [[package]] 75 name = "anstyle-wincon" 76 - version = "3.0.9" 77 source = "registry+https://github.com/rust-lang/crates.io-index" 78 - checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" 79 dependencies = [ 80 "anstyle", 81 "once_cell_polyfill", 82 - "windows-sys 0.59.0", 83 ] 84 85 [[package]] 86 name = "anyhow" 87 - version = "1.0.98" 88 source = "registry+https://github.com/rust-lang/crates.io-index" 89 - checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 90 91 [[package]] 92 name = "async-trait" 93 - version = "0.1.88" 94 source = "registry+https://github.com/rust-lang/crates.io-index" 95 - checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" 96 dependencies = [ 97 "proc-macro2", 98 "quote", 99 - "syn", 100 ] 101 102 [[package]] ··· 127 "serde_ipld_dagcbor", 128 "serde_json", 129 "sha2", 130 - "thiserror 2.0.12", 131 "tokio", 132 ] 133 ··· 149 "secrecy", 150 "serde", 151 "serde_json", 152 - "thiserror 2.0.12", 153 "tokio", 154 "tracing", 155 "urlencoding", 156 ] 157 158 [[package]] 159 name = "atproto-identity" 160 version = "0.13.0" 161 dependencies = [ ··· 175 "serde", 176 "serde_ipld_dagcbor", 177 "serde_json", 178 - "thiserror 2.0.12", 179 "tokio", 180 "tracing", 181 "url", ··· 195 "http", 196 "serde", 197 "serde_json", 198 - "thiserror 2.0.12", 199 "tokio", 200 "tokio-util", 201 "tokio-websockets", ··· 218 "reqwest", 219 "serde", 220 "serde_json", 221 - "thiserror 2.0.12", 222 "tokio", 223 "tracing", 224 "zeroize", ··· 249 "serde_ipld_dagcbor", 250 "serde_json", 251 "sha2", 252 - "thiserror 2.0.12", 253 "tokio", 254 "tracing", 255 "ulid", ··· 267 "reqwest", 268 "serde", 269 "serde_json", 270 - "thiserror 2.0.12", 271 "zeroize", 272 ] 273 ··· 294 "secrecy", 295 "serde", 296 "serde_json", 297 - "thiserror 2.0.12", 298 "tokio", 299 "tracing", 300 "zeroize", ··· 317 "serde_ipld_dagcbor", 318 "serde_json", 319 "sha2", 320 - "thiserror 2.0.12", 321 "tokio", 322 ] 323 324 [[package]] ··· 342 "reqwest-middleware", 343 "serde", 344 "serde_json", 345 - "thiserror 2.0.12", 346 "tokio", 347 "tracing", 348 ] ··· 369 "reqwest-middleware", 370 "serde", 371 "serde_json", 372 - "thiserror 2.0.12", 373 "tokio", 374 "tracing", 375 ] 376 377 [[package]] 378 name = "autocfg" 379 - version = "1.4.0" 380 source = "registry+https://github.com/rust-lang/crates.io-index" 381 - checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 382 383 [[package]] 384 name = "axum" 385 - version = "0.8.4" 386 source = "registry+https://github.com/rust-lang/crates.io-index" 387 - checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" 388 dependencies = [ 389 "axum-core", 390 "axum-macros", ··· 402 "mime", 403 "percent-encoding", 404 "pin-project-lite", 405 - "rustversion", 406 - "serde", 407 "serde_json", 408 "serde_path_to_error", 409 "serde_urlencoded", ··· 417 418 [[package]] 419 name = "axum-core" 420 - version = "0.5.2" 421 source = "registry+https://github.com/rust-lang/crates.io-index" 422 - checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" 423 dependencies = [ 424 "bytes", 425 "futures-core", ··· 428 "http-body-util", 429 "mime", 430 "pin-project-lite", 431 - "rustversion", 432 "sync_wrapper", 433 "tower-layer", 434 "tower-service", ··· 443 dependencies = [ 444 "proc-macro2", 445 "quote", 446 - "syn", 447 - ] 448 - 449 - [[package]] 450 - name = "backtrace" 451 - version = "0.3.75" 452 - source = "registry+https://github.com/rust-lang/crates.io-index" 453 - checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 454 - dependencies = [ 455 - "addr2line", 456 - "cfg-if", 457 - "libc", 458 - "miniz_oxide", 459 - "object", 460 - "rustc-demangle", 461 - "windows-targets 0.52.6", 462 ] 463 464 [[package]] ··· 474 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 475 476 [[package]] 477 name = "base64" 478 version = "0.22.1" 479 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 481 482 [[package]] 483 name = "base64ct" 484 - version = "1.7.3" 485 source = "registry+https://github.com/rust-lang/crates.io-index" 486 - checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" 487 488 [[package]] 489 name = "bitflags" 490 - version = "2.9.1" 491 source = "registry+https://github.com/rust-lang/crates.io-index" 492 - checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 493 494 [[package]] 495 name = "block-buffer" ··· 502 503 [[package]] 504 name = "bumpalo" 505 - version = "3.17.0" 506 source = "registry+https://github.com/rust-lang/crates.io-index" 507 - checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 508 509 [[package]] 510 name = "bytes" ··· 513 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 514 515 [[package]] 516 name = "cbor4ii" 517 version = "0.2.14" 518 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 523 524 [[package]] 525 name = "cc" 526 - version = "1.2.24" 527 source = "registry+https://github.com/rust-lang/crates.io-index" 528 - checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" 529 dependencies = [ 530 "jobserver", 531 "libc", 532 "shlex", ··· 534 535 [[package]] 536 name = "cfg-if" 537 - version = "1.0.0" 538 source = "registry+https://github.com/rust-lang/crates.io-index" 539 - checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 540 541 [[package]] 542 name = "cfg_aliases" ··· 546 547 [[package]] 548 name = "chrono" 549 - version = "0.4.41" 550 source = "registry+https://github.com/rust-lang/crates.io-index" 551 - checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 552 dependencies = [ 553 "num-traits", 554 "serde", ··· 570 571 [[package]] 572 name = "clap" 573 - version = "4.5.40" 574 source = "registry+https://github.com/rust-lang/crates.io-index" 575 - checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" 576 dependencies = [ 577 "clap_builder", 578 "clap_derive", ··· 580 581 [[package]] 582 name = "clap_builder" 583 - version = "4.5.40" 584 source = "registry+https://github.com/rust-lang/crates.io-index" 585 - checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" 586 dependencies = [ 587 "anstream", 588 "anstyle", ··· 592 593 [[package]] 594 name = "clap_derive" 595 - version = "4.5.40" 596 source = "registry+https://github.com/rust-lang/crates.io-index" 597 - checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" 598 dependencies = [ 599 "heck", 600 "proc-macro2", 601 "quote", 602 - "syn", 603 ] 604 605 [[package]] 606 name = "clap_lex" 607 - version = "0.7.5" 608 source = "registry+https://github.com/rust-lang/crates.io-index" 609 - checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 610 611 [[package]] 612 name = "colorchoice" ··· 615 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 616 617 [[package]] 618 name = "const-oid" 619 version = "0.9.6" 620 source = "registry+https://github.com/rust-lang/crates.io-index" 621 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 622 623 [[package]] 624 name = "core-foundation" 625 version = "0.9.4" 626 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 739 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 740 dependencies = [ 741 "data-encoding", 742 - "syn", 743 ] 744 745 [[package]] ··· 773 dependencies = [ 774 "proc-macro2", 775 "quote", 776 - "syn", 777 ] 778 779 [[package]] ··· 833 "heck", 834 "proc-macro2", 835 "quote", 836 - "syn", 837 ] 838 839 [[package]] ··· 853 ] 854 855 [[package]] 856 name = "fnv" 857 version = "1.0.7" 858 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 866 867 [[package]] 868 name = "form_urlencoded" 869 - version = "1.2.1" 870 source = "registry+https://github.com/rust-lang/crates.io-index" 871 - checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 872 dependencies = [ 873 "percent-encoding", 874 ] ··· 929 dependencies = [ 930 "proc-macro2", 931 "quote", 932 - "syn", 933 ] 934 935 [[package]] ··· 960 "pin-project-lite", 961 "pin-utils", 962 "slab", 963 - ] 964 - 965 - [[package]] 966 - name = "generator" 967 - version = "0.8.5" 968 - source = "registry+https://github.com/rust-lang/crates.io-index" 969 - checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" 970 - dependencies = [ 971 - "cc", 972 - "cfg-if", 973 - "libc", 974 - "log", 975 - "rustversion", 976 - "windows", 977 ] 978 979 [[package]] 980 name = "generic-array" 981 - version = "0.14.7" 982 source = "registry+https://github.com/rust-lang/crates.io-index" 983 - checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 984 dependencies = [ 985 "typenum", 986 "version_check", ··· 996 "cfg-if", 997 "js-sys", 998 "libc", 999 - "wasi 0.11.0+wasi-snapshot-preview1", 1000 "wasm-bindgen", 1001 ] 1002 1003 [[package]] 1004 name = "getrandom" 1005 - version = "0.3.3" 1006 source = "registry+https://github.com/rust-lang/crates.io-index" 1007 - checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 1008 dependencies = [ 1009 "cfg-if", 1010 "js-sys", 1011 "libc", 1012 "r-efi", 1013 - "wasi 0.14.2+wasi-0.2.4", 1014 "wasm-bindgen", 1015 ] 1016 - 1017 - [[package]] 1018 - name = "gimli" 1019 - version = "0.31.1" 1020 - source = "registry+https://github.com/rust-lang/crates.io-index" 1021 - checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 1022 1023 [[package]] 1024 name = "group" ··· 1033 1034 [[package]] 1035 name = "h2" 1036 - version = "0.4.10" 1037 source = "registry+https://github.com/rust-lang/crates.io-index" 1038 - checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" 1039 dependencies = [ 1040 "atomic-waker", 1041 "bytes", ··· 1052 1053 [[package]] 1054 name = "hashbrown" 1055 - version = "0.15.3" 1056 source = "registry+https://github.com/rust-lang/crates.io-index" 1057 - checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" 1058 dependencies = [ 1059 "allocator-api2", 1060 "equivalent", ··· 1062 ] 1063 1064 [[package]] 1065 name = "heck" 1066 version = "0.5.0" 1067 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1083 "idna", 1084 "ipnet", 1085 "once_cell", 1086 - "rand 0.9.1", 1087 "ring", 1088 - "thiserror 2.0.12", 1089 "tinyvec", 1090 "tokio", 1091 "tracing", ··· 1105 "moka", 1106 "once_cell", 1107 "parking_lot", 1108 - "rand 0.9.1", 1109 "resolv-conf", 1110 "smallvec", 1111 - "thiserror 2.0.12", 1112 "tokio", 1113 "tracing", 1114 ] ··· 1179 1180 [[package]] 1181 name = "hyper" 1182 - version = "1.6.0" 1183 source = "registry+https://github.com/rust-lang/crates.io-index" 1184 - checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 1185 dependencies = [ 1186 "bytes", 1187 "futures-channel", 1188 - "futures-util", 1189 "h2", 1190 "http", 1191 "http-body", ··· 1193 "httpdate", 1194 "itoa", 1195 "pin-project-lite", 1196 "smallvec", 1197 "tokio", 1198 "want", ··· 1200 1201 [[package]] 1202 name = "hyper-rustls" 1203 - version = "0.27.6" 1204 source = "registry+https://github.com/rust-lang/crates.io-index" 1205 - checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" 1206 dependencies = [ 1207 "http", 1208 "hyper", ··· 1217 1218 [[package]] 1219 name = "hyper-util" 1220 - version = "0.1.13" 1221 source = "registry+https://github.com/rust-lang/crates.io-index" 1222 - checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" 1223 dependencies = [ 1224 "base64", 1225 "bytes", ··· 1233 "libc", 1234 "percent-encoding", 1235 "pin-project-lite", 1236 - "socket2", 1237 "system-configuration", 1238 "tokio", 1239 "tower-service", ··· 1243 1244 [[package]] 1245 name = "icu_collections" 1246 - version = "2.0.0" 1247 source = "registry+https://github.com/rust-lang/crates.io-index" 1248 - checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" 1249 dependencies = [ 1250 "displaydoc", 1251 "potential_utf", ··· 1256 1257 [[package]] 1258 name = "icu_locale_core" 1259 - version = "2.0.0" 1260 source = "registry+https://github.com/rust-lang/crates.io-index" 1261 - checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" 1262 dependencies = [ 1263 "displaydoc", 1264 "litemap", ··· 1269 1270 [[package]] 1271 name = "icu_normalizer" 1272 - version = "2.0.0" 1273 source = "registry+https://github.com/rust-lang/crates.io-index" 1274 - checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" 1275 dependencies = [ 1276 - "displaydoc", 1277 "icu_collections", 1278 "icu_normalizer_data", 1279 "icu_properties", ··· 1284 1285 [[package]] 1286 name = "icu_normalizer_data" 1287 - version = "2.0.0" 1288 source = "registry+https://github.com/rust-lang/crates.io-index" 1289 - checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" 1290 1291 [[package]] 1292 name = "icu_properties" 1293 - version = "2.0.1" 1294 source = "registry+https://github.com/rust-lang/crates.io-index" 1295 - checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" 1296 dependencies = [ 1297 - "displaydoc", 1298 "icu_collections", 1299 "icu_locale_core", 1300 "icu_properties_data", 1301 "icu_provider", 1302 - "potential_utf", 1303 "zerotrie", 1304 "zerovec", 1305 ] 1306 1307 [[package]] 1308 name = "icu_properties_data" 1309 - version = "2.0.1" 1310 source = "registry+https://github.com/rust-lang/crates.io-index" 1311 - checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" 1312 1313 [[package]] 1314 name = "icu_provider" 1315 - version = "2.0.0" 1316 source = "registry+https://github.com/rust-lang/crates.io-index" 1317 - checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" 1318 dependencies = [ 1319 "displaydoc", 1320 "icu_locale_core", 1321 - "stable_deref_trait", 1322 - "tinystr", 1323 "writeable", 1324 "yoke", 1325 "zerofrom", ··· 1329 1330 [[package]] 1331 name = "idna" 1332 - version = "1.0.3" 1333 source = "registry+https://github.com/rust-lang/crates.io-index" 1334 - checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 1335 dependencies = [ 1336 "idna_adapter", 1337 "smallvec", ··· 1350 1351 [[package]] 1352 name = "indexmap" 1353 - version = "2.9.0" 1354 source = "registry+https://github.com/rust-lang/crates.io-index" 1355 - checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 1356 dependencies = [ 1357 "equivalent", 1358 - "hashbrown", 1359 ] 1360 1361 [[package]] ··· 1364 source = "registry+https://github.com/rust-lang/crates.io-index" 1365 checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1366 dependencies = [ 1367 - "socket2", 1368 "widestring", 1369 "windows-sys 0.48.0", 1370 "winreg", ··· 1389 1390 [[package]] 1391 name = "iri-string" 1392 - version = "0.7.8" 1393 source = "registry+https://github.com/rust-lang/crates.io-index" 1394 - checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1395 dependencies = [ 1396 "memchr", 1397 "serde", ··· 1399 1400 [[package]] 1401 name = "is_terminal_polyfill" 1402 - version = "1.70.1" 1403 source = "registry+https://github.com/rust-lang/crates.io-index" 1404 - checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 1405 1406 [[package]] 1407 name = "itoa" ··· 1411 1412 [[package]] 1413 name = "jobserver" 1414 - version = "0.1.33" 1415 source = "registry+https://github.com/rust-lang/crates.io-index" 1416 - checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" 1417 dependencies = [ 1418 - "getrandom 0.3.3", 1419 "libc", 1420 ] 1421 1422 [[package]] 1423 name = "js-sys" 1424 - version = "0.3.77" 1425 source = "registry+https://github.com/rust-lang/crates.io-index" 1426 - checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 1427 dependencies = [ 1428 "once_cell", 1429 "wasm-bindgen", ··· 1451 1452 [[package]] 1453 name = "libc" 1454 - version = "0.2.172" 1455 source = "registry+https://github.com/rust-lang/crates.io-index" 1456 - checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 1457 1458 [[package]] 1459 name = "litemap" 1460 - version = "0.8.0" 1461 source = "registry+https://github.com/rust-lang/crates.io-index" 1462 - checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" 1463 1464 [[package]] 1465 name = "lock_api" 1466 - version = "0.4.13" 1467 source = "registry+https://github.com/rust-lang/crates.io-index" 1468 - checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 1469 dependencies = [ 1470 - "autocfg", 1471 "scopeguard", 1472 ] 1473 1474 [[package]] 1475 name = "log" 1476 - version = "0.4.27" 1477 source = "registry+https://github.com/rust-lang/crates.io-index" 1478 - checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 1479 - 1480 - [[package]] 1481 - name = "loom" 1482 - version = "0.7.2" 1483 - source = "registry+https://github.com/rust-lang/crates.io-index" 1484 - checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 1485 - dependencies = [ 1486 - "cfg-if", 1487 - "generator", 1488 - "scoped-tls", 1489 - "tracing", 1490 - "tracing-subscriber", 1491 - ] 1492 1493 [[package]] 1494 name = "lru" ··· 1496 source = "registry+https://github.com/rust-lang/crates.io-index" 1497 checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1498 dependencies = [ 1499 - "hashbrown", 1500 ] 1501 1502 [[package]] ··· 1504 version = "0.1.2" 1505 source = "registry+https://github.com/rust-lang/crates.io-index" 1506 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1507 1508 [[package]] 1509 name = "matchers" 1510 - version = "0.1.0" 1511 source = "registry+https://github.com/rust-lang/crates.io-index" 1512 - checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 1513 dependencies = [ 1514 - "regex-automata 0.1.10", 1515 ] 1516 1517 [[package]] ··· 1522 1523 [[package]] 1524 name = "memchr" 1525 - version = "2.7.4" 1526 source = "registry+https://github.com/rust-lang/crates.io-index" 1527 - checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 1528 1529 [[package]] 1530 name = "mime" ··· 1543 ] 1544 1545 [[package]] 1546 - name = "miniz_oxide" 1547 - version = "0.8.8" 1548 - source = "registry+https://github.com/rust-lang/crates.io-index" 1549 - checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 1550 - dependencies = [ 1551 - "adler2", 1552 - ] 1553 - 1554 - [[package]] 1555 name = "mio" 1556 - version = "1.0.4" 1557 source = "registry+https://github.com/rust-lang/crates.io-index" 1558 - checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 1559 dependencies = [ 1560 "libc", 1561 - "wasi 0.11.0+wasi-snapshot-preview1", 1562 - "windows-sys 0.59.0", 1563 ] 1564 1565 [[package]] 1566 name = "moka" 1567 - version = "0.12.10" 1568 source = "registry+https://github.com/rust-lang/crates.io-index" 1569 - checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" 1570 dependencies = [ 1571 "crossbeam-channel", 1572 "crossbeam-epoch", 1573 "crossbeam-utils", 1574 - "loom", 1575 "parking_lot", 1576 "portable-atomic", 1577 "rustc_version", 1578 "smallvec", 1579 "tagptr", 1580 - "thiserror 1.0.69", 1581 "uuid", 1582 ] 1583 1584 [[package]] 1585 name = "multibase" 1586 - version = "0.9.1" 1587 source = "registry+https://github.com/rust-lang/crates.io-index" 1588 - checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404" 1589 dependencies = [ 1590 "base-x", 1591 "data-encoding", 1592 "data-encoding-macro", 1593 ] ··· 1605 1606 [[package]] 1607 name = "nu-ansi-term" 1608 - version = "0.46.0" 1609 source = "registry+https://github.com/rust-lang/crates.io-index" 1610 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1611 dependencies = [ 1612 - "overload", 1613 - "winapi", 1614 ] 1615 1616 [[package]] ··· 1620 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1621 dependencies = [ 1622 "autocfg", 1623 - ] 1624 - 1625 - [[package]] 1626 - name = "object" 1627 - version = "0.36.7" 1628 - source = "registry+https://github.com/rust-lang/crates.io-index" 1629 - checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 1630 - dependencies = [ 1631 - "memchr", 1632 ] 1633 1634 [[package]] ··· 1643 1644 [[package]] 1645 name = "once_cell_polyfill" 1646 - version = "1.70.1" 1647 source = "registry+https://github.com/rust-lang/crates.io-index" 1648 - checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 1649 1650 [[package]] 1651 name = "openssl-probe" 1652 version = "0.1.6" 1653 source = "registry+https://github.com/rust-lang/crates.io-index" 1654 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1655 - 1656 - [[package]] 1657 - name = "overload" 1658 - version = "0.1.1" 1659 - source = "registry+https://github.com/rust-lang/crates.io-index" 1660 - checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1661 1662 [[package]] 1663 name = "p256" ··· 1687 1688 [[package]] 1689 name = "parking_lot" 1690 - version = "0.12.4" 1691 source = "registry+https://github.com/rust-lang/crates.io-index" 1692 - checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 1693 dependencies = [ 1694 "lock_api", 1695 "parking_lot_core", ··· 1697 1698 [[package]] 1699 name = "parking_lot_core" 1700 - version = "0.9.11" 1701 source = "registry+https://github.com/rust-lang/crates.io-index" 1702 - checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 1703 dependencies = [ 1704 "cfg-if", 1705 "libc", 1706 "redox_syscall", 1707 "smallvec", 1708 - "windows-targets 0.52.6", 1709 ] 1710 1711 [[package]] ··· 1719 1720 [[package]] 1721 name = "percent-encoding" 1722 - version = "2.3.1" 1723 source = "registry+https://github.com/rust-lang/crates.io-index" 1724 - checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1725 1726 [[package]] 1727 name = "pin-project-lite" ··· 1753 1754 [[package]] 1755 name = "portable-atomic" 1756 - version = "1.11.0" 1757 source = "registry+https://github.com/rust-lang/crates.io-index" 1758 - checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 1759 1760 [[package]] 1761 name = "potential_utf" 1762 - version = "0.1.2" 1763 source = "registry+https://github.com/rust-lang/crates.io-index" 1764 - checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" 1765 dependencies = [ 1766 "zerovec", 1767 ] ··· 1787 1788 [[package]] 1789 name = "proc-macro2" 1790 - version = "1.0.95" 1791 source = "registry+https://github.com/rust-lang/crates.io-index" 1792 - checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 1793 dependencies = [ 1794 "unicode-ident", 1795 ] 1796 1797 [[package]] 1798 name = "quinn" 1799 - version = "0.11.8" 1800 source = "registry+https://github.com/rust-lang/crates.io-index" 1801 - checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" 1802 dependencies = [ 1803 "bytes", 1804 "cfg_aliases", ··· 1807 "quinn-udp", 1808 "rustc-hash", 1809 "rustls", 1810 - "socket2", 1811 - "thiserror 2.0.12", 1812 "tokio", 1813 "tracing", 1814 "web-time", ··· 1816 1817 [[package]] 1818 name = "quinn-proto" 1819 - version = "0.11.12" 1820 source = "registry+https://github.com/rust-lang/crates.io-index" 1821 - checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" 1822 dependencies = [ 1823 "bytes", 1824 - "getrandom 0.3.3", 1825 "lru-slab", 1826 - "rand 0.9.1", 1827 "ring", 1828 "rustc-hash", 1829 "rustls", 1830 "rustls-pki-types", 1831 "slab", 1832 - "thiserror 2.0.12", 1833 "tinyvec", 1834 "tracing", 1835 "web-time", ··· 1837 1838 [[package]] 1839 name = "quinn-udp" 1840 - version = "0.5.12" 1841 source = "registry+https://github.com/rust-lang/crates.io-index" 1842 - checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" 1843 dependencies = [ 1844 "cfg_aliases", 1845 "libc", 1846 "once_cell", 1847 - "socket2", 1848 "tracing", 1849 - "windows-sys 0.59.0", 1850 ] 1851 1852 [[package]] 1853 name = "quote" 1854 - version = "1.0.40" 1855 source = "registry+https://github.com/rust-lang/crates.io-index" 1856 - checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 1857 dependencies = [ 1858 "proc-macro2", 1859 ] 1860 1861 [[package]] 1862 name = "r-efi" 1863 - version = "5.2.0" 1864 source = "registry+https://github.com/rust-lang/crates.io-index" 1865 - checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1866 1867 [[package]] 1868 name = "rand" ··· 1877 1878 [[package]] 1879 name = "rand" 1880 - version = "0.9.1" 1881 source = "registry+https://github.com/rust-lang/crates.io-index" 1882 - checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 1883 dependencies = [ 1884 "rand_chacha 0.9.0", 1885 "rand_core 0.9.3", ··· 1920 source = "registry+https://github.com/rust-lang/crates.io-index" 1921 checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1922 dependencies = [ 1923 - "getrandom 0.3.3", 1924 ] 1925 1926 [[package]] 1927 name = "redox_syscall" 1928 - version = "0.5.12" 1929 source = "registry+https://github.com/rust-lang/crates.io-index" 1930 - checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" 1931 dependencies = [ 1932 "bitflags", 1933 ] 1934 1935 [[package]] 1936 name = "regex" 1937 - version = "1.11.1" 1938 source = "registry+https://github.com/rust-lang/crates.io-index" 1939 - checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1940 dependencies = [ 1941 "aho-corasick", 1942 "memchr", 1943 - "regex-automata 0.4.9", 1944 - "regex-syntax 0.8.5", 1945 - ] 1946 - 1947 - [[package]] 1948 - name = "regex-automata" 1949 - version = "0.1.10" 1950 - source = "registry+https://github.com/rust-lang/crates.io-index" 1951 - checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 1952 - dependencies = [ 1953 - "regex-syntax 0.6.29", 1954 ] 1955 1956 [[package]] 1957 name = "regex-automata" 1958 - version = "0.4.9" 1959 source = "registry+https://github.com/rust-lang/crates.io-index" 1960 - checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1961 dependencies = [ 1962 "aho-corasick", 1963 "memchr", 1964 - "regex-syntax 0.8.5", 1965 ] 1966 1967 [[package]] 1968 name = "regex-syntax" 1969 - version = "0.6.29" 1970 - source = "registry+https://github.com/rust-lang/crates.io-index" 1971 - checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 1972 - 1973 - [[package]] 1974 - name = "regex-syntax" 1975 - version = "0.8.5" 1976 source = "registry+https://github.com/rust-lang/crates.io-index" 1977 - checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1978 1979 [[package]] 1980 name = "reqwest" 1981 - version = "0.12.18" 1982 source = "registry+https://github.com/rust-lang/crates.io-index" 1983 - checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5" 1984 dependencies = [ 1985 "base64", 1986 "bytes", ··· 1994 "hyper", 1995 "hyper-rustls", 1996 "hyper-util", 1997 - "ipnet", 1998 "js-sys", 1999 "log", 2000 "mime", 2001 "mime_guess", 2002 - "once_cell", 2003 "percent-encoding", 2004 "pin-project-lite", 2005 "quinn", ··· 2050 2051 [[package]] 2052 name = "resolv-conf" 2053 - version = "0.7.4" 2054 source = "registry+https://github.com/rust-lang/crates.io-index" 2055 - checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" 2056 2057 [[package]] 2058 name = "rfc6979" ··· 2100 ] 2101 2102 [[package]] 2103 - name = "rustc-demangle" 2104 - version = "0.1.24" 2105 - source = "registry+https://github.com/rust-lang/crates.io-index" 2106 - checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 2107 - 2108 - [[package]] 2109 name = "rustc-hash" 2110 version = "2.1.1" 2111 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2122 2123 [[package]] 2124 name = "rustls" 2125 - version = "0.23.27" 2126 source = "registry+https://github.com/rust-lang/crates.io-index" 2127 - checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" 2128 dependencies = [ 2129 "once_cell", 2130 "ring", ··· 2136 2137 [[package]] 2138 name = "rustls-native-certs" 2139 - version = "0.8.1" 2140 source = "registry+https://github.com/rust-lang/crates.io-index" 2141 - checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" 2142 dependencies = [ 2143 "openssl-probe", 2144 "rustls-pki-types", ··· 2148 2149 [[package]] 2150 name = "rustls-pki-types" 2151 - version = "1.12.0" 2152 source = "registry+https://github.com/rust-lang/crates.io-index" 2153 - checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 2154 dependencies = [ 2155 "web-time", 2156 "zeroize", ··· 2158 2159 [[package]] 2160 name = "rustls-webpki" 2161 - version = "0.103.3" 2162 source = "registry+https://github.com/rust-lang/crates.io-index" 2163 - checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" 2164 dependencies = [ 2165 "ring", 2166 "rustls-pki-types", ··· 2169 2170 [[package]] 2171 name = "rustversion" 2172 - version = "1.0.21" 2173 source = "registry+https://github.com/rust-lang/crates.io-index" 2174 - checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 2175 2176 [[package]] 2177 name = "ryu" ··· 2181 2182 [[package]] 2183 name = "schannel" 2184 - version = "0.1.27" 2185 source = "registry+https://github.com/rust-lang/crates.io-index" 2186 - checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 2187 dependencies = [ 2188 - "windows-sys 0.59.0", 2189 ] 2190 - 2191 - [[package]] 2192 - name = "scoped-tls" 2193 - version = "1.0.1" 2194 - source = "registry+https://github.com/rust-lang/crates.io-index" 2195 - checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 2196 2197 [[package]] 2198 name = "scopeguard" ··· 2227 2228 [[package]] 2229 name = "security-framework" 2230 - version = "3.2.0" 2231 source = "registry+https://github.com/rust-lang/crates.io-index" 2232 - checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" 2233 dependencies = [ 2234 "bitflags", 2235 "core-foundation 0.10.1", ··· 2240 2241 [[package]] 2242 name = "security-framework-sys" 2243 - version = "2.14.0" 2244 source = "registry+https://github.com/rust-lang/crates.io-index" 2245 - checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 2246 dependencies = [ 2247 "core-foundation-sys", 2248 "libc", ··· 2250 2251 [[package]] 2252 name = "semver" 2253 - version = "1.0.26" 2254 source = "registry+https://github.com/rust-lang/crates.io-index" 2255 - checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" 2256 2257 [[package]] 2258 name = "serde" 2259 - version = "1.0.219" 2260 source = "registry+https://github.com/rust-lang/crates.io-index" 2261 - checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 2262 dependencies = [ 2263 "serde_derive", 2264 ] 2265 2266 [[package]] 2267 name = "serde_bytes" 2268 - version = "0.11.17" 2269 source = "registry+https://github.com/rust-lang/crates.io-index" 2270 - checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" 2271 dependencies = [ 2272 "serde", 2273 ] 2274 2275 [[package]] 2276 name = "serde_derive" 2277 - version = "1.0.219" 2278 source = "registry+https://github.com/rust-lang/crates.io-index" 2279 - checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 2280 dependencies = [ 2281 "proc-macro2", 2282 "quote", 2283 - "syn", 2284 ] 2285 2286 [[package]] 2287 name = "serde_ipld_dagcbor" 2288 - version = "0.6.3" 2289 source = "registry+https://github.com/rust-lang/crates.io-index" 2290 - checksum = "99600723cf53fb000a66175555098db7e75217c415bdd9a16a65d52a19dcc4fc" 2291 dependencies = [ 2292 "cbor4ii", 2293 "ipld-core", ··· 2297 2298 [[package]] 2299 name = "serde_json" 2300 - version = "1.0.140" 2301 source = "registry+https://github.com/rust-lang/crates.io-index" 2302 - checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 2303 dependencies = [ 2304 "indexmap", 2305 "itoa", 2306 "memchr", 2307 "ryu", 2308 "serde", 2309 ] 2310 2311 [[package]] 2312 name = "serde_path_to_error" 2313 - version = "0.1.17" 2314 source = "registry+https://github.com/rust-lang/crates.io-index" 2315 - checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" 2316 dependencies = [ 2317 "itoa", 2318 "serde", 2319 ] 2320 2321 [[package]] ··· 2368 2369 [[package]] 2370 name = "signal-hook-registry" 2371 - version = "1.4.5" 2372 source = "registry+https://github.com/rust-lang/crates.io-index" 2373 - checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 2374 dependencies = [ 2375 "libc", 2376 ] ··· 2393 2394 [[package]] 2395 name = "slab" 2396 - version = "0.4.9" 2397 source = "registry+https://github.com/rust-lang/crates.io-index" 2398 - checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 2399 - dependencies = [ 2400 - "autocfg", 2401 - ] 2402 2403 [[package]] 2404 name = "smallvec" 2405 - version = "1.15.0" 2406 source = "registry+https://github.com/rust-lang/crates.io-index" 2407 - checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 2408 2409 [[package]] 2410 name = "socket2" ··· 2417 ] 2418 2419 [[package]] 2420 name = "spki" 2421 version = "0.7.3" 2422 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2428 2429 [[package]] 2430 name = "stable_deref_trait" 2431 - version = "1.2.0" 2432 source = "registry+https://github.com/rust-lang/crates.io-index" 2433 - checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 2434 2435 [[package]] 2436 name = "strsim" ··· 2446 2447 [[package]] 2448 name = "syn" 2449 - version = "2.0.101" 2450 source = "registry+https://github.com/rust-lang/crates.io-index" 2451 - checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 2452 dependencies = [ 2453 "proc-macro2", 2454 "quote", ··· 2472 dependencies = [ 2473 "proc-macro2", 2474 "quote", 2475 - "syn", 2476 ] 2477 2478 [[package]] ··· 2513 2514 [[package]] 2515 name = "thiserror" 2516 - version = "2.0.12" 2517 source = "registry+https://github.com/rust-lang/crates.io-index" 2518 - checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 2519 dependencies = [ 2520 - "thiserror-impl 2.0.12", 2521 ] 2522 2523 [[package]] ··· 2528 dependencies = [ 2529 "proc-macro2", 2530 "quote", 2531 - "syn", 2532 ] 2533 2534 [[package]] 2535 name = "thiserror-impl" 2536 - version = "2.0.12" 2537 source = "registry+https://github.com/rust-lang/crates.io-index" 2538 - checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 2539 dependencies = [ 2540 "proc-macro2", 2541 "quote", 2542 - "syn", 2543 ] 2544 2545 [[package]] 2546 name = "thread_local" 2547 - version = "1.1.8" 2548 source = "registry+https://github.com/rust-lang/crates.io-index" 2549 - checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 2550 dependencies = [ 2551 "cfg-if", 2552 - "once_cell", 2553 ] 2554 2555 [[package]] 2556 name = "tinystr" 2557 - version = "0.8.1" 2558 source = "registry+https://github.com/rust-lang/crates.io-index" 2559 - checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" 2560 dependencies = [ 2561 "displaydoc", 2562 "zerovec", ··· 2564 2565 [[package]] 2566 name = "tinyvec" 2567 - version = "1.9.0" 2568 source = "registry+https://github.com/rust-lang/crates.io-index" 2569 - checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" 2570 dependencies = [ 2571 "tinyvec_macros", 2572 ] ··· 2579 2580 [[package]] 2581 name = "tokio" 2582 - version = "1.45.1" 2583 source = "registry+https://github.com/rust-lang/crates.io-index" 2584 - checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" 2585 dependencies = [ 2586 - "backtrace", 2587 "bytes", 2588 "libc", 2589 "mio", 2590 "parking_lot", 2591 "pin-project-lite", 2592 "signal-hook-registry", 2593 - "socket2", 2594 "tokio-macros", 2595 - "windows-sys 0.52.0", 2596 ] 2597 2598 [[package]] 2599 name = "tokio-macros" 2600 - version = "2.5.0" 2601 source = "registry+https://github.com/rust-lang/crates.io-index" 2602 - checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 2603 dependencies = [ 2604 "proc-macro2", 2605 "quote", 2606 - "syn", 2607 ] 2608 2609 [[package]] 2610 name = "tokio-rustls" 2611 - version = "0.26.2" 2612 source = "registry+https://github.com/rust-lang/crates.io-index" 2613 - checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 2614 dependencies = [ 2615 "rustls", 2616 "tokio", 2617 ] 2618 2619 [[package]] 2620 name = "tokio-util" 2621 - version = "0.7.15" 2622 source = "registry+https://github.com/rust-lang/crates.io-index" 2623 - checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" 2624 dependencies = [ 2625 "bytes", 2626 "futures-core", ··· 2641 "futures-sink", 2642 "http", 2643 "httparse", 2644 - "rand 0.9.1", 2645 "ring", 2646 "rustls-native-certs", 2647 "rustls-pki-types", ··· 2669 2670 [[package]] 2671 name = "tower-http" 2672 - version = "0.6.4" 2673 source = "registry+https://github.com/rust-lang/crates.io-index" 2674 - checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" 2675 dependencies = [ 2676 "bitflags", 2677 "bytes", ··· 2711 2712 [[package]] 2713 name = "tracing-attributes" 2714 - version = "0.1.28" 2715 source = "registry+https://github.com/rust-lang/crates.io-index" 2716 - checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" 2717 dependencies = [ 2718 "proc-macro2", 2719 "quote", 2720 - "syn", 2721 ] 2722 2723 [[package]] 2724 name = "tracing-core" 2725 - version = "0.1.33" 2726 source = "registry+https://github.com/rust-lang/crates.io-index" 2727 - checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 2728 dependencies = [ 2729 "once_cell", 2730 "valuable", ··· 2743 2744 [[package]] 2745 name = "tracing-subscriber" 2746 - version = "0.3.19" 2747 source = "registry+https://github.com/rust-lang/crates.io-index" 2748 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2749 dependencies = [ 2750 "matchers", 2751 "nu-ansi-term", 2752 "once_cell", 2753 - "regex", 2754 "sharded-slab", 2755 "smallvec", 2756 "thread_local", ··· 2767 2768 [[package]] 2769 name = "typenum" 2770 - version = "1.18.0" 2771 source = "registry+https://github.com/rust-lang/crates.io-index" 2772 - checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 2773 2774 [[package]] 2775 name = "ulid" ··· 2777 source = "registry+https://github.com/rust-lang/crates.io-index" 2778 checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" 2779 dependencies = [ 2780 - "rand 0.9.1", 2781 "web-time", 2782 ] 2783 ··· 2789 2790 [[package]] 2791 name = "unicode-ident" 2792 - version = "1.0.18" 2793 source = "registry+https://github.com/rust-lang/crates.io-index" 2794 - checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 2795 2796 [[package]] 2797 name = "unsigned-varint" ··· 2807 2808 [[package]] 2809 name = "url" 2810 - version = "2.5.4" 2811 source = "registry+https://github.com/rust-lang/crates.io-index" 2812 - checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 2813 dependencies = [ 2814 "form_urlencoded", 2815 "idna", 2816 "percent-encoding", 2817 ] 2818 2819 [[package]] ··· 2836 2837 [[package]] 2838 name = "uuid" 2839 - version = "1.17.0" 2840 source = "registry+https://github.com/rust-lang/crates.io-index" 2841 - checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" 2842 dependencies = [ 2843 - "getrandom 0.3.3", 2844 "js-sys", 2845 "wasm-bindgen", 2846 ] ··· 2868 2869 [[package]] 2870 name = "wasi" 2871 - version = "0.11.0+wasi-snapshot-preview1" 2872 source = "registry+https://github.com/rust-lang/crates.io-index" 2873 - checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 2874 2875 [[package]] 2876 - name = "wasi" 2877 - version = "0.14.2+wasi-0.2.4" 2878 source = "registry+https://github.com/rust-lang/crates.io-index" 2879 - checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 2880 dependencies = [ 2881 - "wit-bindgen-rt", 2882 ] 2883 2884 [[package]] 2885 name = "wasm-bindgen" 2886 - version = "0.2.100" 2887 source = "registry+https://github.com/rust-lang/crates.io-index" 2888 - checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 2889 dependencies = [ 2890 "cfg-if", 2891 "once_cell", 2892 "rustversion", 2893 "wasm-bindgen-macro", 2894 - ] 2895 - 2896 - [[package]] 2897 - name = "wasm-bindgen-backend" 2898 - version = "0.2.100" 2899 - source = "registry+https://github.com/rust-lang/crates.io-index" 2900 - checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 2901 - dependencies = [ 2902 - "bumpalo", 2903 - "log", 2904 - "proc-macro2", 2905 - "quote", 2906 - "syn", 2907 "wasm-bindgen-shared", 2908 ] 2909 2910 [[package]] 2911 name = "wasm-bindgen-futures" 2912 - version = "0.4.50" 2913 source = "registry+https://github.com/rust-lang/crates.io-index" 2914 - checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 2915 dependencies = [ 2916 "cfg-if", 2917 "js-sys", ··· 2922 2923 [[package]] 2924 name = "wasm-bindgen-macro" 2925 - version = "0.2.100" 2926 source = "registry+https://github.com/rust-lang/crates.io-index" 2927 - checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 2928 dependencies = [ 2929 "quote", 2930 "wasm-bindgen-macro-support", ··· 2932 2933 [[package]] 2934 name = "wasm-bindgen-macro-support" 2935 - version = "0.2.100" 2936 source = "registry+https://github.com/rust-lang/crates.io-index" 2937 - checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 2938 dependencies = [ 2939 "proc-macro2", 2940 "quote", 2941 - "syn", 2942 - "wasm-bindgen-backend", 2943 "wasm-bindgen-shared", 2944 ] 2945 2946 [[package]] 2947 name = "wasm-bindgen-shared" 2948 - version = "0.2.100" 2949 source = "registry+https://github.com/rust-lang/crates.io-index" 2950 - checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 2951 dependencies = [ 2952 "unicode-ident", 2953 ] 2954 2955 [[package]] 2956 name = "web-sys" 2957 - version = "0.3.77" 2958 source = "registry+https://github.com/rust-lang/crates.io-index" 2959 - checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2960 dependencies = [ 2961 "js-sys", 2962 "wasm-bindgen", ··· 2974 2975 [[package]] 2976 name = "webpki-roots" 2977 - version = "1.0.0" 2978 source = "registry+https://github.com/rust-lang/crates.io-index" 2979 - checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" 2980 dependencies = [ 2981 "rustls-pki-types", 2982 ] 2983 2984 [[package]] 2985 name = "widestring" 2986 - version = "1.2.0" 2987 - source = "registry+https://github.com/rust-lang/crates.io-index" 2988 - checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" 2989 - 2990 - [[package]] 2991 - name = "winapi" 2992 - version = "0.3.9" 2993 - source = "registry+https://github.com/rust-lang/crates.io-index" 2994 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2995 - dependencies = [ 2996 - "winapi-i686-pc-windows-gnu", 2997 - "winapi-x86_64-pc-windows-gnu", 2998 - ] 2999 - 3000 - [[package]] 3001 - name = "winapi-i686-pc-windows-gnu" 3002 - version = "0.4.0" 3003 - source = "registry+https://github.com/rust-lang/crates.io-index" 3004 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 3005 - 3006 - [[package]] 3007 - name = "winapi-x86_64-pc-windows-gnu" 3008 - version = "0.4.0" 3009 - source = "registry+https://github.com/rust-lang/crates.io-index" 3010 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 3011 - 3012 - [[package]] 3013 - name = "windows" 3014 - version = "0.61.1" 3015 - source = "registry+https://github.com/rust-lang/crates.io-index" 3016 - checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" 3017 - dependencies = [ 3018 - "windows-collections", 3019 - "windows-core", 3020 - "windows-future", 3021 - "windows-link", 3022 - "windows-numerics", 3023 - ] 3024 - 3025 - [[package]] 3026 - name = "windows-collections" 3027 - version = "0.2.0" 3028 - source = "registry+https://github.com/rust-lang/crates.io-index" 3029 - checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 3030 - dependencies = [ 3031 - "windows-core", 3032 - ] 3033 - 3034 - [[package]] 3035 - name = "windows-core" 3036 - version = "0.61.2" 3037 source = "registry+https://github.com/rust-lang/crates.io-index" 3038 - checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 3039 - dependencies = [ 3040 - "windows-implement", 3041 - "windows-interface", 3042 - "windows-link", 3043 - "windows-result", 3044 - "windows-strings 0.4.2", 3045 - ] 3046 3047 [[package]] 3048 - name = "windows-future" 3049 - version = "0.2.1" 3050 source = "registry+https://github.com/rust-lang/crates.io-index" 3051 - checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 3052 - dependencies = [ 3053 - "windows-core", 3054 - "windows-link", 3055 - "windows-threading", 3056 - ] 3057 - 3058 - [[package]] 3059 - name = "windows-implement" 3060 - version = "0.60.0" 3061 - source = "registry+https://github.com/rust-lang/crates.io-index" 3062 - checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 3063 - dependencies = [ 3064 - "proc-macro2", 3065 - "quote", 3066 - "syn", 3067 - ] 3068 - 3069 - [[package]] 3070 - name = "windows-interface" 3071 - version = "0.59.1" 3072 - source = "registry+https://github.com/rust-lang/crates.io-index" 3073 - checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 3074 - dependencies = [ 3075 - "proc-macro2", 3076 - "quote", 3077 - "syn", 3078 - ] 3079 3080 [[package]] 3081 name = "windows-link" 3082 - version = "0.1.1" 3083 - source = "registry+https://github.com/rust-lang/crates.io-index" 3084 - checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 3085 - 3086 - [[package]] 3087 - name = "windows-numerics" 3088 - version = "0.2.0" 3089 source = "registry+https://github.com/rust-lang/crates.io-index" 3090 - checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 3091 - dependencies = [ 3092 - "windows-core", 3093 - "windows-link", 3094 - ] 3095 3096 [[package]] 3097 name = "windows-registry" 3098 - version = "0.4.0" 3099 source = "registry+https://github.com/rust-lang/crates.io-index" 3100 - checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" 3101 dependencies = [ 3102 "windows-result", 3103 - "windows-strings 0.3.1", 3104 - "windows-targets 0.53.0", 3105 ] 3106 3107 [[package]] ··· 3110 source = "registry+https://github.com/rust-lang/crates.io-index" 3111 checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 3112 dependencies = [ 3113 - "windows-link", 3114 - ] 3115 - 3116 - [[package]] 3117 - name = "windows-strings" 3118 - version = "0.3.1" 3119 - source = "registry+https://github.com/rust-lang/crates.io-index" 3120 - checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" 3121 - dependencies = [ 3122 - "windows-link", 3123 ] 3124 3125 [[package]] ··· 3128 source = "registry+https://github.com/rust-lang/crates.io-index" 3129 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 3130 dependencies = [ 3131 - "windows-link", 3132 ] 3133 3134 [[package]] ··· 3159 ] 3160 3161 [[package]] 3162 name = "windows-targets" 3163 version = "0.48.5" 3164 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3191 3192 [[package]] 3193 name = "windows-targets" 3194 - version = "0.53.0" 3195 source = "registry+https://github.com/rust-lang/crates.io-index" 3196 - checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" 3197 dependencies = [ 3198 - "windows_aarch64_gnullvm 0.53.0", 3199 - "windows_aarch64_msvc 0.53.0", 3200 - "windows_i686_gnu 0.53.0", 3201 - "windows_i686_gnullvm 0.53.0", 3202 - "windows_i686_msvc 0.53.0", 3203 - "windows_x86_64_gnu 0.53.0", 3204 - "windows_x86_64_gnullvm 0.53.0", 3205 - "windows_x86_64_msvc 0.53.0", 3206 - ] 3207 - 3208 - [[package]] 3209 - name = "windows-threading" 3210 - version = "0.1.0" 3211 - source = "registry+https://github.com/rust-lang/crates.io-index" 3212 - checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" 3213 - dependencies = [ 3214 - "windows-link", 3215 ] 3216 3217 [[package]] ··· 3228 3229 [[package]] 3230 name = "windows_aarch64_gnullvm" 3231 - version = "0.53.0" 3232 source = "registry+https://github.com/rust-lang/crates.io-index" 3233 - checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 3234 3235 [[package]] 3236 name = "windows_aarch64_msvc" ··· 3246 3247 [[package]] 3248 name = "windows_aarch64_msvc" 3249 - version = "0.53.0" 3250 source = "registry+https://github.com/rust-lang/crates.io-index" 3251 - checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 3252 3253 [[package]] 3254 name = "windows_i686_gnu" ··· 3264 3265 [[package]] 3266 name = "windows_i686_gnu" 3267 - version = "0.53.0" 3268 source = "registry+https://github.com/rust-lang/crates.io-index" 3269 - checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 3270 3271 [[package]] 3272 name = "windows_i686_gnullvm" ··· 3276 3277 [[package]] 3278 name = "windows_i686_gnullvm" 3279 - version = "0.53.0" 3280 source = "registry+https://github.com/rust-lang/crates.io-index" 3281 - checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 3282 3283 [[package]] 3284 name = "windows_i686_msvc" ··· 3294 3295 [[package]] 3296 name = "windows_i686_msvc" 3297 - version = "0.53.0" 3298 source = "registry+https://github.com/rust-lang/crates.io-index" 3299 - checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 3300 3301 [[package]] 3302 name = "windows_x86_64_gnu" ··· 3312 3313 [[package]] 3314 name = "windows_x86_64_gnu" 3315 - version = "0.53.0" 3316 source = "registry+https://github.com/rust-lang/crates.io-index" 3317 - checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 3318 3319 [[package]] 3320 name = "windows_x86_64_gnullvm" ··· 3330 3331 [[package]] 3332 name = "windows_x86_64_gnullvm" 3333 - version = "0.53.0" 3334 source = "registry+https://github.com/rust-lang/crates.io-index" 3335 - checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 3336 3337 [[package]] 3338 name = "windows_x86_64_msvc" ··· 3348 3349 [[package]] 3350 name = "windows_x86_64_msvc" 3351 - version = "0.53.0" 3352 source = "registry+https://github.com/rust-lang/crates.io-index" 3353 - checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 3354 3355 [[package]] 3356 name = "winreg" ··· 3363 ] 3364 3365 [[package]] 3366 - name = "wit-bindgen-rt" 3367 - version = "0.39.0" 3368 source = "registry+https://github.com/rust-lang/crates.io-index" 3369 - checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 3370 - dependencies = [ 3371 - "bitflags", 3372 - ] 3373 3374 [[package]] 3375 name = "writeable" 3376 - version = "0.6.1" 3377 source = "registry+https://github.com/rust-lang/crates.io-index" 3378 - checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" 3379 3380 [[package]] 3381 name = "yoke" 3382 - version = "0.8.0" 3383 source = "registry+https://github.com/rust-lang/crates.io-index" 3384 - checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" 3385 dependencies = [ 3386 - "serde", 3387 "stable_deref_trait", 3388 "yoke-derive", 3389 "zerofrom", ··· 3391 3392 [[package]] 3393 name = "yoke-derive" 3394 - version = "0.8.0" 3395 source = "registry+https://github.com/rust-lang/crates.io-index" 3396 - checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" 3397 dependencies = [ 3398 "proc-macro2", 3399 "quote", 3400 - "syn", 3401 "synstructure", 3402 ] 3403 3404 [[package]] 3405 name = "zerocopy" 3406 - version = "0.8.25" 3407 source = "registry+https://github.com/rust-lang/crates.io-index" 3408 - checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" 3409 dependencies = [ 3410 "zerocopy-derive", 3411 ] 3412 3413 [[package]] 3414 name = "zerocopy-derive" 3415 - version = "0.8.25" 3416 source = "registry+https://github.com/rust-lang/crates.io-index" 3417 - checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" 3418 dependencies = [ 3419 "proc-macro2", 3420 "quote", 3421 - "syn", 3422 ] 3423 3424 [[package]] ··· 3438 dependencies = [ 3439 "proc-macro2", 3440 "quote", 3441 - "syn", 3442 "synstructure", 3443 ] 3444 3445 [[package]] 3446 name = "zeroize" 3447 - version = "1.8.1" 3448 source = "registry+https://github.com/rust-lang/crates.io-index" 3449 - checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 3450 dependencies = [ 3451 "zeroize_derive", 3452 ] ··· 3459 dependencies = [ 3460 "proc-macro2", 3461 "quote", 3462 - "syn", 3463 ] 3464 3465 [[package]] 3466 name = "zerotrie" 3467 - version = "0.2.2" 3468 source = "registry+https://github.com/rust-lang/crates.io-index" 3469 - checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" 3470 dependencies = [ 3471 "displaydoc", 3472 "yoke", ··· 3475 3476 [[package]] 3477 name = "zerovec" 3478 - version = "0.11.2" 3479 source = "registry+https://github.com/rust-lang/crates.io-index" 3480 - checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" 3481 dependencies = [ 3482 "yoke", 3483 "zerofrom", ··· 3486 3487 [[package]] 3488 name = "zerovec-derive" 3489 - version = "0.11.1" 3490 source = "registry+https://github.com/rust-lang/crates.io-index" 3491 - checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" 3492 dependencies = [ 3493 "proc-macro2", 3494 "quote", 3495 - "syn", 3496 ] 3497 3498 [[package]] ··· 3515 3516 [[package]] 3517 name = "zstd-sys" 3518 - version = "2.0.15+zstd.1.5.7" 3519 source = "registry+https://github.com/rust-lang/crates.io-index" 3520 - checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" 3521 dependencies = [ 3522 "cc", 3523 "pkg-config",
··· 3 version = 4 4 5 [[package]] 6 name = "aho-corasick" 7 + version = "1.1.4" 8 source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 dependencies = [ 11 "memchr", 12 ] ··· 19 20 [[package]] 21 name = "anstream" 22 + version = "0.6.21" 23 source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 25 dependencies = [ 26 "anstyle", 27 "anstyle-parse", ··· 34 35 [[package]] 36 name = "anstyle" 37 + version = "1.0.13" 38 source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 40 41 [[package]] 42 name = "anstyle-parse" ··· 49 50 [[package]] 51 name = "anstyle-query" 52 + version = "1.1.4" 53 source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 55 dependencies = [ 56 + "windows-sys 0.60.2", 57 ] 58 59 [[package]] 60 name = "anstyle-wincon" 61 + version = "3.0.10" 62 source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 64 dependencies = [ 65 "anstyle", 66 "once_cell_polyfill", 67 + "windows-sys 0.60.2", 68 ] 69 70 [[package]] 71 name = "anyhow" 72 + version = "1.0.100" 73 source = "registry+https://github.com/rust-lang/crates.io-index" 74 + checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 75 76 [[package]] 77 name = "async-trait" 78 + version = "0.1.89" 79 source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 81 dependencies = [ 82 "proc-macro2", 83 "quote", 84 + "syn 2.0.109", 85 ] 86 87 [[package]] ··· 112 "serde_ipld_dagcbor", 113 "serde_json", 114 "sha2", 115 + "thiserror 2.0.17", 116 "tokio", 117 ] 118 ··· 134 "secrecy", 135 "serde", 136 "serde_json", 137 + "thiserror 2.0.17", 138 "tokio", 139 "tracing", 140 "urlencoding", 141 ] 142 143 [[package]] 144 + name = "atproto-extras" 145 + version = "0.13.0" 146 + dependencies = [ 147 + "anyhow", 148 + "async-trait", 149 + "atproto-identity", 150 + "atproto-record", 151 + "clap", 152 + "regex", 153 + "reqwest", 154 + "serde_json", 155 + "tokio", 156 + ] 157 + 158 + [[package]] 159 name = "atproto-identity" 160 version = "0.13.0" 161 dependencies = [ ··· 175 "serde", 176 "serde_ipld_dagcbor", 177 "serde_json", 178 + "thiserror 2.0.17", 179 "tokio", 180 "tracing", 181 "url", ··· 195 "http", 196 "serde", 197 "serde_json", 198 + "thiserror 2.0.17", 199 "tokio", 200 "tokio-util", 201 "tokio-websockets", ··· 218 "reqwest", 219 "serde", 220 "serde_json", 221 + "thiserror 2.0.17", 222 "tokio", 223 "tracing", 224 "zeroize", ··· 249 "serde_ipld_dagcbor", 250 "serde_json", 251 "sha2", 252 + "thiserror 2.0.17", 253 "tokio", 254 "tracing", 255 "ulid", ··· 267 "reqwest", 268 "serde", 269 "serde_json", 270 + "thiserror 2.0.17", 271 "zeroize", 272 ] 273 ··· 294 "secrecy", 295 "serde", 296 "serde_json", 297 + "thiserror 2.0.17", 298 "tokio", 299 "tracing", 300 "zeroize", ··· 317 "serde_ipld_dagcbor", 318 "serde_json", 319 "sha2", 320 + "thiserror 2.0.17", 321 + "tokio", 322 + ] 323 + 324 + [[package]] 325 + name = "atproto-tap" 326 + version = "0.13.0" 327 + dependencies = [ 328 + "atproto-client", 329 + "atproto-identity", 330 + "base64", 331 + "clap", 332 + "compact_str", 333 + "futures", 334 + "http", 335 + "itoa", 336 + "reqwest", 337 + "serde", 338 + "serde_json", 339 + "thiserror 2.0.17", 340 "tokio", 341 + "tokio-stream", 342 + "tokio-websockets", 343 + "tracing", 344 + "tracing-subscriber", 345 ] 346 347 [[package]] ··· 365 "reqwest-middleware", 366 "serde", 367 "serde_json", 368 + "thiserror 2.0.17", 369 "tokio", 370 "tracing", 371 ] ··· 392 "reqwest-middleware", 393 "serde", 394 "serde_json", 395 + "thiserror 2.0.17", 396 "tokio", 397 "tracing", 398 ] 399 400 [[package]] 401 name = "autocfg" 402 + version = "1.5.0" 403 source = "registry+https://github.com/rust-lang/crates.io-index" 404 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 405 406 [[package]] 407 name = "axum" 408 + version = "0.8.6" 409 source = "registry+https://github.com/rust-lang/crates.io-index" 410 + checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" 411 dependencies = [ 412 "axum-core", 413 "axum-macros", ··· 425 "mime", 426 "percent-encoding", 427 "pin-project-lite", 428 + "serde_core", 429 "serde_json", 430 "serde_path_to_error", 431 "serde_urlencoded", ··· 439 440 [[package]] 441 name = "axum-core" 442 + version = "0.5.5" 443 source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" 445 dependencies = [ 446 "bytes", 447 "futures-core", ··· 450 "http-body-util", 451 "mime", 452 "pin-project-lite", 453 "sync_wrapper", 454 "tower-layer", 455 "tower-service", ··· 464 dependencies = [ 465 "proc-macro2", 466 "quote", 467 + "syn 2.0.109", 468 ] 469 470 [[package]] ··· 480 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 481 482 [[package]] 483 + name = "base256emoji" 484 + version = "1.0.2" 485 + source = "registry+https://github.com/rust-lang/crates.io-index" 486 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 487 + dependencies = [ 488 + "const-str", 489 + "match-lookup", 490 + ] 491 + 492 + [[package]] 493 name = "base64" 494 version = "0.22.1" 495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 497 498 [[package]] 499 name = "base64ct" 500 + version = "1.8.0" 501 source = "registry+https://github.com/rust-lang/crates.io-index" 502 + checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 503 504 [[package]] 505 name = "bitflags" 506 + version = "2.10.0" 507 source = "registry+https://github.com/rust-lang/crates.io-index" 508 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 509 510 [[package]] 511 name = "block-buffer" ··· 518 519 [[package]] 520 name = "bumpalo" 521 + version = "3.19.0" 522 source = "registry+https://github.com/rust-lang/crates.io-index" 523 + checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 524 525 [[package]] 526 name = "bytes" ··· 529 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 530 531 [[package]] 532 + name = "castaway" 533 + version = "0.2.4" 534 + source = "registry+https://github.com/rust-lang/crates.io-index" 535 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 536 + dependencies = [ 537 + "rustversion", 538 + ] 539 + 540 + [[package]] 541 name = "cbor4ii" 542 version = "0.2.14" 543 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 548 549 [[package]] 550 name = "cc" 551 + version = "1.2.44" 552 source = "registry+https://github.com/rust-lang/crates.io-index" 553 + checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" 554 dependencies = [ 555 + "find-msvc-tools", 556 "jobserver", 557 "libc", 558 "shlex", ··· 560 561 [[package]] 562 name = "cfg-if" 563 + version = "1.0.4" 564 source = "registry+https://github.com/rust-lang/crates.io-index" 565 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 566 567 [[package]] 568 name = "cfg_aliases" ··· 572 573 [[package]] 574 name = "chrono" 575 + version = "0.4.42" 576 source = "registry+https://github.com/rust-lang/crates.io-index" 577 + checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 578 dependencies = [ 579 "num-traits", 580 "serde", ··· 596 597 [[package]] 598 name = "clap" 599 + version = "4.5.51" 600 source = "registry+https://github.com/rust-lang/crates.io-index" 601 + checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" 602 dependencies = [ 603 "clap_builder", 604 "clap_derive", ··· 606 607 [[package]] 608 name = "clap_builder" 609 + version = "4.5.51" 610 source = "registry+https://github.com/rust-lang/crates.io-index" 611 + checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" 612 dependencies = [ 613 "anstream", 614 "anstyle", ··· 618 619 [[package]] 620 name = "clap_derive" 621 + version = "4.5.49" 622 source = "registry+https://github.com/rust-lang/crates.io-index" 623 + checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 624 dependencies = [ 625 "heck", 626 "proc-macro2", 627 "quote", 628 + "syn 2.0.109", 629 ] 630 631 [[package]] 632 name = "clap_lex" 633 + version = "0.7.6" 634 source = "registry+https://github.com/rust-lang/crates.io-index" 635 + checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 636 637 [[package]] 638 name = "colorchoice" ··· 641 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 642 643 [[package]] 644 + name = "compact_str" 645 + version = "0.8.1" 646 + source = "registry+https://github.com/rust-lang/crates.io-index" 647 + checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 648 + dependencies = [ 649 + "castaway", 650 + "cfg-if", 651 + "itoa", 652 + "rustversion", 653 + "ryu", 654 + "serde", 655 + "static_assertions", 656 + ] 657 + 658 + [[package]] 659 name = "const-oid" 660 version = "0.9.6" 661 source = "registry+https://github.com/rust-lang/crates.io-index" 662 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 663 664 [[package]] 665 + name = "const-str" 666 + version = "0.4.3" 667 + source = "registry+https://github.com/rust-lang/crates.io-index" 668 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 669 + 670 + [[package]] 671 name = "core-foundation" 672 version = "0.9.4" 673 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 786 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 787 dependencies = [ 788 "data-encoding", 789 + "syn 2.0.109", 790 ] 791 792 [[package]] ··· 820 dependencies = [ 821 "proc-macro2", 822 "quote", 823 + "syn 2.0.109", 824 ] 825 826 [[package]] ··· 880 "heck", 881 "proc-macro2", 882 "quote", 883 + "syn 2.0.109", 884 ] 885 886 [[package]] ··· 900 ] 901 902 [[package]] 903 + name = "find-msvc-tools" 904 + version = "0.1.4" 905 + source = "registry+https://github.com/rust-lang/crates.io-index" 906 + checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" 907 + 908 + [[package]] 909 name = "fnv" 910 version = "1.0.7" 911 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 919 920 [[package]] 921 name = "form_urlencoded" 922 + version = "1.2.2" 923 source = "registry+https://github.com/rust-lang/crates.io-index" 924 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 925 dependencies = [ 926 "percent-encoding", 927 ] ··· 982 dependencies = [ 983 "proc-macro2", 984 "quote", 985 + "syn 2.0.109", 986 ] 987 988 [[package]] ··· 1013 "pin-project-lite", 1014 "pin-utils", 1015 "slab", 1016 ] 1017 1018 [[package]] 1019 name = "generic-array" 1020 + version = "0.14.9" 1021 source = "registry+https://github.com/rust-lang/crates.io-index" 1022 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 1023 dependencies = [ 1024 "typenum", 1025 "version_check", ··· 1035 "cfg-if", 1036 "js-sys", 1037 "libc", 1038 + "wasi", 1039 "wasm-bindgen", 1040 ] 1041 1042 [[package]] 1043 name = "getrandom" 1044 + version = "0.3.4" 1045 source = "registry+https://github.com/rust-lang/crates.io-index" 1046 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 1047 dependencies = [ 1048 "cfg-if", 1049 "js-sys", 1050 "libc", 1051 "r-efi", 1052 + "wasip2", 1053 "wasm-bindgen", 1054 ] 1055 1056 [[package]] 1057 name = "group" ··· 1066 1067 [[package]] 1068 name = "h2" 1069 + version = "0.4.12" 1070 source = "registry+https://github.com/rust-lang/crates.io-index" 1071 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1072 dependencies = [ 1073 "atomic-waker", 1074 "bytes", ··· 1085 1086 [[package]] 1087 name = "hashbrown" 1088 + version = "0.15.5" 1089 source = "registry+https://github.com/rust-lang/crates.io-index" 1090 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 1091 dependencies = [ 1092 "allocator-api2", 1093 "equivalent", ··· 1095 ] 1096 1097 [[package]] 1098 + name = "hashbrown" 1099 + version = "0.16.0" 1100 + source = "registry+https://github.com/rust-lang/crates.io-index" 1101 + checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1102 + 1103 + [[package]] 1104 name = "heck" 1105 version = "0.5.0" 1106 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1122 "idna", 1123 "ipnet", 1124 "once_cell", 1125 + "rand 0.9.2", 1126 "ring", 1127 + "thiserror 2.0.17", 1128 "tinyvec", 1129 "tokio", 1130 "tracing", ··· 1144 "moka", 1145 "once_cell", 1146 "parking_lot", 1147 + "rand 0.9.2", 1148 "resolv-conf", 1149 "smallvec", 1150 + "thiserror 2.0.17", 1151 "tokio", 1152 "tracing", 1153 ] ··· 1218 1219 [[package]] 1220 name = "hyper" 1221 + version = "1.7.0" 1222 source = "registry+https://github.com/rust-lang/crates.io-index" 1223 + checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1224 dependencies = [ 1225 + "atomic-waker", 1226 "bytes", 1227 "futures-channel", 1228 + "futures-core", 1229 "h2", 1230 "http", 1231 "http-body", ··· 1233 "httpdate", 1234 "itoa", 1235 "pin-project-lite", 1236 + "pin-utils", 1237 "smallvec", 1238 "tokio", 1239 "want", ··· 1241 1242 [[package]] 1243 name = "hyper-rustls" 1244 + version = "0.27.7" 1245 source = "registry+https://github.com/rust-lang/crates.io-index" 1246 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1247 dependencies = [ 1248 "http", 1249 "hyper", ··· 1258 1259 [[package]] 1260 name = "hyper-util" 1261 + version = "0.1.17" 1262 source = "registry+https://github.com/rust-lang/crates.io-index" 1263 + checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1264 dependencies = [ 1265 "base64", 1266 "bytes", ··· 1274 "libc", 1275 "percent-encoding", 1276 "pin-project-lite", 1277 + "socket2 0.6.1", 1278 "system-configuration", 1279 "tokio", 1280 "tower-service", ··· 1284 1285 [[package]] 1286 name = "icu_collections" 1287 + version = "2.1.1" 1288 source = "registry+https://github.com/rust-lang/crates.io-index" 1289 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 1290 dependencies = [ 1291 "displaydoc", 1292 "potential_utf", ··· 1297 1298 [[package]] 1299 name = "icu_locale_core" 1300 + version = "2.1.1" 1301 source = "registry+https://github.com/rust-lang/crates.io-index" 1302 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 1303 dependencies = [ 1304 "displaydoc", 1305 "litemap", ··· 1310 1311 [[package]] 1312 name = "icu_normalizer" 1313 + version = "2.1.1" 1314 source = "registry+https://github.com/rust-lang/crates.io-index" 1315 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 1316 dependencies = [ 1317 "icu_collections", 1318 "icu_normalizer_data", 1319 "icu_properties", ··· 1324 1325 [[package]] 1326 name = "icu_normalizer_data" 1327 + version = "2.1.1" 1328 source = "registry+https://github.com/rust-lang/crates.io-index" 1329 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 1330 1331 [[package]] 1332 name = "icu_properties" 1333 + version = "2.1.1" 1334 source = "registry+https://github.com/rust-lang/crates.io-index" 1335 + checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" 1336 dependencies = [ 1337 "icu_collections", 1338 "icu_locale_core", 1339 "icu_properties_data", 1340 "icu_provider", 1341 "zerotrie", 1342 "zerovec", 1343 ] 1344 1345 [[package]] 1346 name = "icu_properties_data" 1347 + version = "2.1.1" 1348 source = "registry+https://github.com/rust-lang/crates.io-index" 1349 + checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" 1350 1351 [[package]] 1352 name = "icu_provider" 1353 + version = "2.1.1" 1354 source = "registry+https://github.com/rust-lang/crates.io-index" 1355 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 1356 dependencies = [ 1357 "displaydoc", 1358 "icu_locale_core", 1359 "writeable", 1360 "yoke", 1361 "zerofrom", ··· 1365 1366 [[package]] 1367 name = "idna" 1368 + version = "1.1.0" 1369 source = "registry+https://github.com/rust-lang/crates.io-index" 1370 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1371 dependencies = [ 1372 "idna_adapter", 1373 "smallvec", ··· 1386 1387 [[package]] 1388 name = "indexmap" 1389 + version = "2.12.0" 1390 source = "registry+https://github.com/rust-lang/crates.io-index" 1391 + checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" 1392 dependencies = [ 1393 "equivalent", 1394 + "hashbrown 0.16.0", 1395 ] 1396 1397 [[package]] ··· 1400 source = "registry+https://github.com/rust-lang/crates.io-index" 1401 checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1402 dependencies = [ 1403 + "socket2 0.5.10", 1404 "widestring", 1405 "windows-sys 0.48.0", 1406 "winreg", ··· 1425 1426 [[package]] 1427 name = "iri-string" 1428 + version = "0.7.9" 1429 source = "registry+https://github.com/rust-lang/crates.io-index" 1430 + checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" 1431 dependencies = [ 1432 "memchr", 1433 "serde", ··· 1435 1436 [[package]] 1437 name = "is_terminal_polyfill" 1438 + version = "1.70.2" 1439 source = "registry+https://github.com/rust-lang/crates.io-index" 1440 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 1441 1442 [[package]] 1443 name = "itoa" ··· 1447 1448 [[package]] 1449 name = "jobserver" 1450 + version = "0.1.34" 1451 source = "registry+https://github.com/rust-lang/crates.io-index" 1452 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1453 dependencies = [ 1454 + "getrandom 0.3.4", 1455 "libc", 1456 ] 1457 1458 [[package]] 1459 name = "js-sys" 1460 + version = "0.3.82" 1461 source = "registry+https://github.com/rust-lang/crates.io-index" 1462 + checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" 1463 dependencies = [ 1464 "once_cell", 1465 "wasm-bindgen", ··· 1487 1488 [[package]] 1489 name = "libc" 1490 + version = "0.2.177" 1491 source = "registry+https://github.com/rust-lang/crates.io-index" 1492 + checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 1493 1494 [[package]] 1495 name = "litemap" 1496 + version = "0.8.1" 1497 source = "registry+https://github.com/rust-lang/crates.io-index" 1498 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 1499 1500 [[package]] 1501 name = "lock_api" 1502 + version = "0.4.14" 1503 source = "registry+https://github.com/rust-lang/crates.io-index" 1504 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1505 dependencies = [ 1506 "scopeguard", 1507 ] 1508 1509 [[package]] 1510 name = "log" 1511 + version = "0.4.28" 1512 source = "registry+https://github.com/rust-lang/crates.io-index" 1513 + checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 1514 1515 [[package]] 1516 name = "lru" ··· 1518 source = "registry+https://github.com/rust-lang/crates.io-index" 1519 checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1520 dependencies = [ 1521 + "hashbrown 0.15.5", 1522 ] 1523 1524 [[package]] ··· 1526 version = "0.1.2" 1527 source = "registry+https://github.com/rust-lang/crates.io-index" 1528 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1529 + 1530 + [[package]] 1531 + name = "match-lookup" 1532 + version = "0.1.1" 1533 + source = "registry+https://github.com/rust-lang/crates.io-index" 1534 + checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" 1535 + dependencies = [ 1536 + "proc-macro2", 1537 + "quote", 1538 + "syn 1.0.109", 1539 + ] 1540 1541 [[package]] 1542 name = "matchers" 1543 + version = "0.2.0" 1544 source = "registry+https://github.com/rust-lang/crates.io-index" 1545 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1546 dependencies = [ 1547 + "regex-automata", 1548 ] 1549 1550 [[package]] ··· 1555 1556 [[package]] 1557 name = "memchr" 1558 + version = "2.7.6" 1559 source = "registry+https://github.com/rust-lang/crates.io-index" 1560 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1561 1562 [[package]] 1563 name = "mime" ··· 1576 ] 1577 1578 [[package]] 1579 name = "mio" 1580 + version = "1.1.0" 1581 source = "registry+https://github.com/rust-lang/crates.io-index" 1582 + checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 1583 dependencies = [ 1584 "libc", 1585 + "wasi", 1586 + "windows-sys 0.61.2", 1587 ] 1588 1589 [[package]] 1590 name = "moka" 1591 + version = "0.12.11" 1592 source = "registry+https://github.com/rust-lang/crates.io-index" 1593 + checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" 1594 dependencies = [ 1595 "crossbeam-channel", 1596 "crossbeam-epoch", 1597 "crossbeam-utils", 1598 + "equivalent", 1599 "parking_lot", 1600 "portable-atomic", 1601 "rustc_version", 1602 "smallvec", 1603 "tagptr", 1604 "uuid", 1605 ] 1606 1607 [[package]] 1608 name = "multibase" 1609 + version = "0.9.2" 1610 source = "registry+https://github.com/rust-lang/crates.io-index" 1611 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 1612 dependencies = [ 1613 "base-x", 1614 + "base256emoji", 1615 "data-encoding", 1616 "data-encoding-macro", 1617 ] ··· 1629 1630 [[package]] 1631 name = "nu-ansi-term" 1632 + version = "0.50.3" 1633 source = "registry+https://github.com/rust-lang/crates.io-index" 1634 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1635 dependencies = [ 1636 + "windows-sys 0.61.2", 1637 ] 1638 1639 [[package]] ··· 1643 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1644 dependencies = [ 1645 "autocfg", 1646 ] 1647 1648 [[package]] ··· 1657 1658 [[package]] 1659 name = "once_cell_polyfill" 1660 + version = "1.70.2" 1661 source = "registry+https://github.com/rust-lang/crates.io-index" 1662 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1663 1664 [[package]] 1665 name = "openssl-probe" 1666 version = "0.1.6" 1667 source = "registry+https://github.com/rust-lang/crates.io-index" 1668 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1669 1670 [[package]] 1671 name = "p256" ··· 1695 1696 [[package]] 1697 name = "parking_lot" 1698 + version = "0.12.5" 1699 source = "registry+https://github.com/rust-lang/crates.io-index" 1700 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1701 dependencies = [ 1702 "lock_api", 1703 "parking_lot_core", ··· 1705 1706 [[package]] 1707 name = "parking_lot_core" 1708 + version = "0.9.12" 1709 source = "registry+https://github.com/rust-lang/crates.io-index" 1710 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1711 dependencies = [ 1712 "cfg-if", 1713 "libc", 1714 "redox_syscall", 1715 "smallvec", 1716 + "windows-link 0.2.1", 1717 ] 1718 1719 [[package]] ··· 1727 1728 [[package]] 1729 name = "percent-encoding" 1730 + version = "2.3.2" 1731 source = "registry+https://github.com/rust-lang/crates.io-index" 1732 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1733 1734 [[package]] 1735 name = "pin-project-lite" ··· 1761 1762 [[package]] 1763 name = "portable-atomic" 1764 + version = "1.11.1" 1765 source = "registry+https://github.com/rust-lang/crates.io-index" 1766 + checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 1767 1768 [[package]] 1769 name = "potential_utf" 1770 + version = "0.1.4" 1771 source = "registry+https://github.com/rust-lang/crates.io-index" 1772 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 1773 dependencies = [ 1774 "zerovec", 1775 ] ··· 1795 1796 [[package]] 1797 name = "proc-macro2" 1798 + version = "1.0.103" 1799 source = "registry+https://github.com/rust-lang/crates.io-index" 1800 + checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 1801 dependencies = [ 1802 "unicode-ident", 1803 ] 1804 1805 [[package]] 1806 name = "quinn" 1807 + version = "0.11.9" 1808 source = "registry+https://github.com/rust-lang/crates.io-index" 1809 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 1810 dependencies = [ 1811 "bytes", 1812 "cfg_aliases", ··· 1815 "quinn-udp", 1816 "rustc-hash", 1817 "rustls", 1818 + "socket2 0.6.1", 1819 + "thiserror 2.0.17", 1820 "tokio", 1821 "tracing", 1822 "web-time", ··· 1824 1825 [[package]] 1826 name = "quinn-proto" 1827 + version = "0.11.13" 1828 source = "registry+https://github.com/rust-lang/crates.io-index" 1829 + checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 1830 dependencies = [ 1831 "bytes", 1832 + "getrandom 0.3.4", 1833 "lru-slab", 1834 + "rand 0.9.2", 1835 "ring", 1836 "rustc-hash", 1837 "rustls", 1838 "rustls-pki-types", 1839 "slab", 1840 + "thiserror 2.0.17", 1841 "tinyvec", 1842 "tracing", 1843 "web-time", ··· 1845 1846 [[package]] 1847 name = "quinn-udp" 1848 + version = "0.5.14" 1849 source = "registry+https://github.com/rust-lang/crates.io-index" 1850 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 1851 dependencies = [ 1852 "cfg_aliases", 1853 "libc", 1854 "once_cell", 1855 + "socket2 0.6.1", 1856 "tracing", 1857 + "windows-sys 0.60.2", 1858 ] 1859 1860 [[package]] 1861 name = "quote" 1862 + version = "1.0.41" 1863 source = "registry+https://github.com/rust-lang/crates.io-index" 1864 + checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 1865 dependencies = [ 1866 "proc-macro2", 1867 ] 1868 1869 [[package]] 1870 name = "r-efi" 1871 + version = "5.3.0" 1872 source = "registry+https://github.com/rust-lang/crates.io-index" 1873 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1874 1875 [[package]] 1876 name = "rand" ··· 1885 1886 [[package]] 1887 name = "rand" 1888 + version = "0.9.2" 1889 source = "registry+https://github.com/rust-lang/crates.io-index" 1890 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1891 dependencies = [ 1892 "rand_chacha 0.9.0", 1893 "rand_core 0.9.3", ··· 1928 source = "registry+https://github.com/rust-lang/crates.io-index" 1929 checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1930 dependencies = [ 1931 + "getrandom 0.3.4", 1932 ] 1933 1934 [[package]] 1935 name = "redox_syscall" 1936 + version = "0.5.18" 1937 source = "registry+https://github.com/rust-lang/crates.io-index" 1938 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1939 dependencies = [ 1940 "bitflags", 1941 ] 1942 1943 [[package]] 1944 name = "regex" 1945 + version = "1.12.2" 1946 source = "registry+https://github.com/rust-lang/crates.io-index" 1947 + checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 1948 dependencies = [ 1949 "aho-corasick", 1950 "memchr", 1951 + "regex-automata", 1952 + "regex-syntax", 1953 ] 1954 1955 [[package]] 1956 name = "regex-automata" 1957 + version = "0.4.13" 1958 source = "registry+https://github.com/rust-lang/crates.io-index" 1959 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 1960 dependencies = [ 1961 "aho-corasick", 1962 "memchr", 1963 + "regex-syntax", 1964 ] 1965 1966 [[package]] 1967 name = "regex-syntax" 1968 + version = "0.8.8" 1969 source = "registry+https://github.com/rust-lang/crates.io-index" 1970 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 1971 1972 [[package]] 1973 name = "reqwest" 1974 + version = "0.12.24" 1975 source = "registry+https://github.com/rust-lang/crates.io-index" 1976 + checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" 1977 dependencies = [ 1978 "base64", 1979 "bytes", ··· 1987 "hyper", 1988 "hyper-rustls", 1989 "hyper-util", 1990 "js-sys", 1991 "log", 1992 "mime", 1993 "mime_guess", 1994 "percent-encoding", 1995 "pin-project-lite", 1996 "quinn", ··· 2041 2042 [[package]] 2043 name = "resolv-conf" 2044 + version = "0.7.5" 2045 source = "registry+https://github.com/rust-lang/crates.io-index" 2046 + checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2047 2048 [[package]] 2049 name = "rfc6979" ··· 2091 ] 2092 2093 [[package]] 2094 name = "rustc-hash" 2095 version = "2.1.1" 2096 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2107 2108 [[package]] 2109 name = "rustls" 2110 + version = "0.23.35" 2111 source = "registry+https://github.com/rust-lang/crates.io-index" 2112 + checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" 2113 dependencies = [ 2114 "once_cell", 2115 "ring", ··· 2121 2122 [[package]] 2123 name = "rustls-native-certs" 2124 + version = "0.8.2" 2125 source = "registry+https://github.com/rust-lang/crates.io-index" 2126 + checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" 2127 dependencies = [ 2128 "openssl-probe", 2129 "rustls-pki-types", ··· 2133 2134 [[package]] 2135 name = "rustls-pki-types" 2136 + version = "1.13.0" 2137 source = "registry+https://github.com/rust-lang/crates.io-index" 2138 + checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" 2139 dependencies = [ 2140 "web-time", 2141 "zeroize", ··· 2143 2144 [[package]] 2145 name = "rustls-webpki" 2146 + version = "0.103.8" 2147 source = "registry+https://github.com/rust-lang/crates.io-index" 2148 + checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" 2149 dependencies = [ 2150 "ring", 2151 "rustls-pki-types", ··· 2154 2155 [[package]] 2156 name = "rustversion" 2157 + version = "1.0.22" 2158 source = "registry+https://github.com/rust-lang/crates.io-index" 2159 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2160 2161 [[package]] 2162 name = "ryu" ··· 2166 2167 [[package]] 2168 name = "schannel" 2169 + version = "0.1.28" 2170 source = "registry+https://github.com/rust-lang/crates.io-index" 2171 + checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 2172 dependencies = [ 2173 + "windows-sys 0.61.2", 2174 ] 2175 2176 [[package]] 2177 name = "scopeguard" ··· 2206 2207 [[package]] 2208 name = "security-framework" 2209 + version = "3.5.1" 2210 source = "registry+https://github.com/rust-lang/crates.io-index" 2211 + checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" 2212 dependencies = [ 2213 "bitflags", 2214 "core-foundation 0.10.1", ··· 2219 2220 [[package]] 2221 name = "security-framework-sys" 2222 + version = "2.15.0" 2223 source = "registry+https://github.com/rust-lang/crates.io-index" 2224 + checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 2225 dependencies = [ 2226 "core-foundation-sys", 2227 "libc", ··· 2229 2230 [[package]] 2231 name = "semver" 2232 + version = "1.0.27" 2233 source = "registry+https://github.com/rust-lang/crates.io-index" 2234 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 2235 2236 [[package]] 2237 name = "serde" 2238 + version = "1.0.228" 2239 source = "registry+https://github.com/rust-lang/crates.io-index" 2240 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2241 dependencies = [ 2242 + "serde_core", 2243 "serde_derive", 2244 ] 2245 2246 [[package]] 2247 name = "serde_bytes" 2248 + version = "0.11.19" 2249 source = "registry+https://github.com/rust-lang/crates.io-index" 2250 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 2251 dependencies = [ 2252 "serde", 2253 + "serde_core", 2254 + ] 2255 + 2256 + [[package]] 2257 + name = "serde_core" 2258 + version = "1.0.228" 2259 + source = "registry+https://github.com/rust-lang/crates.io-index" 2260 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2261 + dependencies = [ 2262 + "serde_derive", 2263 ] 2264 2265 [[package]] 2266 name = "serde_derive" 2267 + version = "1.0.228" 2268 source = "registry+https://github.com/rust-lang/crates.io-index" 2269 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 2270 dependencies = [ 2271 "proc-macro2", 2272 "quote", 2273 + "syn 2.0.109", 2274 ] 2275 2276 [[package]] 2277 name = "serde_ipld_dagcbor" 2278 + version = "0.6.4" 2279 source = "registry+https://github.com/rust-lang/crates.io-index" 2280 + checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" 2281 dependencies = [ 2282 "cbor4ii", 2283 "ipld-core", ··· 2287 2288 [[package]] 2289 name = "serde_json" 2290 + version = "1.0.145" 2291 source = "registry+https://github.com/rust-lang/crates.io-index" 2292 + checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 2293 dependencies = [ 2294 "indexmap", 2295 "itoa", 2296 "memchr", 2297 "ryu", 2298 "serde", 2299 + "serde_core", 2300 ] 2301 2302 [[package]] 2303 name = "serde_path_to_error" 2304 + version = "0.1.20" 2305 source = "registry+https://github.com/rust-lang/crates.io-index" 2306 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 2307 dependencies = [ 2308 "itoa", 2309 "serde", 2310 + "serde_core", 2311 ] 2312 2313 [[package]] ··· 2360 2361 [[package]] 2362 name = "signal-hook-registry" 2363 + version = "1.4.6" 2364 source = "registry+https://github.com/rust-lang/crates.io-index" 2365 + checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 2366 dependencies = [ 2367 "libc", 2368 ] ··· 2385 2386 [[package]] 2387 name = "slab" 2388 + version = "0.4.11" 2389 source = "registry+https://github.com/rust-lang/crates.io-index" 2390 + checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 2391 2392 [[package]] 2393 name = "smallvec" 2394 + version = "1.15.1" 2395 source = "registry+https://github.com/rust-lang/crates.io-index" 2396 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 2397 2398 [[package]] 2399 name = "socket2" ··· 2406 ] 2407 2408 [[package]] 2409 + name = "socket2" 2410 + version = "0.6.1" 2411 + source = "registry+https://github.com/rust-lang/crates.io-index" 2412 + checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" 2413 + dependencies = [ 2414 + "libc", 2415 + "windows-sys 0.60.2", 2416 + ] 2417 + 2418 + [[package]] 2419 name = "spki" 2420 version = "0.7.3" 2421 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2427 2428 [[package]] 2429 name = "stable_deref_trait" 2430 + version = "1.2.1" 2431 source = "registry+https://github.com/rust-lang/crates.io-index" 2432 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 2433 + 2434 + [[package]] 2435 + name = "static_assertions" 2436 + version = "1.1.0" 2437 + source = "registry+https://github.com/rust-lang/crates.io-index" 2438 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 2439 2440 [[package]] 2441 name = "strsim" ··· 2451 2452 [[package]] 2453 name = "syn" 2454 + version = "1.0.109" 2455 + source = "registry+https://github.com/rust-lang/crates.io-index" 2456 + checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 2457 + dependencies = [ 2458 + "proc-macro2", 2459 + "quote", 2460 + "unicode-ident", 2461 + ] 2462 + 2463 + [[package]] 2464 + name = "syn" 2465 + version = "2.0.109" 2466 source = "registry+https://github.com/rust-lang/crates.io-index" 2467 + checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" 2468 dependencies = [ 2469 "proc-macro2", 2470 "quote", ··· 2488 dependencies = [ 2489 "proc-macro2", 2490 "quote", 2491 + "syn 2.0.109", 2492 ] 2493 2494 [[package]] ··· 2529 2530 [[package]] 2531 name = "thiserror" 2532 + version = "2.0.17" 2533 source = "registry+https://github.com/rust-lang/crates.io-index" 2534 + checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 2535 dependencies = [ 2536 + "thiserror-impl 2.0.17", 2537 ] 2538 2539 [[package]] ··· 2544 dependencies = [ 2545 "proc-macro2", 2546 "quote", 2547 + "syn 2.0.109", 2548 ] 2549 2550 [[package]] 2551 name = "thiserror-impl" 2552 + version = "2.0.17" 2553 source = "registry+https://github.com/rust-lang/crates.io-index" 2554 + checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 2555 dependencies = [ 2556 "proc-macro2", 2557 "quote", 2558 + "syn 2.0.109", 2559 ] 2560 2561 [[package]] 2562 name = "thread_local" 2563 + version = "1.1.9" 2564 source = "registry+https://github.com/rust-lang/crates.io-index" 2565 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 2566 dependencies = [ 2567 "cfg-if", 2568 ] 2569 2570 [[package]] 2571 name = "tinystr" 2572 + version = "0.8.2" 2573 source = "registry+https://github.com/rust-lang/crates.io-index" 2574 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 2575 dependencies = [ 2576 "displaydoc", 2577 "zerovec", ··· 2579 2580 [[package]] 2581 name = "tinyvec" 2582 + version = "1.10.0" 2583 source = "registry+https://github.com/rust-lang/crates.io-index" 2584 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 2585 dependencies = [ 2586 "tinyvec_macros", 2587 ] ··· 2594 2595 [[package]] 2596 name = "tokio" 2597 + version = "1.48.0" 2598 source = "registry+https://github.com/rust-lang/crates.io-index" 2599 + checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" 2600 dependencies = [ 2601 "bytes", 2602 "libc", 2603 "mio", 2604 "parking_lot", 2605 "pin-project-lite", 2606 "signal-hook-registry", 2607 + "socket2 0.6.1", 2608 "tokio-macros", 2609 + "windows-sys 0.61.2", 2610 ] 2611 2612 [[package]] 2613 name = "tokio-macros" 2614 + version = "2.6.0" 2615 source = "registry+https://github.com/rust-lang/crates.io-index" 2616 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 2617 dependencies = [ 2618 "proc-macro2", 2619 "quote", 2620 + "syn 2.0.109", 2621 ] 2622 2623 [[package]] 2624 name = "tokio-rustls" 2625 + version = "0.26.4" 2626 source = "registry+https://github.com/rust-lang/crates.io-index" 2627 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 2628 dependencies = [ 2629 "rustls", 2630 "tokio", 2631 ] 2632 2633 [[package]] 2634 + name = "tokio-stream" 2635 + version = "0.1.17" 2636 + source = "registry+https://github.com/rust-lang/crates.io-index" 2637 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 2638 + dependencies = [ 2639 + "futures-core", 2640 + "pin-project-lite", 2641 + "tokio", 2642 + ] 2643 + 2644 + [[package]] 2645 name = "tokio-util" 2646 + version = "0.7.17" 2647 source = "registry+https://github.com/rust-lang/crates.io-index" 2648 + checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" 2649 dependencies = [ 2650 "bytes", 2651 "futures-core", ··· 2666 "futures-sink", 2667 "http", 2668 "httparse", 2669 + "rand 0.9.2", 2670 "ring", 2671 "rustls-native-certs", 2672 "rustls-pki-types", ··· 2694 2695 [[package]] 2696 name = "tower-http" 2697 + version = "0.6.6" 2698 source = "registry+https://github.com/rust-lang/crates.io-index" 2699 + checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 2700 dependencies = [ 2701 "bitflags", 2702 "bytes", ··· 2736 2737 [[package]] 2738 name = "tracing-attributes" 2739 + version = "0.1.30" 2740 source = "registry+https://github.com/rust-lang/crates.io-index" 2741 + checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 2742 dependencies = [ 2743 "proc-macro2", 2744 "quote", 2745 + "syn 2.0.109", 2746 ] 2747 2748 [[package]] 2749 name = "tracing-core" 2750 + version = "0.1.34" 2751 source = "registry+https://github.com/rust-lang/crates.io-index" 2752 + checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 2753 dependencies = [ 2754 "once_cell", 2755 "valuable", ··· 2768 2769 [[package]] 2770 name = "tracing-subscriber" 2771 + version = "0.3.20" 2772 source = "registry+https://github.com/rust-lang/crates.io-index" 2773 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 2774 dependencies = [ 2775 "matchers", 2776 "nu-ansi-term", 2777 "once_cell", 2778 + "regex-automata", 2779 "sharded-slab", 2780 "smallvec", 2781 "thread_local", ··· 2792 2793 [[package]] 2794 name = "typenum" 2795 + version = "1.19.0" 2796 source = "registry+https://github.com/rust-lang/crates.io-index" 2797 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2798 2799 [[package]] 2800 name = "ulid" ··· 2802 source = "registry+https://github.com/rust-lang/crates.io-index" 2803 checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" 2804 dependencies = [ 2805 + "rand 0.9.2", 2806 "web-time", 2807 ] 2808 ··· 2814 2815 [[package]] 2816 name = "unicode-ident" 2817 + version = "1.0.22" 2818 source = "registry+https://github.com/rust-lang/crates.io-index" 2819 + checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 2820 2821 [[package]] 2822 name = "unsigned-varint" ··· 2832 2833 [[package]] 2834 name = "url" 2835 + version = "2.5.7" 2836 source = "registry+https://github.com/rust-lang/crates.io-index" 2837 + checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" 2838 dependencies = [ 2839 "form_urlencoded", 2840 "idna", 2841 "percent-encoding", 2842 + "serde", 2843 ] 2844 2845 [[package]] ··· 2862 2863 [[package]] 2864 name = "uuid" 2865 + version = "1.18.1" 2866 source = "registry+https://github.com/rust-lang/crates.io-index" 2867 + checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" 2868 dependencies = [ 2869 + "getrandom 0.3.4", 2870 "js-sys", 2871 "wasm-bindgen", 2872 ] ··· 2894 2895 [[package]] 2896 name = "wasi" 2897 + version = "0.11.1+wasi-snapshot-preview1" 2898 source = "registry+https://github.com/rust-lang/crates.io-index" 2899 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 2900 2901 [[package]] 2902 + name = "wasip2" 2903 + version = "1.0.1+wasi-0.2.4" 2904 source = "registry+https://github.com/rust-lang/crates.io-index" 2905 + checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 2906 dependencies = [ 2907 + "wit-bindgen", 2908 ] 2909 2910 [[package]] 2911 name = "wasm-bindgen" 2912 + version = "0.2.105" 2913 source = "registry+https://github.com/rust-lang/crates.io-index" 2914 + checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" 2915 dependencies = [ 2916 "cfg-if", 2917 "once_cell", 2918 "rustversion", 2919 "wasm-bindgen-macro", 2920 "wasm-bindgen-shared", 2921 ] 2922 2923 [[package]] 2924 name = "wasm-bindgen-futures" 2925 + version = "0.4.55" 2926 source = "registry+https://github.com/rust-lang/crates.io-index" 2927 + checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" 2928 dependencies = [ 2929 "cfg-if", 2930 "js-sys", ··· 2935 2936 [[package]] 2937 name = "wasm-bindgen-macro" 2938 + version = "0.2.105" 2939 source = "registry+https://github.com/rust-lang/crates.io-index" 2940 + checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" 2941 dependencies = [ 2942 "quote", 2943 "wasm-bindgen-macro-support", ··· 2945 2946 [[package]] 2947 name = "wasm-bindgen-macro-support" 2948 + version = "0.2.105" 2949 source = "registry+https://github.com/rust-lang/crates.io-index" 2950 + checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" 2951 dependencies = [ 2952 + "bumpalo", 2953 "proc-macro2", 2954 "quote", 2955 + "syn 2.0.109", 2956 "wasm-bindgen-shared", 2957 ] 2958 2959 [[package]] 2960 name = "wasm-bindgen-shared" 2961 + version = "0.2.105" 2962 source = "registry+https://github.com/rust-lang/crates.io-index" 2963 + checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" 2964 dependencies = [ 2965 "unicode-ident", 2966 ] 2967 2968 [[package]] 2969 name = "web-sys" 2970 + version = "0.3.82" 2971 source = "registry+https://github.com/rust-lang/crates.io-index" 2972 + checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" 2973 dependencies = [ 2974 "js-sys", 2975 "wasm-bindgen", ··· 2987 2988 [[package]] 2989 name = "webpki-roots" 2990 + version = "1.0.4" 2991 source = "registry+https://github.com/rust-lang/crates.io-index" 2992 + checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" 2993 dependencies = [ 2994 "rustls-pki-types", 2995 ] 2996 2997 [[package]] 2998 name = "widestring" 2999 + version = "1.2.1" 3000 source = "registry+https://github.com/rust-lang/crates.io-index" 3001 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 3002 3003 [[package]] 3004 + name = "windows-link" 3005 + version = "0.1.3" 3006 source = "registry+https://github.com/rust-lang/crates.io-index" 3007 + checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 3008 3009 [[package]] 3010 name = "windows-link" 3011 + version = "0.2.1" 3012 source = "registry+https://github.com/rust-lang/crates.io-index" 3013 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 3014 3015 [[package]] 3016 name = "windows-registry" 3017 + version = "0.5.3" 3018 source = "registry+https://github.com/rust-lang/crates.io-index" 3019 + checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 3020 dependencies = [ 3021 + "windows-link 0.1.3", 3022 "windows-result", 3023 + "windows-strings", 3024 ] 3025 3026 [[package]] ··· 3029 source = "registry+https://github.com/rust-lang/crates.io-index" 3030 checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 3031 dependencies = [ 3032 + "windows-link 0.1.3", 3033 ] 3034 3035 [[package]] ··· 3038 source = "registry+https://github.com/rust-lang/crates.io-index" 3039 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 3040 dependencies = [ 3041 + "windows-link 0.1.3", 3042 ] 3043 3044 [[package]] ··· 3069 ] 3070 3071 [[package]] 3072 + name = "windows-sys" 3073 + version = "0.60.2" 3074 + source = "registry+https://github.com/rust-lang/crates.io-index" 3075 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 3076 + dependencies = [ 3077 + "windows-targets 0.53.5", 3078 + ] 3079 + 3080 + [[package]] 3081 + name = "windows-sys" 3082 + version = "0.61.2" 3083 + source = "registry+https://github.com/rust-lang/crates.io-index" 3084 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 3085 + dependencies = [ 3086 + "windows-link 0.2.1", 3087 + ] 3088 + 3089 + [[package]] 3090 name = "windows-targets" 3091 version = "0.48.5" 3092 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3119 3120 [[package]] 3121 name = "windows-targets" 3122 + version = "0.53.5" 3123 source = "registry+https://github.com/rust-lang/crates.io-index" 3124 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 3125 dependencies = [ 3126 + "windows-link 0.2.1", 3127 + "windows_aarch64_gnullvm 0.53.1", 3128 + "windows_aarch64_msvc 0.53.1", 3129 + "windows_i686_gnu 0.53.1", 3130 + "windows_i686_gnullvm 0.53.1", 3131 + "windows_i686_msvc 0.53.1", 3132 + "windows_x86_64_gnu 0.53.1", 3133 + "windows_x86_64_gnullvm 0.53.1", 3134 + "windows_x86_64_msvc 0.53.1", 3135 ] 3136 3137 [[package]] ··· 3148 3149 [[package]] 3150 name = "windows_aarch64_gnullvm" 3151 + version = "0.53.1" 3152 source = "registry+https://github.com/rust-lang/crates.io-index" 3153 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 3154 3155 [[package]] 3156 name = "windows_aarch64_msvc" ··· 3166 3167 [[package]] 3168 name = "windows_aarch64_msvc" 3169 + version = "0.53.1" 3170 source = "registry+https://github.com/rust-lang/crates.io-index" 3171 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 3172 3173 [[package]] 3174 name = "windows_i686_gnu" ··· 3184 3185 [[package]] 3186 name = "windows_i686_gnu" 3187 + version = "0.53.1" 3188 source = "registry+https://github.com/rust-lang/crates.io-index" 3189 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 3190 3191 [[package]] 3192 name = "windows_i686_gnullvm" ··· 3196 3197 [[package]] 3198 name = "windows_i686_gnullvm" 3199 + version = "0.53.1" 3200 source = "registry+https://github.com/rust-lang/crates.io-index" 3201 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 3202 3203 [[package]] 3204 name = "windows_i686_msvc" ··· 3214 3215 [[package]] 3216 name = "windows_i686_msvc" 3217 + version = "0.53.1" 3218 source = "registry+https://github.com/rust-lang/crates.io-index" 3219 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 3220 3221 [[package]] 3222 name = "windows_x86_64_gnu" ··· 3232 3233 [[package]] 3234 name = "windows_x86_64_gnu" 3235 + version = "0.53.1" 3236 source = "registry+https://github.com/rust-lang/crates.io-index" 3237 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 3238 3239 [[package]] 3240 name = "windows_x86_64_gnullvm" ··· 3250 3251 [[package]] 3252 name = "windows_x86_64_gnullvm" 3253 + version = "0.53.1" 3254 source = "registry+https://github.com/rust-lang/crates.io-index" 3255 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 3256 3257 [[package]] 3258 name = "windows_x86_64_msvc" ··· 3268 3269 [[package]] 3270 name = "windows_x86_64_msvc" 3271 + version = "0.53.1" 3272 source = "registry+https://github.com/rust-lang/crates.io-index" 3273 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 3274 3275 [[package]] 3276 name = "winreg" ··· 3283 ] 3284 3285 [[package]] 3286 + name = "wit-bindgen" 3287 + version = "0.46.0" 3288 source = "registry+https://github.com/rust-lang/crates.io-index" 3289 + checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 3290 3291 [[package]] 3292 name = "writeable" 3293 + version = "0.6.2" 3294 source = "registry+https://github.com/rust-lang/crates.io-index" 3295 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 3296 3297 [[package]] 3298 name = "yoke" 3299 + version = "0.8.1" 3300 source = "registry+https://github.com/rust-lang/crates.io-index" 3301 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 3302 dependencies = [ 3303 "stable_deref_trait", 3304 "yoke-derive", 3305 "zerofrom", ··· 3307 3308 [[package]] 3309 name = "yoke-derive" 3310 + version = "0.8.1" 3311 source = "registry+https://github.com/rust-lang/crates.io-index" 3312 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 3313 dependencies = [ 3314 "proc-macro2", 3315 "quote", 3316 + "syn 2.0.109", 3317 "synstructure", 3318 ] 3319 3320 [[package]] 3321 name = "zerocopy" 3322 + version = "0.8.27" 3323 source = "registry+https://github.com/rust-lang/crates.io-index" 3324 + checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 3325 dependencies = [ 3326 "zerocopy-derive", 3327 ] 3328 3329 [[package]] 3330 name = "zerocopy-derive" 3331 + version = "0.8.27" 3332 source = "registry+https://github.com/rust-lang/crates.io-index" 3333 + checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 3334 dependencies = [ 3335 "proc-macro2", 3336 "quote", 3337 + "syn 2.0.109", 3338 ] 3339 3340 [[package]] ··· 3354 dependencies = [ 3355 "proc-macro2", 3356 "quote", 3357 + "syn 2.0.109", 3358 "synstructure", 3359 ] 3360 3361 [[package]] 3362 name = "zeroize" 3363 + version = "1.8.2" 3364 source = "registry+https://github.com/rust-lang/crates.io-index" 3365 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 3366 dependencies = [ 3367 "zeroize_derive", 3368 ] ··· 3375 dependencies = [ 3376 "proc-macro2", 3377 "quote", 3378 + "syn 2.0.109", 3379 ] 3380 3381 [[package]] 3382 name = "zerotrie" 3383 + version = "0.2.3" 3384 source = "registry+https://github.com/rust-lang/crates.io-index" 3385 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 3386 dependencies = [ 3387 "displaydoc", 3388 "yoke", ··· 3391 3392 [[package]] 3393 name = "zerovec" 3394 + version = "0.11.5" 3395 source = "registry+https://github.com/rust-lang/crates.io-index" 3396 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 3397 dependencies = [ 3398 "yoke", 3399 "zerofrom", ··· 3402 3403 [[package]] 3404 name = "zerovec-derive" 3405 + version = "0.11.2" 3406 source = "registry+https://github.com/rust-lang/crates.io-index" 3407 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 3408 dependencies = [ 3409 "proc-macro2", 3410 "quote", 3411 + "syn 2.0.109", 3412 ] 3413 3414 [[package]] ··· 3431 3432 [[package]] 3433 name = "zstd-sys" 3434 + version = "2.0.16+zstd.1.5.7" 3435 source = "registry+https://github.com/rust-lang/crates.io-index" 3436 + checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" 3437 dependencies = [ 3438 "cc", 3439 "pkg-config",
+26 -20
Cargo.toml
··· 1 [workspace] 2 members = [ 3 "crates/atproto-client", 4 "crates/atproto-identity", 5 "crates/atproto-jetstream", 6 "crates/atproto-oauth-aip", 7 "crates/atproto-oauth-axum", 8 "crates/atproto-oauth", 9 "crates/atproto-record", 10 "crates/atproto-xrpcs-helloworld", 11 "crates/atproto-xrpcs", 12 "crates/atproto-lexicon", ··· 24 categories = ["command-line-utilities", "web-programming"] 25 26 [workspace.dependencies] 27 atproto-client = { version = "0.13.0", path = "crates/atproto-client" } 28 atproto-identity = { version = "0.13.0", path = "crates/atproto-identity" } 29 atproto-oauth = { version = "0.13.0", path = "crates/atproto-oauth" } 30 - atproto-oauth-axum = { version = "0.13.0", path = "crates/atproto-oauth-axum" } 31 atproto-oauth-aip = { version = "0.13.0", path = "crates/atproto-oauth-aip" } 32 atproto-record = { version = "0.13.0", path = "crates/atproto-record" } 33 atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" } 34 - atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" } 35 - atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" } 36 37 anyhow = "1.0" 38 - async-trait = "0.1.88" 39 - base64 = "0.22.1" 40 - chrono = {version = "0.4.41", default-features = false, features = ["std", "now"]} 41 clap = { version = "4.5", features = ["derive", "env"] } 42 - ecdsa = { version = "0.16.9", features = ["std"] } 43 - elliptic-curve = { version = "0.13.8", features = ["jwk", "serde"] } 44 futures = "0.3" 45 hickory-resolver = { version = "0.25" } 46 - http = "1.3.1" 47 - k256 = "0.13.4" 48 lru = "0.12" 49 - multibase = "0.9.1" 50 - p256 = "0.13.2" 51 - p384 = "0.13.0" 52 rand = "0.8" 53 reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "system-proxy", "json", "rustls-tls"] } 54 - reqwest-chain = "1.0.0" 55 - reqwest-middleware = { version = "0.4.2", features = ["json", "multipart"]} 56 rpassword = "7.3" 57 secrecy = { version = "0.10", features = ["serde"] } 58 serde = { version = "1.0", features = ["derive"] } 59 - serde_ipld_dagcbor = "0.6.3" 60 - serde_json = "1.0" 61 - sha2 = "0.10.9" 62 thiserror = "2.0" 63 tokio = { version = "1.41", features = ["macros", "rt", "rt-multi-thread"] } 64 tokio-websockets = { version = "0.11.4", features = ["client", "rustls-native-roots", "rand", "ring"] } 65 tokio-util = "0.7" 66 tracing = { version = "0.1", features = ["async-await"] } 67 - ulid = "1.2.1" 68 zstd = "0.13" 69 url = "2.5" 70 urlencoding = "2.1" 71 72 - zeroize = { version = "1.8.1", features = ["zeroize_derive"] } 73 74 [workspace.lints.rust] 75 unsafe_code = "forbid"
··· 1 [workspace] 2 members = [ 3 "crates/atproto-client", 4 + "crates/atproto-extras", 5 "crates/atproto-identity", 6 "crates/atproto-jetstream", 7 "crates/atproto-oauth-aip", 8 "crates/atproto-oauth-axum", 9 "crates/atproto-oauth", 10 "crates/atproto-record", 11 + "crates/atproto-tap", 12 "crates/atproto-xrpcs-helloworld", 13 "crates/atproto-xrpcs", 14 "crates/atproto-lexicon", ··· 26 categories = ["command-line-utilities", "web-programming"] 27 28 [workspace.dependencies] 29 + atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" } 30 atproto-client = { version = "0.13.0", path = "crates/atproto-client" } 31 + atproto-extras = { version = "0.13.0", path = "crates/atproto-extras" } 32 atproto-identity = { version = "0.13.0", path = "crates/atproto-identity" } 33 + atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" } 34 atproto-oauth = { version = "0.13.0", path = "crates/atproto-oauth" } 35 atproto-oauth-aip = { version = "0.13.0", path = "crates/atproto-oauth-aip" } 36 + atproto-oauth-axum = { version = "0.13.0", path = "crates/atproto-oauth-axum" } 37 atproto-record = { version = "0.13.0", path = "crates/atproto-record" } 38 + atproto-tap = { version = "0.13.0", path = "crates/atproto-tap" } 39 atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" } 40 41 + ammonia = "4.0" 42 anyhow = "1.0" 43 + async-trait = "0.1" 44 + base64 = "0.22" 45 + chrono = {version = "0.4", default-features = false, features = ["std", "now"]} 46 clap = { version = "4.5", features = ["derive", "env"] } 47 + ecdsa = { version = "0.16", features = ["std"] } 48 + elliptic-curve = { version = "0.13", features = ["jwk", "serde"] } 49 futures = "0.3" 50 hickory-resolver = { version = "0.25" } 51 + http = "1.3" 52 + k256 = "0.13" 53 lru = "0.12" 54 + multibase = "0.9" 55 + p256 = "0.13" 56 + p384 = "0.13" 57 rand = "0.8" 58 + regex = "1.11" 59 reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "system-proxy", "json", "rustls-tls"] } 60 + reqwest-chain = "1.0" 61 + reqwest-middleware = { version = "0.4", features = ["json", "multipart"]} 62 rpassword = "7.3" 63 secrecy = { version = "0.10", features = ["serde"] } 64 serde = { version = "1.0", features = ["derive"] } 65 + serde_ipld_dagcbor = "0.6" 66 + serde_json = { version = "1.0", features = ["unbounded_depth"] } 67 + sha2 = "0.10" 68 thiserror = "2.0" 69 tokio = { version = "1.41", features = ["macros", "rt", "rt-multi-thread"] } 70 tokio-websockets = { version = "0.11.4", features = ["client", "rustls-native-roots", "rand", "ring"] } 71 tokio-util = "0.7" 72 tracing = { version = "0.1", features = ["async-await"] } 73 + ulid = "1.2" 74 zstd = "0.13" 75 url = "2.5" 76 urlencoding = "2.1" 77 78 + zeroize = { version = "1.8", features = ["zeroize_derive"] } 79 80 [workspace.lints.rust] 81 unsafe_code = "forbid"
+4 -4
README.md
··· 131 ### XRPC Service 132 133 ```rust 134 - use atproto_xrpcs::authorization::ResolvingAuthorization; 135 use axum::{Json, Router, extract::Query, routing::get}; 136 use serde::Deserialize; 137 use serde_json::json; ··· 143 144 async fn handle_hello( 145 params: Query<HelloParams>, 146 - authorization: Option<ResolvingAuthorization>, 147 ) -> Json<serde_json::Value> { 148 let subject = params.subject.as_deref().unwrap_or("World"); 149 - 150 let message = if let Some(auth) = authorization { 151 format!("Hello, authenticated {}! (caller: {})", subject, auth.subject()) 152 } else { 153 format!("Hello, {}!", subject) 154 }; 155 - 156 Json(json!({ "message": message })) 157 } 158
··· 131 ### XRPC Service 132 133 ```rust 134 + use atproto_xrpcs::authorization::Authorization; 135 use axum::{Json, Router, extract::Query, routing::get}; 136 use serde::Deserialize; 137 use serde_json::json; ··· 143 144 async fn handle_hello( 145 params: Query<HelloParams>, 146 + authorization: Option<Authorization>, 147 ) -> Json<serde_json::Value> { 148 let subject = params.subject.as_deref().unwrap_or("World"); 149 + 150 let message = if let Some(auth) = authorization { 151 format!("Hello, authenticated {}! (caller: {})", subject, auth.subject()) 152 } else { 153 format!("Hello, {}!", subject) 154 }; 155 + 156 Json(json!({ "message": message })) 157 } 158
+179
crates/atproto-attestation/src/attestation.rs
··· 43 Ok(Value::Object(record_obj)) 44 } 45 46 /// Creates a remote attestation with both the attested record and proof record. 47 /// 48 /// This is the recommended way to create remote attestations. It generates both: ··· 602 assert!(sig.get("signature").is_some()); 603 assert!(sig.get("key").is_some()); 604 assert!(sig.get("repository").is_none()); // Should not be in final signature 605 606 Ok(()) 607 }
··· 43 Ok(Value::Object(record_obj)) 44 } 45 46 + /// Creates a cryptographic signature for a record with attestation metadata. 47 + /// 48 + /// This is a low-level function that generates just the signature bytes without 49 + /// embedding them in a record structure. It's useful when you need to create 50 + /// signatures independently or for custom attestation workflows. 51 + /// 52 + /// The signature is created over a content CID that binds together: 53 + /// - The record content 54 + /// - The attestation metadata 55 + /// - The repository DID (to prevent replay attacks) 56 + /// 57 + /// # Arguments 58 + /// 59 + /// * `record_input` - The record to sign (as AnyInput: String, Json, or TypedLexicon) 60 + /// * `attestation_input` - The attestation metadata (as AnyInput) 61 + /// * `repository` - The repository DID where this record will be stored 62 + /// * `private_key_data` - The private key to use for signing 63 + /// 64 + /// # Returns 65 + /// 66 + /// A byte vector containing the normalized ECDSA signature that can be verified 67 + /// against the same content CID. 68 + /// 69 + /// # Errors 70 + /// 71 + /// Returns an error if: 72 + /// - CID generation fails 73 + /// - Signature creation fails 74 + /// - Signature normalization fails 75 + /// 76 + /// # Example 77 + /// 78 + /// ```rust 79 + /// use atproto_attestation::{create_signature, input::AnyInput}; 80 + /// use atproto_identity::key::{KeyType, generate_key}; 81 + /// use serde_json::json; 82 + /// 83 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 84 + /// let private_key = generate_key(KeyType::K256Private)?; 85 + /// 86 + /// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 87 + /// let metadata = json!({"$type": "com.example.signature"}); 88 + /// 89 + /// let signature_bytes = create_signature( 90 + /// AnyInput::Serialize(record), 91 + /// AnyInput::Serialize(metadata), 92 + /// "did:plc:repo123", 93 + /// &private_key 94 + /// )?; 95 + /// 96 + /// // signature_bytes can now be base64-encoded or used as needed 97 + /// # Ok(()) 98 + /// # } 99 + /// ``` 100 + pub fn create_signature<R, M>( 101 + record_input: AnyInput<R>, 102 + attestation_input: AnyInput<M>, 103 + repository: &str, 104 + private_key_data: &KeyData, 105 + ) -> Result<Vec<u8>, AttestationError> 106 + where 107 + R: Serialize + Clone, 108 + M: Serialize + Clone, 109 + { 110 + // Step 1: Create a content CID from record + attestation + repository 111 + let content_cid = create_attestation_cid(record_input, attestation_input, repository)?; 112 + 113 + // Step 2: Sign the CID bytes 114 + let raw_signature = sign(private_key_data, &content_cid.to_bytes()) 115 + .map_err(|error| AttestationError::SignatureCreationFailed { error })?; 116 + 117 + // Step 3: Normalize the signature to ensure consistent format 118 + normalize_signature(raw_signature, private_key_data.key_type()) 119 + } 120 + 121 /// Creates a remote attestation with both the attested record and proof record. 122 /// 123 /// This is the recommended way to create remote attestations. It generates both: ··· 677 assert!(sig.get("signature").is_some()); 678 assert!(sig.get("key").is_some()); 679 assert!(sig.get("repository").is_none()); // Should not be in final signature 680 + 681 + Ok(()) 682 + } 683 + 684 + #[test] 685 + fn create_signature_returns_valid_bytes() -> Result<(), Box<dyn std::error::Error>> { 686 + let private_key = generate_key(KeyType::K256Private)?; 687 + let public_key = to_public(&private_key)?; 688 + 689 + let record = json!({ 690 + "$type": "app.example.record", 691 + "body": "Test signature creation" 692 + }); 693 + 694 + let metadata = json!({ 695 + "$type": "com.example.signature", 696 + "key": format!("{}", public_key) 697 + }); 698 + 699 + let repository = "did:plc:test123"; 700 + 701 + // Create signature 702 + let signature_bytes = create_signature( 703 + AnyInput::Serialize(record.clone()), 704 + AnyInput::Serialize(metadata.clone()), 705 + repository, 706 + &private_key, 707 + )?; 708 + 709 + // Verify signature is not empty 710 + assert!(!signature_bytes.is_empty(), "Signature bytes should not be empty"); 711 + 712 + // Verify signature length is reasonable for ECDSA (typically 64-72 bytes for secp256k1) 713 + assert!( 714 + signature_bytes.len() >= 64 && signature_bytes.len() <= 73, 715 + "Signature length should be between 64 and 73 bytes, got {}", 716 + signature_bytes.len() 717 + ); 718 + 719 + // Verify we can validate the signature 720 + let content_cid = create_attestation_cid( 721 + AnyInput::Serialize(record), 722 + AnyInput::Serialize(metadata), 723 + repository, 724 + )?; 725 + 726 + validate(&public_key, &signature_bytes, &content_cid.to_bytes())?; 727 + 728 + Ok(()) 729 + } 730 + 731 + #[test] 732 + fn create_signature_different_inputs_produce_different_signatures() -> Result<(), Box<dyn std::error::Error>> { 733 + let private_key = generate_key(KeyType::K256Private)?; 734 + 735 + let record1 = json!({"$type": "app.example.record", "body": "First message"}); 736 + let record2 = json!({"$type": "app.example.record", "body": "Second message"}); 737 + let metadata = json!({"$type": "com.example.signature"}); 738 + let repository = "did:plc:test123"; 739 + 740 + let sig1 = create_signature( 741 + AnyInput::Serialize(record1), 742 + AnyInput::Serialize(metadata.clone()), 743 + repository, 744 + &private_key, 745 + )?; 746 + 747 + let sig2 = create_signature( 748 + AnyInput::Serialize(record2), 749 + AnyInput::Serialize(metadata), 750 + repository, 751 + &private_key, 752 + )?; 753 + 754 + assert_ne!(sig1, sig2, "Different records should produce different signatures"); 755 + 756 + Ok(()) 757 + } 758 + 759 + #[test] 760 + fn create_signature_different_repositories_produce_different_signatures() -> Result<(), Box<dyn std::error::Error>> { 761 + let private_key = generate_key(KeyType::K256Private)?; 762 + 763 + let record = json!({"$type": "app.example.record", "body": "Message"}); 764 + let metadata = json!({"$type": "com.example.signature"}); 765 + 766 + let sig1 = create_signature( 767 + AnyInput::Serialize(record.clone()), 768 + AnyInput::Serialize(metadata.clone()), 769 + "did:plc:repo1", 770 + &private_key, 771 + )?; 772 + 773 + let sig2 = create_signature( 774 + AnyInput::Serialize(record), 775 + AnyInput::Serialize(metadata), 776 + "did:plc:repo2", 777 + &private_key, 778 + )?; 779 + 780 + assert_ne!( 781 + sig1, sig2, 782 + "Different repository DIDs should produce different signatures" 783 + ); 784 785 Ok(()) 786 }
+19 -6
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
··· 47 48 use anyhow::{Context, Result, anyhow}; 49 use atproto_attestation::AnyInput; 50 - use atproto_identity::key::{KeyData, KeyResolver}; 51 use clap::Parser; 52 use serde_json::Value; 53 use std::{ ··· 115 attestation: Option<String>, 116 } 117 118 - struct FakeKeyResolver {} 119 120 #[async_trait::async_trait] 121 - impl KeyResolver for FakeKeyResolver { 122 - async fn resolve(&self, _subject: &str) -> Result<KeyData> { 123 - todo!() 124 } 125 } 126 ··· 175 identity_resolver, 176 }; 177 178 - let key_resolver = FakeKeyResolver {}; 179 180 atproto_attestation::verify_record( 181 AnyInput::Serialize(record.clone()),
··· 47 48 use anyhow::{Context, Result, anyhow}; 49 use atproto_attestation::AnyInput; 50 + use atproto_identity::key::{KeyData, KeyResolver, identify_key}; 51 use clap::Parser; 52 use serde_json::Value; 53 use std::{ ··· 115 attestation: Option<String>, 116 } 117 118 + /// A key resolver that supports `did:key:` identifiers directly. 119 + /// 120 + /// This resolver handles key references that are encoded as `did:key:` strings, 121 + /// parsing them to extract the cryptographic key data. For other DID methods, 122 + /// it returns an error since those would require fetching DID documents. 123 + struct DidKeyResolver {} 124 125 #[async_trait::async_trait] 126 + impl KeyResolver for DidKeyResolver { 127 + async fn resolve(&self, subject: &str) -> Result<KeyData> { 128 + if subject.starts_with("did:key:") { 129 + identify_key(subject) 130 + .map_err(|e| anyhow!("Failed to parse did:key '{}': {}", subject, e)) 131 + } else { 132 + Err(anyhow!( 133 + "Subject '{}' is not a did:key: identifier. Only did:key: subjects are supported by this resolver.", 134 + subject 135 + )) 136 + } 137 } 138 } 139 ··· 188 identity_resolver, 189 }; 190 191 + let key_resolver = DidKeyResolver {}; 192 193 atproto_attestation::verify_record( 194 AnyInput::Serialize(record.clone()),
+1 -1
crates/atproto-attestation/src/lib.rs
··· 59 // Re-export attestation functions 60 pub use attestation::{ 61 append_inline_attestation, append_remote_attestation, create_inline_attestation, 62 - create_remote_attestation, 63 }; 64 65 // Re-export input types
··· 59 // Re-export attestation functions 60 pub use attestation::{ 61 append_inline_attestation, append_remote_attestation, create_inline_attestation, 62 + create_remote_attestation, create_signature, 63 }; 64 65 // Re-export input types
+6
crates/atproto-client/Cargo.toml
··· 35 doc = true 36 required-features = ["clap"] 37 38 [dependencies] 39 atproto-identity.workspace = true 40 atproto-oauth.workspace = true
··· 35 doc = true 36 required-features = ["clap"] 37 38 + [[bin]] 39 + name = "atproto-client-put-record" 40 + test = false 41 + bench = false 42 + doc = true 43 + 44 [dependencies] 45 atproto-identity.workspace = true 46 atproto-oauth.workspace = true
+165
crates/atproto-client/src/bin/atproto-client-put-record.rs
···
··· 1 + //! AT Protocol client tool for writing records to a repository. 2 + //! 3 + //! This binary tool creates or updates records in an AT Protocol repository 4 + //! using app password authentication. It resolves the subject to a DID, 5 + //! creates a session, and writes the record using the putRecord XRPC method. 6 + //! 7 + //! # Usage 8 + //! 9 + //! ```text 10 + //! ATPROTO_PASSWORD=<password> atproto-client-put-record <subject> <record_key> <record_json> 11 + //! ``` 12 + //! 13 + //! # Environment Variables 14 + //! 15 + //! - `ATPROTO_PASSWORD` - Required. App password for authentication. 16 + //! - `CERTIFICATE_BUNDLES` - Custom CA certificate bundles. 17 + //! - `USER_AGENT` - Custom user agent string. 18 + //! - `DNS_NAMESERVERS` - Custom DNS nameservers. 19 + //! - `PLC_HOSTNAME` - Override PLC hostname (default: plc.directory). 20 + 21 + use anyhow::Result; 22 + use atproto_client::{ 23 + client::{AppPasswordAuth, Auth}, 24 + com::atproto::{ 25 + repo::{put_record, PutRecordRequest, PutRecordResponse}, 26 + server::create_session, 27 + }, 28 + errors::CliError, 29 + }; 30 + use atproto_identity::{ 31 + config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 32 + plc, 33 + resolve::{HickoryDnsResolver, resolve_subject}, 34 + web, 35 + }; 36 + use std::env; 37 + 38 + fn print_usage() { 39 + eprintln!("Usage: atproto-client-put-record <subject> <record_key> <record_json>"); 40 + eprintln!(); 41 + eprintln!("Arguments:"); 42 + eprintln!(" <subject> Handle or DID of the repository owner"); 43 + eprintln!(" <record_key> Record key (rkey) for the record"); 44 + eprintln!(" <record_json> JSON record data (must include $type field)"); 45 + eprintln!(); 46 + eprintln!("Environment Variables:"); 47 + eprintln!(" ATPROTO_PASSWORD Required. App password for authentication."); 48 + eprintln!(" CERTIFICATE_BUNDLES Custom CA certificate bundles."); 49 + eprintln!(" USER_AGENT Custom user agent string."); 50 + eprintln!(" DNS_NAMESERVERS Custom DNS nameservers."); 51 + eprintln!(" PLC_HOSTNAME Override PLC hostname (default: plc.directory)."); 52 + } 53 + 54 + #[tokio::main] 55 + async fn main() -> Result<()> { 56 + let args: Vec<String> = env::args().collect(); 57 + 58 + if args.len() != 4 { 59 + print_usage(); 60 + std::process::exit(1); 61 + } 62 + 63 + let subject = &args[1]; 64 + let record_key = &args[2]; 65 + let record_json = &args[3]; 66 + 67 + // Get password from environment variable 68 + let password = env::var("ATPROTO_PASSWORD").map_err(|_| { 69 + anyhow::anyhow!("ATPROTO_PASSWORD environment variable is required") 70 + })?; 71 + 72 + // Set up HTTP client configuration 73 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 74 + let default_user_agent = format!( 75 + "atproto-identity-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)", 76 + version()? 77 + ); 78 + let user_agent = default_env("USER_AGENT", &default_user_agent); 79 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 80 + let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 81 + 82 + let mut client_builder = reqwest::Client::builder(); 83 + for ca_certificate in certificate_bundles.as_ref() { 84 + let cert = std::fs::read(ca_certificate)?; 85 + let cert = reqwest::Certificate::from_pem(&cert)?; 86 + client_builder = client_builder.add_root_certificate(cert); 87 + } 88 + 89 + client_builder = client_builder.user_agent(user_agent); 90 + let http_client = client_builder.build()?; 91 + 92 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 93 + 94 + // Parse the record JSON 95 + let record: serde_json::Value = serde_json::from_str(record_json).map_err(|err| { 96 + tracing::error!(error = ?err, "Failed to parse record JSON"); 97 + anyhow::anyhow!("Failed to parse record JSON: {}", err) 98 + })?; 99 + 100 + // Extract collection from $type field 101 + let collection = record 102 + .get("$type") 103 + .and_then(|v| v.as_str()) 104 + .ok_or_else(|| anyhow::anyhow!("Record must contain a $type field for the collection"))? 105 + .to_string(); 106 + 107 + // Resolve subject to DID 108 + let did = resolve_subject(&http_client, &dns_resolver, subject).await?; 109 + 110 + // Get DID document to find PDS endpoint 111 + let document = if did.starts_with("did:plc:") { 112 + plc::query(&http_client, &plc_hostname, &did).await? 113 + } else if did.starts_with("did:web:") { 114 + web::query(&http_client, &did).await? 115 + } else { 116 + anyhow::bail!("Unsupported DID method: {}", did); 117 + }; 118 + 119 + // Get PDS endpoint from the DID document 120 + let pds_endpoints = document.pds_endpoints(); 121 + let pds_endpoint = pds_endpoints 122 + .first() 123 + .ok_or_else(|| CliError::NoPdsEndpointFound { did: did.clone() })?; 124 + 125 + // Create session 126 + let session = create_session(&http_client, pds_endpoint, &did, &password, None).await?; 127 + 128 + // Set up app password authentication 129 + let auth = Auth::AppPassword(AppPasswordAuth { 130 + access_token: session.access_jwt.clone(), 131 + }); 132 + 133 + // Create put record request 134 + let put_request = PutRecordRequest { 135 + repo: session.did.clone(), 136 + collection, 137 + record_key: record_key.clone(), 138 + validate: true, 139 + record, 140 + swap_commit: None, 141 + swap_record: None, 142 + }; 143 + 144 + // Execute put record 145 + let response = put_record(&http_client, &auth, pds_endpoint, put_request).await?; 146 + 147 + match response { 148 + PutRecordResponse::StrongRef { uri, cid, .. } => { 149 + println!( 150 + "{}", 151 + serde_json::to_string_pretty(&serde_json::json!({ 152 + "uri": uri, 153 + "cid": cid 154 + }))? 155 + ); 156 + } 157 + PutRecordResponse::Error(err) => { 158 + let error_message = err.error_message(); 159 + tracing::error!(error = %error_message, "putRecord failed"); 160 + anyhow::bail!("putRecord failed: {}", error_message); 161 + } 162 + } 163 + 164 + Ok(()) 165 + }
+31 -5
crates/atproto-client/src/record_resolver.rs
··· 1 //! Helpers for resolving AT Protocol records referenced by URI. 2 3 use std::str::FromStr; 4 5 use anyhow::{Result, anyhow, bail}; 6 use async_trait::async_trait; 7 use atproto_record::aturi::ATURI; 8 9 use crate::{ ··· 24 } 25 26 /// Resolver that fetches records using public XRPC endpoints. 27 #[derive(Clone)] 28 pub struct HttpRecordResolver { 29 http_client: reqwest::Client, 30 - base_url: String, 31 } 32 33 impl HttpRecordResolver { 34 - /// Create a new resolver using the provided HTTP client and PDS base URL. 35 - pub fn new(http_client: reqwest::Client, base_url: impl Into<String>) -> Self { 36 Self { 37 http_client, 38 - base_url: base_url.into(), 39 } 40 } 41 } ··· 47 T: serde::de::DeserializeOwned + Send, 48 { 49 let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?; 50 let auth = Auth::None; 51 52 let response = get_record( 53 &self.http_client, 54 &auth, 55 - &self.base_url, 56 &parsed.authority, 57 &parsed.collection, 58 &parsed.record_key,
··· 1 //! Helpers for resolving AT Protocol records referenced by URI. 2 3 use std::str::FromStr; 4 + use std::sync::Arc; 5 6 use anyhow::{Result, anyhow, bail}; 7 use async_trait::async_trait; 8 + use atproto_identity::traits::IdentityResolver; 9 use atproto_record::aturi::ATURI; 10 11 use crate::{ ··· 26 } 27 28 /// Resolver that fetches records using public XRPC endpoints. 29 + /// 30 + /// Uses an identity resolver to dynamically determine the PDS endpoint for each record. 31 #[derive(Clone)] 32 pub struct HttpRecordResolver { 33 http_client: reqwest::Client, 34 + identity_resolver: Arc<dyn IdentityResolver>, 35 } 36 37 impl HttpRecordResolver { 38 + /// Create a new resolver using the provided HTTP client and identity resolver. 39 + /// 40 + /// The identity resolver is used to dynamically determine the PDS endpoint for each record 41 + /// based on the authority (DID or handle) in the AT URI. 42 + pub fn new( 43 + http_client: reqwest::Client, 44 + identity_resolver: Arc<dyn IdentityResolver>, 45 + ) -> Self { 46 Self { 47 http_client, 48 + identity_resolver, 49 } 50 } 51 } ··· 57 T: serde::de::DeserializeOwned + Send, 58 { 59 let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?; 60 + 61 + // Resolve the authority (DID or handle) to get the DID document 62 + let document = self 63 + .identity_resolver 64 + .resolve(&parsed.authority) 65 + .await 66 + .map_err(|error| { 67 + anyhow!("Failed to resolve identity for {}: {}", parsed.authority, error) 68 + })?; 69 + 70 + // Extract PDS endpoint from the DID document 71 + let pds_endpoints = document.pds_endpoints(); 72 + let base_url = pds_endpoints 73 + .first() 74 + .ok_or_else(|| anyhow!("No PDS endpoint found for {}", parsed.authority))?; 75 + 76 let auth = Auth::None; 77 78 let response = get_record( 79 &self.http_client, 80 &auth, 81 + base_url, 82 &parsed.authority, 83 &parsed.collection, 84 &parsed.record_key,
+43
crates/atproto-extras/Cargo.toml
···
··· 1 + [package] 2 + name = "atproto-extras" 3 + version = "0.13.0" 4 + description = "AT Protocol extras - facet parsing and rich text utilities" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-extras" 8 + 9 + edition.workspace = true 10 + rust-version.workspace = true 11 + authors.workspace = true 12 + repository.workspace = true 13 + license.workspace = true 14 + keywords.workspace = true 15 + categories.workspace = true 16 + 17 + [dependencies] 18 + atproto-identity.workspace = true 19 + atproto-record.workspace = true 20 + 21 + anyhow.workspace = true 22 + async-trait.workspace = true 23 + clap = { workspace = true, optional = true } 24 + regex.workspace = true 25 + reqwest = { workspace = true, optional = true } 26 + serde_json = { workspace = true, optional = true } 27 + tokio = { workspace = true, optional = true } 28 + 29 + [dev-dependencies] 30 + tokio = { workspace = true, features = ["macros", "rt"] } 31 + 32 + [features] 33 + default = ["hickory-dns"] 34 + hickory-dns = ["atproto-identity/hickory-dns"] 35 + clap = ["dep:clap"] 36 + cli = ["dep:clap", "dep:serde_json", "dep:tokio", "dep:reqwest"] 37 + 38 + [[bin]] 39 + name = "atproto-extras-parse-facets" 40 + required-features = ["clap", "cli", "hickory-dns"] 41 + 42 + [lints] 43 + workspace = true
+128
crates/atproto-extras/README.md
···
··· 1 + # atproto-extras 2 + 3 + Extra utilities for AT Protocol applications, including rich text facet parsing. 4 + 5 + ## Features 6 + 7 + - **Facet Parsing**: Extract mentions (`@handle`), URLs, and hashtags (`#tag`) from plain text with correct UTF-8 byte offset calculation 8 + - **Identity Integration**: Resolve mention handles to DIDs during parsing 9 + 10 + ## Installation 11 + 12 + Add to your `Cargo.toml`: 13 + 14 + ```toml 15 + [dependencies] 16 + atproto-extras = "0.13" 17 + ``` 18 + 19 + ## Usage 20 + 21 + ### Parsing Text for Facets 22 + 23 + ```rust 24 + use atproto_extras::{parse_urls, parse_tags}; 25 + use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 26 + 27 + let text = "Check out https://example.com #rust"; 28 + 29 + // Parse URLs and tags - returns Vec<Facet> directly 30 + let url_facets = parse_urls(text); 31 + let tag_facets = parse_tags(text); 32 + 33 + // Each facet includes byte positions and typed features 34 + for facet in url_facets { 35 + if let Some(FacetFeature::Link(link)) = facet.features.first() { 36 + println!("URL at bytes {}..{}: {}", 37 + facet.index.byte_start, facet.index.byte_end, link.uri); 38 + } 39 + } 40 + 41 + for facet in tag_facets { 42 + if let Some(FacetFeature::Tag(tag)) = facet.features.first() { 43 + println!("Tag at bytes {}..{}: #{}", 44 + facet.index.byte_start, facet.index.byte_end, tag.tag); 45 + } 46 + } 47 + ``` 48 + 49 + ### Parsing Mentions 50 + 51 + Mention parsing requires an `IdentityResolver` to convert handles to DIDs: 52 + 53 + ```rust 54 + use atproto_extras::{parse_mentions, FacetLimits}; 55 + use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 56 + 57 + let text = "Hello @alice.bsky.social!"; 58 + let limits = FacetLimits::default(); 59 + 60 + // Requires an async context and IdentityResolver 61 + let facets = parse_mentions(text, &resolver, &limits).await; 62 + 63 + for facet in facets { 64 + if let Some(FacetFeature::Mention(mention)) = facet.features.first() { 65 + println!("Mention at bytes {}..{} resolved to {}", 66 + facet.index.byte_start, facet.index.byte_end, mention.did); 67 + } 68 + } 69 + ``` 70 + 71 + Mentions that cannot be resolved to a valid DID are automatically skipped. Mentions appearing within URLs are also excluded. 72 + 73 + ### Creating AT Protocol Facets 74 + 75 + ```rust 76 + use atproto_extras::{parse_facets_from_text, FacetLimits}; 77 + 78 + let text = "Hello @alice.bsky.social! Check https://rust-lang.org #rust"; 79 + let limits = FacetLimits::default(); 80 + 81 + // Requires an async context and IdentityResolver 82 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 83 + 84 + if let Some(facets) = facets { 85 + for facet in &facets { 86 + println!("Facet at {}..{}", facet.index.byte_start, facet.index.byte_end); 87 + } 88 + } 89 + ``` 90 + 91 + ## Byte Offset Handling 92 + 93 + AT Protocol facets use UTF-8 byte offsets, not character indices. This is critical for correct handling of multi-byte characters like emojis or non-ASCII text. 94 + 95 + ```rust 96 + use atproto_extras::parse_urls; 97 + 98 + // Text with emojis (multi-byte UTF-8 characters) 99 + let text = "โœจ Check https://example.com โœจ"; 100 + 101 + let facets = parse_urls(text); 102 + // Byte positions correctly account for the 4-byte emoji 103 + assert_eq!(facets[0].index.byte_start, 11); // After "โœจ Check " (4 + 1 + 6 = 11 bytes) 104 + ``` 105 + 106 + ## Facet Limits 107 + 108 + Use `FacetLimits` to control the maximum number of facets processed: 109 + 110 + ```rust 111 + use atproto_extras::FacetLimits; 112 + 113 + // Default limits 114 + let limits = FacetLimits::default(); 115 + // mentions_max: 5, tags_max: 5, links_max: 5, max: 10 116 + 117 + // Custom limits 118 + let custom = FacetLimits { 119 + mentions_max: 10, 120 + tags_max: 10, 121 + links_max: 10, 122 + max: 20, 123 + }; 124 + ``` 125 + 126 + ## License 127 + 128 + MIT
+176
crates/atproto-extras/src/bin/atproto-extras-parse-facets.rs
···
··· 1 + //! Command-line tool for generating AT Protocol facet arrays from text. 2 + //! 3 + //! This tool parses a string and outputs the facet array in JSON format. 4 + //! Facets include mentions (@handle), URLs (https://...), and hashtags (#tag). 5 + //! 6 + //! By default, mentions are detected but output with placeholder DIDs. Use 7 + //! `--resolve-mentions` to resolve handles to actual DIDs (requires network access). 8 + //! 9 + //! # Usage 10 + //! 11 + //! ```bash 12 + //! # Parse facets without resolving mentions 13 + //! cargo run --features clap,serde_json,tokio,hickory-dns --bin atproto-extras-parse-facets -- "Check out https://example.com and #rust" 14 + //! 15 + //! # Resolve mentions to DIDs 16 + //! cargo run --features clap,serde_json,tokio,hickory-dns --bin atproto-extras-parse-facets -- --resolve-mentions "Hello @bsky.app!" 17 + //! ``` 18 + 19 + use atproto_extras::{FacetLimits, parse_mentions, parse_tags, parse_urls}; 20 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 21 + use atproto_record::lexicon::app::bsky::richtext::facet::{ 22 + ByteSlice, Facet, FacetFeature, Mention, 23 + }; 24 + use clap::Parser; 25 + use regex::bytes::Regex; 26 + use std::sync::Arc; 27 + 28 + /// Parse text and output AT Protocol facets as JSON. 29 + #[derive(Parser)] 30 + #[command( 31 + name = "atproto-extras-parse-facets", 32 + version, 33 + about = "Parse text and output AT Protocol facets as JSON", 34 + long_about = "This tool parses a string for mentions, URLs, and hashtags,\n\ 35 + then outputs the corresponding AT Protocol facet array in JSON format.\n\n\ 36 + By default, mentions are detected but output with placeholder DIDs.\n\ 37 + Use --resolve-mentions to resolve handles to actual DIDs (requires network)." 38 + )] 39 + struct Args { 40 + /// The text to parse for facets 41 + text: String, 42 + 43 + /// Resolve mention handles to DIDs (requires network access) 44 + #[arg(long)] 45 + resolve_mentions: bool, 46 + 47 + /// Show debug information on stderr 48 + #[arg(long, short = 'd')] 49 + debug: bool, 50 + } 51 + 52 + /// Parse mention spans from text without resolution (returns placeholder DIDs). 53 + fn parse_mention_spans(text: &str) -> Vec<Facet> { 54 + let mut facets = Vec::new(); 55 + 56 + // Get URL ranges to exclude mentions within URLs 57 + let url_facets = parse_urls(text); 58 + 59 + // Same regex pattern as parse_mentions 60 + let mention_regex = Regex::new( 61 + r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)", 62 + ) 63 + .expect("Invalid mention regex"); 64 + 65 + let text_bytes = text.as_bytes(); 66 + 67 + for capture in mention_regex.captures_iter(text_bytes) { 68 + if let Some(mention_match) = capture.get(1) { 69 + let start = mention_match.start(); 70 + let end = mention_match.end(); 71 + 72 + // Check if this mention overlaps with any URL 73 + let overlaps_url = url_facets.iter().any(|facet| { 74 + (start >= facet.index.byte_start && start < facet.index.byte_end) 75 + || (end > facet.index.byte_start && end <= facet.index.byte_end) 76 + }); 77 + 78 + if !overlaps_url { 79 + let handle = std::str::from_utf8(&mention_match.as_bytes()[1..]) 80 + .unwrap_or_default() 81 + .to_string(); 82 + 83 + facets.push(Facet { 84 + index: ByteSlice { 85 + byte_start: start, 86 + byte_end: end, 87 + }, 88 + features: vec![FacetFeature::Mention(Mention { 89 + did: format!("did:plc:<unresolved:{}>", handle), 90 + })], 91 + }); 92 + } 93 + } 94 + } 95 + 96 + facets 97 + } 98 + 99 + #[tokio::main] 100 + async fn main() { 101 + let args = Args::parse(); 102 + let text = &args.text; 103 + let mut facets: Vec<Facet> = Vec::new(); 104 + let limits = FacetLimits::default(); 105 + 106 + // Parse mentions (either resolved or with placeholders) 107 + if args.resolve_mentions { 108 + let http_client = reqwest::Client::new(); 109 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 110 + let resolver = InnerIdentityResolver { 111 + http_client, 112 + dns_resolver: Arc::new(dns_resolver), 113 + plc_hostname: "plc.directory".to_string(), 114 + }; 115 + let mention_facets = parse_mentions(text, &resolver, &limits).await; 116 + facets.extend(mention_facets); 117 + } else { 118 + let mention_facets = parse_mention_spans(text); 119 + facets.extend(mention_facets); 120 + } 121 + 122 + // Parse URLs 123 + let url_facets = parse_urls(text); 124 + facets.extend(url_facets); 125 + 126 + // Parse hashtags 127 + let tag_facets = parse_tags(text); 128 + facets.extend(tag_facets); 129 + 130 + // Sort facets by byte_start for consistent output 131 + facets.sort_by_key(|f| f.index.byte_start); 132 + 133 + // Output as JSON 134 + if facets.is_empty() { 135 + println!("null"); 136 + } else { 137 + match serde_json::to_string_pretty(&facets) { 138 + Ok(json) => println!("{}", json), 139 + Err(e) => { 140 + eprintln!( 141 + "error-atproto-extras-parse-facets-1 Error serializing facets: {}", 142 + e 143 + ); 144 + std::process::exit(1); 145 + } 146 + } 147 + } 148 + 149 + // Show debug info if requested 150 + if args.debug { 151 + eprintln!(); 152 + eprintln!("--- Debug Info ---"); 153 + eprintln!("Input text: {:?}", text); 154 + eprintln!("Text length: {} bytes", text.len()); 155 + eprintln!("Facets found: {}", facets.len()); 156 + eprintln!("Mentions resolved: {}", args.resolve_mentions); 157 + 158 + // Show byte slice verification 159 + let text_bytes = text.as_bytes(); 160 + for (i, facet) in facets.iter().enumerate() { 161 + let start = facet.index.byte_start; 162 + let end = facet.index.byte_end; 163 + let slice_text = 164 + std::str::from_utf8(&text_bytes[start..end]).unwrap_or("<invalid utf8>"); 165 + let feature_type = match &facet.features[0] { 166 + FacetFeature::Mention(_) => "mention", 167 + FacetFeature::Link(_) => "link", 168 + FacetFeature::Tag(_) => "tag", 169 + }; 170 + eprintln!( 171 + " [{}] {} @ bytes {}..{}: {:?}", 172 + i, feature_type, start, end, slice_text 173 + ); 174 + } 175 + } 176 + }
+942
crates/atproto-extras/src/facets.rs
···
··· 1 + //! Rich text facet parsing for AT Protocol. 2 + //! 3 + //! This module provides functionality for extracting semantic annotations (facets) 4 + //! from plain text. Facets include mentions, links (URLs), and hashtags. 5 + //! 6 + //! # Overview 7 + //! 8 + //! AT Protocol rich text uses "facets" to annotate specific byte ranges within text with 9 + //! semantic meaning. This module handles: 10 + //! 11 + //! - **Parsing**: Extract mentions, URLs, and hashtags from plain text 12 + //! - **Facet Creation**: Build proper AT Protocol facet structures with resolved DIDs 13 + //! 14 + //! # Byte Offset Calculation 15 + //! 16 + //! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol. 17 + //! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done 18 + //! using `regex::bytes::Regex` which operates on byte slices and returns byte positions, 19 + //! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars). 20 + //! 21 + //! # Example 22 + //! 23 + //! ```ignore 24 + //! use atproto_extras::facets::{parse_urls, parse_tags, FacetLimits}; 25 + //! use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 26 + //! 27 + //! let text = "Check out https://example.com #rust"; 28 + //! 29 + //! // Parse URLs and tags as Facet objects 30 + //! let url_facets = parse_urls(text); 31 + //! let tag_facets = parse_tags(text); 32 + //! 33 + //! // Access facet data directly 34 + //! for facet in url_facets { 35 + //! if let Some(FacetFeature::Link(link)) = facet.features.first() { 36 + //! println!("URL at bytes {}..{}: {}", 37 + //! facet.index.byte_start, facet.index.byte_end, link.uri); 38 + //! } 39 + //! } 40 + //! ``` 41 + 42 + use atproto_identity::resolve::IdentityResolver; 43 + use atproto_record::lexicon::app::bsky::richtext::facet::{ 44 + ByteSlice, Facet, FacetFeature, Link, Mention, Tag, 45 + }; 46 + use regex::bytes::Regex; 47 + 48 + /// Configuration for facet parsing limits. 49 + /// 50 + /// These limits protect against abuse by capping the number of facets 51 + /// that will be processed. This is important for both performance and 52 + /// security when handling user-generated content. 53 + /// 54 + /// # Example 55 + /// 56 + /// ``` 57 + /// use atproto_extras::FacetLimits; 58 + /// 59 + /// // Use defaults 60 + /// let limits = FacetLimits::default(); 61 + /// 62 + /// // Or customize 63 + /// let custom = FacetLimits { 64 + /// mentions_max: 10, 65 + /// tags_max: 10, 66 + /// links_max: 10, 67 + /// max: 20, 68 + /// }; 69 + /// ``` 70 + #[derive(Debug, Clone, Copy)] 71 + pub struct FacetLimits { 72 + /// Maximum number of mention facets to process (default: 5) 73 + pub mentions_max: usize, 74 + /// Maximum number of tag facets to process (default: 5) 75 + pub tags_max: usize, 76 + /// Maximum number of link facets to process (default: 5) 77 + pub links_max: usize, 78 + /// Maximum total number of facets to process (default: 10) 79 + pub max: usize, 80 + } 81 + 82 + impl Default for FacetLimits { 83 + fn default() -> Self { 84 + Self { 85 + mentions_max: 5, 86 + tags_max: 5, 87 + links_max: 5, 88 + max: 10, 89 + } 90 + } 91 + } 92 + 93 + /// Parse mentions from text and return them as Facet objects with resolved DIDs. 94 + /// 95 + /// This function extracts AT Protocol handle mentions (e.g., `@alice.bsky.social`) 96 + /// from text, resolves each handle to a DID using the provided identity resolver, 97 + /// and returns AT Protocol Facet objects with Mention features. 98 + /// 99 + /// Mentions that cannot be resolved to a valid DID are skipped. Mentions that 100 + /// appear within URLs are also excluded to avoid false positives. 101 + /// 102 + /// # Arguments 103 + /// 104 + /// * `text` - The text to parse for mentions 105 + /// * `identity_resolver` - Resolver for converting handles to DIDs 106 + /// * `limits` - Configuration for maximum mentions to process 107 + /// 108 + /// # Returns 109 + /// 110 + /// A vector of Facet objects for successfully resolved mentions. 111 + /// 112 + /// # Example 113 + /// 114 + /// ```ignore 115 + /// use atproto_extras::{parse_mentions, FacetLimits}; 116 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 117 + /// 118 + /// let text = "Hello @alice.bsky.social!"; 119 + /// let limits = FacetLimits::default(); 120 + /// 121 + /// // Requires an async context and identity resolver 122 + /// let facets = parse_mentions(text, &resolver, &limits).await; 123 + /// 124 + /// for facet in facets { 125 + /// if let Some(FacetFeature::Mention(mention)) = facet.features.first() { 126 + /// println!("Mention {} resolved to {}", 127 + /// &text[facet.index.byte_start..facet.index.byte_end], 128 + /// mention.did); 129 + /// } 130 + /// } 131 + /// ``` 132 + pub async fn parse_mentions( 133 + text: &str, 134 + identity_resolver: &dyn IdentityResolver, 135 + limits: &FacetLimits, 136 + ) -> Vec<Facet> { 137 + let mut facets = Vec::new(); 138 + 139 + // First, parse all URLs to exclude mention matches within them 140 + let url_facets = parse_urls(text); 141 + 142 + // Regex based on: https://atproto.com/specs/handle#handle-identifier-syntax 143 + // Pattern: [$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?) 144 + let mention_regex = Regex::new( 145 + r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)", 146 + ) 147 + .unwrap(); 148 + 149 + let text_bytes = text.as_bytes(); 150 + let mut mention_count = 0; 151 + 152 + for capture in mention_regex.captures_iter(text_bytes) { 153 + if mention_count >= limits.mentions_max { 154 + break; 155 + } 156 + 157 + if let Some(mention_match) = capture.get(1) { 158 + let start = mention_match.start(); 159 + let end = mention_match.end(); 160 + 161 + // Check if this mention overlaps with any URL 162 + let overlaps_url = url_facets.iter().any(|facet| { 163 + // Check if mention is within or overlaps the URL span 164 + (start >= facet.index.byte_start && start < facet.index.byte_end) 165 + || (end > facet.index.byte_start && end <= facet.index.byte_end) 166 + }); 167 + 168 + // Only process the mention if it doesn't overlap with a URL 169 + if !overlaps_url { 170 + let handle = std::str::from_utf8(&mention_match.as_bytes()[1..]) 171 + .unwrap_or_default() 172 + .to_string(); 173 + 174 + // Try to resolve the handle to a DID 175 + // First try with at:// prefix, then without 176 + let at_uri = format!("at://{}", handle); 177 + let did_result = match identity_resolver.resolve(&at_uri).await { 178 + Ok(doc) => Ok(doc), 179 + Err(_) => identity_resolver.resolve(&handle).await, 180 + }; 181 + 182 + // Only add the mention facet if we successfully resolved the DID 183 + if let Ok(did_doc) = did_result { 184 + facets.push(Facet { 185 + index: ByteSlice { 186 + byte_start: start, 187 + byte_end: end, 188 + }, 189 + features: vec![FacetFeature::Mention(Mention { 190 + did: did_doc.id.to_string(), 191 + })], 192 + }); 193 + mention_count += 1; 194 + } 195 + } 196 + } 197 + } 198 + 199 + facets 200 + } 201 + 202 + /// Parse URLs from text and return them as Facet objects. 203 + /// 204 + /// This function extracts HTTP and HTTPS URLs from text with correct 205 + /// byte position tracking for UTF-8 text, returning AT Protocol Facet objects 206 + /// with Link features. 207 + /// 208 + /// # Supported URL Patterns 209 + /// 210 + /// - HTTP URLs: `http://example.com` 211 + /// - HTTPS URLs: `https://example.com` 212 + /// - URLs with paths, query strings, and fragments 213 + /// - URLs with subdomains: `https://www.example.com` 214 + /// 215 + /// # Example 216 + /// 217 + /// ``` 218 + /// use atproto_extras::parse_urls; 219 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 220 + /// 221 + /// let text = "Visit https://example.com/path?query=1 for more info"; 222 + /// let facets = parse_urls(text); 223 + /// 224 + /// assert_eq!(facets.len(), 1); 225 + /// assert_eq!(facets[0].index.byte_start, 6); 226 + /// assert_eq!(facets[0].index.byte_end, 38); 227 + /// if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 228 + /// assert_eq!(link.uri, "https://example.com/path?query=1"); 229 + /// } 230 + /// ``` 231 + /// 232 + /// # Multi-byte Character Handling 233 + /// 234 + /// Byte positions are correctly calculated even with emojis and other 235 + /// multi-byte UTF-8 characters: 236 + /// 237 + /// ``` 238 + /// use atproto_extras::parse_urls; 239 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 240 + /// 241 + /// let text = "Check out https://example.com now!"; 242 + /// let facets = parse_urls(text); 243 + /// let text_bytes = text.as_bytes(); 244 + /// 245 + /// // The byte slice matches the URL 246 + /// let url_bytes = &text_bytes[facets[0].index.byte_start..facets[0].index.byte_end]; 247 + /// assert_eq!(std::str::from_utf8(url_bytes).unwrap(), "https://example.com"); 248 + /// ``` 249 + pub fn parse_urls(text: &str) -> Vec<Facet> { 250 + let mut facets = Vec::new(); 251 + 252 + // Partial/naive URL regex based on: https://stackoverflow.com/a/3809435 253 + // Pattern: [$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?) 254 + // Modified to use + instead of {1,6} to support longer TLDs and multi-level subdomains 255 + let url_regex = Regex::new( 256 + r"(?:^|[^\w])(https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)" 257 + ).unwrap(); 258 + 259 + let text_bytes = text.as_bytes(); 260 + for capture in url_regex.captures_iter(text_bytes) { 261 + if let Some(url_match) = capture.get(1) { 262 + let url = std::str::from_utf8(url_match.as_bytes()) 263 + .unwrap_or_default() 264 + .to_string(); 265 + 266 + facets.push(Facet { 267 + index: ByteSlice { 268 + byte_start: url_match.start(), 269 + byte_end: url_match.end(), 270 + }, 271 + features: vec![FacetFeature::Link(Link { uri: url })], 272 + }); 273 + } 274 + } 275 + 276 + facets 277 + } 278 + 279 + /// Parse hashtags from text and return them as Facet objects. 280 + /// 281 + /// This function extracts hashtags (e.g., `#rust`, `#ATProto`) from text, 282 + /// returning AT Protocol Facet objects with Tag features. 283 + /// It supports both standard `#` and full-width `๏ผƒ` (U+FF03) hash symbols. 284 + /// 285 + /// # Tag Syntax 286 + /// 287 + /// - Tags must start with `#` or `๏ผƒ` (full-width) 288 + /// - Tag content follows word character rules (`\w`) 289 + /// - Purely numeric tags (e.g., `#123`) are excluded 290 + /// 291 + /// # Example 292 + /// 293 + /// ``` 294 + /// use atproto_extras::parse_tags; 295 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 296 + /// 297 + /// let text = "Learning #rust and #golang today! #100DaysOfCode"; 298 + /// let facets = parse_tags(text); 299 + /// 300 + /// assert_eq!(facets.len(), 3); 301 + /// if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 302 + /// assert_eq!(tag.tag, "rust"); 303 + /// } 304 + /// if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() { 305 + /// assert_eq!(tag.tag, "golang"); 306 + /// } 307 + /// if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() { 308 + /// assert_eq!(tag.tag, "100DaysOfCode"); 309 + /// } 310 + /// ``` 311 + /// 312 + /// # Numeric Tags 313 + /// 314 + /// Purely numeric tags are excluded: 315 + /// 316 + /// ``` 317 + /// use atproto_extras::parse_tags; 318 + /// 319 + /// let text = "Item #42 is special"; 320 + /// let facets = parse_tags(text); 321 + /// 322 + /// // #42 is not extracted because it's purely numeric 323 + /// assert_eq!(facets.len(), 0); 324 + /// ``` 325 + pub fn parse_tags(text: &str) -> Vec<Facet> { 326 + let mut facets = Vec::new(); 327 + 328 + // Regex based on: https://github.com/bluesky-social/atproto/blob/d91988fe79030b61b556dd6f16a46f0c3b9d0b44/packages/api/src/rich-text/util.ts 329 + // Simplified for Rust - matches hashtags at word boundaries 330 + // Pattern matches: start of string or non-word char, then # or ๏ผƒ, then tag content 331 + let tag_regex = Regex::new(r"(?:^|[^\w])([#\xEF\xBC\x83])([\w]+(?:[\w]*)*)").unwrap(); 332 + 333 + let text_bytes = text.as_bytes(); 334 + 335 + // Work with bytes for proper position tracking 336 + for capture in tag_regex.captures_iter(text_bytes) { 337 + if let (Some(full_match), Some(hash_match), Some(tag_match)) = 338 + (capture.get(0), capture.get(1), capture.get(2)) 339 + { 340 + // Calculate the absolute byte position of the hash symbol 341 + // The full match includes the preceding character (if any) 342 + // so we need to adjust for that 343 + let match_start = full_match.start(); 344 + let hash_offset = hash_match.start() - full_match.start(); 345 + let start = match_start + hash_offset; 346 + let end = match_start + hash_offset + hash_match.len() + tag_match.len(); 347 + 348 + // Extract just the tag text (without the hash symbol) 349 + let tag = std::str::from_utf8(tag_match.as_bytes()).unwrap_or_default(); 350 + 351 + // Only include tags that are not purely numeric 352 + if !tag.chars().all(|c| c.is_ascii_digit()) { 353 + facets.push(Facet { 354 + index: ByteSlice { 355 + byte_start: start, 356 + byte_end: end, 357 + }, 358 + features: vec![FacetFeature::Tag(Tag { 359 + tag: tag.to_string(), 360 + })], 361 + }); 362 + } 363 + } 364 + } 365 + 366 + facets 367 + } 368 + 369 + /// Parse facets from text and return a vector of Facet objects. 370 + /// 371 + /// This function extracts mentions, URLs, and hashtags from the provided text 372 + /// and creates AT Protocol facets with proper byte indices. 373 + /// 374 + /// Mentions are resolved to actual DIDs using the provided identity resolver. 375 + /// If a handle cannot be resolved to a DID, the mention facet is skipped. 376 + /// 377 + /// # Arguments 378 + /// 379 + /// * `text` - The text to extract facets from 380 + /// * `identity_resolver` - Resolver for converting handles to DIDs 381 + /// * `limits` - Configuration for maximum facets per type and total 382 + /// 383 + /// # Returns 384 + /// 385 + /// Optional vector of facets. Returns `None` if no facets were found. 386 + /// 387 + /// # Example 388 + /// 389 + /// ```ignore 390 + /// use atproto_extras::{parse_facets_from_text, FacetLimits}; 391 + /// 392 + /// let text = "Hello @alice.bsky.social! Check #rust at https://rust-lang.org"; 393 + /// let limits = FacetLimits::default(); 394 + /// 395 + /// // Requires an async context and identity resolver 396 + /// let facets = parse_facets_from_text(text, &resolver, &limits).await; 397 + /// 398 + /// if let Some(facets) = facets { 399 + /// for facet in &facets { 400 + /// println!("Facet at {}..{}", facet.index.byte_start, facet.index.byte_end); 401 + /// } 402 + /// } 403 + /// ``` 404 + /// 405 + /// # Mention Resolution 406 + /// 407 + /// Mentions are only included if the handle resolves to a valid DID: 408 + /// 409 + /// ```ignore 410 + /// let text = "@valid.handle.com and @invalid.handle.xyz"; 411 + /// let facets = parse_facets_from_text(text, &resolver, &limits).await; 412 + /// 413 + /// // Only @valid.handle.com appears as a facet if @invalid.handle.xyz 414 + /// // cannot be resolved to a DID 415 + /// ``` 416 + pub async fn parse_facets_from_text( 417 + text: &str, 418 + identity_resolver: &dyn IdentityResolver, 419 + limits: &FacetLimits, 420 + ) -> Option<Vec<Facet>> { 421 + let mut facets = Vec::new(); 422 + 423 + // Parse mentions (already limited by mentions_max in parse_mentions) 424 + let mention_facets = parse_mentions(text, identity_resolver, limits).await; 425 + facets.extend(mention_facets); 426 + 427 + // Parse URLs (limited by links_max) 428 + let url_facets = parse_urls(text); 429 + for (idx, facet) in url_facets.into_iter().enumerate() { 430 + if idx >= limits.links_max { 431 + break; 432 + } 433 + facets.push(facet); 434 + } 435 + 436 + // Parse hashtags (limited by tags_max) 437 + let tag_facets = parse_tags(text); 438 + for (idx, facet) in tag_facets.into_iter().enumerate() { 439 + if idx >= limits.tags_max { 440 + break; 441 + } 442 + facets.push(facet); 443 + } 444 + 445 + // Apply global facet limit (truncate if exceeds max) 446 + if facets.len() > limits.max { 447 + facets.truncate(limits.max); 448 + } 449 + 450 + // Only return facets if we found any 451 + if !facets.is_empty() { 452 + Some(facets) 453 + } else { 454 + None 455 + } 456 + } 457 + 458 + #[cfg(test)] 459 + mod tests { 460 + use async_trait::async_trait; 461 + use atproto_identity::model::Document; 462 + use std::collections::HashMap; 463 + 464 + use super::*; 465 + 466 + /// Mock identity resolver for testing 467 + struct MockIdentityResolver { 468 + handles_to_dids: HashMap<String, String>, 469 + } 470 + 471 + impl MockIdentityResolver { 472 + fn new() -> Self { 473 + let mut handles_to_dids = HashMap::new(); 474 + handles_to_dids.insert( 475 + "alice.bsky.social".to_string(), 476 + "did:plc:alice123".to_string(), 477 + ); 478 + handles_to_dids.insert( 479 + "at://alice.bsky.social".to_string(), 480 + "did:plc:alice123".to_string(), 481 + ); 482 + Self { handles_to_dids } 483 + } 484 + 485 + fn add_identity(&mut self, handle: &str, did: &str) { 486 + self.handles_to_dids 487 + .insert(handle.to_string(), did.to_string()); 488 + self.handles_to_dids 489 + .insert(format!("at://{}", handle), did.to_string()); 490 + } 491 + } 492 + 493 + #[async_trait] 494 + impl IdentityResolver for MockIdentityResolver { 495 + async fn resolve(&self, handle: &str) -> anyhow::Result<Document> { 496 + let handle_key = handle.to_string(); 497 + 498 + if let Some(did) = self.handles_to_dids.get(&handle_key) { 499 + Ok(Document { 500 + context: vec![], 501 + id: did.clone(), 502 + also_known_as: vec![format!("at://{}", handle_key.trim_start_matches("at://"))], 503 + verification_method: vec![], 504 + service: vec![], 505 + extra: HashMap::new(), 506 + }) 507 + } else { 508 + Err(anyhow::anyhow!("Handle not found")) 509 + } 510 + } 511 + } 512 + 513 + #[tokio::test] 514 + async fn test_parse_facets_from_text_comprehensive() { 515 + let mut resolver = MockIdentityResolver::new(); 516 + resolver.add_identity("bob.test.com", "did:plc:bob456"); 517 + 518 + let limits = FacetLimits::default(); 519 + let text = "Join @alice.bsky.social and @bob.test.com at https://example.com #rust #golang"; 520 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 521 + 522 + assert!(facets.is_some()); 523 + let facets = facets.unwrap(); 524 + assert_eq!(facets.len(), 5); // 2 mentions, 1 URL, 2 hashtags 525 + 526 + // Check first mention 527 + assert_eq!(facets[0].index.byte_start, 5); 528 + assert_eq!(facets[0].index.byte_end, 23); 529 + if let FacetFeature::Mention(ref mention) = facets[0].features[0] { 530 + assert_eq!(mention.did, "did:plc:alice123"); 531 + } else { 532 + panic!("Expected Mention feature"); 533 + } 534 + 535 + // Check second mention 536 + assert_eq!(facets[1].index.byte_start, 28); 537 + assert_eq!(facets[1].index.byte_end, 41); 538 + if let FacetFeature::Mention(mention) = &facets[1].features[0] { 539 + assert_eq!(mention.did, "did:plc:bob456"); 540 + } else { 541 + panic!("Expected Mention feature"); 542 + } 543 + 544 + // Check URL 545 + assert_eq!(facets[2].index.byte_start, 45); 546 + assert_eq!(facets[2].index.byte_end, 64); 547 + if let FacetFeature::Link(link) = &facets[2].features[0] { 548 + assert_eq!(link.uri, "https://example.com"); 549 + } else { 550 + panic!("Expected Link feature"); 551 + } 552 + 553 + // Check first hashtag 554 + assert_eq!(facets[3].index.byte_start, 65); 555 + assert_eq!(facets[3].index.byte_end, 70); 556 + if let FacetFeature::Tag(tag) = &facets[3].features[0] { 557 + assert_eq!(tag.tag, "rust"); 558 + } else { 559 + panic!("Expected Tag feature"); 560 + } 561 + 562 + // Check second hashtag 563 + assert_eq!(facets[4].index.byte_start, 71); 564 + assert_eq!(facets[4].index.byte_end, 78); 565 + if let FacetFeature::Tag(tag) = &facets[4].features[0] { 566 + assert_eq!(tag.tag, "golang"); 567 + } else { 568 + panic!("Expected Tag feature"); 569 + } 570 + } 571 + 572 + #[tokio::test] 573 + async fn test_parse_facets_from_text_with_unresolvable_mention() { 574 + let resolver = MockIdentityResolver::new(); 575 + let limits = FacetLimits::default(); 576 + 577 + // Only alice.bsky.social is in the resolver, not unknown.handle.com 578 + let text = "Contact @unknown.handle.com for details #rust"; 579 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 580 + 581 + assert!(facets.is_some()); 582 + let facets = facets.unwrap(); 583 + // Should only have 1 facet (the hashtag) since the mention couldn't be resolved 584 + assert_eq!(facets.len(), 1); 585 + 586 + // Check that it's the hashtag facet 587 + if let FacetFeature::Tag(tag) = &facets[0].features[0] { 588 + assert_eq!(tag.tag, "rust"); 589 + } else { 590 + panic!("Expected Tag feature"); 591 + } 592 + } 593 + 594 + #[tokio::test] 595 + async fn test_parse_facets_from_text_empty() { 596 + let resolver = MockIdentityResolver::new(); 597 + let limits = FacetLimits::default(); 598 + let text = "No mentions, URLs, or hashtags here"; 599 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 600 + assert!(facets.is_none()); 601 + } 602 + 603 + #[tokio::test] 604 + async fn test_parse_facets_from_text_url_with_at_mention() { 605 + let resolver = MockIdentityResolver::new(); 606 + let limits = FacetLimits::default(); 607 + 608 + // URLs with @ should not create mention facets 609 + let text = "Tangled https://tangled.org/@smokesignal.events"; 610 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 611 + 612 + assert!(facets.is_some()); 613 + let facets = facets.unwrap(); 614 + 615 + // Should have exactly 1 facet (the URL), not 2 (URL + mention) 616 + assert_eq!( 617 + facets.len(), 618 + 1, 619 + "Expected 1 facet (URL only), got {}", 620 + facets.len() 621 + ); 622 + 623 + // Verify it's a link facet, not a mention 624 + if let FacetFeature::Link(link) = &facets[0].features[0] { 625 + assert_eq!(link.uri, "https://tangled.org/@smokesignal.events"); 626 + } else { 627 + panic!("Expected Link feature, got Mention or Tag instead"); 628 + } 629 + } 630 + 631 + #[tokio::test] 632 + async fn test_parse_facets_with_mention_limit() { 633 + let mut resolver = MockIdentityResolver::new(); 634 + resolver.add_identity("bob.test.com", "did:plc:bob456"); 635 + resolver.add_identity("charlie.test.com", "did:plc:charlie789"); 636 + 637 + // Limit to 2 mentions 638 + let limits = FacetLimits { 639 + mentions_max: 2, 640 + tags_max: 5, 641 + links_max: 5, 642 + max: 10, 643 + }; 644 + 645 + let text = "Join @alice.bsky.social @bob.test.com @charlie.test.com"; 646 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 647 + 648 + assert!(facets.is_some()); 649 + let facets = facets.unwrap(); 650 + // Should only have 2 mentions (alice and bob), charlie should be skipped 651 + assert_eq!(facets.len(), 2); 652 + 653 + // Verify they're both mentions 654 + for facet in &facets { 655 + assert!(matches!(facet.features[0], FacetFeature::Mention(_))); 656 + } 657 + } 658 + 659 + #[tokio::test] 660 + async fn test_parse_facets_with_global_limit() { 661 + let mut resolver = MockIdentityResolver::new(); 662 + resolver.add_identity("bob.test.com", "did:plc:bob456"); 663 + 664 + // Very restrictive global limit 665 + let limits = FacetLimits { 666 + mentions_max: 5, 667 + tags_max: 5, 668 + links_max: 5, 669 + max: 3, // Only allow 3 total facets 670 + }; 671 + 672 + let text = 673 + "Join @alice.bsky.social @bob.test.com at https://example.com #rust #golang #python"; 674 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 675 + 676 + assert!(facets.is_some()); 677 + let facets = facets.unwrap(); 678 + // Should be truncated to 3 facets total 679 + assert_eq!(facets.len(), 3); 680 + } 681 + 682 + #[test] 683 + fn test_parse_urls_multiple_links() { 684 + let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd & Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"; 685 + 686 + let facets = parse_urls(text); 687 + 688 + // Should find both URLs 689 + assert_eq!( 690 + facets.len(), 691 + 2, 692 + "Expected 2 URLs but found {}", 693 + facets.len() 694 + ); 695 + 696 + // Check first URL 697 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 698 + assert_eq!(link.uri, "https://www.ietf.org/meeting/124/"); 699 + } else { 700 + panic!("Expected Link feature"); 701 + } 702 + 703 + // Check second URL 704 + if let Some(FacetFeature::Link(link)) = facets[1].features.first() { 705 + assert_eq!( 706 + link.uri, 707 + "https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164" 708 + ); 709 + } else { 710 + panic!("Expected Link feature"); 711 + } 712 + } 713 + 714 + #[test] 715 + fn test_parse_urls_with_html_entity() { 716 + // Test with the HTML entity &amp; in the text 717 + let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd &amp; Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"; 718 + 719 + let facets = parse_urls(text); 720 + 721 + // Should find both URLs 722 + assert_eq!( 723 + facets.len(), 724 + 2, 725 + "Expected 2 URLs but found {}", 726 + facets.len() 727 + ); 728 + 729 + // Check first URL 730 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 731 + assert_eq!(link.uri, "https://www.ietf.org/meeting/124/"); 732 + } else { 733 + panic!("Expected Link feature"); 734 + } 735 + 736 + // Check second URL 737 + if let Some(FacetFeature::Link(link)) = facets[1].features.first() { 738 + assert_eq!( 739 + link.uri, 740 + "https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164" 741 + ); 742 + } else { 743 + panic!("Expected Link feature"); 744 + } 745 + } 746 + 747 + #[test] 748 + fn test_byte_offset_with_html_entities() { 749 + // This test demonstrates that HTML entity escaping shifts byte positions. 750 + // The byte positions shift: 751 + // In original: '&' is at byte 8 (1 byte) 752 + // In escaped: '&amp;' starts at byte 8 (5 bytes) 753 + // This causes facet byte offsets to be misaligned if text is escaped before rendering. 754 + 755 + // If we have a URL after the ampersand in the original: 756 + let original_with_url = "Nov 3rd & Tuesday https://example.com"; 757 + let escaped_with_url = "Nov 3rd &amp; Tuesday https://example.com"; 758 + 759 + // Parse URLs from both versions 760 + let original_facets = parse_urls(original_with_url); 761 + let escaped_facets = parse_urls(escaped_with_url); 762 + 763 + // Both should find the URL, but at different byte positions 764 + assert_eq!(original_facets.len(), 1); 765 + assert_eq!(escaped_facets.len(), 1); 766 + 767 + // The byte positions will be different 768 + assert_eq!(original_facets[0].index.byte_start, 18); // After "Nov 3rd & Tuesday " 769 + assert_eq!(escaped_facets[0].index.byte_start, 22); // After "Nov 3rd &amp; Tuesday " (4 extra bytes for &amp;) 770 + } 771 + 772 + #[test] 773 + fn test_parse_urls_from_atproto_record_text() { 774 + // Test parsing URLs from real AT Protocol record description text. 775 + // This demonstrates the correct byte positions that should be used for facets. 776 + let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\nโœจthe danielโœจ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/"; 777 + 778 + let facets = parse_urls(text); 779 + 780 + assert_eq!(facets.len(), 2, "Should find 2 URLs"); 781 + 782 + // First URL: https://stream.place/psingletary.com 783 + assert_eq!(facets[0].index.byte_start, 221); 784 + assert_eq!(facets[0].index.byte_end, 257); 785 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 786 + assert_eq!(link.uri, "https://stream.place/psingletary.com"); 787 + } 788 + 789 + // Second URL: https://atprotocalls.leaflet.pub/ 790 + assert_eq!(facets[1].index.byte_start, 290); 791 + assert_eq!(facets[1].index.byte_end, 323); 792 + if let Some(FacetFeature::Link(link)) = facets[1].features.first() { 793 + assert_eq!(link.uri, "https://atprotocalls.leaflet.pub/"); 794 + } 795 + 796 + // Verify the byte slices match the expected text 797 + let text_bytes = text.as_bytes(); 798 + assert_eq!( 799 + std::str::from_utf8(&text_bytes[221..257]).unwrap(), 800 + "https://stream.place/psingletary.com" 801 + ); 802 + assert_eq!( 803 + std::str::from_utf8(&text_bytes[290..323]).unwrap(), 804 + "https://atprotocalls.leaflet.pub/" 805 + ); 806 + } 807 + 808 + #[tokio::test] 809 + async fn test_parse_mentions_basic() { 810 + let resolver = MockIdentityResolver::new(); 811 + let limits = FacetLimits::default(); 812 + let text = "Hello @alice.bsky.social!"; 813 + let facets = parse_mentions(text, &resolver, &limits).await; 814 + 815 + assert_eq!(facets.len(), 1); 816 + assert_eq!(facets[0].index.byte_start, 6); 817 + assert_eq!(facets[0].index.byte_end, 24); 818 + if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() { 819 + assert_eq!(mention.did, "did:plc:alice123"); 820 + } else { 821 + panic!("Expected Mention feature"); 822 + } 823 + } 824 + 825 + #[tokio::test] 826 + async fn test_parse_mentions_multiple() { 827 + let mut resolver = MockIdentityResolver::new(); 828 + resolver.add_identity("bob.example.com", "did:plc:bob456"); 829 + let limits = FacetLimits::default(); 830 + let text = "CC @alice.bsky.social and @bob.example.com"; 831 + let facets = parse_mentions(text, &resolver, &limits).await; 832 + 833 + assert_eq!(facets.len(), 2); 834 + if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() { 835 + assert_eq!(mention.did, "did:plc:alice123"); 836 + } 837 + if let Some(FacetFeature::Mention(mention)) = facets[1].features.first() { 838 + assert_eq!(mention.did, "did:plc:bob456"); 839 + } 840 + } 841 + 842 + #[tokio::test] 843 + async fn test_parse_mentions_unresolvable() { 844 + let resolver = MockIdentityResolver::new(); 845 + let limits = FacetLimits::default(); 846 + // unknown.handle.com is not in the resolver 847 + let text = "Hello @unknown.handle.com!"; 848 + let facets = parse_mentions(text, &resolver, &limits).await; 849 + 850 + // Should be empty since the handle can't be resolved 851 + assert_eq!(facets.len(), 0); 852 + } 853 + 854 + #[tokio::test] 855 + async fn test_parse_mentions_in_url_excluded() { 856 + let resolver = MockIdentityResolver::new(); 857 + let limits = FacetLimits::default(); 858 + // The @smokesignal.events is inside a URL and should not be parsed as a mention 859 + let text = "Check https://tangled.org/@smokesignal.events"; 860 + let facets = parse_mentions(text, &resolver, &limits).await; 861 + 862 + // Should be empty since the mention is inside a URL 863 + assert_eq!(facets.len(), 0); 864 + } 865 + 866 + #[test] 867 + fn test_parse_tags_basic() { 868 + let text = "Learning #rust today!"; 869 + let facets = parse_tags(text); 870 + 871 + assert_eq!(facets.len(), 1); 872 + assert_eq!(facets[0].index.byte_start, 9); 873 + assert_eq!(facets[0].index.byte_end, 14); 874 + if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 875 + assert_eq!(tag.tag, "rust"); 876 + } else { 877 + panic!("Expected Tag feature"); 878 + } 879 + } 880 + 881 + #[test] 882 + fn test_parse_tags_multiple() { 883 + let text = "#rust #golang #python are great!"; 884 + let facets = parse_tags(text); 885 + 886 + assert_eq!(facets.len(), 3); 887 + if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 888 + assert_eq!(tag.tag, "rust"); 889 + } 890 + if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() { 891 + assert_eq!(tag.tag, "golang"); 892 + } 893 + if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() { 894 + assert_eq!(tag.tag, "python"); 895 + } 896 + } 897 + 898 + #[test] 899 + fn test_parse_tags_excludes_numeric() { 900 + let text = "Item #42 is special #test123"; 901 + let facets = parse_tags(text); 902 + 903 + // #42 should be excluded (purely numeric), #test123 should be included 904 + assert_eq!(facets.len(), 1); 905 + if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 906 + assert_eq!(tag.tag, "test123"); 907 + } 908 + } 909 + 910 + #[test] 911 + fn test_parse_urls_basic() { 912 + let text = "Visit https://example.com today!"; 913 + let facets = parse_urls(text); 914 + 915 + assert_eq!(facets.len(), 1); 916 + assert_eq!(facets[0].index.byte_start, 6); 917 + assert_eq!(facets[0].index.byte_end, 25); 918 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 919 + assert_eq!(link.uri, "https://example.com"); 920 + } 921 + } 922 + 923 + #[test] 924 + fn test_parse_urls_with_path() { 925 + let text = "Check https://example.com/path/to/page?query=1#section"; 926 + let facets = parse_urls(text); 927 + 928 + assert_eq!(facets.len(), 1); 929 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 930 + assert_eq!(link.uri, "https://example.com/path/to/page?query=1#section"); 931 + } 932 + } 933 + 934 + #[test] 935 + fn test_facet_limits_default() { 936 + let limits = FacetLimits::default(); 937 + assert_eq!(limits.mentions_max, 5); 938 + assert_eq!(limits.tags_max, 5); 939 + assert_eq!(limits.links_max, 5); 940 + assert_eq!(limits.max, 10); 941 + } 942 + }
+50
crates/atproto-extras/src/lib.rs
···
··· 1 + //! Extra utilities for AT Protocol applications. 2 + //! 3 + //! This crate provides additional utilities that complement the core AT Protocol 4 + //! identity and record crates. Currently, it focuses on rich text facet parsing. 5 + //! 6 + //! ## Features 7 + //! 8 + //! - **Facet Parsing**: Extract mentions, URLs, and hashtags from plain text 9 + //! with correct UTF-8 byte offset calculation 10 + //! - **Identity Integration**: Resolve mention handles to DIDs during parsing 11 + //! 12 + //! ## Example 13 + //! 14 + //! ```ignore 15 + //! use atproto_extras::{parse_facets_from_text, FacetLimits}; 16 + //! 17 + //! // Parse facets from text (requires an IdentityResolver) 18 + //! let text = "Hello @alice.bsky.social! Check out https://example.com #rust"; 19 + //! let limits = FacetLimits::default(); 20 + //! let facets = parse_facets_from_text(text, &resolver, &limits).await; 21 + //! ``` 22 + //! 23 + //! ## Byte Offset Calculation 24 + //! 25 + //! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol. 26 + //! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done 27 + //! using `regex::bytes::Regex` which operates on byte slices and returns byte positions, 28 + //! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars). 29 + 30 + #![forbid(unsafe_code)] 31 + #![warn(missing_docs)] 32 + 33 + /// Rich text facet parsing for AT Protocol. 34 + /// 35 + /// This module provides functionality for extracting semantic annotations (facets) 36 + /// from plain text. Facets include: 37 + /// 38 + /// - **Mentions**: User handles prefixed with `@` (e.g., `@alice.bsky.social`) 39 + /// - **Links**: HTTP/HTTPS URLs 40 + /// - **Tags**: Hashtags prefixed with `#` or `๏ผƒ` (e.g., `#rust`) 41 + /// 42 + /// ## Byte Offsets 43 + /// 44 + /// All facet indices use UTF-8 byte offsets, not character indices. This is 45 + /// critical for correct handling of multi-byte characters like emojis or 46 + /// non-ASCII text. 47 + pub mod facets; 48 + 49 + /// Re-export commonly used types for convenience. 50 + pub use facets::{FacetLimits, parse_facets_from_text, parse_mentions, parse_tags, parse_urls};
+19 -1
crates/atproto-identity/src/model.rs
··· 70 /// The DID identifier (e.g., "did:plc:abc123"). 71 pub id: String, 72 /// Alternative identifiers like handles and domains. 73 pub also_known_as: Vec<String>, 74 /// Available services for this identity. 75 pub service: Vec<Service>, 76 77 /// Cryptographic verification methods. 78 - #[serde(alias = "verificationMethod")] 79 pub verification_method: Vec<VerificationMethod>, 80 81 /// Additional document properties not explicitly defined. ··· 402 let document = document.unwrap(); 403 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 404 } 405 } 406 }
··· 70 /// The DID identifier (e.g., "did:plc:abc123"). 71 pub id: String, 72 /// Alternative identifiers like handles and domains. 73 + #[serde(default)] 74 pub also_known_as: Vec<String>, 75 /// Available services for this identity. 76 + #[serde(default)] 77 pub service: Vec<Service>, 78 79 /// Cryptographic verification methods. 80 + #[serde(alias = "verificationMethod", default)] 81 pub verification_method: Vec<VerificationMethod>, 82 83 /// Additional document properties not explicitly defined. ··· 404 let document = document.unwrap(); 405 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 406 } 407 + } 408 + 409 + #[test] 410 + fn test_deserialize_service_did_document() { 411 + // DID document from api.bsky.app - a service DID without alsoKnownAs 412 + let document = serde_json::from_str::<Document>( 413 + r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:web:api.bsky.app","verificationMethod":[{"id":"did:web:api.bsky.app#atproto","type":"Multikey","controller":"did:web:api.bsky.app","publicKeyMultibase":"zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"}],"service":[{"id":"#bsky_notif","type":"BskyNotificationService","serviceEndpoint":"https://api.bsky.app"},{"id":"#bsky_appview","type":"BskyAppView","serviceEndpoint":"https://api.bsky.app"}]}"##, 414 + ); 415 + assert!(document.is_ok(), "Failed to parse: {:?}", document.err()); 416 + 417 + let document = document.unwrap(); 418 + assert_eq!(document.id, "did:web:api.bsky.app"); 419 + assert!(document.also_known_as.is_empty()); 420 + assert_eq!(document.service.len(), 2); 421 + assert_eq!(document.service[0].id, "#bsky_notif"); 422 + assert_eq!(document.service[1].id, "#bsky_appview"); 423 } 424 }
+75 -24
crates/atproto-jetstream/src/consumer.rs
··· 2 //! 3 //! WebSocket event consumption with background processing and 4 //! customizable event handler dispatch. 5 6 use crate::errors::ConsumerError; 7 use anyhow::Result; ··· 133 #[async_trait] 134 pub trait EventHandler: Send + Sync { 135 /// Handle a received event 136 - async fn handle_event(&self, event: JetstreamEvent) -> Result<()>; 137 138 /// Get the handler's identifier 139 - fn handler_id(&self) -> String; 140 } 141 142 #[cfg_attr(debug_assertions, derive(Debug))] ··· 167 pub struct Consumer { 168 config: ConsumerTaskConfig, 169 handlers: Arc<RwLock<HashMap<String, Arc<dyn EventHandler>>>>, 170 - event_sender: Arc<RwLock<Option<broadcast::Sender<JetstreamEvent>>>>, 171 } 172 173 impl Consumer { ··· 185 let handler_id = handler.handler_id(); 186 let mut handlers = self.handlers.write().await; 187 188 - if handlers.contains_key(&handler_id) { 189 return Err(ConsumerError::HandlerRegistrationFailed(format!( 190 "Handler with ID '{}' already registered", 191 handler_id ··· 193 .into()); 194 } 195 196 - handlers.insert(handler_id.clone(), handler); 197 Ok(()) 198 } 199 ··· 205 } 206 207 /// Get a broadcast receiver for events 208 - pub async fn get_event_receiver(&self) -> Result<broadcast::Receiver<JetstreamEvent>> { 209 let sender_guard = self.event_sender.read().await; 210 match sender_guard.as_ref() { 211 Some(sender) => Ok(sender.subscribe()), ··· 249 tracing::info!("Starting Jetstream consumer"); 250 251 // Build WebSocket URL with query parameters 252 - let mut query_params = vec![]; 253 254 // Add compression parameter 255 - query_params.push(format!("compress={}", self.config.compression)); 256 257 // Add requireHello parameter 258 - query_params.push(format!("requireHello={}", self.config.require_hello)); 259 260 // Add wantedCollections if specified (each collection as a separate query parameter) 261 if !self.config.collections.is_empty() && !self.config.require_hello { 262 for collection in &self.config.collections { 263 - query_params.push(format!( 264 - "wantedCollections={}", 265 - urlencoding::encode(collection) 266 - )); 267 } 268 } 269 270 // Add wantedDids if specified (each DID as a separate query parameter) 271 if !self.config.dids.is_empty() && !self.config.require_hello { 272 for did in &self.config.dids { 273 - query_params.push(format!("wantedDids={}", urlencoding::encode(did))); 274 } 275 } 276 277 // Add maxMessageSizeBytes if specified 278 if let Some(max_size) = self.config.max_message_size_bytes { 279 - query_params.push(format!("maxMessageSizeBytes={}", max_size)); 280 } 281 282 // Add cursor if specified 283 if let Some(cursor) = self.config.cursor { 284 - query_params.push(format!("cursor={}", cursor)); 285 } 286 - 287 - let query_string = query_params.join("&"); 288 let ws_url = Uri::from_str(&format!( 289 "wss://{}/subscribe?{}", 290 self.config.jetstream_hostname, query_string ··· 335 break; 336 }, 337 () = &mut sleeper => { 338 - // consumer_control_insert(&self.pool, &self.config.jetstream_hostname, time_usec).await?; 339 - 340 sleeper.as_mut().reset(Instant::now() + interval); 341 }, 342 item = client.next() => { ··· 404 } 405 406 /// Dispatch event to all registered handlers 407 async fn dispatch_to_handlers(&self, event: JetstreamEvent) -> Result<()> { 408 let handlers = self.handlers.read().await; 409 410 for (handler_id, handler) in handlers.iter() { 411 let handler_span = tracing::debug_span!("handler_dispatch", handler_id = %handler_id); 412 async { 413 - if let Err(err) = handler.handle_event(event.clone()).await { 414 tracing::error!( 415 error = ?err, 416 handler_id = %handler_id, ··· 440 441 #[async_trait] 442 impl EventHandler for LoggingHandler { 443 - async fn handle_event(&self, _event: JetstreamEvent) -> Result<()> { 444 Ok(()) 445 } 446 447 - fn handler_id(&self) -> String { 448 - self.id.clone() 449 } 450 } 451
··· 2 //! 3 //! WebSocket event consumption with background processing and 4 //! customizable event handler dispatch. 5 + //! 6 + //! ## Memory Efficiency 7 + //! 8 + //! This module is optimized for high-throughput event processing with minimal allocations: 9 + //! 10 + //! - **Arc-based event sharing**: Events are wrapped in `Arc` and shared across all handlers, 11 + //! avoiding expensive clones of event data structures. 12 + //! - **Zero-copy handler IDs**: Handler identifiers use string slices to avoid allocations 13 + //! during registration and dispatch. 14 + //! - **Optimized query building**: WebSocket query strings are built with pre-allocated 15 + //! capacity to minimize reallocations. 16 + //! 17 + //! ## Usage 18 + //! 19 + //! Implement the `EventHandler` trait to process events: 20 + //! 21 + //! ```rust 22 + //! use atproto_jetstream::{EventHandler, JetstreamEvent}; 23 + //! use async_trait::async_trait; 24 + //! use std::sync::Arc; 25 + //! use anyhow::Result; 26 + //! 27 + //! struct MyHandler; 28 + //! 29 + //! #[async_trait] 30 + //! impl EventHandler for MyHandler { 31 + //! async fn handle_event(&self, event: Arc<JetstreamEvent>) -> Result<()> { 32 + //! // Process event without cloning 33 + //! Ok(()) 34 + //! } 35 + //! 36 + //! fn handler_id(&self) -> &str { 37 + //! "my-handler" 38 + //! } 39 + //! } 40 + //! ``` 41 42 use crate::errors::ConsumerError; 43 use anyhow::Result; ··· 169 #[async_trait] 170 pub trait EventHandler: Send + Sync { 171 /// Handle a received event 172 + /// 173 + /// Events are wrapped in Arc to enable efficient sharing across multiple handlers 174 + /// without cloning the entire event data structure. 175 + async fn handle_event(&self, event: Arc<JetstreamEvent>) -> Result<()>; 176 177 /// Get the handler's identifier 178 + /// 179 + /// Returns a string slice to avoid unnecessary allocations. 180 + fn handler_id(&self) -> &str; 181 } 182 183 #[cfg_attr(debug_assertions, derive(Debug))] ··· 208 pub struct Consumer { 209 config: ConsumerTaskConfig, 210 handlers: Arc<RwLock<HashMap<String, Arc<dyn EventHandler>>>>, 211 + event_sender: Arc<RwLock<Option<broadcast::Sender<Arc<JetstreamEvent>>>>>, 212 } 213 214 impl Consumer { ··· 226 let handler_id = handler.handler_id(); 227 let mut handlers = self.handlers.write().await; 228 229 + if handlers.contains_key(handler_id) { 230 return Err(ConsumerError::HandlerRegistrationFailed(format!( 231 "Handler with ID '{}' already registered", 232 handler_id ··· 234 .into()); 235 } 236 237 + handlers.insert(handler_id.to_string(), handler); 238 Ok(()) 239 } 240 ··· 246 } 247 248 /// Get a broadcast receiver for events 249 + /// 250 + /// Events are wrapped in Arc to enable efficient sharing without cloning. 251 + pub async fn get_event_receiver(&self) -> Result<broadcast::Receiver<Arc<JetstreamEvent>>> { 252 let sender_guard = self.event_sender.read().await; 253 match sender_guard.as_ref() { 254 Some(sender) => Ok(sender.subscribe()), ··· 292 tracing::info!("Starting Jetstream consumer"); 293 294 // Build WebSocket URL with query parameters 295 + // Pre-allocate capacity to avoid reallocations during string building 296 + let capacity = 50 // Base parameters 297 + + self.config.collections.len() * 30 // Estimate per collection 298 + + self.config.dids.len() * 60; // Estimate per DID 299 + let mut query_string = String::with_capacity(capacity); 300 301 // Add compression parameter 302 + query_string.push_str("compress="); 303 + query_string.push_str(if self.config.compression { "true" } else { "false" }); 304 305 // Add requireHello parameter 306 + query_string.push_str("&requireHello="); 307 + query_string.push_str(if self.config.require_hello { "true" } else { "false" }); 308 309 // Add wantedCollections if specified (each collection as a separate query parameter) 310 if !self.config.collections.is_empty() && !self.config.require_hello { 311 for collection in &self.config.collections { 312 + query_string.push_str("&wantedCollections="); 313 + query_string.push_str(&urlencoding::encode(collection)); 314 } 315 } 316 317 // Add wantedDids if specified (each DID as a separate query parameter) 318 if !self.config.dids.is_empty() && !self.config.require_hello { 319 for did in &self.config.dids { 320 + query_string.push_str("&wantedDids="); 321 + query_string.push_str(&urlencoding::encode(did)); 322 } 323 } 324 325 // Add maxMessageSizeBytes if specified 326 if let Some(max_size) = self.config.max_message_size_bytes { 327 + use std::fmt::Write; 328 + write!(&mut query_string, "&maxMessageSizeBytes={}", max_size).unwrap(); 329 } 330 331 // Add cursor if specified 332 if let Some(cursor) = self.config.cursor { 333 + use std::fmt::Write; 334 + write!(&mut query_string, "&cursor={}", cursor).unwrap(); 335 } 336 let ws_url = Uri::from_str(&format!( 337 "wss://{}/subscribe?{}", 338 self.config.jetstream_hostname, query_string ··· 383 break; 384 }, 385 () = &mut sleeper => { 386 sleeper.as_mut().reset(Instant::now() + interval); 387 }, 388 item = client.next() => { ··· 450 } 451 452 /// Dispatch event to all registered handlers 453 + /// 454 + /// Wraps the event in Arc once and shares it across all handlers, 455 + /// avoiding expensive clones of the event data structure. 456 async fn dispatch_to_handlers(&self, event: JetstreamEvent) -> Result<()> { 457 let handlers = self.handlers.read().await; 458 + let event = Arc::new(event); 459 460 for (handler_id, handler) in handlers.iter() { 461 let handler_span = tracing::debug_span!("handler_dispatch", handler_id = %handler_id); 462 + let event_ref = Arc::clone(&event); 463 async { 464 + if let Err(err) = handler.handle_event(event_ref).await { 465 tracing::error!( 466 error = ?err, 467 handler_id = %handler_id, ··· 491 492 #[async_trait] 493 impl EventHandler for LoggingHandler { 494 + async fn handle_event(&self, _event: Arc<JetstreamEvent>) -> Result<()> { 495 Ok(()) 496 } 497 498 + fn handler_id(&self) -> &str { 499 + &self.id 500 } 501 } 502
+374 -5
crates/atproto-oauth/src/scopes.rs
··· 38 Atproto, 39 /// Transition scope for migration operations 40 Transition(TransitionScope), 41 /// OpenID Connect scope - required for OpenID Connect authentication 42 OpenId, 43 /// Profile scope - access to user profile information ··· 91 Generic, 92 /// Email transition operations 93 Email, 94 } 95 96 /// Blob scope with mime type constraints ··· 310 "rpc", 311 "atproto", 312 "transition", 313 "openid", 314 "profile", 315 "email", ··· 349 "rpc" => Self::parse_rpc(suffix), 350 "atproto" => Self::parse_atproto(suffix), 351 "transition" => Self::parse_transition(suffix), 352 "openid" => Self::parse_openid(suffix), 353 "profile" => Self::parse_profile(suffix), 354 "email" => Self::parse_email(suffix), ··· 573 Ok(Scope::Transition(scope)) 574 } 575 576 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 577 if suffix.is_some() { 578 return Err(ParseError::InvalidResource( ··· 677 if let Some(lxm) = scope.lxm.iter().next() { 678 match lxm { 679 RpcLexicon::All => "rpc:*".to_string(), 680 - RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), 681 } 682 } else { 683 "rpc:*".to_string() ··· 713 TransitionScope::Generic => "transition:generic".to_string(), 714 TransitionScope::Email => "transition:email".to_string(), 715 }, 716 Scope::OpenId => "openid".to_string(), 717 Scope::Profile => "profile".to_string(), 718 Scope::Email => "email".to_string(), ··· 732 // Other scopes don't grant transition scopes 733 (_, Scope::Transition(_)) => false, 734 (Scope::Transition(_), _) => false, 735 // OpenID Connect scopes only grant themselves 736 (Scope::OpenId, Scope::OpenId) => true, 737 (Scope::OpenId, _) => false, ··· 873 params 874 } 875 876 /// Error type for scope parsing 877 #[derive(Debug, Clone, PartialEq, Eq)] 878 pub enum ParseError { ··· 1056 ("repo:foo.bar", "repo:foo.bar"), 1057 ("repo:foo.bar?action=create", "repo:foo.bar?action=create"), 1058 ("rpc:*", "rpc:*"), 1059 ]; 1060 1061 for (input, expected) in tests { ··· 1677 1678 // Test with complex scopes including query parameters 1679 let scopes = vec![ 1680 - Scope::parse("rpc:com.example.service?aud=did:example:123&lxm=com.example.method") 1681 - .unwrap(), 1682 Scope::parse("repo:foo.bar?action=create&action=update").unwrap(), 1683 Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1684 ]; 1685 let result = Scope::serialize_multiple(&scopes); 1686 // The result should be sorted alphabetically 1687 - // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 1688 assert!(result.starts_with("blob:")); 1689 assert!(result.contains(" repo:")); 1690 - assert!(result.contains("rpc?aud=did:example:123&lxm=com.example.service")); 1691 1692 // Test with transition scopes 1693 let scopes = vec![ ··· 1835 assert!(!result.contains(&Scope::parse("account:email").unwrap())); 1836 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 1837 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 1838 } 1839 }
··· 38 Atproto, 39 /// Transition scope for migration operations 40 Transition(TransitionScope), 41 + /// Include scope for referencing permission sets by NSID 42 + Include(IncludeScope), 43 /// OpenID Connect scope - required for OpenID Connect authentication 44 OpenId, 45 /// Profile scope - access to user profile information ··· 93 Generic, 94 /// Email transition operations 95 Email, 96 + } 97 + 98 + /// Include scope for referencing permission sets by NSID 99 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 100 + pub struct IncludeScope { 101 + /// The permission set NSID (e.g., "app.example.authFull") 102 + pub nsid: String, 103 + /// Optional audience DID for inherited RPC permissions 104 + pub aud: Option<String>, 105 } 106 107 /// Blob scope with mime type constraints ··· 321 "rpc", 322 "atproto", 323 "transition", 324 + "include", 325 "openid", 326 "profile", 327 "email", ··· 361 "rpc" => Self::parse_rpc(suffix), 362 "atproto" => Self::parse_atproto(suffix), 363 "transition" => Self::parse_transition(suffix), 364 + "include" => Self::parse_include(suffix), 365 "openid" => Self::parse_openid(suffix), 366 "profile" => Self::parse_profile(suffix), 367 "email" => Self::parse_email(suffix), ··· 586 Ok(Scope::Transition(scope)) 587 } 588 589 + fn parse_include(suffix: Option<&str>) -> Result<Self, ParseError> { 590 + let (nsid, params) = match suffix { 591 + Some(s) => { 592 + if let Some(pos) = s.find('?') { 593 + (&s[..pos], Some(&s[pos + 1..])) 594 + } else { 595 + (s, None) 596 + } 597 + } 598 + None => return Err(ParseError::MissingResource), 599 + }; 600 + 601 + if nsid.is_empty() { 602 + return Err(ParseError::MissingResource); 603 + } 604 + 605 + let aud = if let Some(params) = params { 606 + let parsed_params = parse_query_string(params); 607 + parsed_params 608 + .get("aud") 609 + .and_then(|v| v.first()) 610 + .map(|s| url_decode(s)) 611 + } else { 612 + None 613 + }; 614 + 615 + Ok(Scope::Include(IncludeScope { 616 + nsid: nsid.to_string(), 617 + aud, 618 + })) 619 + } 620 + 621 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 622 if suffix.is_some() { 623 return Err(ParseError::InvalidResource( ··· 722 if let Some(lxm) = scope.lxm.iter().next() { 723 match lxm { 724 RpcLexicon::All => "rpc:*".to_string(), 725 + RpcLexicon::Nsid(nsid) => format!("rpc:{}?aud=*", nsid), 726 + } 727 + } else { 728 + "rpc:*".to_string() 729 + } 730 + } else if scope.lxm.len() == 1 && scope.aud.len() == 1 { 731 + // Single lxm and single aud (aud is not All, handled above) 732 + if let (Some(lxm), Some(aud)) = 733 + (scope.lxm.iter().next(), scope.aud.iter().next()) 734 + { 735 + match (lxm, aud) { 736 + (RpcLexicon::Nsid(nsid), RpcAudience::Did(did)) => { 737 + format!("rpc:{}?aud={}", nsid, did) 738 + } 739 + (RpcLexicon::All, RpcAudience::Did(did)) => { 740 + format!("rpc:*?aud={}", did) 741 + } 742 + _ => "rpc:*".to_string(), 743 } 744 } else { 745 "rpc:*".to_string() ··· 775 TransitionScope::Generic => "transition:generic".to_string(), 776 TransitionScope::Email => "transition:email".to_string(), 777 }, 778 + Scope::Include(scope) => { 779 + if let Some(ref aud) = scope.aud { 780 + format!("include:{}?aud={}", scope.nsid, url_encode(aud)) 781 + } else { 782 + format!("include:{}", scope.nsid) 783 + } 784 + } 785 Scope::OpenId => "openid".to_string(), 786 Scope::Profile => "profile".to_string(), 787 Scope::Email => "email".to_string(), ··· 801 // Other scopes don't grant transition scopes 802 (_, Scope::Transition(_)) => false, 803 (Scope::Transition(_), _) => false, 804 + // Include scopes only grant themselves (exact match including aud) 805 + (Scope::Include(a), Scope::Include(b)) => a == b, 806 + // Other scopes don't grant include scopes 807 + (_, Scope::Include(_)) => false, 808 + (Scope::Include(_), _) => false, 809 // OpenID Connect scopes only grant themselves 810 (Scope::OpenId, Scope::OpenId) => true, 811 (Scope::OpenId, _) => false, ··· 947 params 948 } 949 950 + /// Decode a percent-encoded string 951 + fn url_decode(s: &str) -> String { 952 + let mut result = String::with_capacity(s.len()); 953 + let mut chars = s.chars().peekable(); 954 + 955 + while let Some(c) = chars.next() { 956 + if c == '%' { 957 + let hex: String = chars.by_ref().take(2).collect(); 958 + if hex.len() == 2 { 959 + if let Ok(byte) = u8::from_str_radix(&hex, 16) { 960 + result.push(byte as char); 961 + continue; 962 + } 963 + } 964 + result.push('%'); 965 + result.push_str(&hex); 966 + } else { 967 + result.push(c); 968 + } 969 + } 970 + 971 + result 972 + } 973 + 974 + /// Encode a string for use in a URL query parameter 975 + fn url_encode(s: &str) -> String { 976 + let mut result = String::with_capacity(s.len() * 3); 977 + 978 + for c in s.chars() { 979 + match c { 980 + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | ':' => { 981 + result.push(c); 982 + } 983 + _ => { 984 + for byte in c.to_string().as_bytes() { 985 + result.push_str(&format!("%{:02X}", byte)); 986 + } 987 + } 988 + } 989 + } 990 + 991 + result 992 + } 993 + 994 /// Error type for scope parsing 995 #[derive(Debug, Clone, PartialEq, Eq)] 996 pub enum ParseError { ··· 1174 ("repo:foo.bar", "repo:foo.bar"), 1175 ("repo:foo.bar?action=create", "repo:foo.bar?action=create"), 1176 ("rpc:*", "rpc:*"), 1177 + ("rpc:com.example.service", "rpc:com.example.service?aud=*"), 1178 + ( 1179 + "rpc:com.example.service?aud=did:example:123", 1180 + "rpc:com.example.service?aud=did:example:123", 1181 + ), 1182 ]; 1183 1184 for (input, expected) in tests { ··· 1800 1801 // Test with complex scopes including query parameters 1802 let scopes = vec![ 1803 + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 1804 Scope::parse("repo:foo.bar?action=create&action=update").unwrap(), 1805 Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1806 ]; 1807 let result = Scope::serialize_multiple(&scopes); 1808 // The result should be sorted alphabetically 1809 + // Single lxm + single aud is serialized as "rpc:[lxm]?aud=[aud]" 1810 assert!(result.starts_with("blob:")); 1811 assert!(result.contains(" repo:")); 1812 + assert!(result.contains("rpc:com.example.service?aud=did:example:123")); 1813 1814 // Test with transition scopes 1815 let scopes = vec![ ··· 1957 assert!(!result.contains(&Scope::parse("account:email").unwrap())); 1958 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 1959 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 1960 + } 1961 + 1962 + #[test] 1963 + fn test_repo_nsid_with_wildcard_suffix() { 1964 + // Test parsing "repo:app.bsky.feed.*" - the asterisk is treated as a literal part of the NSID, 1965 + // not as a wildcard pattern. Only "repo:*" has special wildcard behavior for ALL collections. 1966 + let scope = Scope::parse("repo:app.bsky.feed.*").unwrap(); 1967 + 1968 + // Verify it parses as a specific NSID, not as a wildcard 1969 + assert_eq!( 1970 + scope, 1971 + Scope::Repo(RepoScope { 1972 + collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()), 1973 + actions: { 1974 + let mut actions = BTreeSet::new(); 1975 + actions.insert(RepoAction::Create); 1976 + actions.insert(RepoAction::Update); 1977 + actions.insert(RepoAction::Delete); 1978 + actions 1979 + } 1980 + }) 1981 + ); 1982 + 1983 + // Verify normalization preserves the literal NSID 1984 + assert_eq!(scope.to_string_normalized(), "repo:app.bsky.feed.*"); 1985 + 1986 + // Test that it does NOT grant access to "app.bsky.feed.post" 1987 + // (because "app.bsky.feed.*" is a literal NSID, not a pattern) 1988 + let specific_feed = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1989 + assert!(!scope.grants(&specific_feed)); 1990 + 1991 + // Test that only "repo:*" grants access to "app.bsky.feed.*" 1992 + let repo_all = Scope::parse("repo:*").unwrap(); 1993 + assert!(repo_all.grants(&scope)); 1994 + 1995 + // Test that "repo:app.bsky.feed.*" only grants itself 1996 + assert!(scope.grants(&scope)); 1997 + 1998 + // Test with actions 1999 + let scope_with_create = Scope::parse("repo:app.bsky.feed.*?action=create").unwrap(); 2000 + assert_eq!( 2001 + scope_with_create, 2002 + Scope::Repo(RepoScope { 2003 + collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()), 2004 + actions: { 2005 + let mut actions = BTreeSet::new(); 2006 + actions.insert(RepoAction::Create); 2007 + actions 2008 + } 2009 + }) 2010 + ); 2011 + 2012 + // The full scope (with all actions) grants the create-only scope 2013 + assert!(scope.grants(&scope_with_create)); 2014 + // But the create-only scope does NOT grant the full scope 2015 + assert!(!scope_with_create.grants(&scope)); 2016 + 2017 + // Test parsing multiple scopes with NSID wildcards 2018 + let scopes = Scope::parse_multiple("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap(); 2019 + assert_eq!(scopes.len(), 3); 2020 + 2021 + // Test that parse_multiple_reduced properly reduces when "repo:*" is present 2022 + let reduced = Scope::parse_multiple_reduced("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap(); 2023 + assert_eq!(reduced.len(), 1); 2024 + assert_eq!(reduced[0], repo_all); 2025 + } 2026 + 2027 + #[test] 2028 + fn test_include_scope_parsing() { 2029 + // Test basic include scope 2030 + let scope = Scope::parse("include:app.example.authFull").unwrap(); 2031 + assert_eq!( 2032 + scope, 2033 + Scope::Include(IncludeScope { 2034 + nsid: "app.example.authFull".to_string(), 2035 + aud: None, 2036 + }) 2037 + ); 2038 + 2039 + // Test include scope with audience 2040 + let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap(); 2041 + assert_eq!( 2042 + scope, 2043 + Scope::Include(IncludeScope { 2044 + nsid: "app.example.authFull".to_string(), 2045 + aud: Some("did:web:api.example.com".to_string()), 2046 + }) 2047 + ); 2048 + 2049 + // Test include scope with URL-encoded audience (with fragment) 2050 + let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap(); 2051 + assert_eq!( 2052 + scope, 2053 + Scope::Include(IncludeScope { 2054 + nsid: "app.example.authFull".to_string(), 2055 + aud: Some("did:web:api.example.com#svc_chat".to_string()), 2056 + }) 2057 + ); 2058 + 2059 + // Test missing NSID 2060 + assert!(matches!( 2061 + Scope::parse("include"), 2062 + Err(ParseError::MissingResource) 2063 + )); 2064 + 2065 + // Test empty NSID with query params 2066 + assert!(matches!( 2067 + Scope::parse("include:?aud=did:example:123"), 2068 + Err(ParseError::MissingResource) 2069 + )); 2070 + } 2071 + 2072 + #[test] 2073 + fn test_include_scope_normalization() { 2074 + // Test normalization without audience 2075 + let scope = Scope::parse("include:com.example.authBasic").unwrap(); 2076 + assert_eq!(scope.to_string_normalized(), "include:com.example.authBasic"); 2077 + 2078 + // Test normalization with audience (no special chars) 2079 + let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap(); 2080 + assert_eq!( 2081 + scope.to_string_normalized(), 2082 + "include:com.example.authBasic?aud=did:plc:xyz123" 2083 + ); 2084 + 2085 + // Test normalization with URL encoding (fragment needs encoding) 2086 + let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap(); 2087 + let normalized = scope.to_string_normalized(); 2088 + assert_eq!( 2089 + normalized, 2090 + "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat" 2091 + ); 2092 + } 2093 + 2094 + #[test] 2095 + fn test_include_scope_grants() { 2096 + let include1 = Scope::parse("include:app.example.authFull").unwrap(); 2097 + let include2 = Scope::parse("include:app.example.authBasic").unwrap(); 2098 + let include1_with_aud = Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap(); 2099 + let account = Scope::parse("account:email").unwrap(); 2100 + 2101 + // Include scopes only grant themselves (exact match) 2102 + assert!(include1.grants(&include1)); 2103 + assert!(!include1.grants(&include2)); 2104 + assert!(!include1.grants(&include1_with_aud)); // Different because aud differs 2105 + assert!(include1_with_aud.grants(&include1_with_aud)); 2106 + 2107 + // Include scopes don't grant other scope types 2108 + assert!(!include1.grants(&account)); 2109 + assert!(!account.grants(&include1)); 2110 + 2111 + // Include scopes don't grant atproto or transition 2112 + let atproto = Scope::parse("atproto").unwrap(); 2113 + let transition = Scope::parse("transition:generic").unwrap(); 2114 + assert!(!include1.grants(&atproto)); 2115 + assert!(!include1.grants(&transition)); 2116 + assert!(!atproto.grants(&include1)); 2117 + assert!(!transition.grants(&include1)); 2118 + } 2119 + 2120 + #[test] 2121 + fn test_parse_multiple_with_include() { 2122 + let scopes = Scope::parse_multiple("atproto include:app.example.auth repo:*").unwrap(); 2123 + assert_eq!(scopes.len(), 3); 2124 + assert_eq!(scopes[0], Scope::Atproto); 2125 + assert!(matches!(scopes[1], Scope::Include(_))); 2126 + assert!(matches!(scopes[2], Scope::Repo(_))); 2127 + 2128 + // Test with URL-encoded audience 2129 + let scopes = Scope::parse_multiple( 2130 + "include:app.example.auth?aud=did:web:api.example.com%23svc account:email" 2131 + ).unwrap(); 2132 + assert_eq!(scopes.len(), 2); 2133 + if let Scope::Include(inc) = &scopes[0] { 2134 + assert_eq!(inc.nsid, "app.example.auth"); 2135 + assert_eq!(inc.aud, Some("did:web:api.example.com#svc".to_string())); 2136 + } else { 2137 + panic!("Expected Include scope"); 2138 + } 2139 + } 2140 + 2141 + #[test] 2142 + fn test_parse_multiple_reduced_with_include() { 2143 + // Include scopes don't reduce each other (each is distinct) 2144 + let scopes = Scope::parse_multiple_reduced( 2145 + "include:app.example.auth include:app.example.other include:app.example.auth" 2146 + ).unwrap(); 2147 + assert_eq!(scopes.len(), 2); // Duplicates are removed 2148 + assert!(scopes.contains(&Scope::Include(IncludeScope { 2149 + nsid: "app.example.auth".to_string(), 2150 + aud: None, 2151 + }))); 2152 + assert!(scopes.contains(&Scope::Include(IncludeScope { 2153 + nsid: "app.example.other".to_string(), 2154 + aud: None, 2155 + }))); 2156 + 2157 + // Include scopes with different audiences are not duplicates 2158 + let scopes = Scope::parse_multiple_reduced( 2159 + "include:app.example.auth include:app.example.auth?aud=did:plc:xyz" 2160 + ).unwrap(); 2161 + assert_eq!(scopes.len(), 2); 2162 + } 2163 + 2164 + #[test] 2165 + fn test_serialize_multiple_with_include() { 2166 + let scopes = vec![ 2167 + Scope::parse("repo:*").unwrap(), 2168 + Scope::parse("include:app.example.authFull").unwrap(), 2169 + Scope::Atproto, 2170 + ]; 2171 + let result = Scope::serialize_multiple(&scopes); 2172 + assert_eq!(result, "atproto include:app.example.authFull repo:*"); 2173 + 2174 + // Test with URL-encoded audience 2175 + let scopes = vec![ 2176 + Scope::Include(IncludeScope { 2177 + nsid: "app.example.auth".to_string(), 2178 + aud: Some("did:web:api.example.com#svc".to_string()), 2179 + }), 2180 + ]; 2181 + let result = Scope::serialize_multiple(&scopes); 2182 + assert_eq!(result, "include:app.example.auth?aud=did:web:api.example.com%23svc"); 2183 + } 2184 + 2185 + #[test] 2186 + fn test_remove_scope_with_include() { 2187 + let scopes = vec![ 2188 + Scope::Atproto, 2189 + Scope::parse("include:app.example.auth").unwrap(), 2190 + Scope::parse("account:email").unwrap(), 2191 + ]; 2192 + let to_remove = Scope::parse("include:app.example.auth").unwrap(); 2193 + let result = Scope::remove_scope(&scopes, &to_remove); 2194 + assert_eq!(result.len(), 2); 2195 + assert!(!result.contains(&to_remove)); 2196 + assert!(result.contains(&Scope::Atproto)); 2197 + } 2198 + 2199 + #[test] 2200 + fn test_include_scope_roundtrip() { 2201 + // Test that parse and serialize are inverses 2202 + let original = "include:com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview"; 2203 + let scope = Scope::parse(original).unwrap(); 2204 + let serialized = scope.to_string_normalized(); 2205 + let reparsed = Scope::parse(&serialized).unwrap(); 2206 + assert_eq!(scope, reparsed); 2207 } 2208 }
+205
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
···
··· 1 + //! AT Protocol rich text facet types. 2 + //! 3 + //! This module provides types for annotating rich text content with semantic 4 + //! meaning, based on the `app.bsky.richtext.facet` lexicon. Facets enable 5 + //! mentions, links, hashtags, and other structured metadata to be attached 6 + //! to specific byte ranges within text content. 7 + //! 8 + //! # Overview 9 + //! 10 + //! Facets consist of: 11 + //! - A byte range (start/end indices in UTF-8 encoded text) 12 + //! - One or more features (mention, link, tag) that apply to that range 13 + //! 14 + //! # Example 15 + //! 16 + //! ```ignore 17 + //! use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, ByteSlice, FacetFeature, Mention}; 18 + //! 19 + //! // Create a mention facet for "@alice.bsky.social" 20 + //! let facet = Facet { 21 + //! index: ByteSlice { byte_start: 0, byte_end: 19 }, 22 + //! features: vec![ 23 + //! FacetFeature::Mention(Mention { 24 + //! did: "did:plc:alice123".to_string(), 25 + //! }) 26 + //! ], 27 + //! }; 28 + //! ``` 29 + 30 + use serde::{Deserialize, Serialize}; 31 + 32 + /// Byte range specification for facet features. 33 + /// 34 + /// Specifies the sub-string range a facet feature applies to using 35 + /// zero-indexed byte offsets in UTF-8 encoded text. Start index is 36 + /// inclusive, end index is exclusive. 37 + /// 38 + /// # Example 39 + /// 40 + /// ```ignore 41 + /// use atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice; 42 + /// 43 + /// // Represents bytes 0-5 of the text 44 + /// let slice = ByteSlice { 45 + /// byte_start: 0, 46 + /// byte_end: 5, 47 + /// }; 48 + /// ``` 49 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 50 + #[serde(rename_all = "camelCase")] 51 + pub struct ByteSlice { 52 + /// Starting byte index (inclusive) 53 + pub byte_start: usize, 54 + 55 + /// Ending byte index (exclusive) 56 + pub byte_end: usize, 57 + } 58 + 59 + /// Mention facet feature for referencing another account. 60 + /// 61 + /// The text content typically displays a handle with '@' prefix (e.g., "@alice.bsky.social"), 62 + /// but the facet reference must use the account's DID for stable identification. 63 + /// 64 + /// # Example 65 + /// 66 + /// ```ignore 67 + /// use atproto_record::lexicon::app::bsky::richtext::facet::Mention; 68 + /// 69 + /// let mention = Mention { 70 + /// did: "did:plc:alice123".to_string(), 71 + /// }; 72 + /// ``` 73 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 74 + pub struct Mention { 75 + /// DID of the mentioned account 76 + pub did: String, 77 + } 78 + 79 + /// Link facet feature for URL references. 80 + /// 81 + /// The text content may be simplified or truncated for display purposes, 82 + /// but the facet reference should contain the complete, valid URL. 83 + /// 84 + /// # Example 85 + /// 86 + /// ```ignore 87 + /// use atproto_record::lexicon::app::bsky::richtext::facet::Link; 88 + /// 89 + /// let link = Link { 90 + /// uri: "https://example.com/full/path".to_string(), 91 + /// }; 92 + /// ``` 93 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 94 + pub struct Link { 95 + /// Complete URI/URL for the link 96 + pub uri: String, 97 + } 98 + 99 + /// Tag facet feature for hashtags. 100 + /// 101 + /// The text content typically includes a '#' prefix for display, 102 + /// but the facet reference should contain only the tag text without the prefix. 103 + /// 104 + /// # Example 105 + /// 106 + /// ```ignore 107 + /// use atproto_record::lexicon::app::bsky::richtext::facet::Tag; 108 + /// 109 + /// // For text "#atproto", store just "atproto" 110 + /// let tag = Tag { 111 + /// tag: "atproto".to_string(), 112 + /// }; 113 + /// ``` 114 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 115 + pub struct Tag { 116 + /// Tag text without '#' prefix 117 + pub tag: String, 118 + } 119 + 120 + /// Discriminated union of facet feature types. 121 + /// 122 + /// Represents the different types of semantic annotations that can be 123 + /// applied to text ranges. Each variant corresponds to a specific lexicon 124 + /// type in the `app.bsky.richtext.facet` namespace. 125 + /// 126 + /// # Example 127 + /// 128 + /// ```ignore 129 + /// use atproto_record::lexicon::app::bsky::richtext::facet::{FacetFeature, Mention, Link, Tag}; 130 + /// 131 + /// // Create different feature types 132 + /// let mention = FacetFeature::Mention(Mention { 133 + /// did: "did:plc:alice123".to_string(), 134 + /// }); 135 + /// 136 + /// let link = FacetFeature::Link(Link { 137 + /// uri: "https://example.com".to_string(), 138 + /// }); 139 + /// 140 + /// let tag = FacetFeature::Tag(Tag { 141 + /// tag: "rust".to_string(), 142 + /// }); 143 + /// ``` 144 + #[derive(Serialize, Deserialize, Clone, PartialEq)] 145 + #[cfg_attr(debug_assertions, derive(Debug))] 146 + #[serde(tag = "$type")] 147 + pub enum FacetFeature { 148 + /// Account mention feature 149 + #[serde(rename = "app.bsky.richtext.facet#mention")] 150 + Mention(Mention), 151 + 152 + /// URL link feature 153 + #[serde(rename = "app.bsky.richtext.facet#link")] 154 + Link(Link), 155 + 156 + /// Hashtag feature 157 + #[serde(rename = "app.bsky.richtext.facet#tag")] 158 + Tag(Tag), 159 + } 160 + 161 + /// Rich text facet annotation. 162 + /// 163 + /// Associates one or more semantic features with a specific byte range 164 + /// within text content. Multiple features can apply to the same range 165 + /// (e.g., a URL that is also a hashtag). 166 + /// 167 + /// # Example 168 + /// 169 + /// ```ignore 170 + /// use atproto_record::lexicon::app::bsky::richtext::facet::{ 171 + /// Facet, ByteSlice, FacetFeature, Mention, Link 172 + /// }; 173 + /// 174 + /// // Annotate "@alice.bsky.social" at bytes 0-19 175 + /// let facet = Facet { 176 + /// index: ByteSlice { byte_start: 0, byte_end: 19 }, 177 + /// features: vec![ 178 + /// FacetFeature::Mention(Mention { 179 + /// did: "did:plc:alice123".to_string(), 180 + /// }), 181 + /// ], 182 + /// }; 183 + /// 184 + /// // Multiple features for the same range 185 + /// let multi_facet = Facet { 186 + /// index: ByteSlice { byte_start: 20, byte_end: 35 }, 187 + /// features: vec![ 188 + /// FacetFeature::Link(Link { 189 + /// uri: "https://example.com".to_string(), 190 + /// }), 191 + /// FacetFeature::Tag(Tag { 192 + /// tag: "example".to_string(), 193 + /// }), 194 + /// ], 195 + /// }; 196 + /// ``` 197 + #[derive(Serialize, Deserialize, Clone, PartialEq)] 198 + #[cfg_attr(debug_assertions, derive(Debug))] 199 + pub struct Facet { 200 + /// Byte range this facet applies to 201 + pub index: ByteSlice, 202 + 203 + /// Semantic features applied to this range 204 + pub features: Vec<FacetFeature>, 205 + }
+19 -68
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
··· 30 /// 31 /// // Inline signature 32 /// let inline = SignatureOrRef::Inline(create_typed_signature( 33 - /// "did:plc:issuer".to_string(), 34 /// Bytes { bytes: b"signature".to_vec() }, 35 /// )); 36 /// ··· 55 56 /// Cryptographic signature structure. 57 /// 58 - /// Represents a signature created by an issuer (identified by DID) over 59 - /// some data. The signature can be used to verify authenticity, authorization, 60 - /// or other properties of the signed content. 61 /// 62 /// # Fields 63 /// 64 - /// - `issuer`: DID of the entity that created the signature 65 /// - `signature`: The actual signature bytes 66 /// - `extra`: Additional fields that may be present in the signature 67 /// ··· 73 /// use std::collections::HashMap; 74 /// 75 /// let sig = Signature { 76 - /// issuer: "did:plc:example".to_string(), 77 /// signature: Bytes { bytes: b"signature_bytes".to_vec() }, 78 /// extra: HashMap::new(), 79 /// }; ··· 81 #[derive(Deserialize, Serialize, Clone, PartialEq)] 82 #[cfg_attr(debug_assertions, derive(Debug))] 83 pub struct Signature { 84 - /// DID of the entity that created this signature 85 - pub issuer: String, 86 - 87 /// The cryptographic signature bytes 88 pub signature: Bytes, 89 ··· 116 /// 117 /// # Arguments 118 /// 119 - /// * `issuer` - DID of the signature issuer 120 /// * `signature` - The signature bytes 121 /// 122 /// # Example ··· 126 /// use atproto_record::lexicon::Bytes; 127 /// 128 /// let sig = create_typed_signature( 129 - /// "did:plc:issuer".to_string(), 130 /// Bytes { bytes: b"sig_data".to_vec() }, 131 /// ); 132 /// ``` 133 - pub fn create_typed_signature(issuer: String, signature: Bytes) -> TypedSignature { 134 TypedLexicon::new(Signature { 135 - issuer, 136 signature, 137 extra: HashMap::new(), 138 }) ··· 150 let json_str = r#"{ 151 "$type": "community.lexicon.attestation.signature", 152 "issuedAt": "2025-08-19T20:17:17.133Z", 153 - "issuer": "did:web:acudo-dev.smokesignal.tools", 154 "signature": { 155 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 156 } ··· 160 let typed_sig_result: Result<TypedSignature, _> = serde_json::from_str(json_str); 161 match &typed_sig_result { 162 Ok(sig) => { 163 - println!("TypedSignature OK: issuer={}", sig.inner.issuer); 164 - assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 165 } 166 Err(e) => { 167 eprintln!("TypedSignature deserialization error: {}", e); ··· 172 let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str); 173 match &sig_or_ref_result { 174 Ok(SignatureOrRef::Inline(sig)) => { 175 - println!("SignatureOrRef OK (Inline): issuer={}", sig.inner.issuer); 176 - assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 177 } 178 Ok(SignatureOrRef::Reference(_)) => { 179 panic!("Expected Inline signature, got Reference"); ··· 186 // Try without $type field 187 let json_no_type = r#"{ 188 "issuedAt": "2025-08-19T20:17:17.133Z", 189 - "issuer": "did:web:acudo-dev.smokesignal.tools", 190 "signature": { 191 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 192 } ··· 195 let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type); 196 match &no_type_result { 197 Ok(sig) => { 198 - println!("Signature (no type) OK: issuer={}", sig.issuer); 199 - assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools"); 200 assert_eq!(sig.signature.bytes.len(), 64); 201 202 // Now wrap it in TypedLexicon and try as SignatureOrRef ··· 220 fn test_signature_deserialization() { 221 let json_str = r#"{ 222 "$type": "community.lexicon.attestation.signature", 223 - "issuer": "did:plc:test123", 224 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 225 }"#; 226 227 let signature: Signature = serde_json::from_str(json_str).unwrap(); 228 229 - assert_eq!(signature.issuer, "did:plc:test123"); 230 assert_eq!(signature.signature.bytes, b"test signature"); 231 // The $type field will be captured in extra due to #[serde(flatten)] 232 assert_eq!(signature.extra.len(), 1); ··· 237 fn test_signature_deserialization_with_extra_fields() { 238 let json_str = r#"{ 239 "$type": "community.lexicon.attestation.signature", 240 - "issuer": "did:plc:test123", 241 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}, 242 "issuedAt": "2024-01-01T00:00:00.000Z", 243 "purpose": "verification" ··· 245 246 let signature: Signature = serde_json::from_str(json_str).unwrap(); 247 248 - assert_eq!(signature.issuer, "did:plc:test123"); 249 assert_eq!(signature.signature.bytes, b"test signature"); 250 // 3 extra fields: $type, issuedAt, purpose 251 assert_eq!(signature.extra.len(), 3); ··· 263 extra.insert("custom_field".to_string(), json!("custom_value")); 264 265 let signature = Signature { 266 - issuer: "did:plc:serializer".to_string(), 267 signature: Bytes { 268 bytes: b"hello world".to_vec(), 269 }, ··· 274 275 // Without custom Serialize impl, $type is not automatically added 276 assert!(!json.as_object().unwrap().contains_key("$type")); 277 - assert_eq!(json["issuer"], "did:plc:serializer"); 278 // "hello world" base64 encoded is "aGVsbG8gd29ybGQ=" 279 assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ="); 280 assert_eq!(json["custom_field"], "custom_value"); ··· 283 #[test] 284 fn test_signature_round_trip() { 285 let original = Signature { 286 - issuer: "did:plc:roundtrip".to_string(), 287 signature: Bytes { 288 bytes: b"round trip test".to_vec(), 289 }, ··· 296 // Deserialize back 297 let deserialized: Signature = serde_json::from_str(&json).unwrap(); 298 299 - assert_eq!(original.issuer, deserialized.issuer); 300 assert_eq!(original.signature.bytes, deserialized.signature.bytes); 301 // Without the custom Serialize impl, no $type is added 302 // so the round-trip preserves the empty extra map ··· 317 extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"])); 318 319 let signature = Signature { 320 - issuer: "did:plc:complex".to_string(), 321 signature: Bytes { 322 bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA], 323 }, ··· 328 329 // Without custom Serialize impl, $type is not automatically added 330 assert!(!json.as_object().unwrap().contains_key("$type")); 331 - assert_eq!(json["issuer"], "did:plc:complex"); 332 assert_eq!(json["timestamp"], 1234567890); 333 assert_eq!(json["metadata"]["version"], "1.0"); 334 assert_eq!(json["metadata"]["algorithm"], "ES256"); ··· 338 #[test] 339 fn test_empty_signature() { 340 let signature = Signature { 341 - issuer: String::new(), 342 signature: Bytes { bytes: Vec::new() }, 343 extra: HashMap::new(), 344 }; ··· 347 348 // Without custom Serialize impl, $type is not automatically added 349 assert!(!json.as_object().unwrap().contains_key("$type")); 350 - assert_eq!(json["issuer"], ""); 351 assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string 352 } 353 ··· 356 // Test with plain Vec<Signature> for basic signature serialization 357 let signatures: Vec<Signature> = vec![ 358 Signature { 359 - issuer: "did:plc:first".to_string(), 360 signature: Bytes { 361 bytes: b"first".to_vec(), 362 }, 363 extra: HashMap::new(), 364 }, 365 Signature { 366 - issuer: "did:plc:second".to_string(), 367 signature: Bytes { 368 bytes: b"second".to_vec(), 369 }, ··· 375 376 assert!(json.is_array()); 377 assert_eq!(json.as_array().unwrap().len(), 2); 378 - assert_eq!(json[0]["issuer"], "did:plc:first"); 379 - assert_eq!(json[1]["issuer"], "did:plc:second"); 380 } 381 382 #[test] ··· 384 // Test the new Signatures type with inline signatures 385 let signatures: Signatures = vec![ 386 SignatureOrRef::Inline(create_typed_signature( 387 - "did:plc:first".to_string(), 388 Bytes { 389 bytes: b"first".to_vec(), 390 }, 391 )), 392 SignatureOrRef::Inline(create_typed_signature( 393 - "did:plc:second".to_string(), 394 Bytes { 395 bytes: b"second".to_vec(), 396 }, ··· 402 assert!(json.is_array()); 403 assert_eq!(json.as_array().unwrap().len(), 2); 404 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 405 - assert_eq!(json[0]["issuer"], "did:plc:first"); 406 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 407 - assert_eq!(json[1]["issuer"], "did:plc:second"); 408 } 409 410 #[test] 411 fn test_typed_signature_serialization() { 412 let typed_sig = create_typed_signature( 413 - "did:plc:typed".to_string(), 414 Bytes { 415 bytes: b"typed signature".to_vec(), 416 }, ··· 419 let json = serde_json::to_value(&typed_sig).unwrap(); 420 421 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 422 - assert_eq!(json["issuer"], "did:plc:typed"); 423 // "typed signature" base64 encoded 424 assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl"); 425 } ··· 428 fn test_typed_signature_deserialization() { 429 let json = json!({ 430 "$type": "community.lexicon.attestation.signature", 431 - "issuer": "did:plc:typed", 432 "signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"} 433 }); 434 435 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 436 437 - assert_eq!(typed_sig.inner.issuer, "did:plc:typed"); 438 assert_eq!(typed_sig.inner.signature.bytes, b"typed signature"); 439 assert!(typed_sig.has_type_field()); 440 assert!(typed_sig.validate().is_ok()); ··· 443 #[test] 444 fn test_typed_signature_without_type_field() { 445 let json = json!({ 446 - "issuer": "did:plc:notype", 447 "signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64 448 }); 449 450 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 451 452 - assert_eq!(typed_sig.inner.issuer, "did:plc:notype"); 453 assert_eq!(typed_sig.inner.signature.bytes, b"no type"); 454 assert!(!typed_sig.has_type_field()); 455 // Validation should still pass because type_required() returns false for Signature ··· 459 #[test] 460 fn test_typed_signature_with_extra_fields() { 461 let mut sig = Signature { 462 - issuer: "did:plc:extra".to_string(), 463 signature: Bytes { 464 bytes: b"extra test".to_vec(), 465 }, ··· 474 let json = serde_json::to_value(&typed_sig).unwrap(); 475 476 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 477 - assert_eq!(json["issuer"], "did:plc:extra"); 478 assert_eq!(json["customField"], "customValue"); 479 assert_eq!(json["timestamp"], 1234567890); 480 } ··· 482 #[test] 483 fn test_typed_signature_round_trip() { 484 let original = Signature { 485 - issuer: "did:plc:roundtrip2".to_string(), 486 signature: Bytes { 487 bytes: b"round trip typed".to_vec(), 488 }, ··· 494 let json = serde_json::to_string(&typed).unwrap(); 495 let deserialized: TypedSignature = serde_json::from_str(&json).unwrap(); 496 497 - assert_eq!(deserialized.inner.issuer, original.issuer); 498 assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes); 499 assert!(deserialized.has_type_field()); 500 } ··· 503 fn test_typed_signatures_vec() { 504 let typed_sigs: Vec<TypedSignature> = vec![ 505 create_typed_signature( 506 - "did:plc:first".to_string(), 507 Bytes { 508 bytes: b"first".to_vec(), 509 }, 510 ), 511 create_typed_signature( 512 - "did:plc:second".to_string(), 513 Bytes { 514 bytes: b"second".to_vec(), 515 }, ··· 520 521 assert!(json.is_array()); 522 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 523 - assert_eq!(json[0]["issuer"], "did:plc:first"); 524 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 525 - assert_eq!(json[1]["issuer"], "did:plc:second"); 526 } 527 528 #[test] 529 fn test_plain_vs_typed_signature() { 530 // Plain Signature doesn't include $type field 531 let plain_sig = Signature { 532 - issuer: "did:plc:plain".to_string(), 533 signature: Bytes { 534 bytes: b"plain sig".to_vec(), 535 }, ··· 548 ); 549 550 // Both have the same core data 551 - assert_eq!(plain_json["issuer"], typed_json["issuer"]); 552 assert_eq!(plain_json["signature"], typed_json["signature"]); 553 } 554 ··· 556 fn test_signature_or_ref_inline() { 557 // Test inline signature 558 let inline_sig = create_typed_signature( 559 - "did:plc:inline".to_string(), 560 Bytes { 561 bytes: b"inline signature".to_vec(), 562 }, ··· 567 // Serialize 568 let json = serde_json::to_value(&sig_or_ref).unwrap(); 569 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 570 - assert_eq!(json["issuer"], "did:plc:inline"); 571 assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64 572 573 // Deserialize 574 let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap(); 575 match deserialized { 576 SignatureOrRef::Inline(sig) => { 577 - assert_eq!(sig.inner.issuer, "did:plc:inline"); 578 assert_eq!(sig.inner.signature.bytes, b"inline signature"); 579 } 580 _ => panic!("Expected inline signature"), ··· 621 let signatures: Signatures = vec![ 622 // Inline signature 623 SignatureOrRef::Inline(create_typed_signature( 624 - "did:plc:signer1".to_string(), 625 Bytes { 626 bytes: b"sig1".to_vec(), 627 }, ··· 633 })), 634 // Another inline signature 635 SignatureOrRef::Inline(create_typed_signature( 636 - "did:plc:signer3".to_string(), 637 Bytes { 638 bytes: b"sig3".to_vec(), 639 }, ··· 648 649 // First element should be inline signature 650 assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature"); 651 - assert_eq!(array[0]["issuer"], "did:plc:signer1"); 652 653 // Second element should be reference 654 assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef"); ··· 659 660 // Third element should be inline signature 661 assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature"); 662 - assert_eq!(array[2]["issuer"], "did:plc:signer3"); 663 664 // Deserialize back 665 let deserialized: Signatures = serde_json::from_value(json).unwrap(); ··· 667 668 // Verify each element 669 match &deserialized[0] { 670 - SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer1"), 671 _ => panic!("Expected inline signature at index 0"), 672 } 673 ··· 682 } 683 684 match &deserialized[2] { 685 - SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer3"), 686 _ => panic!("Expected inline signature at index 2"), 687 } 688 } ··· 694 // Inline signature JSON 695 let inline_json = r#"{ 696 "$type": "community.lexicon.attestation.signature", 697 - "issuer": "did:plc:testinline", 698 "signature": {"$bytes": "aGVsbG8="} 699 }"#; 700 701 let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap(); 702 match inline_deser { 703 SignatureOrRef::Inline(sig) => { 704 - assert_eq!(sig.inner.issuer, "did:plc:testinline"); 705 assert_eq!(sig.inner.signature.bytes, b"hello"); 706 } 707 _ => panic!("Expected inline signature"),
··· 30 /// 31 /// // Inline signature 32 /// let inline = SignatureOrRef::Inline(create_typed_signature( 33 /// Bytes { bytes: b"signature".to_vec() }, 34 /// )); 35 /// ··· 54 55 /// Cryptographic signature structure. 56 /// 57 + /// Represents a cryptographic signature over some data. The signature can be 58 + /// used to verify authenticity, authorization, or other properties of the 59 + /// signed content. 60 /// 61 /// # Fields 62 /// 63 /// - `signature`: The actual signature bytes 64 /// - `extra`: Additional fields that may be present in the signature 65 /// ··· 71 /// use std::collections::HashMap; 72 /// 73 /// let sig = Signature { 74 /// signature: Bytes { bytes: b"signature_bytes".to_vec() }, 75 /// extra: HashMap::new(), 76 /// }; ··· 78 #[derive(Deserialize, Serialize, Clone, PartialEq)] 79 #[cfg_attr(debug_assertions, derive(Debug))] 80 pub struct Signature { 81 /// The cryptographic signature bytes 82 pub signature: Bytes, 83 ··· 110 /// 111 /// # Arguments 112 /// 113 /// * `signature` - The signature bytes 114 /// 115 /// # Example ··· 119 /// use atproto_record::lexicon::Bytes; 120 /// 121 /// let sig = create_typed_signature( 122 /// Bytes { bytes: b"sig_data".to_vec() }, 123 /// ); 124 /// ``` 125 + pub fn create_typed_signature(signature: Bytes) -> TypedSignature { 126 TypedLexicon::new(Signature { 127 signature, 128 extra: HashMap::new(), 129 }) ··· 141 let json_str = r#"{ 142 "$type": "community.lexicon.attestation.signature", 143 "issuedAt": "2025-08-19T20:17:17.133Z", 144 "signature": { 145 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 146 } ··· 150 let typed_sig_result: Result<TypedSignature, _> = serde_json::from_str(json_str); 151 match &typed_sig_result { 152 Ok(sig) => { 153 + println!("TypedSignature OK: signature bytes len={}", sig.inner.signature.bytes.len()); 154 + assert_eq!(sig.inner.signature.bytes.len(), 64); 155 } 156 Err(e) => { 157 eprintln!("TypedSignature deserialization error: {}", e); ··· 162 let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str); 163 match &sig_or_ref_result { 164 Ok(SignatureOrRef::Inline(sig)) => { 165 + println!("SignatureOrRef OK (Inline): signature bytes len={}", sig.inner.signature.bytes.len()); 166 + assert_eq!(sig.inner.signature.bytes.len(), 64); 167 } 168 Ok(SignatureOrRef::Reference(_)) => { 169 panic!("Expected Inline signature, got Reference"); ··· 176 // Try without $type field 177 let json_no_type = r#"{ 178 "issuedAt": "2025-08-19T20:17:17.133Z", 179 "signature": { 180 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 181 } ··· 184 let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type); 185 match &no_type_result { 186 Ok(sig) => { 187 + println!("Signature (no type) OK: signature bytes len={}", sig.signature.bytes.len()); 188 assert_eq!(sig.signature.bytes.len(), 64); 189 190 // Now wrap it in TypedLexicon and try as SignatureOrRef ··· 208 fn test_signature_deserialization() { 209 let json_str = r#"{ 210 "$type": "community.lexicon.attestation.signature", 211 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 212 }"#; 213 214 let signature: Signature = serde_json::from_str(json_str).unwrap(); 215 216 assert_eq!(signature.signature.bytes, b"test signature"); 217 // The $type field will be captured in extra due to #[serde(flatten)] 218 assert_eq!(signature.extra.len(), 1); ··· 223 fn test_signature_deserialization_with_extra_fields() { 224 let json_str = r#"{ 225 "$type": "community.lexicon.attestation.signature", 226 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}, 227 "issuedAt": "2024-01-01T00:00:00.000Z", 228 "purpose": "verification" ··· 230 231 let signature: Signature = serde_json::from_str(json_str).unwrap(); 232 233 assert_eq!(signature.signature.bytes, b"test signature"); 234 // 3 extra fields: $type, issuedAt, purpose 235 assert_eq!(signature.extra.len(), 3); ··· 247 extra.insert("custom_field".to_string(), json!("custom_value")); 248 249 let signature = Signature { 250 signature: Bytes { 251 bytes: b"hello world".to_vec(), 252 }, ··· 257 258 // Without custom Serialize impl, $type is not automatically added 259 assert!(!json.as_object().unwrap().contains_key("$type")); 260 // "hello world" base64 encoded is "aGVsbG8gd29ybGQ=" 261 assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ="); 262 assert_eq!(json["custom_field"], "custom_value"); ··· 265 #[test] 266 fn test_signature_round_trip() { 267 let original = Signature { 268 signature: Bytes { 269 bytes: b"round trip test".to_vec(), 270 }, ··· 277 // Deserialize back 278 let deserialized: Signature = serde_json::from_str(&json).unwrap(); 279 280 assert_eq!(original.signature.bytes, deserialized.signature.bytes); 281 // Without the custom Serialize impl, no $type is added 282 // so the round-trip preserves the empty extra map ··· 297 extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"])); 298 299 let signature = Signature { 300 signature: Bytes { 301 bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA], 302 }, ··· 307 308 // Without custom Serialize impl, $type is not automatically added 309 assert!(!json.as_object().unwrap().contains_key("$type")); 310 assert_eq!(json["timestamp"], 1234567890); 311 assert_eq!(json["metadata"]["version"], "1.0"); 312 assert_eq!(json["metadata"]["algorithm"], "ES256"); ··· 316 #[test] 317 fn test_empty_signature() { 318 let signature = Signature { 319 signature: Bytes { bytes: Vec::new() }, 320 extra: HashMap::new(), 321 }; ··· 324 325 // Without custom Serialize impl, $type is not automatically added 326 assert!(!json.as_object().unwrap().contains_key("$type")); 327 assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string 328 } 329 ··· 332 // Test with plain Vec<Signature> for basic signature serialization 333 let signatures: Vec<Signature> = vec![ 334 Signature { 335 signature: Bytes { 336 bytes: b"first".to_vec(), 337 }, 338 extra: HashMap::new(), 339 }, 340 Signature { 341 signature: Bytes { 342 bytes: b"second".to_vec(), 343 }, ··· 349 350 assert!(json.is_array()); 351 assert_eq!(json.as_array().unwrap().len(), 2); 352 + assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64 353 + assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64 354 } 355 356 #[test] ··· 358 // Test the new Signatures type with inline signatures 359 let signatures: Signatures = vec![ 360 SignatureOrRef::Inline(create_typed_signature( 361 Bytes { 362 bytes: b"first".to_vec(), 363 }, 364 )), 365 SignatureOrRef::Inline(create_typed_signature( 366 Bytes { 367 bytes: b"second".to_vec(), 368 }, ··· 374 assert!(json.is_array()); 375 assert_eq!(json.as_array().unwrap().len(), 2); 376 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 377 + assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64 378 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 379 + assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64 380 } 381 382 #[test] 383 fn test_typed_signature_serialization() { 384 let typed_sig = create_typed_signature( 385 Bytes { 386 bytes: b"typed signature".to_vec(), 387 }, ··· 390 let json = serde_json::to_value(&typed_sig).unwrap(); 391 392 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 393 // "typed signature" base64 encoded 394 assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl"); 395 } ··· 398 fn test_typed_signature_deserialization() { 399 let json = json!({ 400 "$type": "community.lexicon.attestation.signature", 401 "signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"} 402 }); 403 404 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 405 406 assert_eq!(typed_sig.inner.signature.bytes, b"typed signature"); 407 assert!(typed_sig.has_type_field()); 408 assert!(typed_sig.validate().is_ok()); ··· 411 #[test] 412 fn test_typed_signature_without_type_field() { 413 let json = json!({ 414 "signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64 415 }); 416 417 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 418 419 assert_eq!(typed_sig.inner.signature.bytes, b"no type"); 420 assert!(!typed_sig.has_type_field()); 421 // Validation should still pass because type_required() returns false for Signature ··· 425 #[test] 426 fn test_typed_signature_with_extra_fields() { 427 let mut sig = Signature { 428 signature: Bytes { 429 bytes: b"extra test".to_vec(), 430 }, ··· 439 let json = serde_json::to_value(&typed_sig).unwrap(); 440 441 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 442 assert_eq!(json["customField"], "customValue"); 443 assert_eq!(json["timestamp"], 1234567890); 444 } ··· 446 #[test] 447 fn test_typed_signature_round_trip() { 448 let original = Signature { 449 signature: Bytes { 450 bytes: b"round trip typed".to_vec(), 451 }, ··· 457 let json = serde_json::to_string(&typed).unwrap(); 458 let deserialized: TypedSignature = serde_json::from_str(&json).unwrap(); 459 460 assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes); 461 assert!(deserialized.has_type_field()); 462 } ··· 465 fn test_typed_signatures_vec() { 466 let typed_sigs: Vec<TypedSignature> = vec![ 467 create_typed_signature( 468 Bytes { 469 bytes: b"first".to_vec(), 470 }, 471 ), 472 create_typed_signature( 473 Bytes { 474 bytes: b"second".to_vec(), 475 }, ··· 480 481 assert!(json.is_array()); 482 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 483 + assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64 484 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 485 + assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64 486 } 487 488 #[test] 489 fn test_plain_vs_typed_signature() { 490 // Plain Signature doesn't include $type field 491 let plain_sig = Signature { 492 signature: Bytes { 493 bytes: b"plain sig".to_vec(), 494 }, ··· 507 ); 508 509 // Both have the same core data 510 assert_eq!(plain_json["signature"], typed_json["signature"]); 511 } 512 ··· 514 fn test_signature_or_ref_inline() { 515 // Test inline signature 516 let inline_sig = create_typed_signature( 517 Bytes { 518 bytes: b"inline signature".to_vec(), 519 }, ··· 524 // Serialize 525 let json = serde_json::to_value(&sig_or_ref).unwrap(); 526 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 527 assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64 528 529 // Deserialize 530 let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap(); 531 match deserialized { 532 SignatureOrRef::Inline(sig) => { 533 assert_eq!(sig.inner.signature.bytes, b"inline signature"); 534 } 535 _ => panic!("Expected inline signature"), ··· 576 let signatures: Signatures = vec![ 577 // Inline signature 578 SignatureOrRef::Inline(create_typed_signature( 579 Bytes { 580 bytes: b"sig1".to_vec(), 581 }, ··· 587 })), 588 // Another inline signature 589 SignatureOrRef::Inline(create_typed_signature( 590 Bytes { 591 bytes: b"sig3".to_vec(), 592 }, ··· 601 602 // First element should be inline signature 603 assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature"); 604 + assert_eq!(array[0]["signature"]["$bytes"], "c2lnMQ=="); // "sig1" in base64 605 606 // Second element should be reference 607 assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef"); ··· 612 613 // Third element should be inline signature 614 assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature"); 615 + assert_eq!(array[2]["signature"]["$bytes"], "c2lnMw=="); // "sig3" in base64 616 617 // Deserialize back 618 let deserialized: Signatures = serde_json::from_value(json).unwrap(); ··· 620 621 // Verify each element 622 match &deserialized[0] { 623 + SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig1"), 624 _ => panic!("Expected inline signature at index 0"), 625 } 626 ··· 635 } 636 637 match &deserialized[2] { 638 + SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig3"), 639 _ => panic!("Expected inline signature at index 2"), 640 } 641 } ··· 647 // Inline signature JSON 648 let inline_json = r#"{ 649 "$type": "community.lexicon.attestation.signature", 650 "signature": {"$bytes": "aGVsbG8="} 651 }"#; 652 653 let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap(); 654 match inline_deser { 655 SignatureOrRef::Inline(sig) => { 656 assert_eq!(sig.inner.signature.bytes, b"hello"); 657 } 658 _ => panic!("Expected inline signature"),
+1 -2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
··· 311 // The signature should be inline in this test 312 match sig_or_ref { 313 crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => { 314 - assert_eq!(sig.issuer, "did:plc:issuer"); 315 // The bytes should match the decoded base64 value 316 // "dGVzdCBzaWduYXR1cmU=" decodes to "test signature" 317 - assert_eq!(sig.signature.bytes, b"test signature".to_vec()); 318 } 319 _ => panic!("Expected inline signature"), 320 }
··· 311 // The signature should be inline in this test 312 match sig_or_ref { 313 crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => { 314 // The bytes should match the decoded base64 value 315 // "dGVzdCBzaWduYXR1cmU=" decodes to "test signature" 316 + assert_eq!(sig.inner.signature.bytes, b"test signature".to_vec()); 317 } 318 _ => panic!("Expected inline signature"), 319 }
+43 -9
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
··· 10 11 use crate::datetime::format as datetime_format; 12 use crate::datetime::optional_format as optional_datetime_format; 13 use crate::lexicon::TypedBlob; 14 use crate::lexicon::community::lexicon::location::Locations; 15 use crate::typed::{LexiconType, TypedLexicon}; 16 17 - /// The namespace identifier for events 18 pub const NSID: &str = "community.lexicon.calendar.event"; 19 20 /// Event status enumeration. ··· 65 Hybrid, 66 } 67 68 - /// The namespace identifier for named URIs 69 pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri"; 70 71 /// Named URI structure. ··· 89 } 90 } 91 92 - /// Type alias for NamedUri with automatic $type field handling 93 pub type TypedNamedUri = TypedLexicon<NamedUri>; 94 95 - /// The namespace identifier for event links 96 pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri"; 97 98 /// Event link structure. ··· 116 } 117 } 118 119 - /// Type alias for EventLink with automatic $type field handling 120 pub type TypedEventLink = TypedLexicon<EventLink>; 121 122 - /// A vector of typed event links 123 pub type EventLinks = Vec<TypedEventLink>; 124 125 /// Aspect ratio for media content. ··· 134 pub height: u64, 135 } 136 137 - /// The namespace identifier for media 138 pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media"; 139 140 /// Media structure for event-related visual content. ··· 163 } 164 } 165 166 - /// Type alias for Media with automatic $type field handling 167 pub type TypedMedia = TypedLexicon<Media>; 168 169 - /// A vector of typed media items 170 pub type MediaList = Vec<TypedMedia>; 171 172 /// Calendar event structure. ··· 248 #[serde(skip_serializing_if = "Vec::is_empty", default)] 249 pub media: MediaList, 250 251 /// Extension fields for forward compatibility. 252 /// This catch-all allows unknown fields to be preserved and indexed 253 /// for potential future use without requiring re-indexing. ··· 312 locations: vec![], 313 uris: vec![], 314 media: vec![], 315 extra: HashMap::new(), 316 }; 317 ··· 466 locations: vec![], 467 uris: vec![TypedLexicon::new(event_link)], 468 media: vec![TypedLexicon::new(media)], 469 extra: HashMap::new(), 470 }; 471
··· 10 11 use crate::datetime::format as datetime_format; 12 use crate::datetime::optional_format as optional_datetime_format; 13 + use crate::lexicon::app::bsky::richtext::facet::Facet; 14 use crate::lexicon::TypedBlob; 15 use crate::lexicon::community::lexicon::location::Locations; 16 use crate::typed::{LexiconType, TypedLexicon}; 17 18 + /// Lexicon namespace identifier for calendar events. 19 + /// 20 + /// Used as the `$type` field value for event records in the AT Protocol. 21 pub const NSID: &str = "community.lexicon.calendar.event"; 22 23 /// Event status enumeration. ··· 68 Hybrid, 69 } 70 71 + /// Lexicon namespace identifier for named URIs in calendar events. 72 + /// 73 + /// Used as the `$type` field value for URI references associated with events. 74 pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri"; 75 76 /// Named URI structure. ··· 94 } 95 } 96 97 + /// Type alias for NamedUri with automatic $type field handling. 98 + /// 99 + /// Wraps `NamedUri` in `TypedLexicon` to ensure proper serialization 100 + /// and deserialization of the `$type` field. 101 pub type TypedNamedUri = TypedLexicon<NamedUri>; 102 103 + /// Lexicon namespace identifier for event links. 104 + /// 105 + /// Used as the `$type` field value for event link references. 106 + /// Note: This shares the same NSID as `NAMED_URI_NSID` for compatibility. 107 pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri"; 108 109 /// Event link structure. ··· 127 } 128 } 129 130 + /// Type alias for EventLink with automatic $type field handling. 131 + /// 132 + /// Wraps `EventLink` in `TypedLexicon` to ensure proper serialization 133 + /// and deserialization of the `$type` field. 134 pub type TypedEventLink = TypedLexicon<EventLink>; 135 136 + /// Collection of typed event links. 137 + /// 138 + /// Represents multiple URI references associated with an event, 139 + /// such as registration pages, live streams, or related content. 140 pub type EventLinks = Vec<TypedEventLink>; 141 142 /// Aspect ratio for media content. ··· 151 pub height: u64, 152 } 153 154 + /// Lexicon namespace identifier for event media. 155 + /// 156 + /// Used as the `$type` field value for media attachments associated with events. 157 pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media"; 158 159 /// Media structure for event-related visual content. ··· 182 } 183 } 184 185 + /// Type alias for Media with automatic $type field handling. 186 + /// 187 + /// Wraps `Media` in `TypedLexicon` to ensure proper serialization 188 + /// and deserialization of the `$type` field. 189 pub type TypedMedia = TypedLexicon<Media>; 190 191 + /// Collection of typed media items. 192 + /// 193 + /// Represents multiple media attachments for an event, such as banners, 194 + /// posters, thumbnails, or promotional images. 195 pub type MediaList = Vec<TypedMedia>; 196 197 /// Calendar event structure. ··· 273 #[serde(skip_serializing_if = "Vec::is_empty", default)] 274 pub media: MediaList, 275 276 + /// Rich text facets for semantic annotations in description field. 277 + /// 278 + /// Enables mentions, links, and hashtags to be embedded in the event 279 + /// description text with proper semantic metadata. 280 + #[serde(skip_serializing_if = "Option::is_none")] 281 + pub facets: Option<Vec<Facet>>, 282 + 283 /// Extension fields for forward compatibility. 284 /// This catch-all allows unknown fields to be preserved and indexed 285 /// for potential future use without requiring re-indexing. ··· 344 locations: vec![], 345 uris: vec![], 346 media: vec![], 347 + facets: None, 348 extra: HashMap::new(), 349 }; 350 ··· 499 locations: vec![], 500 uris: vec![TypedLexicon::new(event_link)], 501 media: vec![TypedLexicon::new(media)], 502 + facets: None, 503 extra: HashMap::new(), 504 }; 505
-3
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
··· 294 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 295 match &typed_rsvp.inner.signatures[0] { 296 SignatureOrRef::Inline(sig) => { 297 - assert_eq!(sig.inner.issuer, "did:plc:issuer"); 298 assert_eq!(sig.inner.signature.bytes, b"test signature"); 299 } 300 _ => panic!("Expected inline signature"), ··· 364 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 365 match &typed_rsvp.inner.signatures[0] { 366 SignatureOrRef::Inline(sig) => { 367 - assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 368 - 369 // Verify the issuedAt field if present 370 if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") { 371 assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
··· 294 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 295 match &typed_rsvp.inner.signatures[0] { 296 SignatureOrRef::Inline(sig) => { 297 assert_eq!(sig.inner.signature.bytes, b"test signature"); 298 } 299 _ => panic!("Expected inline signature"), ··· 363 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 364 match &typed_rsvp.inner.signatures[0] { 365 SignatureOrRef::Inline(sig) => { 366 // Verify the issuedAt field if present 367 if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") { 368 assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
+22
crates/atproto-record/src/lexicon/mod.rs
··· 37 mod community_lexicon_calendar_event; 38 mod community_lexicon_calendar_rsvp; 39 mod community_lexicon_location; 40 mod primatives; 41 42 pub use primatives::*; 43 44 /// AT Protocol core types namespace 45 pub mod com {
··· 37 mod community_lexicon_calendar_event; 38 mod community_lexicon_calendar_rsvp; 39 mod community_lexicon_location; 40 + mod app_bsky_richtext_facet; 41 mod primatives; 42 43 + // Re-export primitive types for convenience 44 pub use primatives::*; 45 + 46 + /// Bluesky application namespace. 47 + /// 48 + /// Contains lexicon types specific to the Bluesky application, 49 + /// including rich text formatting and social features. 50 + pub mod app { 51 + /// Bluesky namespace. 52 + pub mod bsky { 53 + /// Rich text formatting types. 54 + pub mod richtext { 55 + /// Facet types for semantic text annotations. 56 + /// 57 + /// Provides types for mentions, links, hashtags, and other 58 + /// structured metadata that can be attached to text content. 59 + pub mod facet { 60 + pub use crate::lexicon::app_bsky_richtext_facet::*; 61 + } 62 + } 63 + } 64 + } 65 66 /// AT Protocol core types namespace 67 pub mod com {
+53
crates/atproto-tap/Cargo.toml
···
··· 1 + [package] 2 + name = "atproto-tap" 3 + version = "0.13.0" 4 + description = "AT Protocol TAP (Trusted Attestation Protocol) service consumer" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-tap" 8 + 9 + edition.workspace = true 10 + rust-version.workspace = true 11 + authors.workspace = true 12 + repository.workspace = true 13 + license.workspace = true 14 + keywords.workspace = true 15 + categories.workspace = true 16 + 17 + [dependencies] 18 + tokio = { workspace = true, features = ["sync", "time"] } 19 + tokio-stream = "0.1" 20 + tokio-websockets = { workspace = true } 21 + futures = { workspace = true } 22 + reqwest = { workspace = true } 23 + serde = { workspace = true } 24 + serde_json = { workspace = true } 25 + thiserror = { workspace = true } 26 + tracing = { workspace = true } 27 + http = { workspace = true } 28 + base64 = { workspace = true } 29 + atproto-identity.workspace = true 30 + atproto-client = { workspace = true, optional = true } 31 + 32 + # Memory efficiency 33 + compact_str = { version = "0.8", features = ["serde"] } 34 + itoa = "1.0" 35 + 36 + # Optional for CLI 37 + clap = { workspace = true, optional = true } 38 + tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } 39 + 40 + [features] 41 + default = [] 42 + clap = ["dep:clap", "dep:tracing-subscriber", "dep:atproto-client", "tokio/rt-multi-thread", "tokio/macros", "tokio/signal"] 43 + 44 + [[bin]] 45 + name = "atproto-tap-client" 46 + required-features = ["clap"] 47 + 48 + [[bin]] 49 + name = "atproto-tap-extras" 50 + required-features = ["clap"] 51 + 52 + [lints] 53 + workspace = true
+351
crates/atproto-tap/src/bin/atproto-tap-client.rs
···
··· 1 + //! Command-line client for TAP services. 2 + //! 3 + //! This tool provides commands for consuming TAP events and managing tracked repositories. 4 + //! 5 + //! # Usage 6 + //! 7 + //! ```bash 8 + //! # Stream events from a TAP service 9 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 read 10 + //! 11 + //! # Stream with authentication and filters 12 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret read --live-only 13 + //! 14 + //! # Add repositories to track 15 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret repos add did:plc:xyz did:plc:abc 16 + //! 17 + //! # Remove repositories from tracking 18 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret repos remove did:plc:xyz 19 + //! 20 + //! # Resolve a DID to its DID document 21 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 resolve did:plc:xyz 22 + //! 23 + //! # Resolve a DID and only output the handle 24 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 resolve did:plc:xyz --handle-only 25 + //! 26 + //! # Get repository tracking info 27 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 info did:plc:xyz 28 + //! ``` 29 + 30 + use atproto_tap::{TapClient, TapConfig, TapEvent, connect}; 31 + use clap::{Parser, Subcommand}; 32 + use std::time::Duration; 33 + use tokio_stream::StreamExt; 34 + 35 + /// TAP service client for consuming events and managing repositories. 36 + #[derive(Parser)] 37 + #[command( 38 + name = "atproto-tap-client", 39 + version, 40 + about = "TAP service client for AT Protocol", 41 + long_about = "Connect to a TAP service to stream repository/identity events or manage tracked repositories.\n\n\ 42 + Events are printed to stdout as JSON, one per line.\n\ 43 + Use Ctrl+C to gracefully stop the consumer." 44 + )] 45 + struct Args { 46 + /// TAP service hostname (e.g., localhost:2480) 47 + hostname: String, 48 + 49 + /// Admin password for authentication 50 + #[arg(short, long, global = true)] 51 + password: Option<String>, 52 + 53 + #[command(subcommand)] 54 + command: Command, 55 + } 56 + 57 + #[derive(Subcommand)] 58 + enum Command { 59 + /// Connect to TAP and stream events as JSON 60 + Read { 61 + /// Disable acknowledgments 62 + #[arg(long)] 63 + no_acks: bool, 64 + 65 + /// Maximum reconnection attempts (0 = unlimited) 66 + #[arg(long, default_value = "0")] 67 + max_reconnects: u32, 68 + 69 + /// Print debug information to stderr 70 + #[arg(short, long)] 71 + debug: bool, 72 + 73 + /// Filter to specific collections (comma-separated) 74 + #[arg(long)] 75 + collections: Option<String>, 76 + 77 + /// Only show live events (skip backfill) 78 + #[arg(long)] 79 + live_only: bool, 80 + }, 81 + 82 + /// Manage tracked repositories 83 + Repos { 84 + #[command(subcommand)] 85 + action: ReposAction, 86 + }, 87 + 88 + /// Resolve a DID to its DID document 89 + Resolve { 90 + /// DID to resolve (e.g., did:plc:xyz123) 91 + did: String, 92 + 93 + /// Only output the handle (instead of full DID document) 94 + #[arg(long)] 95 + handle_only: bool, 96 + }, 97 + 98 + /// Get tracking info for a repository 99 + Info { 100 + /// DID to get info for (e.g., did:plc:xyz123) 101 + did: String, 102 + }, 103 + } 104 + 105 + #[derive(Subcommand)] 106 + enum ReposAction { 107 + /// Add repositories to track 108 + Add { 109 + /// DIDs to add (e.g., did:plc:xyz123) 110 + #[arg(required = true)] 111 + dids: Vec<String>, 112 + }, 113 + 114 + /// Remove repositories from tracking 115 + Remove { 116 + /// DIDs to remove 117 + #[arg(required = true)] 118 + dids: Vec<String>, 119 + }, 120 + } 121 + 122 + #[tokio::main] 123 + async fn main() { 124 + let args = Args::parse(); 125 + 126 + match args.command { 127 + Command::Read { 128 + no_acks, 129 + max_reconnects, 130 + debug, 131 + collections, 132 + live_only, 133 + } => { 134 + run_read( 135 + &args.hostname, 136 + args.password, 137 + no_acks, 138 + max_reconnects, 139 + debug, 140 + collections, 141 + live_only, 142 + ) 143 + .await; 144 + } 145 + Command::Repos { action } => { 146 + run_repos(&args.hostname, args.password, action).await; 147 + } 148 + Command::Resolve { did, handle_only } => { 149 + run_resolve(&args.hostname, args.password, &did, handle_only).await; 150 + } 151 + Command::Info { did } => { 152 + run_info(&args.hostname, args.password, &did).await; 153 + } 154 + } 155 + } 156 + 157 + async fn run_read( 158 + hostname: &str, 159 + password: Option<String>, 160 + no_acks: bool, 161 + max_reconnects: u32, 162 + debug: bool, 163 + collections: Option<String>, 164 + live_only: bool, 165 + ) { 166 + // Initialize tracing if debug mode 167 + if debug { 168 + tracing_subscriber::fmt() 169 + .with_env_filter("atproto_tap=debug") 170 + .with_writer(std::io::stderr) 171 + .init(); 172 + } 173 + 174 + // Build configuration 175 + let mut config_builder = TapConfig::builder() 176 + .hostname(hostname) 177 + .send_acks(!no_acks); 178 + 179 + if let Some(password) = password { 180 + config_builder = config_builder.admin_password(password); 181 + } 182 + 183 + if max_reconnects > 0 { 184 + config_builder = config_builder.max_reconnect_attempts(Some(max_reconnects)); 185 + } 186 + 187 + // Set reasonable defaults for CLI usage 188 + config_builder = config_builder 189 + .initial_reconnect_delay(Duration::from_secs(1)) 190 + .max_reconnect_delay(Duration::from_secs(30)); 191 + 192 + let config = config_builder.build(); 193 + 194 + eprintln!("Connecting to TAP service at {}...", hostname); 195 + 196 + let mut stream = connect(config); 197 + 198 + // Parse collection filters 199 + let collection_filters: Vec<String> = collections 200 + .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) 201 + .unwrap_or_default(); 202 + 203 + // Handle Ctrl+C 204 + let ctrl_c = tokio::signal::ctrl_c(); 205 + tokio::pin!(ctrl_c); 206 + 207 + loop { 208 + tokio::select! { 209 + Some(result) = stream.next() => { 210 + match result { 211 + Ok(event) => { 212 + // Apply filters 213 + let should_print = match event.as_ref() { 214 + TapEvent::Record { record, .. } => { 215 + // Filter by live flag 216 + if live_only && !record.live { 217 + false 218 + } 219 + // Filter by collection 220 + else if !collection_filters.is_empty() { 221 + collection_filters.iter().any(|c| record.collection.as_ref() == c) 222 + } else { 223 + true 224 + } 225 + } 226 + TapEvent::Identity { .. } => !live_only, // Always show identity unless live_only 227 + }; 228 + 229 + if should_print { 230 + // Print as JSON to stdout 231 + match serde_json::to_string(event.as_ref()) { 232 + Ok(json) => println!("{}", json), 233 + Err(e) => { 234 + eprintln!("Failed to serialize event: {}", e); 235 + } 236 + } 237 + } 238 + } 239 + Err(e) => { 240 + eprintln!("Error: {}", e); 241 + 242 + // Exit on fatal errors 243 + if e.is_fatal() { 244 + eprintln!("Fatal error, exiting"); 245 + std::process::exit(1); 246 + } 247 + } 248 + } 249 + } 250 + _ = &mut ctrl_c => { 251 + eprintln!("\nReceived Ctrl+C, shutting down..."); 252 + stream.close().await; 253 + break; 254 + } 255 + } 256 + } 257 + 258 + eprintln!("Client stopped"); 259 + } 260 + 261 + async fn run_repos(hostname: &str, password: Option<String>, action: ReposAction) { 262 + let client = TapClient::new(hostname, password); 263 + 264 + match action { 265 + ReposAction::Add { dids } => { 266 + let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect(); 267 + 268 + match client.add_repos(&did_refs).await { 269 + Ok(()) => { 270 + eprintln!("Added {} repository(ies) to tracking", dids.len()); 271 + for did in &dids { 272 + println!("{}", did); 273 + } 274 + } 275 + Err(e) => { 276 + eprintln!("Failed to add repositories: {}", e); 277 + std::process::exit(1); 278 + } 279 + } 280 + } 281 + ReposAction::Remove { dids } => { 282 + let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect(); 283 + 284 + match client.remove_repos(&did_refs).await { 285 + Ok(()) => { 286 + eprintln!("Removed {} repository(ies) from tracking", dids.len()); 287 + for did in &dids { 288 + println!("{}", did); 289 + } 290 + } 291 + Err(e) => { 292 + eprintln!("Failed to remove repositories: {}", e); 293 + std::process::exit(1); 294 + } 295 + } 296 + } 297 + } 298 + } 299 + 300 + async fn run_resolve(hostname: &str, password: Option<String>, did: &str, handle_only: bool) { 301 + let client = TapClient::new(hostname, password); 302 + 303 + match client.resolve(did).await { 304 + Ok(doc) => { 305 + if handle_only { 306 + // Use the handles() method from atproto_identity::model::Document 307 + match doc.handles() { 308 + Some(handle) => println!("{}", handle), 309 + None => { 310 + eprintln!("No handle found in DID document"); 311 + std::process::exit(1); 312 + } 313 + } 314 + } else { 315 + // Print full DID document as JSON 316 + match serde_json::to_string_pretty(&doc) { 317 + Ok(json) => println!("{}", json), 318 + Err(e) => { 319 + eprintln!("Failed to serialize DID document: {}", e); 320 + std::process::exit(1); 321 + } 322 + } 323 + } 324 + } 325 + Err(e) => { 326 + eprintln!("Failed to resolve DID: {}", e); 327 + std::process::exit(1); 328 + } 329 + } 330 + } 331 + 332 + async fn run_info(hostname: &str, password: Option<String>, did: &str) { 333 + let client = TapClient::new(hostname, password); 334 + 335 + match client.info(did).await { 336 + Ok(info) => { 337 + // Print as JSON for easy parsing 338 + match serde_json::to_string_pretty(&info) { 339 + Ok(json) => println!("{}", json), 340 + Err(e) => { 341 + eprintln!("Failed to serialize info: {}", e); 342 + std::process::exit(1); 343 + } 344 + } 345 + } 346 + Err(e) => { 347 + eprintln!("Failed to get repository info: {}", e); 348 + std::process::exit(1); 349 + } 350 + } 351 + }
+214
crates/atproto-tap/src/bin/atproto-tap-extras.rs
···
··· 1 + //! Additional TAP client utilities for AT Protocol. 2 + //! 3 + //! This tool provides extra commands for managing TAP tracked repositories 4 + //! based on social graph data. 5 + //! 6 + //! # Usage 7 + //! 8 + //! ```bash 9 + //! # Add all accounts followed by a DID to TAP tracking 10 + //! cargo run --features cli --bin atproto-tap-extras -- localhost:2480 repos-add-followers did:plc:xyz 11 + //! 12 + //! # With authentication 13 + //! cargo run --features cli --bin atproto-tap-extras -- localhost:2480 -p secret repos-add-followers did:plc:xyz 14 + //! ``` 15 + 16 + use atproto_client::client::Auth; 17 + use atproto_client::com::atproto::repo::{ListRecordsParams, list_records}; 18 + use atproto_identity::plc::query as plc_query; 19 + use atproto_tap::TapClient; 20 + use clap::{Parser, Subcommand}; 21 + use serde::Deserialize; 22 + 23 + /// TAP extras utility for managing tracked repositories. 24 + #[derive(Parser)] 25 + #[command( 26 + name = "atproto-tap-extras", 27 + version, 28 + about = "TAP extras utility for AT Protocol", 29 + long_about = "Additional utilities for managing TAP tracked repositories based on social graph data." 30 + )] 31 + struct Args { 32 + /// TAP service hostname (e.g., localhost:2480) 33 + hostname: String, 34 + 35 + /// Admin password for TAP authentication 36 + #[arg(short, long, global = true)] 37 + password: Option<String>, 38 + 39 + /// PLC directory hostname for DID resolution 40 + #[arg(long, default_value = "plc.directory", global = true)] 41 + plc_hostname: String, 42 + 43 + #[command(subcommand)] 44 + command: Command, 45 + } 46 + 47 + #[derive(Subcommand)] 48 + enum Command { 49 + /// Add accounts followed by a DID to TAP tracking. 50 + /// 51 + /// Fetches all app.bsky.graph.follow records from the specified DID's repository 52 + /// and adds the followed DIDs to TAP for tracking. 53 + ReposAddFollowers { 54 + /// DID to read followers from (e.g., did:plc:xyz123) 55 + did: String, 56 + 57 + /// Batch size for adding repos to TAP 58 + #[arg(long, default_value = "100")] 59 + batch_size: usize, 60 + 61 + /// Dry run - print DIDs without adding to TAP 62 + #[arg(long)] 63 + dry_run: bool, 64 + }, 65 + } 66 + 67 + /// Follow record structure from app.bsky.graph.follow. 68 + #[derive(Debug, Deserialize)] 69 + struct FollowRecord { 70 + /// The DID of the account being followed. 71 + subject: String, 72 + } 73 + 74 + #[tokio::main] 75 + async fn main() { 76 + let args = Args::parse(); 77 + 78 + match args.command { 79 + Command::ReposAddFollowers { 80 + did, 81 + batch_size, 82 + dry_run, 83 + } => { 84 + run_repos_add_followers( 85 + &args.hostname, 86 + args.password, 87 + &args.plc_hostname, 88 + &did, 89 + batch_size, 90 + dry_run, 91 + ) 92 + .await; 93 + } 94 + } 95 + } 96 + 97 + async fn run_repos_add_followers( 98 + tap_hostname: &str, 99 + tap_password: Option<String>, 100 + plc_hostname: &str, 101 + did: &str, 102 + batch_size: usize, 103 + dry_run: bool, 104 + ) { 105 + let http_client = reqwest::Client::new(); 106 + 107 + // Resolve the DID to get the PDS endpoint 108 + eprintln!("Resolving DID: {}", did); 109 + let document = match plc_query(&http_client, plc_hostname, did).await { 110 + Ok(doc) => doc, 111 + Err(e) => { 112 + eprintln!("Failed to resolve DID: {}", e); 113 + std::process::exit(1); 114 + } 115 + }; 116 + 117 + let pds_endpoints = document.pds_endpoints(); 118 + if pds_endpoints.is_empty() { 119 + eprintln!("No PDS endpoint found in DID document"); 120 + std::process::exit(1); 121 + } 122 + let pds_url = pds_endpoints[0]; 123 + eprintln!("Using PDS: {}", pds_url); 124 + 125 + // Collect all followed DIDs 126 + let mut followed_dids: Vec<String> = Vec::new(); 127 + let mut cursor: Option<String> = None; 128 + let collection = "app.bsky.graph.follow".to_string(); 129 + 130 + eprintln!("Fetching follow records..."); 131 + 132 + loop { 133 + let params = if let Some(c) = cursor.take() { 134 + ListRecordsParams::new().limit(100).cursor(c) 135 + } else { 136 + ListRecordsParams::new().limit(100) 137 + }; 138 + 139 + let response = match list_records::<FollowRecord>( 140 + &http_client, 141 + &Auth::None, 142 + pds_url, 143 + did.to_string(), 144 + collection.clone(), 145 + params, 146 + ) 147 + .await 148 + { 149 + Ok(resp) => resp, 150 + Err(e) => { 151 + eprintln!("Failed to list records: {}", e); 152 + std::process::exit(1); 153 + } 154 + }; 155 + 156 + for record in &response.records { 157 + followed_dids.push(record.value.subject.clone()); 158 + } 159 + 160 + eprintln!( 161 + " Fetched {} records (total: {})", 162 + response.records.len(), 163 + followed_dids.len() 164 + ); 165 + 166 + match response.cursor { 167 + Some(c) if !response.records.is_empty() => { 168 + cursor = Some(c); 169 + } 170 + _ => break, 171 + } 172 + } 173 + 174 + if followed_dids.is_empty() { 175 + eprintln!("No follow records found"); 176 + return; 177 + } 178 + 179 + eprintln!("Found {} followed accounts", followed_dids.len()); 180 + 181 + if dry_run { 182 + eprintln!("\nDry run - would add these DIDs to TAP:"); 183 + for did in &followed_dids { 184 + println!("{}", did); 185 + } 186 + return; 187 + } 188 + 189 + // Add to TAP in batches 190 + let tap_client = TapClient::new(tap_hostname, tap_password); 191 + let mut added = 0; 192 + 193 + for chunk in followed_dids.chunks(batch_size) { 194 + let did_refs: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect(); 195 + 196 + match tap_client.add_repos(&did_refs).await { 197 + Ok(()) => { 198 + added += chunk.len(); 199 + eprintln!("Added {} DIDs to TAP (total: {})", chunk.len(), added); 200 + } 201 + Err(e) => { 202 + eprintln!("Failed to add repos to TAP: {}", e); 203 + std::process::exit(1); 204 + } 205 + } 206 + } 207 + 208 + eprintln!("Successfully added {} DIDs to TAP", added); 209 + 210 + // Print all added DIDs 211 + for did in &followed_dids { 212 + println!("{}", did); 213 + } 214 + }
+371
crates/atproto-tap/src/client.rs
···
··· 1 + //! HTTP client for TAP management API. 2 + //! 3 + //! This module provides [`TapClient`] for interacting with the TAP service's 4 + //! HTTP management endpoints, including adding/removing tracked repositories. 5 + 6 + use crate::errors::TapError; 7 + use atproto_identity::model::Document; 8 + use base64::Engine; 9 + use base64::engine::general_purpose::STANDARD as BASE64; 10 + use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + /// HTTP client for TAP management API. 14 + /// 15 + /// Provides methods for managing which repositories the TAP service tracks, 16 + /// checking service health, and querying repository status. 17 + /// 18 + /// # Example 19 + /// 20 + /// ```ignore 21 + /// use atproto_tap::TapClient; 22 + /// 23 + /// let client = TapClient::new("localhost:2480", Some("admin_password".to_string())); 24 + /// 25 + /// // Add repositories to track 26 + /// client.add_repos(&["did:plc:xyz123", "did:plc:abc456"]).await?; 27 + /// 28 + /// // Check health 29 + /// if client.health().await? { 30 + /// println!("TAP service is healthy"); 31 + /// } 32 + /// ``` 33 + #[derive(Debug, Clone)] 34 + pub struct TapClient { 35 + http_client: reqwest::Client, 36 + base_url: String, 37 + auth_header: Option<HeaderValue>, 38 + } 39 + 40 + impl TapClient { 41 + /// Create a new TAP management client. 42 + /// 43 + /// # Arguments 44 + /// 45 + /// * `hostname` - TAP service hostname (e.g., "localhost:2480") 46 + /// * `admin_password` - Optional admin password for authentication 47 + pub fn new(hostname: &str, admin_password: Option<String>) -> Self { 48 + let auth_header = admin_password.map(|password| { 49 + let credentials = format!("admin:{}", password); 50 + let encoded = BASE64.encode(credentials.as_bytes()); 51 + HeaderValue::from_str(&format!("Basic {}", encoded)) 52 + .expect("Invalid auth header value") 53 + }); 54 + 55 + Self { 56 + http_client: reqwest::Client::new(), 57 + base_url: format!("http://{}", hostname), 58 + auth_header, 59 + } 60 + } 61 + 62 + /// Create default headers for requests. 63 + fn default_headers(&self) -> HeaderMap { 64 + let mut headers = HeaderMap::new(); 65 + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 66 + if let Some(auth) = &self.auth_header { 67 + headers.insert(AUTHORIZATION, auth.clone()); 68 + } 69 + headers 70 + } 71 + 72 + /// Add repositories to track. 73 + /// 74 + /// Sends a POST request to `/repos/add` with the list of DIDs. 75 + /// 76 + /// # Arguments 77 + /// 78 + /// * `dids` - Slice of DID strings to track 79 + /// 80 + /// # Example 81 + /// 82 + /// ```ignore 83 + /// client.add_repos(&[ 84 + /// "did:plc:z72i7hdynmk6r22z27h6tvur", 85 + /// "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 86 + /// ]).await?; 87 + /// ``` 88 + pub async fn add_repos(&self, dids: &[&str]) -> Result<(), TapError> { 89 + let url = format!("{}/repos/add", self.base_url); 90 + let body = AddReposRequest { 91 + dids: dids.iter().map(|s| s.to_string()).collect(), 92 + }; 93 + 94 + let response = self 95 + .http_client 96 + .post(&url) 97 + .headers(self.default_headers()) 98 + .json(&body) 99 + .send() 100 + .await?; 101 + 102 + if response.status().is_success() { 103 + tracing::debug!(count = dids.len(), "Added repositories to TAP"); 104 + Ok(()) 105 + } else { 106 + let status = response.status().as_u16(); 107 + let message = response.text().await.unwrap_or_default(); 108 + Err(TapError::HttpResponseError { status, message }) 109 + } 110 + } 111 + 112 + /// Remove repositories from tracking. 113 + /// 114 + /// Sends a POST request to `/repos/remove` with the list of DIDs. 115 + /// 116 + /// # Arguments 117 + /// 118 + /// * `dids` - Slice of DID strings to stop tracking 119 + pub async fn remove_repos(&self, dids: &[&str]) -> Result<(), TapError> { 120 + let url = format!("{}/repos/remove", self.base_url); 121 + let body = AddReposRequest { 122 + dids: dids.iter().map(|s| s.to_string()).collect(), 123 + }; 124 + 125 + let response = self 126 + .http_client 127 + .post(&url) 128 + .headers(self.default_headers()) 129 + .json(&body) 130 + .send() 131 + .await?; 132 + 133 + if response.status().is_success() { 134 + tracing::debug!(count = dids.len(), "Removed repositories from TAP"); 135 + Ok(()) 136 + } else { 137 + let status = response.status().as_u16(); 138 + let message = response.text().await.unwrap_or_default(); 139 + Err(TapError::HttpResponseError { status, message }) 140 + } 141 + } 142 + 143 + /// Check service health. 144 + /// 145 + /// Sends a GET request to `/health`. 146 + /// 147 + /// # Returns 148 + /// 149 + /// `true` if the service is healthy, `false` otherwise. 150 + pub async fn health(&self) -> Result<bool, TapError> { 151 + let url = format!("{}/health", self.base_url); 152 + 153 + let response = self 154 + .http_client 155 + .get(&url) 156 + .headers(self.default_headers()) 157 + .send() 158 + .await?; 159 + 160 + Ok(response.status().is_success()) 161 + } 162 + 163 + /// Resolve a DID to its DID document. 164 + /// 165 + /// Sends a GET request to `/resolve/:did`. 166 + /// 167 + /// # Arguments 168 + /// 169 + /// * `did` - The DID to resolve 170 + /// 171 + /// # Returns 172 + /// 173 + /// The DID document for the identity. 174 + pub async fn resolve(&self, did: &str) -> Result<Document, TapError> { 175 + let url = format!("{}/resolve/{}", self.base_url, did); 176 + 177 + let response = self 178 + .http_client 179 + .get(&url) 180 + .headers(self.default_headers()) 181 + .send() 182 + .await?; 183 + 184 + if response.status().is_success() { 185 + let doc: Document = response.json().await?; 186 + Ok(doc) 187 + } else { 188 + let status = response.status().as_u16(); 189 + let message = response.text().await.unwrap_or_default(); 190 + Err(TapError::HttpResponseError { status, message }) 191 + } 192 + } 193 + 194 + /// Get info about a tracked repository. 195 + /// 196 + /// Sends a GET request to `/info/:did`. 197 + /// 198 + /// # Arguments 199 + /// 200 + /// * `did` - The DID to get info for 201 + /// 202 + /// # Returns 203 + /// 204 + /// Repository tracking information. 205 + pub async fn info(&self, did: &str) -> Result<RepoInfo, TapError> { 206 + let url = format!("{}/info/{}", self.base_url, did); 207 + 208 + let response = self 209 + .http_client 210 + .get(&url) 211 + .headers(self.default_headers()) 212 + .send() 213 + .await?; 214 + 215 + if response.status().is_success() { 216 + let info: RepoInfo = response.json().await?; 217 + Ok(info) 218 + } else { 219 + let status = response.status().as_u16(); 220 + let message = response.text().await.unwrap_or_default(); 221 + Err(TapError::HttpResponseError { status, message }) 222 + } 223 + } 224 + } 225 + 226 + /// Request body for adding/removing repositories. 227 + #[derive(Debug, Serialize)] 228 + struct AddReposRequest { 229 + dids: Vec<String>, 230 + } 231 + 232 + /// Repository tracking information. 233 + #[derive(Debug, Clone, Serialize, Deserialize)] 234 + pub struct RepoInfo { 235 + /// The repository DID. 236 + pub did: Box<str>, 237 + /// Current sync state. 238 + pub state: RepoState, 239 + /// The handle for the repository. 240 + #[serde(default)] 241 + pub handle: Option<Box<str>>, 242 + /// Number of records in the repository. 243 + #[serde(default)] 244 + pub records: u64, 245 + /// Current repository revision. 246 + #[serde(default)] 247 + pub rev: Option<Box<str>>, 248 + /// Number of retries for syncing. 249 + #[serde(default)] 250 + pub retries: u32, 251 + /// Error message if any. 252 + #[serde(default)] 253 + pub error: Option<Box<str>>, 254 + /// Additional fields may be present depending on TAP version. 255 + #[serde(flatten)] 256 + pub extra: serde_json::Value, 257 + } 258 + 259 + /// Repository sync state. 260 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 261 + #[serde(rename_all = "lowercase")] 262 + pub enum RepoState { 263 + /// Repository is active and synced. 264 + Active, 265 + /// Repository is currently syncing. 266 + Syncing, 267 + /// Repository is fully synced. 268 + Synced, 269 + /// Sync failed for this repository. 270 + Failed, 271 + /// Repository is queued for sync. 272 + Queued, 273 + /// Unknown state. 274 + #[serde(other)] 275 + Unknown, 276 + } 277 + 278 + /// Deprecated alias for RepoState. 279 + #[deprecated(since = "0.13.0", note = "Use RepoState instead")] 280 + pub type RepoStatus = RepoState; 281 + 282 + impl std::fmt::Display for RepoState { 283 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 284 + match self { 285 + RepoState::Active => write!(f, "active"), 286 + RepoState::Syncing => write!(f, "syncing"), 287 + RepoState::Synced => write!(f, "synced"), 288 + RepoState::Failed => write!(f, "failed"), 289 + RepoState::Queued => write!(f, "queued"), 290 + RepoState::Unknown => write!(f, "unknown"), 291 + } 292 + } 293 + } 294 + 295 + #[cfg(test)] 296 + mod tests { 297 + use super::*; 298 + 299 + #[test] 300 + fn test_client_creation() { 301 + let client = TapClient::new("localhost:2480", None); 302 + assert_eq!(client.base_url, "http://localhost:2480"); 303 + assert!(client.auth_header.is_none()); 304 + 305 + let client = TapClient::new("localhost:2480", Some("secret".to_string())); 306 + assert!(client.auth_header.is_some()); 307 + } 308 + 309 + #[test] 310 + fn test_repo_state_display() { 311 + assert_eq!(RepoState::Active.to_string(), "active"); 312 + assert_eq!(RepoState::Syncing.to_string(), "syncing"); 313 + assert_eq!(RepoState::Synced.to_string(), "synced"); 314 + assert_eq!(RepoState::Failed.to_string(), "failed"); 315 + assert_eq!(RepoState::Queued.to_string(), "queued"); 316 + assert_eq!(RepoState::Unknown.to_string(), "unknown"); 317 + } 318 + 319 + #[test] 320 + fn test_repo_state_deserialize() { 321 + let json = r#""active""#; 322 + let state: RepoState = serde_json::from_str(json).unwrap(); 323 + assert_eq!(state, RepoState::Active); 324 + 325 + let json = r#""syncing""#; 326 + let state: RepoState = serde_json::from_str(json).unwrap(); 327 + assert_eq!(state, RepoState::Syncing); 328 + 329 + let json = r#""some_new_state""#; 330 + let state: RepoState = serde_json::from_str(json).unwrap(); 331 + assert_eq!(state, RepoState::Unknown); 332 + } 333 + 334 + #[test] 335 + fn test_repo_info_deserialize() { 336 + let json = r#"{"did":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","error":"","handle":"ngerakines.me","records":21382,"retries":0,"rev":"3mam4aazabs2m","state":"active"}"#; 337 + let info: RepoInfo = serde_json::from_str(json).unwrap(); 338 + assert_eq!(&*info.did, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 339 + assert_eq!(info.state, RepoState::Active); 340 + assert_eq!(info.handle.as_deref(), Some("ngerakines.me")); 341 + assert_eq!(info.records, 21382); 342 + assert_eq!(info.retries, 0); 343 + assert_eq!(info.rev.as_deref(), Some("3mam4aazabs2m")); 344 + // Empty string deserializes as Some("") 345 + assert_eq!(info.error.as_deref(), Some("")); 346 + } 347 + 348 + #[test] 349 + fn test_repo_info_deserialize_minimal() { 350 + // Test with only required fields 351 + let json = r#"{"did":"did:plc:test","state":"syncing"}"#; 352 + let info: RepoInfo = serde_json::from_str(json).unwrap(); 353 + assert_eq!(&*info.did, "did:plc:test"); 354 + assert_eq!(info.state, RepoState::Syncing); 355 + assert_eq!(info.handle, None); 356 + assert_eq!(info.records, 0); 357 + assert_eq!(info.retries, 0); 358 + assert_eq!(info.rev, None); 359 + assert_eq!(info.error, None); 360 + } 361 + 362 + #[test] 363 + fn test_add_repos_request_serialize() { 364 + let req = AddReposRequest { 365 + dids: vec!["did:plc:xyz".to_string(), "did:plc:abc".to_string()], 366 + }; 367 + let json = serde_json::to_string(&req).unwrap(); 368 + assert!(json.contains("dids")); 369 + assert!(json.contains("did:plc:xyz")); 370 + } 371 + }
+220
crates/atproto-tap/src/config.rs
···
··· 1 + //! Configuration for TAP stream connections. 2 + //! 3 + //! This module provides the [`TapConfig`] struct for configuring TAP stream 4 + //! connections, including hostname, authentication, and reconnection behavior. 5 + 6 + use std::time::Duration; 7 + 8 + /// Configuration for a TAP stream connection. 9 + /// 10 + /// Use [`TapConfig::builder()`] for ergonomic construction with defaults. 11 + /// 12 + /// # Example 13 + /// 14 + /// ``` 15 + /// use atproto_tap::TapConfig; 16 + /// use std::time::Duration; 17 + /// 18 + /// let config = TapConfig::builder() 19 + /// .hostname("localhost:2480") 20 + /// .admin_password("secret") 21 + /// .send_acks(true) 22 + /// .max_reconnect_attempts(Some(10)) 23 + /// .build(); 24 + /// ``` 25 + #[derive(Debug, Clone)] 26 + pub struct TapConfig { 27 + /// TAP service hostname (e.g., "localhost:2480"). 28 + /// 29 + /// The WebSocket URL is constructed as `ws://{hostname}/channel`. 30 + pub hostname: String, 31 + 32 + /// Optional admin password for authentication. 33 + /// 34 + /// If set, HTTP Basic Auth is used with username "admin". 35 + pub admin_password: Option<String>, 36 + 37 + /// Whether to send acknowledgments for received messages. 38 + /// 39 + /// Default: `true`. Set to `false` if the TAP service has acks disabled. 40 + pub send_acks: bool, 41 + 42 + /// User-Agent header value for WebSocket connections. 43 + pub user_agent: String, 44 + 45 + /// Maximum reconnection attempts before giving up. 46 + /// 47 + /// `None` means unlimited reconnection attempts (default). 48 + pub max_reconnect_attempts: Option<u32>, 49 + 50 + /// Initial delay before first reconnection attempt. 51 + /// 52 + /// Default: 1 second. 53 + pub initial_reconnect_delay: Duration, 54 + 55 + /// Maximum delay between reconnection attempts. 56 + /// 57 + /// Default: 60 seconds. 58 + pub max_reconnect_delay: Duration, 59 + 60 + /// Multiplier for exponential backoff between reconnections. 61 + /// 62 + /// Default: 2.0 (doubles the delay each attempt). 63 + pub reconnect_backoff_multiplier: f64, 64 + } 65 + 66 + impl Default for TapConfig { 67 + fn default() -> Self { 68 + Self { 69 + hostname: "localhost:2480".to_string(), 70 + admin_password: None, 71 + send_acks: true, 72 + user_agent: format!("atproto-tap/{}", env!("CARGO_PKG_VERSION")), 73 + max_reconnect_attempts: None, 74 + initial_reconnect_delay: Duration::from_secs(1), 75 + max_reconnect_delay: Duration::from_secs(60), 76 + reconnect_backoff_multiplier: 2.0, 77 + } 78 + } 79 + } 80 + 81 + impl TapConfig { 82 + /// Create a new configuration builder with defaults. 83 + pub fn builder() -> TapConfigBuilder { 84 + TapConfigBuilder::default() 85 + } 86 + 87 + /// Create a minimal configuration for the given hostname. 88 + pub fn new(hostname: impl Into<String>) -> Self { 89 + Self { 90 + hostname: hostname.into(), 91 + ..Default::default() 92 + } 93 + } 94 + 95 + /// Returns the WebSocket URL for the TAP channel. 96 + pub fn ws_url(&self) -> String { 97 + format!("ws://{}/channel", self.hostname) 98 + } 99 + 100 + /// Returns the HTTP base URL for the TAP management API. 101 + pub fn http_base_url(&self) -> String { 102 + format!("http://{}", self.hostname) 103 + } 104 + } 105 + 106 + /// Builder for [`TapConfig`]. 107 + #[derive(Debug, Clone, Default)] 108 + pub struct TapConfigBuilder { 109 + config: TapConfig, 110 + } 111 + 112 + impl TapConfigBuilder { 113 + /// Set the TAP service hostname. 114 + pub fn hostname(mut self, hostname: impl Into<String>) -> Self { 115 + self.config.hostname = hostname.into(); 116 + self 117 + } 118 + 119 + /// Set the admin password for authentication. 120 + pub fn admin_password(mut self, password: impl Into<String>) -> Self { 121 + self.config.admin_password = Some(password.into()); 122 + self 123 + } 124 + 125 + /// Set whether to send acknowledgments. 126 + pub fn send_acks(mut self, send_acks: bool) -> Self { 127 + self.config.send_acks = send_acks; 128 + self 129 + } 130 + 131 + /// Set the User-Agent header value. 132 + pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self { 133 + self.config.user_agent = user_agent.into(); 134 + self 135 + } 136 + 137 + /// Set the maximum reconnection attempts. 138 + /// 139 + /// `None` means unlimited attempts. 140 + pub fn max_reconnect_attempts(mut self, max: Option<u32>) -> Self { 141 + self.config.max_reconnect_attempts = max; 142 + self 143 + } 144 + 145 + /// Set the initial reconnection delay. 146 + pub fn initial_reconnect_delay(mut self, delay: Duration) -> Self { 147 + self.config.initial_reconnect_delay = delay; 148 + self 149 + } 150 + 151 + /// Set the maximum reconnection delay. 152 + pub fn max_reconnect_delay(mut self, delay: Duration) -> Self { 153 + self.config.max_reconnect_delay = delay; 154 + self 155 + } 156 + 157 + /// Set the reconnection backoff multiplier. 158 + pub fn reconnect_backoff_multiplier(mut self, multiplier: f64) -> Self { 159 + self.config.reconnect_backoff_multiplier = multiplier; 160 + self 161 + } 162 + 163 + /// Build the configuration. 164 + pub fn build(self) -> TapConfig { 165 + self.config 166 + } 167 + } 168 + 169 + #[cfg(test)] 170 + mod tests { 171 + use super::*; 172 + 173 + #[test] 174 + fn test_default_config() { 175 + let config = TapConfig::default(); 176 + assert_eq!(config.hostname, "localhost:2480"); 177 + assert!(config.admin_password.is_none()); 178 + assert!(config.send_acks); 179 + assert!(config.max_reconnect_attempts.is_none()); 180 + assert_eq!(config.initial_reconnect_delay, Duration::from_secs(1)); 181 + assert_eq!(config.max_reconnect_delay, Duration::from_secs(60)); 182 + assert!((config.reconnect_backoff_multiplier - 2.0).abs() < f64::EPSILON); 183 + } 184 + 185 + #[test] 186 + fn test_builder() { 187 + let config = TapConfig::builder() 188 + .hostname("tap.example.com:2480") 189 + .admin_password("secret123") 190 + .send_acks(false) 191 + .max_reconnect_attempts(Some(5)) 192 + .initial_reconnect_delay(Duration::from_millis(500)) 193 + .max_reconnect_delay(Duration::from_secs(30)) 194 + .reconnect_backoff_multiplier(1.5) 195 + .build(); 196 + 197 + assert_eq!(config.hostname, "tap.example.com:2480"); 198 + assert_eq!(config.admin_password, Some("secret123".to_string())); 199 + assert!(!config.send_acks); 200 + assert_eq!(config.max_reconnect_attempts, Some(5)); 201 + assert_eq!(config.initial_reconnect_delay, Duration::from_millis(500)); 202 + assert_eq!(config.max_reconnect_delay, Duration::from_secs(30)); 203 + assert!((config.reconnect_backoff_multiplier - 1.5).abs() < f64::EPSILON); 204 + } 205 + 206 + #[test] 207 + fn test_ws_url() { 208 + let config = TapConfig::new("localhost:2480"); 209 + assert_eq!(config.ws_url(), "ws://localhost:2480/channel"); 210 + 211 + let config = TapConfig::new("tap.example.com:8080"); 212 + assert_eq!(config.ws_url(), "ws://tap.example.com:8080/channel"); 213 + } 214 + 215 + #[test] 216 + fn test_http_base_url() { 217 + let config = TapConfig::new("localhost:2480"); 218 + assert_eq!(config.http_base_url(), "http://localhost:2480"); 219 + } 220 + }
+168
crates/atproto-tap/src/connection.rs
···
··· 1 + //! WebSocket connection management for TAP streams. 2 + //! 3 + //! This module handles the low-level WebSocket connection to a TAP service, 4 + //! including authentication and message sending/receiving. 5 + 6 + use crate::config::TapConfig; 7 + use crate::errors::TapError; 8 + use base64::Engine; 9 + use base64::engine::general_purpose::STANDARD as BASE64; 10 + use futures::{SinkExt, StreamExt}; 11 + use http::Uri; 12 + use std::str::FromStr; 13 + use tokio_websockets::{ClientBuilder, Message, WebSocketStream}; 14 + use tokio_websockets::MaybeTlsStream; 15 + use tokio::net::TcpStream; 16 + 17 + /// WebSocket connection to a TAP service. 18 + pub(crate) struct TapConnection { 19 + /// The underlying WebSocket stream. 20 + ws: WebSocketStream<MaybeTlsStream<TcpStream>>, 21 + /// Pre-allocated buffer for acknowledgment messages. 22 + ack_buffer: Vec<u8>, 23 + } 24 + 25 + impl TapConnection { 26 + /// Establish a new WebSocket connection to the TAP service. 27 + pub async fn connect(config: &TapConfig) -> Result<Self, TapError> { 28 + let uri = Uri::from_str(&config.ws_url()) 29 + .map_err(|e| TapError::InvalidUrl(e.to_string()))?; 30 + 31 + let mut builder = ClientBuilder::from_uri(uri); 32 + 33 + // Add User-Agent header 34 + builder = builder 35 + .add_header( 36 + http::header::USER_AGENT, 37 + http::HeaderValue::from_str(&config.user_agent) 38 + .map_err(|e| TapError::ConnectionFailed(format!("Invalid user agent: {}", e)))?, 39 + ) 40 + .map_err(|e| TapError::ConnectionFailed(format!("Failed to add header: {}", e)))?; 41 + 42 + // Add Basic Auth header if password is configured 43 + if let Some(password) = &config.admin_password { 44 + let credentials = format!("admin:{}", password); 45 + let encoded = BASE64.encode(credentials.as_bytes()); 46 + let auth_value = format!("Basic {}", encoded); 47 + 48 + builder = builder 49 + .add_header( 50 + http::header::AUTHORIZATION, 51 + http::HeaderValue::from_str(&auth_value) 52 + .map_err(|e| TapError::ConnectionFailed(format!("Invalid auth header: {}", e)))?, 53 + ) 54 + .map_err(|e| TapError::ConnectionFailed(format!("Failed to add auth header: {}", e)))?; 55 + } 56 + 57 + // Connect 58 + let (ws, _response) = builder 59 + .connect() 60 + .await 61 + .map_err(|e| TapError::ConnectionFailed(e.to_string()))?; 62 + 63 + tracing::debug!(hostname = %config.hostname, "Connected to TAP service"); 64 + 65 + Ok(Self { 66 + ws, 67 + ack_buffer: Vec::with_capacity(48), // {"type":"ack","id":18446744073709551615} is 40 bytes max 68 + }) 69 + } 70 + 71 + /// Receive the next message from the WebSocket. 72 + /// 73 + /// Returns `None` if the connection was closed cleanly. 74 + pub async fn recv(&mut self) -> Result<Option<String>, TapError> { 75 + match self.ws.next().await { 76 + Some(Ok(msg)) => { 77 + if msg.is_text() { 78 + msg.as_text() 79 + .map(|s| Some(s.to_string())) 80 + .ok_or_else(|| TapError::ParseError("Failed to get text from message".into())) 81 + } else if msg.is_close() { 82 + tracing::debug!("Received close frame from TAP service"); 83 + Ok(None) 84 + } else { 85 + // Ignore ping/pong and binary messages 86 + tracing::trace!("Received non-text message, ignoring"); 87 + // Recurse to get the next text message 88 + Box::pin(self.recv()).await 89 + } 90 + } 91 + Some(Err(e)) => Err(TapError::ConnectionFailed(e.to_string())), 92 + None => { 93 + tracing::debug!("WebSocket stream ended"); 94 + Ok(None) 95 + } 96 + } 97 + } 98 + 99 + /// Send an acknowledgment for the given event ID. 100 + /// 101 + /// Uses a pre-allocated buffer and itoa for allocation-free formatting. 102 + /// Format: `{"type":"ack","id":12345}` 103 + pub async fn send_ack(&mut self, id: u64) -> Result<(), TapError> { 104 + self.ack_buffer.clear(); 105 + self.ack_buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":"); 106 + let mut itoa_buf = itoa::Buffer::new(); 107 + self.ack_buffer.extend_from_slice(itoa_buf.format(id).as_bytes()); 108 + self.ack_buffer.push(b'}'); 109 + 110 + // All bytes are ASCII so this is always valid UTF-8 111 + let msg = std::str::from_utf8(&self.ack_buffer) 112 + .expect("ack buffer contains only ASCII"); 113 + 114 + self.ws 115 + .send(Message::text(msg.to_string())) 116 + .await 117 + .map_err(|e| TapError::AckFailed(e.to_string()))?; 118 + 119 + // Flush to ensure the ack is sent immediately 120 + self.ws 121 + .flush() 122 + .await 123 + .map_err(|e| TapError::AckFailed(format!("Failed to flush ack: {}", e)))?; 124 + 125 + tracing::trace!(id, "Sent ack"); 126 + Ok(()) 127 + } 128 + 129 + /// Close the WebSocket connection gracefully. 130 + pub async fn close(&mut self) -> Result<(), TapError> { 131 + self.ws 132 + .close() 133 + .await 134 + .map_err(|e| TapError::ConnectionFailed(format!("Failed to close: {}", e)))?; 135 + Ok(()) 136 + } 137 + } 138 + 139 + #[cfg(test)] 140 + mod tests { 141 + #[test] 142 + fn test_ack_buffer_format() { 143 + // Test that our manual JSON formatting is correct 144 + // Format: {"type":"ack","id":12345} 145 + let mut buffer = Vec::with_capacity(64); 146 + 147 + let id: u64 = 12345; 148 + buffer.clear(); 149 + buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":"); 150 + let mut itoa_buf = itoa::Buffer::new(); 151 + buffer.extend_from_slice(itoa_buf.format(id).as_bytes()); 152 + buffer.push(b'}'); 153 + 154 + let result = std::str::from_utf8(&buffer).unwrap(); 155 + assert_eq!(result, r#"{"type":"ack","id":12345}"#); 156 + 157 + // Test max u64 158 + let id: u64 = u64::MAX; 159 + buffer.clear(); 160 + buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":"); 161 + buffer.extend_from_slice(itoa_buf.format(id).as_bytes()); 162 + buffer.push(b'}'); 163 + 164 + let result = std::str::from_utf8(&buffer).unwrap(); 165 + assert_eq!(result, r#"{"type":"ack","id":18446744073709551615}"#); 166 + assert!(buffer.len() <= 64); // Fits in our pre-allocated buffer 167 + } 168 + }
+143
crates/atproto-tap/src/errors.rs
···
··· 1 + //! Error types for TAP operations. 2 + //! 3 + //! This module defines the error types returned by TAP stream and client operations. 4 + 5 + use thiserror::Error; 6 + 7 + /// Errors that can occur during TAP operations. 8 + #[derive(Debug, Error)] 9 + pub enum TapError { 10 + /// WebSocket connection failed. 11 + #[error("error-atproto-tap-connection-1 WebSocket connection failed: {0}")] 12 + ConnectionFailed(String), 13 + 14 + /// Connection was closed unexpectedly. 15 + #[error("error-atproto-tap-connection-2 Connection closed unexpectedly")] 16 + ConnectionClosed, 17 + 18 + /// Maximum reconnection attempts exceeded. 19 + #[error("error-atproto-tap-connection-3 Maximum reconnection attempts exceeded after {0} attempts")] 20 + MaxReconnectAttemptsExceeded(u32), 21 + 22 + /// Authentication failed. 23 + #[error("error-atproto-tap-auth-1 Authentication failed: {0}")] 24 + AuthenticationFailed(String), 25 + 26 + /// Failed to parse a message from the server. 27 + #[error("error-atproto-tap-parse-1 Failed to parse message: {0}")] 28 + ParseError(String), 29 + 30 + /// Failed to send an acknowledgment. 31 + #[error("error-atproto-tap-ack-1 Failed to send acknowledgment: {0}")] 32 + AckFailed(String), 33 + 34 + /// HTTP request failed. 35 + #[error("error-atproto-tap-http-1 HTTP request failed: {0}")] 36 + HttpError(String), 37 + 38 + /// HTTP response indicated an error. 39 + #[error("error-atproto-tap-http-2 HTTP error response: {status} - {message}")] 40 + HttpResponseError { 41 + /// HTTP status code. 42 + status: u16, 43 + /// Error message from response. 44 + message: String, 45 + }, 46 + 47 + /// Invalid URL. 48 + #[error("error-atproto-tap-url-1 Invalid URL: {0}")] 49 + InvalidUrl(String), 50 + 51 + /// I/O error. 52 + #[error("error-atproto-tap-io-1 I/O error: {0}")] 53 + IoError(#[from] std::io::Error), 54 + 55 + /// JSON serialization/deserialization error. 56 + #[error("error-atproto-tap-json-1 JSON error: {0}")] 57 + JsonError(#[from] serde_json::Error), 58 + 59 + /// Stream has been closed and cannot be used. 60 + #[error("error-atproto-tap-stream-1 Stream is closed")] 61 + StreamClosed, 62 + 63 + /// Operation timed out. 64 + #[error("error-atproto-tap-timeout-1 Operation timed out")] 65 + Timeout, 66 + } 67 + 68 + impl TapError { 69 + /// Returns true if this error indicates a connection issue that may be recoverable. 70 + pub fn is_connection_error(&self) -> bool { 71 + matches!( 72 + self, 73 + TapError::ConnectionFailed(_) 74 + | TapError::ConnectionClosed 75 + | TapError::IoError(_) 76 + | TapError::Timeout 77 + ) 78 + } 79 + 80 + /// Returns true if this error is a parse error that doesn't affect connection state. 81 + pub fn is_parse_error(&self) -> bool { 82 + matches!(self, TapError::ParseError(_) | TapError::JsonError(_)) 83 + } 84 + 85 + /// Returns true if this error is fatal and the stream should not attempt recovery. 86 + pub fn is_fatal(&self) -> bool { 87 + matches!( 88 + self, 89 + TapError::MaxReconnectAttemptsExceeded(_) 90 + | TapError::AuthenticationFailed(_) 91 + | TapError::StreamClosed 92 + ) 93 + } 94 + } 95 + 96 + impl From<reqwest::Error> for TapError { 97 + fn from(err: reqwest::Error) -> Self { 98 + if err.is_timeout() { 99 + TapError::Timeout 100 + } else if err.is_connect() { 101 + TapError::ConnectionFailed(err.to_string()) 102 + } else { 103 + TapError::HttpError(err.to_string()) 104 + } 105 + } 106 + } 107 + 108 + #[cfg(test)] 109 + mod tests { 110 + use super::*; 111 + 112 + #[test] 113 + fn test_error_classification() { 114 + assert!(TapError::ConnectionFailed("test".into()).is_connection_error()); 115 + assert!(TapError::ConnectionClosed.is_connection_error()); 116 + assert!(TapError::Timeout.is_connection_error()); 117 + 118 + assert!(TapError::ParseError("test".into()).is_parse_error()); 119 + assert!(TapError::JsonError(serde_json::from_str::<()>("invalid").unwrap_err()).is_parse_error()); 120 + 121 + assert!(TapError::MaxReconnectAttemptsExceeded(5).is_fatal()); 122 + assert!(TapError::AuthenticationFailed("test".into()).is_fatal()); 123 + assert!(TapError::StreamClosed.is_fatal()); 124 + 125 + // Non-fatal errors 126 + assert!(!TapError::ConnectionFailed("test".into()).is_fatal()); 127 + assert!(!TapError::ParseError("test".into()).is_fatal()); 128 + } 129 + 130 + #[test] 131 + fn test_error_display() { 132 + let err = TapError::ConnectionFailed("refused".to_string()); 133 + assert!(err.to_string().contains("error-atproto-tap-connection-1")); 134 + assert!(err.to_string().contains("refused")); 135 + 136 + let err = TapError::HttpResponseError { 137 + status: 404, 138 + message: "Not Found".to_string(), 139 + }; 140 + assert!(err.to_string().contains("404")); 141 + assert!(err.to_string().contains("Not Found")); 142 + } 143 + }
+488
crates/atproto-tap/src/events.rs
···
··· 1 + //! TAP event types for AT Protocol record and identity events. 2 + //! 3 + //! This module defines the event structures received from a TAP service. 4 + //! Events are optimized for memory efficiency using: 5 + //! - `CompactString` for small strings (SSO for โ‰ค24 bytes) 6 + //! - `Box<str>` for immutable strings (no capacity overhead) 7 + //! - `serde_json::Value` for record payloads (allows lazy access) 8 + 9 + use compact_str::CompactString; 10 + use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor}; 11 + use serde::{Deserialize, Serialize, de::DeserializeOwned}; 12 + use std::fmt; 13 + 14 + /// A TAP event received from the stream. 15 + /// 16 + /// TAP delivers two types of events: 17 + /// - `Record`: Repository record changes (create, update, delete) 18 + /// - `Identity`: Identity/handle changes for accounts 19 + #[derive(Debug, Clone, Serialize, Deserialize)] 20 + #[serde(tag = "type", rename_all = "lowercase")] 21 + pub enum TapEvent { 22 + /// A repository record event (create, update, or delete). 23 + Record { 24 + /// Sequential event identifier. 25 + id: u64, 26 + /// The record event data. 27 + record: RecordEvent, 28 + }, 29 + /// An identity change event. 30 + Identity { 31 + /// Sequential event identifier. 32 + id: u64, 33 + /// The identity event data. 34 + identity: IdentityEvent, 35 + }, 36 + } 37 + 38 + impl TapEvent { 39 + /// Returns the event ID. 40 + pub fn id(&self) -> u64 { 41 + match self { 42 + TapEvent::Record { id, .. } => *id, 43 + TapEvent::Identity { id, .. } => *id, 44 + } 45 + } 46 + } 47 + 48 + /// Extract only the event ID from a JSON string without fully parsing it. 49 + /// 50 + /// This is a fallback parser used when full `TapEvent` parsing fails (e.g., due to 51 + /// deeply nested records hitting serde_json's recursion limit). It uses `IgnoredAny` 52 + /// to efficiently skip over nested content without building data structures, allowing 53 + /// us to extract the ID for acknowledgment even when full parsing fails. 54 + /// 55 + /// # Example 56 + /// 57 + /// ``` 58 + /// use atproto_tap::extract_event_id; 59 + /// 60 + /// let json = r#"{"type":"record","id":12345,"record":{"deeply":"nested"}}"#; 61 + /// assert_eq!(extract_event_id(json), Some(12345)); 62 + /// ``` 63 + pub fn extract_event_id(json: &str) -> Option<u64> { 64 + let mut deserializer = serde_json::Deserializer::from_str(json); 65 + deserializer.disable_recursion_limit(); 66 + EventIdOnly::deserialize(&mut deserializer).ok().map(|e| e.id) 67 + } 68 + 69 + /// Internal struct for extracting only the "id" field from a TAP event. 70 + #[derive(Debug)] 71 + struct EventIdOnly { 72 + id: u64, 73 + } 74 + 75 + impl<'de> Deserialize<'de> for EventIdOnly { 76 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 77 + where 78 + D: Deserializer<'de>, 79 + { 80 + deserializer.deserialize_map(EventIdOnlyVisitor) 81 + } 82 + } 83 + 84 + struct EventIdOnlyVisitor; 85 + 86 + impl<'de> Visitor<'de> for EventIdOnlyVisitor { 87 + type Value = EventIdOnly; 88 + 89 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 90 + formatter.write_str("a map with an 'id' field") 91 + } 92 + 93 + fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error> 94 + where 95 + M: MapAccess<'de>, 96 + { 97 + let mut id: Option<u64> = None; 98 + 99 + while let Some(key) = map.next_key::<&str>()? { 100 + if key == "id" { 101 + id = Some(map.next_value()?); 102 + // Found what we need - skip the rest efficiently using IgnoredAny 103 + // which handles deeply nested structures without recursion issues 104 + while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {} 105 + break; 106 + } else { 107 + // Skip this value without fully parsing it 108 + map.next_value::<IgnoredAny>()?; 109 + } 110 + } 111 + 112 + id.map(|id| EventIdOnly { id }) 113 + .ok_or_else(|| de::Error::missing_field("id")) 114 + } 115 + } 116 + 117 + /// A repository record event from TAP. 118 + /// 119 + /// Contains information about a record change in a user's repository, 120 + /// including the action taken and the record data (for creates/updates). 121 + #[derive(Debug, Clone, Serialize, Deserialize)] 122 + pub struct RecordEvent { 123 + /// True if from live firehose, false if from backfill/resync. 124 + /// 125 + /// During initial sync or recovery, TAP delivers historical events 126 + /// with `live: false`. Once caught up, live events have `live: true`. 127 + pub live: bool, 128 + 129 + /// Repository revision identifier. 130 + /// 131 + /// Typically 13 characters, stored inline via CompactString SSO. 132 + pub rev: CompactString, 133 + 134 + /// Actor DID (e.g., "did:plc:xyz123"). 135 + pub did: Box<str>, 136 + 137 + /// Collection NSID (e.g., "app.bsky.feed.post"). 138 + pub collection: Box<str>, 139 + 140 + /// Record key within the collection. 141 + /// 142 + /// Typically a TID (13 characters), stored inline via CompactString SSO. 143 + pub rkey: CompactString, 144 + 145 + /// The action performed on the record. 146 + pub action: RecordAction, 147 + 148 + /// Content identifier (CID) of the record. 149 + /// 150 + /// Present for create and update actions, absent for delete. 151 + #[serde(skip_serializing_if = "Option::is_none")] 152 + pub cid: Option<CompactString>, 153 + 154 + /// Record data as JSON value. 155 + /// 156 + /// Present for create and update actions, absent for delete. 157 + /// Use [`parse_record`](Self::parse_record) to deserialize on demand. 158 + #[serde(skip_serializing_if = "Option::is_none")] 159 + pub record: Option<serde_json::Value>, 160 + } 161 + 162 + impl RecordEvent { 163 + /// Parse the record payload into a typed structure. 164 + /// 165 + /// This method deserializes the raw JSON on demand, avoiding 166 + /// unnecessary allocations when the record data isn't needed. 167 + /// 168 + /// # Errors 169 + /// 170 + /// Returns an error if the record is absent (delete events) or 171 + /// if deserialization fails. 172 + /// 173 + /// # Example 174 + /// 175 + /// ```ignore 176 + /// use serde::Deserialize; 177 + /// 178 + /// #[derive(Deserialize)] 179 + /// struct Post { 180 + /// text: String, 181 + /// #[serde(rename = "createdAt")] 182 + /// created_at: String, 183 + /// } 184 + /// 185 + /// let post: Post = record_event.parse_record()?; 186 + /// println!("Post text: {}", post.text); 187 + /// ``` 188 + pub fn parse_record<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> { 189 + match &self.record { 190 + Some(value) => serde_json::from_value(value.clone()), 191 + None => Err(serde::de::Error::custom("no record data (delete event)")), 192 + } 193 + } 194 + 195 + /// Returns the record as a JSON Value reference, if present. 196 + pub fn record_value(&self) -> Option<&serde_json::Value> { 197 + self.record.as_ref() 198 + } 199 + 200 + /// Returns true if this is a delete event. 201 + pub fn is_delete(&self) -> bool { 202 + self.action == RecordAction::Delete 203 + } 204 + 205 + /// Returns the AT-URI for this record. 206 + /// 207 + /// Format: `at://{did}/{collection}/{rkey}` 208 + pub fn at_uri(&self) -> String { 209 + format!("at://{}/{}/{}", self.did, self.collection, self.rkey) 210 + } 211 + } 212 + 213 + /// The action performed on a record. 214 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 215 + #[serde(rename_all = "lowercase")] 216 + pub enum RecordAction { 217 + /// A new record was created. 218 + Create, 219 + /// An existing record was updated. 220 + Update, 221 + /// A record was deleted. 222 + Delete, 223 + } 224 + 225 + impl std::fmt::Display for RecordAction { 226 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 227 + match self { 228 + RecordAction::Create => write!(f, "create"), 229 + RecordAction::Update => write!(f, "update"), 230 + RecordAction::Delete => write!(f, "delete"), 231 + } 232 + } 233 + } 234 + 235 + /// An identity change event from TAP. 236 + /// 237 + /// Contains information about handle or account status changes. 238 + #[derive(Debug, Clone, Serialize, Deserialize)] 239 + pub struct IdentityEvent { 240 + /// Actor DID. 241 + pub did: Box<str>, 242 + 243 + /// Current handle for the account. 244 + pub handle: Box<str>, 245 + 246 + /// Whether the account is currently active. 247 + #[serde(default)] 248 + pub is_active: bool, 249 + 250 + /// Account status. 251 + #[serde(default)] 252 + pub status: IdentityStatus, 253 + } 254 + 255 + /// Account status in an identity event. 256 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 257 + #[serde(rename_all = "lowercase")] 258 + pub enum IdentityStatus { 259 + /// Account is active and in good standing. 260 + #[default] 261 + Active, 262 + /// Account has been deactivated by the user. 263 + Deactivated, 264 + /// Account has been suspended. 265 + Suspended, 266 + /// Account has been deleted. 267 + Deleted, 268 + /// Account has been taken down. 269 + Takendown, 270 + } 271 + 272 + impl std::fmt::Display for IdentityStatus { 273 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 274 + match self { 275 + IdentityStatus::Active => write!(f, "active"), 276 + IdentityStatus::Deactivated => write!(f, "deactivated"), 277 + IdentityStatus::Suspended => write!(f, "suspended"), 278 + IdentityStatus::Deleted => write!(f, "deleted"), 279 + IdentityStatus::Takendown => write!(f, "takendown"), 280 + } 281 + } 282 + } 283 + 284 + #[cfg(test)] 285 + mod tests { 286 + use super::*; 287 + 288 + #[test] 289 + fn test_parse_record_event() { 290 + let json = r#"{ 291 + "id": 12345, 292 + "type": "record", 293 + "record": { 294 + "live": true, 295 + "rev": "3lyileto4q52k", 296 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 297 + "collection": "app.bsky.feed.post", 298 + "rkey": "3lyiletddxt2c", 299 + "action": "create", 300 + "cid": "bafyreigroo6vhxt62ufcndhaxzas6btq4jmniuz4egszbwuqgiyisqwqoy", 301 + "record": {"$type": "app.bsky.feed.post", "text": "Hello world!", "createdAt": "2025-01-01T00:00:00Z"} 302 + } 303 + }"#; 304 + 305 + let event: TapEvent = serde_json::from_str(json).expect("Failed to parse"); 306 + 307 + match event { 308 + TapEvent::Record { id, record } => { 309 + assert_eq!(id, 12345); 310 + assert!(record.live); 311 + assert_eq!(record.rev.as_str(), "3lyileto4q52k"); 312 + assert_eq!(&*record.did, "did:plc:z72i7hdynmk6r22z27h6tvur"); 313 + assert_eq!(&*record.collection, "app.bsky.feed.post"); 314 + assert_eq!(record.rkey.as_str(), "3lyiletddxt2c"); 315 + assert_eq!(record.action, RecordAction::Create); 316 + assert!(record.cid.is_some()); 317 + assert!(record.record.is_some()); 318 + 319 + // Test lazy parsing 320 + #[derive(Deserialize)] 321 + struct Post { 322 + text: String, 323 + } 324 + let post: Post = record.parse_record().expect("Failed to parse record"); 325 + assert_eq!(post.text, "Hello world!"); 326 + } 327 + _ => panic!("Expected Record event"), 328 + } 329 + } 330 + 331 + #[test] 332 + fn test_parse_delete_event() { 333 + let json = r#"{ 334 + "id": 12346, 335 + "type": "record", 336 + "record": { 337 + "live": true, 338 + "rev": "3lyileto4q52k", 339 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 340 + "collection": "app.bsky.feed.post", 341 + "rkey": "3lyiletddxt2c", 342 + "action": "delete" 343 + } 344 + }"#; 345 + 346 + let event: TapEvent = serde_json::from_str(json).expect("Failed to parse"); 347 + 348 + match event { 349 + TapEvent::Record { id, record } => { 350 + assert_eq!(id, 12346); 351 + assert_eq!(record.action, RecordAction::Delete); 352 + assert!(record.is_delete()); 353 + assert!(record.cid.is_none()); 354 + assert!(record.record.is_none()); 355 + } 356 + _ => panic!("Expected Record event"), 357 + } 358 + } 359 + 360 + #[test] 361 + fn test_parse_identity_event() { 362 + let json = r#"{ 363 + "id": 12347, 364 + "type": "identity", 365 + "identity": { 366 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 367 + "handle": "user.bsky.social", 368 + "is_active": true, 369 + "status": "active" 370 + } 371 + }"#; 372 + 373 + let event: TapEvent = serde_json::from_str(json).expect("Failed to parse"); 374 + 375 + match event { 376 + TapEvent::Identity { id, identity } => { 377 + assert_eq!(id, 12347); 378 + assert_eq!(&*identity.did, "did:plc:z72i7hdynmk6r22z27h6tvur"); 379 + assert_eq!(&*identity.handle, "user.bsky.social"); 380 + assert!(identity.is_active); 381 + assert_eq!(identity.status, IdentityStatus::Active); 382 + } 383 + _ => panic!("Expected Identity event"), 384 + } 385 + } 386 + 387 + #[test] 388 + fn test_record_action_display() { 389 + assert_eq!(RecordAction::Create.to_string(), "create"); 390 + assert_eq!(RecordAction::Update.to_string(), "update"); 391 + assert_eq!(RecordAction::Delete.to_string(), "delete"); 392 + } 393 + 394 + #[test] 395 + fn test_identity_status_display() { 396 + assert_eq!(IdentityStatus::Active.to_string(), "active"); 397 + assert_eq!(IdentityStatus::Deactivated.to_string(), "deactivated"); 398 + assert_eq!(IdentityStatus::Suspended.to_string(), "suspended"); 399 + assert_eq!(IdentityStatus::Deleted.to_string(), "deleted"); 400 + assert_eq!(IdentityStatus::Takendown.to_string(), "takendown"); 401 + } 402 + 403 + #[test] 404 + fn test_at_uri() { 405 + let record = RecordEvent { 406 + live: true, 407 + rev: "3lyileto4q52k".into(), 408 + did: "did:plc:xyz".into(), 409 + collection: "app.bsky.feed.post".into(), 410 + rkey: "abc123".into(), 411 + action: RecordAction::Create, 412 + cid: None, 413 + record: None, 414 + }; 415 + 416 + assert_eq!(record.at_uri(), "at://did:plc:xyz/app.bsky.feed.post/abc123"); 417 + } 418 + 419 + #[test] 420 + fn test_event_id() { 421 + let record_event = TapEvent::Record { 422 + id: 100, 423 + record: RecordEvent { 424 + live: true, 425 + rev: "rev".into(), 426 + did: "did".into(), 427 + collection: "col".into(), 428 + rkey: "rkey".into(), 429 + action: RecordAction::Create, 430 + cid: None, 431 + record: None, 432 + }, 433 + }; 434 + assert_eq!(record_event.id(), 100); 435 + 436 + let identity_event = TapEvent::Identity { 437 + id: 200, 438 + identity: IdentityEvent { 439 + did: "did".into(), 440 + handle: "handle".into(), 441 + is_active: true, 442 + status: IdentityStatus::Active, 443 + }, 444 + }; 445 + assert_eq!(identity_event.id(), 200); 446 + } 447 + 448 + #[test] 449 + fn test_extract_event_id_simple() { 450 + let json = r#"{"type":"record","id":12345,"record":{"deeply":"nested"}}"#; 451 + assert_eq!(extract_event_id(json), Some(12345)); 452 + } 453 + 454 + #[test] 455 + fn test_extract_event_id_at_end() { 456 + let json = r#"{"type":"record","record":{"deeply":"nested"},"id":99999}"#; 457 + assert_eq!(extract_event_id(json), Some(99999)); 458 + } 459 + 460 + #[test] 461 + fn test_extract_event_id_missing() { 462 + let json = r#"{"type":"record","record":{"deeply":"nested"}}"#; 463 + assert_eq!(extract_event_id(json), None); 464 + } 465 + 466 + #[test] 467 + fn test_extract_event_id_invalid_json() { 468 + let json = r#"{"type":"record","id":123"#; // Truncated JSON 469 + assert_eq!(extract_event_id(json), None); 470 + } 471 + 472 + #[test] 473 + fn test_extract_event_id_deeply_nested() { 474 + // Create a deeply nested JSON that would exceed serde_json's default recursion limit 475 + let mut json = String::from(r#"{"id":42,"record":{"nested":"#); 476 + for _ in 0..200 { 477 + json.push_str("["); 478 + } 479 + json.push_str("1"); 480 + for _ in 0..200 { 481 + json.push_str("]"); 482 + } 483 + json.push_str("}}"); 484 + 485 + // extract_event_id should still work because it uses IgnoredAny with disabled recursion limit 486 + assert_eq!(extract_event_id(&json), Some(42)); 487 + } 488 + }
+119
crates/atproto-tap/src/lib.rs
···
··· 1 + //! TAP (Trusted Attestation Protocol) service consumer for AT Protocol. 2 + //! 3 + //! This crate provides a client for consuming events from a TAP service, 4 + //! which delivers filtered, verified AT Protocol repository events. 5 + //! 6 + //! # Overview 7 + //! 8 + //! TAP is a single-tenant service that subscribes to an AT Protocol Relay and 9 + //! outputs filtered, verified events. Key features include: 10 + //! 11 + //! - **Verified Events**: MST integrity checks and signature verification 12 + //! - **Automatic Backfill**: Historical events delivered with `live: false` 13 + //! - **Repository Filtering**: Track specific DIDs or collections 14 + //! - **Acknowledgment Protocol**: At-least-once delivery semantics 15 + //! 16 + //! # Quick Start 17 + //! 18 + //! ```ignore 19 + //! use atproto_tap::{connect_to, TapEvent}; 20 + //! use tokio_stream::StreamExt; 21 + //! 22 + //! #[tokio::main] 23 + //! async fn main() { 24 + //! let mut stream = connect_to("localhost:2480"); 25 + //! 26 + //! while let Some(result) = stream.next().await { 27 + //! match result { 28 + //! Ok(event) => match event.as_ref() { 29 + //! TapEvent::Record { record, .. } => { 30 + //! println!("{} {} {}", record.action, record.collection, record.did); 31 + //! } 32 + //! TapEvent::Identity { identity, .. } => { 33 + //! println!("Identity: {} = {}", identity.did, identity.handle); 34 + //! } 35 + //! }, 36 + //! Err(e) => eprintln!("Error: {}", e), 37 + //! } 38 + //! } 39 + //! } 40 + //! ``` 41 + //! 42 + //! # Using with `tokio::select!` 43 + //! 44 + //! The stream integrates naturally with Tokio's select macro: 45 + //! 46 + //! ```ignore 47 + //! use atproto_tap::{connect, TapConfig}; 48 + //! use tokio_stream::StreamExt; 49 + //! use tokio::signal; 50 + //! 51 + //! #[tokio::main] 52 + //! async fn main() { 53 + //! let config = TapConfig::builder() 54 + //! .hostname("localhost:2480") 55 + //! .admin_password("secret") 56 + //! .build(); 57 + //! 58 + //! let mut stream = connect(config); 59 + //! 60 + //! loop { 61 + //! tokio::select! { 62 + //! Some(result) = stream.next() => { 63 + //! // Process event 64 + //! } 65 + //! _ = signal::ctrl_c() => { 66 + //! break; 67 + //! } 68 + //! } 69 + //! } 70 + //! } 71 + //! ``` 72 + //! 73 + //! # Management API 74 + //! 75 + //! Use [`TapClient`] to manage tracked repositories: 76 + //! 77 + //! ```ignore 78 + //! use atproto_tap::TapClient; 79 + //! 80 + //! let client = TapClient::new("localhost:2480", Some("password".to_string())); 81 + //! 82 + //! // Add repositories to track 83 + //! client.add_repos(&["did:plc:xyz123"]).await?; 84 + //! 85 + //! // Check service health 86 + //! if client.health().await? { 87 + //! println!("TAP service is healthy"); 88 + //! } 89 + //! ``` 90 + //! 91 + //! # Memory Efficiency 92 + //! 93 + //! This crate is optimized for high-throughput event processing: 94 + //! 95 + //! - **Arc-wrapped events**: Events are shared via `Arc` for zero-cost sharing 96 + //! - **CompactString**: Small strings use inline storage (no heap allocation) 97 + //! - **Box<str>**: Immutable strings without capacity overhead 98 + //! - **RawValue**: Record payloads are lazily parsed on demand 99 + //! - **Pre-allocated buffers**: Ack messages avoid per-message allocations 100 + 101 + #![forbid(unsafe_code)] 102 + #![warn(missing_docs)] 103 + 104 + mod client; 105 + mod config; 106 + mod connection; 107 + mod errors; 108 + mod events; 109 + mod stream; 110 + 111 + // Re-export public types 112 + pub use atproto_identity::model::{Document, Service, VerificationMethod}; 113 + pub use client::{RepoInfo, RepoState, TapClient}; 114 + #[allow(deprecated)] 115 + pub use client::RepoStatus; 116 + pub use config::{TapConfig, TapConfigBuilder}; 117 + pub use errors::TapError; 118 + pub use events::{IdentityEvent, IdentityStatus, RecordAction, RecordEvent, TapEvent, extract_event_id}; 119 + pub use stream::{TapStream, connect, connect_to};
+330
crates/atproto-tap/src/stream.rs
···
··· 1 + //! TAP event stream implementation. 2 + //! 3 + //! This module provides [`TapStream`], an async stream that yields TAP events 4 + //! with automatic connection management and reconnection handling. 5 + //! 6 + //! # Design 7 + //! 8 + //! The stream encapsulates all connection logic, allowing consumers to simply 9 + //! iterate over events using standard stream combinators or `tokio::select!`. 10 + //! 11 + //! Reconnection is handled automatically with exponential backoff. Parse errors 12 + //! are yielded as `Err` items but don't affect connection state - only connection 13 + //! errors trigger reconnection attempts. 14 + 15 + use crate::config::TapConfig; 16 + use crate::connection::TapConnection; 17 + use crate::errors::TapError; 18 + use crate::events::{TapEvent, extract_event_id}; 19 + use futures::Stream; 20 + use std::pin::Pin; 21 + use std::sync::Arc; 22 + use std::task::{Context, Poll}; 23 + use std::time::Duration; 24 + use tokio::sync::mpsc; 25 + 26 + /// An async stream of TAP events with automatic reconnection. 27 + /// 28 + /// `TapStream` implements [`Stream`] and yields `Result<Arc<TapEvent>, TapError>`. 29 + /// Events are wrapped in `Arc` for efficient zero-cost sharing across consumers. 30 + /// 31 + /// # Connection Management 32 + /// 33 + /// The stream automatically: 34 + /// - Connects on first poll 35 + /// - Reconnects with exponential backoff on connection errors 36 + /// - Sends acknowledgments after parsing each message (if enabled) 37 + /// - Yields parse errors without affecting connection state 38 + /// 39 + /// # Example 40 + /// 41 + /// ```ignore 42 + /// use atproto_tap::{TapConfig, TapStream}; 43 + /// use tokio_stream::StreamExt; 44 + /// 45 + /// let config = TapConfig::builder() 46 + /// .hostname("localhost:2480") 47 + /// .build(); 48 + /// 49 + /// let mut stream = TapStream::new(config); 50 + /// 51 + /// while let Some(result) = stream.next().await { 52 + /// match result { 53 + /// Ok(event) => println!("Event: {:?}", event), 54 + /// Err(e) => eprintln!("Error: {}", e), 55 + /// } 56 + /// } 57 + /// ``` 58 + pub struct TapStream { 59 + /// Receiver for events from the background task. 60 + receiver: mpsc::Receiver<Result<Arc<TapEvent>, TapError>>, 61 + /// Handle to request stream closure. 62 + close_sender: Option<mpsc::Sender<()>>, 63 + /// Whether the stream has been closed. 64 + closed: bool, 65 + } 66 + 67 + impl TapStream { 68 + /// Create a new TAP stream with the given configuration. 69 + /// 70 + /// The stream will start connecting immediately in a background task. 71 + pub fn new(config: TapConfig) -> Self { 72 + // Channel for events - buffer a few to handle bursts 73 + let (event_tx, event_rx) = mpsc::channel(32); 74 + // Channel for close signal 75 + let (close_tx, close_rx) = mpsc::channel(1); 76 + 77 + // Spawn background task to manage connection 78 + tokio::spawn(connection_task(config, event_tx, close_rx)); 79 + 80 + Self { 81 + receiver: event_rx, 82 + close_sender: Some(close_tx), 83 + closed: false, 84 + } 85 + } 86 + 87 + /// Close the stream and release resources. 88 + /// 89 + /// After calling this, the stream will yield `None` on the next poll. 90 + pub async fn close(&mut self) { 91 + if let Some(sender) = self.close_sender.take() { 92 + // Signal the background task to close 93 + let _ = sender.send(()).await; 94 + } 95 + self.closed = true; 96 + } 97 + 98 + /// Returns true if the stream is closed. 99 + pub fn is_closed(&self) -> bool { 100 + self.closed 101 + } 102 + } 103 + 104 + impl Stream for TapStream { 105 + type Item = Result<Arc<TapEvent>, TapError>; 106 + 107 + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { 108 + if self.closed { 109 + return Poll::Ready(None); 110 + } 111 + 112 + self.receiver.poll_recv(cx) 113 + } 114 + } 115 + 116 + impl Drop for TapStream { 117 + fn drop(&mut self) { 118 + // Drop the close_sender to signal the background task 119 + self.close_sender.take(); 120 + tracing::debug!("TapStream dropped"); 121 + } 122 + } 123 + 124 + /// Background task that manages the WebSocket connection. 125 + async fn connection_task( 126 + config: TapConfig, 127 + event_tx: mpsc::Sender<Result<Arc<TapEvent>, TapError>>, 128 + mut close_rx: mpsc::Receiver<()>, 129 + ) { 130 + let mut current_reconnect_delay = config.initial_reconnect_delay; 131 + let mut attempt: u32 = 0; 132 + 133 + loop { 134 + // Check for close signal 135 + if close_rx.try_recv().is_ok() { 136 + tracing::debug!("Connection task received close signal"); 137 + break; 138 + } 139 + 140 + // Try to connect 141 + tracing::debug!(attempt, hostname = %config.hostname, "Connecting to TAP service"); 142 + let conn_result = TapConnection::connect(&config).await; 143 + 144 + match conn_result { 145 + Ok(mut conn) => { 146 + tracing::info!(hostname = %config.hostname, "TAP stream connected"); 147 + // Reset reconnection state on successful connect 148 + current_reconnect_delay = config.initial_reconnect_delay; 149 + attempt = 0; 150 + 151 + // Event loop for this connection 152 + loop { 153 + tokio::select! { 154 + biased; 155 + 156 + _ = close_rx.recv() => { 157 + tracing::debug!("Connection task received close signal during receive"); 158 + let _ = conn.close().await; 159 + return; 160 + } 161 + 162 + recv_result = conn.recv() => { 163 + match recv_result { 164 + Ok(Some(msg)) => { 165 + // Parse the message 166 + match serde_json::from_str::<TapEvent>(&msg) { 167 + Ok(event) => { 168 + let event_id = event.id(); 169 + 170 + // Send ack if enabled (before sending event to channel) 171 + if config.send_acks 172 + && let Err(err) = conn.send_ack(event_id).await 173 + { 174 + tracing::warn!(error = %err, "Failed to send ack"); 175 + // Don't break connection for ack errors 176 + } 177 + 178 + // Send event to channel 179 + let event = Arc::new(event); 180 + if event_tx.send(Ok(event)).await.is_err() { 181 + // Receiver dropped, exit task 182 + tracing::debug!("Event receiver dropped, closing connection"); 183 + let _ = conn.close().await; 184 + return; 185 + } 186 + } 187 + Err(err) => { 188 + // Parse errors don't affect connection 189 + tracing::warn!(error = %err, "Failed to parse TAP message"); 190 + 191 + // Try to extract just the ID using fallback parser 192 + // so we can still ack the message even if full parsing fails 193 + if config.send_acks { 194 + if let Some(event_id) = extract_event_id(&msg) { 195 + tracing::debug!(event_id, "Extracted event ID via fallback parser"); 196 + if let Err(ack_err) = conn.send_ack(event_id).await { 197 + tracing::warn!(error = %ack_err, "Failed to send ack for unparseable message"); 198 + } 199 + } else { 200 + tracing::warn!("Could not extract event ID from unparseable message"); 201 + } 202 + } 203 + 204 + if event_tx.send(Err(TapError::ParseError(err.to_string()))).await.is_err() { 205 + tracing::debug!("Event receiver dropped, closing connection"); 206 + let _ = conn.close().await; 207 + return; 208 + } 209 + } 210 + } 211 + } 212 + Ok(None) => { 213 + // Connection closed by server 214 + tracing::debug!("TAP connection closed by server"); 215 + break; 216 + } 217 + Err(err) => { 218 + // Connection error 219 + tracing::warn!(error = %err, "TAP connection error"); 220 + break; 221 + } 222 + } 223 + } 224 + } 225 + } 226 + } 227 + Err(err) => { 228 + tracing::warn!(error = %err, attempt, "Failed to connect to TAP service"); 229 + } 230 + } 231 + 232 + // Increment attempt counter 233 + attempt += 1; 234 + 235 + // Check if we've exceeded max attempts 236 + if let Some(max) = config.max_reconnect_attempts 237 + && attempt >= max 238 + { 239 + tracing::error!(attempts = attempt, "Max reconnection attempts exceeded"); 240 + let _ = event_tx 241 + .send(Err(TapError::MaxReconnectAttemptsExceeded(attempt))) 242 + .await; 243 + break; 244 + } 245 + 246 + // Wait before reconnecting with exponential backoff 247 + tracing::debug!( 248 + delay_ms = current_reconnect_delay.as_millis(), 249 + attempt, 250 + "Waiting before reconnection" 251 + ); 252 + 253 + tokio::select! { 254 + _ = close_rx.recv() => { 255 + tracing::debug!("Connection task received close signal during backoff"); 256 + return; 257 + } 258 + _ = tokio::time::sleep(current_reconnect_delay) => { 259 + // Update delay for next attempt 260 + current_reconnect_delay = Duration::from_secs_f64( 261 + (current_reconnect_delay.as_secs_f64() * config.reconnect_backoff_multiplier) 262 + .min(config.max_reconnect_delay.as_secs_f64()), 263 + ); 264 + } 265 + } 266 + } 267 + 268 + tracing::debug!("Connection task exiting"); 269 + } 270 + 271 + /// Create a new TAP stream with the given configuration. 272 + pub fn connect(config: TapConfig) -> TapStream { 273 + TapStream::new(config) 274 + } 275 + 276 + /// Create a new TAP stream connected to the given hostname. 277 + /// 278 + /// Uses default configuration values. 279 + pub fn connect_to(hostname: &str) -> TapStream { 280 + TapStream::new(TapConfig::new(hostname)) 281 + } 282 + 283 + #[cfg(test)] 284 + mod tests { 285 + use super::*; 286 + 287 + #[test] 288 + fn test_stream_initial_state() { 289 + // Note: This test doesn't actually poll the stream, just checks initial state 290 + // Creating a TapStream requires a tokio runtime for the spawn 291 + } 292 + 293 + #[tokio::test] 294 + async fn test_stream_close() { 295 + let mut stream = TapStream::new(TapConfig::new("localhost:9999")); 296 + assert!(!stream.is_closed()); 297 + stream.close().await; 298 + assert!(stream.is_closed()); 299 + } 300 + 301 + #[test] 302 + fn test_connect_functions() { 303 + // These just create configs, actual connection happens in background task 304 + // We can't test without a runtime, so just verify the types compile 305 + let _ = TapConfig::new("localhost:2480"); 306 + } 307 + 308 + #[test] 309 + fn test_reconnect_delay_calculation() { 310 + // Test the delay calculation logic 311 + let initial = Duration::from_secs(1); 312 + let max = Duration::from_secs(10); 313 + let multiplier = 2.0; 314 + 315 + let mut delay = initial; 316 + assert_eq!(delay, Duration::from_secs(1)); 317 + 318 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 319 + assert_eq!(delay, Duration::from_secs(2)); 320 + 321 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 322 + assert_eq!(delay, Duration::from_secs(4)); 323 + 324 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 325 + assert_eq!(delay, Duration::from_secs(8)); 326 + 327 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 328 + assert_eq!(delay, Duration::from_secs(10)); // Capped at max 329 + } 330 + }
+13 -13
crates/atproto-xrpcs/README.md
··· 23 ### Basic XRPC Service 24 25 ```rust 26 - use atproto_xrpcs::authorization::ResolvingAuthorization; 27 use axum::{Json, Router, extract::Query, routing::get}; 28 use serde::Deserialize; 29 use serde_json::json; ··· 35 36 async fn handle_hello( 37 params: Query<HelloParams>, 38 - authorization: Option<ResolvingAuthorization>, 39 ) -> Json<serde_json::Value> { 40 let name = params.name.as_deref().unwrap_or("World"); 41 - 42 let message = if authorization.is_some() { 43 format!("Hello, authenticated {}!", name) 44 } else { 45 format!("Hello, {}!", name) 46 }; 47 - 48 Json(json!({ "message": message })) 49 } 50 ··· 56 ### JWT Authorization 57 58 ```rust 59 - use atproto_xrpcs::authorization::ResolvingAuthorization; 60 61 async fn handle_secure_endpoint( 62 - authorization: ResolvingAuthorization, // Required authorization 63 ) -> Json<serde_json::Value> { 64 - // The ResolvingAuthorization extractor automatically: 65 // 1. Validates the JWT token 66 - // 2. Resolves the caller's DID document 67 // 3. Verifies the signature against the DID document 68 // 4. Provides access to caller identity information 69 - 70 let caller_did = authorization.subject(); 71 Json(json!({"caller": caller_did, "status": "authenticated"})) 72 } ··· 79 use axum::{response::IntoResponse, http::StatusCode}; 80 81 async fn protected_handler( 82 - authorization: Result<ResolvingAuthorization, AuthorizationError>, 83 ) -> impl IntoResponse { 84 match authorization { 85 Ok(auth) => (StatusCode::OK, "Access granted").into_response(), 86 - Err(AuthorizationError::InvalidJWTToken { .. }) => { 87 (StatusCode::UNAUTHORIZED, "Invalid token").into_response() 88 } 89 - Err(AuthorizationError::DIDDocumentResolutionFailed { .. }) => { 90 (StatusCode::FORBIDDEN, "Identity verification failed").into_response() 91 } 92 Err(_) => { ··· 98 99 ## Authorization Flow 100 101 - The `ResolvingAuthorization` extractor implements: 102 103 1. JWT extraction from HTTP Authorization headers 104 2. Token validation (signature and claims structure)
··· 23 ### Basic XRPC Service 24 25 ```rust 26 + use atproto_xrpcs::authorization::Authorization; 27 use axum::{Json, Router, extract::Query, routing::get}; 28 use serde::Deserialize; 29 use serde_json::json; ··· 35 36 async fn handle_hello( 37 params: Query<HelloParams>, 38 + authorization: Option<Authorization>, 39 ) -> Json<serde_json::Value> { 40 let name = params.name.as_deref().unwrap_or("World"); 41 + 42 let message = if authorization.is_some() { 43 format!("Hello, authenticated {}!", name) 44 } else { 45 format!("Hello, {}!", name) 46 }; 47 + 48 Json(json!({ "message": message })) 49 } 50 ··· 56 ### JWT Authorization 57 58 ```rust 59 + use atproto_xrpcs::authorization::Authorization; 60 61 async fn handle_secure_endpoint( 62 + authorization: Authorization, // Required authorization 63 ) -> Json<serde_json::Value> { 64 + // The Authorization extractor automatically: 65 // 1. Validates the JWT token 66 + // 2. Resolves the caller's DID document 67 // 3. Verifies the signature against the DID document 68 // 4. Provides access to caller identity information 69 + 70 let caller_did = authorization.subject(); 71 Json(json!({"caller": caller_did, "status": "authenticated"})) 72 } ··· 79 use axum::{response::IntoResponse, http::StatusCode}; 80 81 async fn protected_handler( 82 + authorization: Result<Authorization, AuthorizationError>, 83 ) -> impl IntoResponse { 84 match authorization { 85 Ok(auth) => (StatusCode::OK, "Access granted").into_response(), 86 + Err(AuthorizationError::InvalidJWTFormat) => { 87 (StatusCode::UNAUTHORIZED, "Invalid token").into_response() 88 } 89 + Err(AuthorizationError::SubjectResolutionFailed { .. }) => { 90 (StatusCode::FORBIDDEN, "Identity verification failed").into_response() 91 } 92 Err(_) => { ··· 98 99 ## Authorization Flow 100 101 + The `Authorization` extractor implements: 102 103 1. JWT extraction from HTTP Authorization headers 104 2. Token validation (signature and claims structure)
+42 -108
crates/atproto-xrpcs/src/authorization.rs
··· 1 //! JWT authorization extractors for XRPC services. 2 //! 3 - //! Axum extractors for JWT validation against DID documents with 4 - //! cached and resolving authorization modes. 5 6 use anyhow::Result; 7 use atproto_identity::key::identify_key; 8 - use atproto_identity::resolve::IdentityResolver; 9 - use atproto_identity::traits::DidDocumentStorage; 10 use atproto_oauth::jwt::{Claims, Header}; 11 use axum::extract::{FromRef, OptionalFromRequestParts}; 12 use axum::http::request::Parts; ··· 17 18 use crate::errors::AuthorizationError; 19 20 - /// JWT authorization extractor that validates tokens against cached DID documents. 21 /// 22 /// Contains JWT header, validated claims, original token, and validation status. 23 - /// Only validates against DID documents already present in storage. 24 pub struct Authorization(pub Header, pub Claims, pub String, pub bool); 25 26 - /// JWT authorization extractor with automatic DID document resolution. 27 - /// 28 - /// Contains JWT header, validated claims, original token, and validation status. 29 - /// Attempts to resolve missing DID documents from authoritative sources when needed. 30 - pub struct ResolvingAuthorization(pub Header, pub Claims, pub String, pub bool); 31 - 32 - impl<S> OptionalFromRequestParts<S> for Authorization 33 - where 34 - S: Send + Sync, 35 - Arc<dyn DidDocumentStorage>: FromRef<S>, 36 - { 37 - type Rejection = Infallible; 38 - 39 - async fn from_request_parts( 40 - parts: &mut Parts, 41 - state: &S, 42 - ) -> Result<Option<Self>, Self::Rejection> { 43 - let auth_header = parts 44 - .headers 45 - .get("authorization") 46 - .and_then(|value| value.to_str().ok()) 47 - .and_then(|s| s.strip_prefix("Bearer ")); 48 - 49 - let token = match auth_header { 50 - Some(token) => token.to_string(), 51 - None => { 52 - return Ok(None); 53 - } 54 - }; 55 - 56 - let did_document_storage = Arc::<dyn DidDocumentStorage>::from_ref(state); 57 - 58 - match validate_jwt(&token, did_document_storage, None).await { 59 - Ok((header, claims)) => Ok(Some(Authorization(header, claims, token, true))), 60 - Err(_) => { 61 - // Return unvalidated authorization so the handler can decide what to do 62 - let header = Header::default(); 63 - let claims = Claims::default(); 64 - Ok(Some(Authorization(header, claims, token, false))) 65 - } 66 } 67 } 68 } 69 70 - impl<S> OptionalFromRequestParts<S> for ResolvingAuthorization 71 where 72 S: Send + Sync, 73 - Arc<dyn DidDocumentStorage>: FromRef<S>, 74 Arc<dyn IdentityResolver>: FromRef<S>, 75 { 76 type Rejection = Infallible; ··· 92 } 93 }; 94 95 - let did_document_storage = Arc::<dyn DidDocumentStorage>::from_ref(state); 96 let identity_resolver = Arc::<dyn IdentityResolver>::from_ref(state); 97 98 - match validate_jwt(&token, did_document_storage, Some(identity_resolver)).await { 99 - Ok((header, claims)) => Ok(Some(ResolvingAuthorization(header, claims, token, true))), 100 Err(_) => { 101 // Return unvalidated authorization so the handler can decide what to do 102 let header = Header::default(); 103 let claims = Claims::default(); 104 - Ok(Some(ResolvingAuthorization(header, claims, token, false))) 105 } 106 } 107 } ··· 109 110 async fn validate_jwt( 111 token: &str, 112 - storage: Arc<dyn DidDocumentStorage + Send + Sync>, 113 - identity_resolver: Option<Arc<dyn IdentityResolver>>, 114 ) -> Result<(Header, Claims)> { 115 // Split and decode JWT 116 let parts: Vec<&str> = token.split('.').collect(); ··· 134 .as_ref() 135 .ok_or_else(|| AuthorizationError::NoIssuerInClaims)?; 136 137 - // Try to look up DID document directly first 138 - let mut did_document = storage.get_document_by_did(issuer).await?; 139 - 140 - // If not found, try to resolve the subject 141 - if did_document.is_none() 142 - && let Some(identity_resolver) = identity_resolver 143 - { 144 - did_document = match identity_resolver.resolve(issuer).await { 145 - Ok(value) => { 146 - storage 147 - .store_document(value.clone()) 148 - .await 149 - .map_err(|err| AuthorizationError::DocumentStorageFailed { error: err })?; 150 - 151 - Some(value) 152 - } 153 - Err(err) => { 154 - return Err(AuthorizationError::SubjectResolutionFailed { 155 - issuer: issuer.to_string(), 156 - error: err, 157 - } 158 - .into()); 159 - } 160 - }; 161 - } 162 - 163 - let did_document = did_document.ok_or_else(|| AuthorizationError::DIDDocumentNotFound { 164 - issuer: issuer.to_string(), 165 })?; 166 167 // Extract keys from DID document ··· 206 mod tests { 207 use super::*; 208 use atproto_identity::model::{Document, VerificationMethod}; 209 - use atproto_identity::traits::DidDocumentStorage; 210 use axum::extract::FromRef; 211 use axum::http::{Method, Request}; 212 use std::collections::HashMap; 213 214 #[derive(Clone)] 215 - struct MockStorage { 216 document: Document, 217 } 218 219 #[async_trait::async_trait] 220 - impl DidDocumentStorage for MockStorage { 221 - async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> { 222 - if did == self.document.id { 223 - Ok(Some(self.document.clone())) 224 } else { 225 - Ok(None) 226 } 227 } 228 - 229 - async fn store_document(&self, _document: Document) -> Result<()> { 230 - Ok(()) 231 - } 232 - 233 - async fn delete_document_by_did(&self, _did: &str) -> Result<()> { 234 - Ok(()) 235 - } 236 } 237 238 #[derive(Clone)] 239 struct TestState { 240 - storage: Arc<dyn DidDocumentStorage + Send + Sync>, 241 } 242 243 - impl FromRef<TestState> for Arc<dyn DidDocumentStorage> { 244 fn from_ref(state: &TestState) -> Self { 245 - state.storage.clone() 246 } 247 } 248 ··· 266 extra: HashMap::new(), 267 }; 268 269 - // Create mock storage 270 - let storage = 271 - Arc::new(MockStorage { document }) as Arc<dyn DidDocumentStorage + Send + Sync>; 272 - let state = TestState { storage }; 273 274 // Create request with Authorization header 275 let request = Request::builder() ··· 307 308 #[tokio::test] 309 async fn test_authorization_no_header() { 310 - // Create mock storage 311 - let storage = Arc::new(MockStorage { 312 document: Document { 313 context: vec![], 314 id: "did:plc:test".to_string(), ··· 317 verification_method: vec![], 318 extra: HashMap::new(), 319 }, 320 - }) as Arc<dyn DidDocumentStorage + Send + Sync>; 321 - let state = TestState { storage }; 322 323 // Create request without Authorization header 324 let request = Request::builder()
··· 1 //! JWT authorization extractors for XRPC services. 2 //! 3 + //! Axum extractors for JWT validation against DID documents resolved 4 + //! via an identity resolver. 5 6 use anyhow::Result; 7 use atproto_identity::key::identify_key; 8 + use atproto_identity::traits::IdentityResolver; 9 use atproto_oauth::jwt::{Claims, Header}; 10 use axum::extract::{FromRef, OptionalFromRequestParts}; 11 use axum::http::request::Parts; ··· 16 17 use crate::errors::AuthorizationError; 18 19 + /// JWT authorization extractor that validates tokens against DID documents. 20 /// 21 /// Contains JWT header, validated claims, original token, and validation status. 22 + /// Resolves DID documents via the configured identity resolver. 23 + #[derive(Clone)] 24 pub struct Authorization(pub Header, pub Claims, pub String, pub bool); 25 26 + impl Authorization { 27 + /// identity returns the optional issuer claim of the authorization structure. 28 + pub fn identity(&self) -> Option<&str> { 29 + if self.3 { 30 + return self.1.jose.issuer.as_deref(); 31 } 32 + None 33 } 34 } 35 36 + impl<S> OptionalFromRequestParts<S> for Authorization 37 where 38 S: Send + Sync, 39 Arc<dyn IdentityResolver>: FromRef<S>, 40 { 41 type Rejection = Infallible; ··· 57 } 58 }; 59 60 let identity_resolver = Arc::<dyn IdentityResolver>::from_ref(state); 61 62 + match validate_jwt(&token, identity_resolver).await { 63 + Ok((header, claims)) => Ok(Some(Authorization(header, claims, token, true))), 64 Err(_) => { 65 // Return unvalidated authorization so the handler can decide what to do 66 let header = Header::default(); 67 let claims = Claims::default(); 68 + Ok(Some(Authorization(header, claims, token, false))) 69 } 70 } 71 } ··· 73 74 async fn validate_jwt( 75 token: &str, 76 + identity_resolver: Arc<dyn IdentityResolver>, 77 ) -> Result<(Header, Claims)> { 78 // Split and decode JWT 79 let parts: Vec<&str> = token.split('.').collect(); ··· 97 .as_ref() 98 .ok_or_else(|| AuthorizationError::NoIssuerInClaims)?; 99 100 + // Resolve the DID document via identity resolver 101 + let did_document = identity_resolver.resolve(issuer).await.map_err(|err| { 102 + AuthorizationError::SubjectResolutionFailed { 103 + issuer: issuer.to_string(), 104 + error: err, 105 + } 106 })?; 107 108 // Extract keys from DID document ··· 147 mod tests { 148 use super::*; 149 use atproto_identity::model::{Document, VerificationMethod}; 150 use axum::extract::FromRef; 151 use axum::http::{Method, Request}; 152 use std::collections::HashMap; 153 154 #[derive(Clone)] 155 + struct MockResolver { 156 document: Document, 157 } 158 159 #[async_trait::async_trait] 160 + impl IdentityResolver for MockResolver { 161 + async fn resolve(&self, subject: &str) -> Result<Document> { 162 + if subject == self.document.id { 163 + Ok(self.document.clone()) 164 } else { 165 + Err(anyhow::anyhow!( 166 + "error-atproto-xrpcs-authorization-1 DID not found: {}", 167 + subject 168 + )) 169 } 170 } 171 } 172 173 #[derive(Clone)] 174 struct TestState { 175 + resolver: Arc<dyn IdentityResolver>, 176 } 177 178 + impl FromRef<TestState> for Arc<dyn IdentityResolver> { 179 fn from_ref(state: &TestState) -> Self { 180 + state.resolver.clone() 181 } 182 } 183 ··· 201 extra: HashMap::new(), 202 }; 203 204 + // Create mock resolver 205 + let resolver = Arc::new(MockResolver { document }) as Arc<dyn IdentityResolver>; 206 + let state = TestState { resolver }; 207 208 // Create request with Authorization header 209 let request = Request::builder() ··· 241 242 #[tokio::test] 243 async fn test_authorization_no_header() { 244 + // Create mock resolver 245 + let resolver = Arc::new(MockResolver { 246 document: Document { 247 context: vec![], 248 id: "did:plc:test".to_string(), ··· 251 verification_method: vec![], 252 extra: HashMap::new(), 253 }, 254 + }) as Arc<dyn IdentityResolver>; 255 + let state = TestState { resolver }; 256 257 // Create request without Authorization header 258 let request = Request::builder()
+5 -49
crates/atproto-xrpcs/src/errors.rs
··· 42 #[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")] 43 NoIssuerInClaims, 44 45 - /// Occurs when DID document is not found for the issuer 46 - #[error("error-atproto-xrpcs-authorization-5 DID document not found for issuer: {issuer}")] 47 - DIDDocumentNotFound { 48 - /// The issuer DID that was not found 49 - issuer: String, 50 - }, 51 - 52 /// Occurs when no verification keys are found in DID document 53 - #[error("error-atproto-xrpcs-authorization-6 No verification keys found in DID document")] 54 NoVerificationKeys, 55 56 /// Occurs when JWT header cannot be base64 decoded 57 - #[error("error-atproto-xrpcs-authorization-7 Failed to decode JWT header: {error}")] 58 HeaderDecodeError { 59 /// The underlying base64 decode error 60 error: base64::DecodeError, 61 }, 62 63 /// Occurs when JWT header cannot be parsed as JSON 64 - #[error("error-atproto-xrpcs-authorization-8 Failed to parse JWT header: {error}")] 65 HeaderParseError { 66 /// The underlying JSON parse error 67 error: serde_json::Error, 68 }, 69 70 /// Occurs when JWT validation fails with all available keys 71 - #[error("error-atproto-xrpcs-authorization-9 JWT validation failed with all available keys")] 72 ValidationFailedAllKeys, 73 74 /// Occurs when subject resolution fails during DID document lookup 75 - #[error("error-atproto-xrpcs-authorization-10 Subject resolution failed: {issuer} {error}")] 76 SubjectResolutionFailed { 77 /// The issuer that failed to resolve 78 issuer: String, 79 /// The underlying resolution error 80 - error: anyhow::Error, 81 - }, 82 - 83 - /// Occurs when DID document lookup fails after successful resolution 84 - #[error( 85 - "error-atproto-xrpcs-authorization-11 DID document not found for resolved issuer: {resolved_did}" 86 - )] 87 - ResolvedDIDDocumentNotFound { 88 - /// The resolved DID that was not found in storage 89 - resolved_did: String, 90 - }, 91 - 92 - /// Occurs when PLC directory query fails 93 - #[error("error-atproto-xrpcs-authorization-12 PLC directory query failed: {error}")] 94 - PLCQueryFailed { 95 - /// The underlying PLC query error 96 - error: anyhow::Error, 97 - }, 98 - 99 - /// Occurs when web DID query fails 100 - #[error("error-atproto-xrpcs-authorization-13 Web DID query failed: {error}")] 101 - WebDIDQueryFailed { 102 - /// The underlying web DID query error 103 - error: anyhow::Error, 104 - }, 105 - 106 - /// Occurs when DID document storage operation fails 107 - #[error("error-atproto-xrpcs-authorization-14 DID document storage failed: {error}")] 108 - DocumentStorageFailed { 109 - /// The underlying storage error 110 - error: anyhow::Error, 111 - }, 112 - 113 - /// Occurs when input parsing fails for resolved DID 114 - #[error("error-atproto-xrpcs-authorization-15 Input parsing failed for resolved DID: {error}")] 115 - InputParsingFailed { 116 - /// The underlying parsing error 117 error: anyhow::Error, 118 }, 119 }
··· 42 #[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")] 43 NoIssuerInClaims, 44 45 /// Occurs when no verification keys are found in DID document 46 + #[error("error-atproto-xrpcs-authorization-5 No verification keys found in DID document")] 47 NoVerificationKeys, 48 49 /// Occurs when JWT header cannot be base64 decoded 50 + #[error("error-atproto-xrpcs-authorization-6 Failed to decode JWT header: {error}")] 51 HeaderDecodeError { 52 /// The underlying base64 decode error 53 error: base64::DecodeError, 54 }, 55 56 /// Occurs when JWT header cannot be parsed as JSON 57 + #[error("error-atproto-xrpcs-authorization-7 Failed to parse JWT header: {error}")] 58 HeaderParseError { 59 /// The underlying JSON parse error 60 error: serde_json::Error, 61 }, 62 63 /// Occurs when JWT validation fails with all available keys 64 + #[error("error-atproto-xrpcs-authorization-8 JWT validation failed with all available keys")] 65 ValidationFailedAllKeys, 66 67 /// Occurs when subject resolution fails during DID document lookup 68 + #[error("error-atproto-xrpcs-authorization-9 Subject resolution failed: {issuer} {error}")] 69 SubjectResolutionFailed { 70 /// The issuer that failed to resolve 71 issuer: String, 72 /// The underlying resolution error 73 error: anyhow::Error, 74 }, 75 }
+3 -13
crates/atproto-xrpcs-helloworld/src/main.rs
··· 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 8 key::{KeyData, KeyResolver, identify_key, to_public}, 9 resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver}, 10 - storage_lru::LruDidDocumentStorage, 11 - traits::DidDocumentStorage, 12 }; 13 - use atproto_xrpcs::authorization::ResolvingAuthorization; 14 use axum::{ 15 Json, Router, 16 extract::{FromRef, Query, State}, ··· 21 use http::{HeaderMap, StatusCode}; 22 use serde::Deserialize; 23 use serde_json::json; 24 - use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc}; 25 26 #[derive(Clone)] 27 pub struct SimpleKeyResolver { ··· 61 62 pub struct InnerWebContext { 63 pub http_client: reqwest::Client, 64 - pub document_storage: Arc<dyn DidDocumentStorage>, 65 pub key_resolver: Arc<dyn KeyResolver>, 66 pub service_document: ServiceDocument, 67 pub service_did: ServiceDID, ··· 97 } 98 } 99 100 - impl FromRef<WebContext> for Arc<dyn DidDocumentStorage> { 101 - fn from_ref(context: &WebContext) -> Self { 102 - context.0.document_storage.clone() 103 - } 104 - } 105 - 106 impl FromRef<WebContext> for Arc<dyn KeyResolver> { 107 fn from_ref(context: &WebContext) -> Self { 108 context.0.key_resolver.clone() ··· 216 217 let web_context = WebContext(Arc::new(InnerWebContext { 218 http_client: http_client.clone(), 219 - document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 220 key_resolver: Arc::new(SimpleKeyResolver { 221 keys: signing_key_storage, 222 }), ··· 284 async fn handle_xrpc_hello_world( 285 parameters: Query<HelloParameters>, 286 headers: HeaderMap, 287 - authorization: Option<ResolvingAuthorization>, 288 ) -> Json<serde_json::Value> { 289 println!("headers {headers:?}"); 290 let subject = parameters.subject.as_deref().unwrap_or("World");
··· 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 8 key::{KeyData, KeyResolver, identify_key, to_public}, 9 resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver}, 10 }; 11 + use atproto_xrpcs::authorization::Authorization; 12 use axum::{ 13 Json, Router, 14 extract::{FromRef, Query, State}, ··· 19 use http::{HeaderMap, StatusCode}; 20 use serde::Deserialize; 21 use serde_json::json; 22 + use std::{collections::HashMap, ops::Deref, sync::Arc}; 23 24 #[derive(Clone)] 25 pub struct SimpleKeyResolver { ··· 59 60 pub struct InnerWebContext { 61 pub http_client: reqwest::Client, 62 pub key_resolver: Arc<dyn KeyResolver>, 63 pub service_document: ServiceDocument, 64 pub service_did: ServiceDID, ··· 94 } 95 } 96 97 impl FromRef<WebContext> for Arc<dyn KeyResolver> { 98 fn from_ref(context: &WebContext) -> Self { 99 context.0.key_resolver.clone() ··· 207 208 let web_context = WebContext(Arc::new(InnerWebContext { 209 http_client: http_client.clone(), 210 key_resolver: Arc::new(SimpleKeyResolver { 211 keys: signing_key_storage, 212 }), ··· 274 async fn handle_xrpc_hello_world( 275 parameters: Query<HelloParameters>, 276 headers: HeaderMap, 277 + authorization: Option<Authorization>, 278 ) -> Json<serde_json::Value> { 279 println!("headers {headers:?}"); 280 let subject = parameters.subject.as_deref().unwrap_or("World");