⚘ use your pds as a git remote if you want to ⚘
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

working on it

notplants d6b652f6

+3231
+3
.gitignore
··· 1 + /target 2 + scripts/pds-dev/pds-data/ 3 + scripts/pds-dev/pds.env
+1727
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "anyhow" 7 + version = "1.0.102" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 10 + 11 + [[package]] 12 + name = "atomic-waker" 13 + version = "1.1.2" 14 + source = "registry+https://github.com/rust-lang/crates.io-index" 15 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 16 + 17 + [[package]] 18 + name = "base64" 19 + version = "0.22.1" 20 + source = "registry+https://github.com/rust-lang/crates.io-index" 21 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 22 + 23 + [[package]] 24 + name = "bitflags" 25 + version = "2.11.0" 26 + source = "registry+https://github.com/rust-lang/crates.io-index" 27 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 28 + 29 + [[package]] 30 + name = "bumpalo" 31 + version = "3.20.2" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 34 + 35 + [[package]] 36 + name = "bytes" 37 + version = "1.11.1" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 40 + 41 + [[package]] 42 + name = "cc" 43 + version = "1.2.56" 44 + source = "registry+https://github.com/rust-lang/crates.io-index" 45 + checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" 46 + dependencies = [ 47 + "find-msvc-tools", 48 + "shlex", 49 + ] 50 + 51 + [[package]] 52 + name = "cfg-if" 53 + version = "1.0.4" 54 + source = "registry+https://github.com/rust-lang/crates.io-index" 55 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 56 + 57 + [[package]] 58 + name = "cfg_aliases" 59 + version = "0.2.1" 60 + source = "registry+https://github.com/rust-lang/crates.io-index" 61 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 62 + 63 + [[package]] 64 + name = "displaydoc" 65 + version = "0.2.5" 66 + source = "registry+https://github.com/rust-lang/crates.io-index" 67 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 68 + dependencies = [ 69 + "proc-macro2", 70 + "quote", 71 + "syn", 72 + ] 73 + 74 + [[package]] 75 + name = "equivalent" 76 + version = "1.0.2" 77 + source = "registry+https://github.com/rust-lang/crates.io-index" 78 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 79 + 80 + [[package]] 81 + name = "errno" 82 + version = "0.3.14" 83 + source = "registry+https://github.com/rust-lang/crates.io-index" 84 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 85 + dependencies = [ 86 + "libc", 87 + "windows-sys 0.61.2", 88 + ] 89 + 90 + [[package]] 91 + name = "fastrand" 92 + version = "2.3.0" 93 + source = "registry+https://github.com/rust-lang/crates.io-index" 94 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 95 + 96 + [[package]] 97 + name = "find-msvc-tools" 98 + version = "0.1.9" 99 + source = "registry+https://github.com/rust-lang/crates.io-index" 100 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 101 + 102 + [[package]] 103 + name = "foldhash" 104 + version = "0.1.5" 105 + source = "registry+https://github.com/rust-lang/crates.io-index" 106 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 107 + 108 + [[package]] 109 + name = "form_urlencoded" 110 + version = "1.2.2" 111 + source = "registry+https://github.com/rust-lang/crates.io-index" 112 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 113 + dependencies = [ 114 + "percent-encoding", 115 + ] 116 + 117 + [[package]] 118 + name = "futures-channel" 119 + version = "0.3.32" 120 + source = "registry+https://github.com/rust-lang/crates.io-index" 121 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 122 + dependencies = [ 123 + "futures-core", 124 + ] 125 + 126 + [[package]] 127 + name = "futures-core" 128 + version = "0.3.32" 129 + source = "registry+https://github.com/rust-lang/crates.io-index" 130 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 131 + 132 + [[package]] 133 + name = "futures-task" 134 + version = "0.3.32" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 137 + 138 + [[package]] 139 + name = "futures-util" 140 + version = "0.3.32" 141 + source = "registry+https://github.com/rust-lang/crates.io-index" 142 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 143 + dependencies = [ 144 + "futures-core", 145 + "futures-task", 146 + "pin-project-lite", 147 + "slab", 148 + ] 149 + 150 + [[package]] 151 + name = "getrandom" 152 + version = "0.2.17" 153 + source = "registry+https://github.com/rust-lang/crates.io-index" 154 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 155 + dependencies = [ 156 + "cfg-if", 157 + "js-sys", 158 + "libc", 159 + "wasi", 160 + "wasm-bindgen", 161 + ] 162 + 163 + [[package]] 164 + name = "getrandom" 165 + version = "0.3.4" 166 + source = "registry+https://github.com/rust-lang/crates.io-index" 167 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 168 + dependencies = [ 169 + "cfg-if", 170 + "js-sys", 171 + "libc", 172 + "r-efi", 173 + "wasip2", 174 + "wasm-bindgen", 175 + ] 176 + 177 + [[package]] 178 + name = "getrandom" 179 + version = "0.4.1" 180 + source = "registry+https://github.com/rust-lang/crates.io-index" 181 + checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" 182 + dependencies = [ 183 + "cfg-if", 184 + "libc", 185 + "r-efi", 186 + "wasip2", 187 + "wasip3", 188 + ] 189 + 190 + [[package]] 191 + name = "hashbrown" 192 + version = "0.15.5" 193 + source = "registry+https://github.com/rust-lang/crates.io-index" 194 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 195 + dependencies = [ 196 + "foldhash", 197 + ] 198 + 199 + [[package]] 200 + name = "hashbrown" 201 + version = "0.16.1" 202 + source = "registry+https://github.com/rust-lang/crates.io-index" 203 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 204 + 205 + [[package]] 206 + name = "heck" 207 + version = "0.5.0" 208 + source = "registry+https://github.com/rust-lang/crates.io-index" 209 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 210 + 211 + [[package]] 212 + name = "http" 213 + version = "1.4.0" 214 + source = "registry+https://github.com/rust-lang/crates.io-index" 215 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 216 + dependencies = [ 217 + "bytes", 218 + "itoa", 219 + ] 220 + 221 + [[package]] 222 + name = "http-body" 223 + version = "1.0.1" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 226 + dependencies = [ 227 + "bytes", 228 + "http", 229 + ] 230 + 231 + [[package]] 232 + name = "http-body-util" 233 + version = "0.1.3" 234 + source = "registry+https://github.com/rust-lang/crates.io-index" 235 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 236 + dependencies = [ 237 + "bytes", 238 + "futures-core", 239 + "http", 240 + "http-body", 241 + "pin-project-lite", 242 + ] 243 + 244 + [[package]] 245 + name = "httparse" 246 + version = "1.10.1" 247 + source = "registry+https://github.com/rust-lang/crates.io-index" 248 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 249 + 250 + [[package]] 251 + name = "hyper" 252 + version = "1.8.1" 253 + source = "registry+https://github.com/rust-lang/crates.io-index" 254 + checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" 255 + dependencies = [ 256 + "atomic-waker", 257 + "bytes", 258 + "futures-channel", 259 + "futures-core", 260 + "http", 261 + "http-body", 262 + "httparse", 263 + "itoa", 264 + "pin-project-lite", 265 + "pin-utils", 266 + "smallvec", 267 + "tokio", 268 + "want", 269 + ] 270 + 271 + [[package]] 272 + name = "hyper-rustls" 273 + version = "0.27.7" 274 + source = "registry+https://github.com/rust-lang/crates.io-index" 275 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 276 + dependencies = [ 277 + "http", 278 + "hyper", 279 + "hyper-util", 280 + "rustls", 281 + "rustls-pki-types", 282 + "tokio", 283 + "tokio-rustls", 284 + "tower-service", 285 + "webpki-roots", 286 + ] 287 + 288 + [[package]] 289 + name = "hyper-util" 290 + version = "0.1.20" 291 + source = "registry+https://github.com/rust-lang/crates.io-index" 292 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 293 + dependencies = [ 294 + "base64", 295 + "bytes", 296 + "futures-channel", 297 + "futures-util", 298 + "http", 299 + "http-body", 300 + "hyper", 301 + "ipnet", 302 + "libc", 303 + "percent-encoding", 304 + "pin-project-lite", 305 + "socket2", 306 + "tokio", 307 + "tower-service", 308 + "tracing", 309 + ] 310 + 311 + [[package]] 312 + name = "icu_collections" 313 + version = "2.1.1" 314 + source = "registry+https://github.com/rust-lang/crates.io-index" 315 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 316 + dependencies = [ 317 + "displaydoc", 318 + "potential_utf", 319 + "yoke", 320 + "zerofrom", 321 + "zerovec", 322 + ] 323 + 324 + [[package]] 325 + name = "icu_locale_core" 326 + version = "2.1.1" 327 + source = "registry+https://github.com/rust-lang/crates.io-index" 328 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 329 + dependencies = [ 330 + "displaydoc", 331 + "litemap", 332 + "tinystr", 333 + "writeable", 334 + "zerovec", 335 + ] 336 + 337 + [[package]] 338 + name = "icu_normalizer" 339 + version = "2.1.1" 340 + source = "registry+https://github.com/rust-lang/crates.io-index" 341 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 342 + dependencies = [ 343 + "icu_collections", 344 + "icu_normalizer_data", 345 + "icu_properties", 346 + "icu_provider", 347 + "smallvec", 348 + "zerovec", 349 + ] 350 + 351 + [[package]] 352 + name = "icu_normalizer_data" 353 + version = "2.1.1" 354 + source = "registry+https://github.com/rust-lang/crates.io-index" 355 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 356 + 357 + [[package]] 358 + name = "icu_properties" 359 + version = "2.1.2" 360 + source = "registry+https://github.com/rust-lang/crates.io-index" 361 + checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" 362 + dependencies = [ 363 + "icu_collections", 364 + "icu_locale_core", 365 + "icu_properties_data", 366 + "icu_provider", 367 + "zerotrie", 368 + "zerovec", 369 + ] 370 + 371 + [[package]] 372 + name = "icu_properties_data" 373 + version = "2.1.2" 374 + source = "registry+https://github.com/rust-lang/crates.io-index" 375 + checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" 376 + 377 + [[package]] 378 + name = "icu_provider" 379 + version = "2.1.1" 380 + source = "registry+https://github.com/rust-lang/crates.io-index" 381 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 382 + dependencies = [ 383 + "displaydoc", 384 + "icu_locale_core", 385 + "writeable", 386 + "yoke", 387 + "zerofrom", 388 + "zerotrie", 389 + "zerovec", 390 + ] 391 + 392 + [[package]] 393 + name = "id-arena" 394 + version = "2.3.0" 395 + source = "registry+https://github.com/rust-lang/crates.io-index" 396 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 397 + 398 + [[package]] 399 + name = "idna" 400 + version = "1.1.0" 401 + source = "registry+https://github.com/rust-lang/crates.io-index" 402 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 403 + dependencies = [ 404 + "idna_adapter", 405 + "smallvec", 406 + "utf8_iter", 407 + ] 408 + 409 + [[package]] 410 + name = "idna_adapter" 411 + version = "1.2.1" 412 + source = "registry+https://github.com/rust-lang/crates.io-index" 413 + checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 414 + dependencies = [ 415 + "icu_normalizer", 416 + "icu_properties", 417 + ] 418 + 419 + [[package]] 420 + name = "indexmap" 421 + version = "2.13.0" 422 + source = "registry+https://github.com/rust-lang/crates.io-index" 423 + checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" 424 + dependencies = [ 425 + "equivalent", 426 + "hashbrown 0.16.1", 427 + "serde", 428 + "serde_core", 429 + ] 430 + 431 + [[package]] 432 + name = "ipnet" 433 + version = "2.11.0" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 436 + 437 + [[package]] 438 + name = "iri-string" 439 + version = "0.7.10" 440 + source = "registry+https://github.com/rust-lang/crates.io-index" 441 + checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" 442 + dependencies = [ 443 + "memchr", 444 + "serde", 445 + ] 446 + 447 + [[package]] 448 + name = "itoa" 449 + version = "1.0.17" 450 + source = "registry+https://github.com/rust-lang/crates.io-index" 451 + checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 452 + 453 + [[package]] 454 + name = "js-sys" 455 + version = "0.3.88" 456 + source = "registry+https://github.com/rust-lang/crates.io-index" 457 + checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" 458 + dependencies = [ 459 + "once_cell", 460 + "wasm-bindgen", 461 + ] 462 + 463 + [[package]] 464 + name = "leb128fmt" 465 + version = "0.1.0" 466 + source = "registry+https://github.com/rust-lang/crates.io-index" 467 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 468 + 469 + [[package]] 470 + name = "libc" 471 + version = "0.2.182" 472 + source = "registry+https://github.com/rust-lang/crates.io-index" 473 + checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 474 + 475 + [[package]] 476 + name = "linux-raw-sys" 477 + version = "0.12.1" 478 + source = "registry+https://github.com/rust-lang/crates.io-index" 479 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 480 + 481 + [[package]] 482 + name = "litemap" 483 + version = "0.8.1" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 486 + 487 + [[package]] 488 + name = "lock_api" 489 + version = "0.4.14" 490 + source = "registry+https://github.com/rust-lang/crates.io-index" 491 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 492 + dependencies = [ 493 + "scopeguard", 494 + ] 495 + 496 + [[package]] 497 + name = "log" 498 + version = "0.4.29" 499 + source = "registry+https://github.com/rust-lang/crates.io-index" 500 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 501 + 502 + [[package]] 503 + name = "lru-slab" 504 + version = "0.1.2" 505 + source = "registry+https://github.com/rust-lang/crates.io-index" 506 + checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 507 + 508 + [[package]] 509 + name = "memchr" 510 + version = "2.8.0" 511 + source = "registry+https://github.com/rust-lang/crates.io-index" 512 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 513 + 514 + [[package]] 515 + name = "mio" 516 + version = "1.1.1" 517 + source = "registry+https://github.com/rust-lang/crates.io-index" 518 + checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 519 + dependencies = [ 520 + "libc", 521 + "wasi", 522 + "windows-sys 0.61.2", 523 + ] 524 + 525 + [[package]] 526 + name = "once_cell" 527 + version = "1.21.3" 528 + source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 530 + 531 + [[package]] 532 + name = "parking_lot" 533 + version = "0.12.5" 534 + source = "registry+https://github.com/rust-lang/crates.io-index" 535 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 536 + dependencies = [ 537 + "lock_api", 538 + "parking_lot_core", 539 + ] 540 + 541 + [[package]] 542 + name = "parking_lot_core" 543 + version = "0.9.12" 544 + source = "registry+https://github.com/rust-lang/crates.io-index" 545 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 546 + dependencies = [ 547 + "cfg-if", 548 + "libc", 549 + "redox_syscall", 550 + "smallvec", 551 + "windows-link", 552 + ] 553 + 554 + [[package]] 555 + name = "pds-git-remote" 556 + version = "0.1.0" 557 + dependencies = [ 558 + "reqwest", 559 + "serde", 560 + "serde_json", 561 + "tempfile", 562 + "tokio", 563 + "tracing", 564 + ] 565 + 566 + [[package]] 567 + name = "percent-encoding" 568 + version = "2.3.2" 569 + source = "registry+https://github.com/rust-lang/crates.io-index" 570 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 571 + 572 + [[package]] 573 + name = "pin-project-lite" 574 + version = "0.2.16" 575 + source = "registry+https://github.com/rust-lang/crates.io-index" 576 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 577 + 578 + [[package]] 579 + name = "pin-utils" 580 + version = "0.1.0" 581 + source = "registry+https://github.com/rust-lang/crates.io-index" 582 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 583 + 584 + [[package]] 585 + name = "potential_utf" 586 + version = "0.1.4" 587 + source = "registry+https://github.com/rust-lang/crates.io-index" 588 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 589 + dependencies = [ 590 + "zerovec", 591 + ] 592 + 593 + [[package]] 594 + name = "ppv-lite86" 595 + version = "0.2.21" 596 + source = "registry+https://github.com/rust-lang/crates.io-index" 597 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 598 + dependencies = [ 599 + "zerocopy", 600 + ] 601 + 602 + [[package]] 603 + name = "prettyplease" 604 + version = "0.2.37" 605 + source = "registry+https://github.com/rust-lang/crates.io-index" 606 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 607 + dependencies = [ 608 + "proc-macro2", 609 + "syn", 610 + ] 611 + 612 + [[package]] 613 + name = "proc-macro2" 614 + version = "1.0.106" 615 + source = "registry+https://github.com/rust-lang/crates.io-index" 616 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 617 + dependencies = [ 618 + "unicode-ident", 619 + ] 620 + 621 + [[package]] 622 + name = "quinn" 623 + version = "0.11.9" 624 + source = "registry+https://github.com/rust-lang/crates.io-index" 625 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 626 + dependencies = [ 627 + "bytes", 628 + "cfg_aliases", 629 + "pin-project-lite", 630 + "quinn-proto", 631 + "quinn-udp", 632 + "rustc-hash", 633 + "rustls", 634 + "socket2", 635 + "thiserror", 636 + "tokio", 637 + "tracing", 638 + "web-time", 639 + ] 640 + 641 + [[package]] 642 + name = "quinn-proto" 643 + version = "0.11.13" 644 + source = "registry+https://github.com/rust-lang/crates.io-index" 645 + checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 646 + dependencies = [ 647 + "bytes", 648 + "getrandom 0.3.4", 649 + "lru-slab", 650 + "rand", 651 + "ring", 652 + "rustc-hash", 653 + "rustls", 654 + "rustls-pki-types", 655 + "slab", 656 + "thiserror", 657 + "tinyvec", 658 + "tracing", 659 + "web-time", 660 + ] 661 + 662 + [[package]] 663 + name = "quinn-udp" 664 + version = "0.5.14" 665 + source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 667 + dependencies = [ 668 + "cfg_aliases", 669 + "libc", 670 + "once_cell", 671 + "socket2", 672 + "tracing", 673 + "windows-sys 0.60.2", 674 + ] 675 + 676 + [[package]] 677 + name = "quote" 678 + version = "1.0.44" 679 + source = "registry+https://github.com/rust-lang/crates.io-index" 680 + checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" 681 + dependencies = [ 682 + "proc-macro2", 683 + ] 684 + 685 + [[package]] 686 + name = "r-efi" 687 + version = "5.3.0" 688 + source = "registry+https://github.com/rust-lang/crates.io-index" 689 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 690 + 691 + [[package]] 692 + name = "rand" 693 + version = "0.9.2" 694 + source = "registry+https://github.com/rust-lang/crates.io-index" 695 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 696 + dependencies = [ 697 + "rand_chacha", 698 + "rand_core", 699 + ] 700 + 701 + [[package]] 702 + name = "rand_chacha" 703 + version = "0.9.0" 704 + source = "registry+https://github.com/rust-lang/crates.io-index" 705 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 706 + dependencies = [ 707 + "ppv-lite86", 708 + "rand_core", 709 + ] 710 + 711 + [[package]] 712 + name = "rand_core" 713 + version = "0.9.5" 714 + source = "registry+https://github.com/rust-lang/crates.io-index" 715 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 716 + dependencies = [ 717 + "getrandom 0.3.4", 718 + ] 719 + 720 + [[package]] 721 + name = "redox_syscall" 722 + version = "0.5.18" 723 + source = "registry+https://github.com/rust-lang/crates.io-index" 724 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 725 + dependencies = [ 726 + "bitflags", 727 + ] 728 + 729 + [[package]] 730 + name = "reqwest" 731 + version = "0.12.28" 732 + source = "registry+https://github.com/rust-lang/crates.io-index" 733 + checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 734 + dependencies = [ 735 + "base64", 736 + "bytes", 737 + "futures-core", 738 + "http", 739 + "http-body", 740 + "http-body-util", 741 + "hyper", 742 + "hyper-rustls", 743 + "hyper-util", 744 + "js-sys", 745 + "log", 746 + "percent-encoding", 747 + "pin-project-lite", 748 + "quinn", 749 + "rustls", 750 + "rustls-pki-types", 751 + "serde", 752 + "serde_json", 753 + "serde_urlencoded", 754 + "sync_wrapper", 755 + "tokio", 756 + "tokio-rustls", 757 + "tower", 758 + "tower-http", 759 + "tower-service", 760 + "url", 761 + "wasm-bindgen", 762 + "wasm-bindgen-futures", 763 + "web-sys", 764 + "webpki-roots", 765 + ] 766 + 767 + [[package]] 768 + name = "ring" 769 + version = "0.17.14" 770 + source = "registry+https://github.com/rust-lang/crates.io-index" 771 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 772 + dependencies = [ 773 + "cc", 774 + "cfg-if", 775 + "getrandom 0.2.17", 776 + "libc", 777 + "untrusted", 778 + "windows-sys 0.52.0", 779 + ] 780 + 781 + [[package]] 782 + name = "rustc-hash" 783 + version = "2.1.1" 784 + source = "registry+https://github.com/rust-lang/crates.io-index" 785 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 786 + 787 + [[package]] 788 + name = "rustix" 789 + version = "1.1.4" 790 + source = "registry+https://github.com/rust-lang/crates.io-index" 791 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 792 + dependencies = [ 793 + "bitflags", 794 + "errno", 795 + "libc", 796 + "linux-raw-sys", 797 + "windows-sys 0.61.2", 798 + ] 799 + 800 + [[package]] 801 + name = "rustls" 802 + version = "0.23.36" 803 + source = "registry+https://github.com/rust-lang/crates.io-index" 804 + checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" 805 + dependencies = [ 806 + "once_cell", 807 + "ring", 808 + "rustls-pki-types", 809 + "rustls-webpki", 810 + "subtle", 811 + "zeroize", 812 + ] 813 + 814 + [[package]] 815 + name = "rustls-pki-types" 816 + version = "1.14.0" 817 + source = "registry+https://github.com/rust-lang/crates.io-index" 818 + checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" 819 + dependencies = [ 820 + "web-time", 821 + "zeroize", 822 + ] 823 + 824 + [[package]] 825 + name = "rustls-webpki" 826 + version = "0.103.9" 827 + source = "registry+https://github.com/rust-lang/crates.io-index" 828 + checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" 829 + dependencies = [ 830 + "ring", 831 + "rustls-pki-types", 832 + "untrusted", 833 + ] 834 + 835 + [[package]] 836 + name = "rustversion" 837 + version = "1.0.22" 838 + source = "registry+https://github.com/rust-lang/crates.io-index" 839 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 840 + 841 + [[package]] 842 + name = "ryu" 843 + version = "1.0.23" 844 + source = "registry+https://github.com/rust-lang/crates.io-index" 845 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 846 + 847 + [[package]] 848 + name = "scopeguard" 849 + version = "1.2.0" 850 + source = "registry+https://github.com/rust-lang/crates.io-index" 851 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 852 + 853 + [[package]] 854 + name = "semver" 855 + version = "1.0.27" 856 + source = "registry+https://github.com/rust-lang/crates.io-index" 857 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 858 + 859 + [[package]] 860 + name = "serde" 861 + version = "1.0.228" 862 + source = "registry+https://github.com/rust-lang/crates.io-index" 863 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 864 + dependencies = [ 865 + "serde_core", 866 + "serde_derive", 867 + ] 868 + 869 + [[package]] 870 + name = "serde_core" 871 + version = "1.0.228" 872 + source = "registry+https://github.com/rust-lang/crates.io-index" 873 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 874 + dependencies = [ 875 + "serde_derive", 876 + ] 877 + 878 + [[package]] 879 + name = "serde_derive" 880 + version = "1.0.228" 881 + source = "registry+https://github.com/rust-lang/crates.io-index" 882 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 883 + dependencies = [ 884 + "proc-macro2", 885 + "quote", 886 + "syn", 887 + ] 888 + 889 + [[package]] 890 + name = "serde_json" 891 + version = "1.0.149" 892 + source = "registry+https://github.com/rust-lang/crates.io-index" 893 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 894 + dependencies = [ 895 + "itoa", 896 + "memchr", 897 + "serde", 898 + "serde_core", 899 + "zmij", 900 + ] 901 + 902 + [[package]] 903 + name = "serde_urlencoded" 904 + version = "0.7.1" 905 + source = "registry+https://github.com/rust-lang/crates.io-index" 906 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 907 + dependencies = [ 908 + "form_urlencoded", 909 + "itoa", 910 + "ryu", 911 + "serde", 912 + ] 913 + 914 + [[package]] 915 + name = "shlex" 916 + version = "1.3.0" 917 + source = "registry+https://github.com/rust-lang/crates.io-index" 918 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 919 + 920 + [[package]] 921 + name = "signal-hook-registry" 922 + version = "1.4.8" 923 + source = "registry+https://github.com/rust-lang/crates.io-index" 924 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 925 + dependencies = [ 926 + "errno", 927 + "libc", 928 + ] 929 + 930 + [[package]] 931 + name = "slab" 932 + version = "0.4.12" 933 + source = "registry+https://github.com/rust-lang/crates.io-index" 934 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 935 + 936 + [[package]] 937 + name = "smallvec" 938 + version = "1.15.1" 939 + source = "registry+https://github.com/rust-lang/crates.io-index" 940 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 941 + 942 + [[package]] 943 + name = "socket2" 944 + version = "0.6.2" 945 + source = "registry+https://github.com/rust-lang/crates.io-index" 946 + checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" 947 + dependencies = [ 948 + "libc", 949 + "windows-sys 0.60.2", 950 + ] 951 + 952 + [[package]] 953 + name = "stable_deref_trait" 954 + version = "1.2.1" 955 + source = "registry+https://github.com/rust-lang/crates.io-index" 956 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 957 + 958 + [[package]] 959 + name = "subtle" 960 + version = "2.6.1" 961 + source = "registry+https://github.com/rust-lang/crates.io-index" 962 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 963 + 964 + [[package]] 965 + name = "syn" 966 + version = "2.0.117" 967 + source = "registry+https://github.com/rust-lang/crates.io-index" 968 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 969 + dependencies = [ 970 + "proc-macro2", 971 + "quote", 972 + "unicode-ident", 973 + ] 974 + 975 + [[package]] 976 + name = "sync_wrapper" 977 + version = "1.0.2" 978 + source = "registry+https://github.com/rust-lang/crates.io-index" 979 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 980 + dependencies = [ 981 + "futures-core", 982 + ] 983 + 984 + [[package]] 985 + name = "synstructure" 986 + version = "0.13.2" 987 + source = "registry+https://github.com/rust-lang/crates.io-index" 988 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 989 + dependencies = [ 990 + "proc-macro2", 991 + "quote", 992 + "syn", 993 + ] 994 + 995 + [[package]] 996 + name = "tempfile" 997 + version = "3.25.0" 998 + source = "registry+https://github.com/rust-lang/crates.io-index" 999 + checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" 1000 + dependencies = [ 1001 + "fastrand", 1002 + "getrandom 0.4.1", 1003 + "once_cell", 1004 + "rustix", 1005 + "windows-sys 0.61.2", 1006 + ] 1007 + 1008 + [[package]] 1009 + name = "thiserror" 1010 + version = "2.0.18" 1011 + source = "registry+https://github.com/rust-lang/crates.io-index" 1012 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1013 + dependencies = [ 1014 + "thiserror-impl", 1015 + ] 1016 + 1017 + [[package]] 1018 + name = "thiserror-impl" 1019 + version = "2.0.18" 1020 + source = "registry+https://github.com/rust-lang/crates.io-index" 1021 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1022 + dependencies = [ 1023 + "proc-macro2", 1024 + "quote", 1025 + "syn", 1026 + ] 1027 + 1028 + [[package]] 1029 + name = "tinystr" 1030 + version = "0.8.2" 1031 + source = "registry+https://github.com/rust-lang/crates.io-index" 1032 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 1033 + dependencies = [ 1034 + "displaydoc", 1035 + "zerovec", 1036 + ] 1037 + 1038 + [[package]] 1039 + name = "tinyvec" 1040 + version = "1.10.0" 1041 + source = "registry+https://github.com/rust-lang/crates.io-index" 1042 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 1043 + dependencies = [ 1044 + "tinyvec_macros", 1045 + ] 1046 + 1047 + [[package]] 1048 + name = "tinyvec_macros" 1049 + version = "0.1.1" 1050 + source = "registry+https://github.com/rust-lang/crates.io-index" 1051 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 1052 + 1053 + [[package]] 1054 + name = "tokio" 1055 + version = "1.49.0" 1056 + source = "registry+https://github.com/rust-lang/crates.io-index" 1057 + checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" 1058 + dependencies = [ 1059 + "bytes", 1060 + "libc", 1061 + "mio", 1062 + "parking_lot", 1063 + "pin-project-lite", 1064 + "signal-hook-registry", 1065 + "socket2", 1066 + "tokio-macros", 1067 + "windows-sys 0.61.2", 1068 + ] 1069 + 1070 + [[package]] 1071 + name = "tokio-macros" 1072 + version = "2.6.0" 1073 + source = "registry+https://github.com/rust-lang/crates.io-index" 1074 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 1075 + dependencies = [ 1076 + "proc-macro2", 1077 + "quote", 1078 + "syn", 1079 + ] 1080 + 1081 + [[package]] 1082 + name = "tokio-rustls" 1083 + version = "0.26.4" 1084 + source = "registry+https://github.com/rust-lang/crates.io-index" 1085 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 1086 + dependencies = [ 1087 + "rustls", 1088 + "tokio", 1089 + ] 1090 + 1091 + [[package]] 1092 + name = "tower" 1093 + version = "0.5.3" 1094 + source = "registry+https://github.com/rust-lang/crates.io-index" 1095 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 1096 + dependencies = [ 1097 + "futures-core", 1098 + "futures-util", 1099 + "pin-project-lite", 1100 + "sync_wrapper", 1101 + "tokio", 1102 + "tower-layer", 1103 + "tower-service", 1104 + ] 1105 + 1106 + [[package]] 1107 + name = "tower-http" 1108 + version = "0.6.8" 1109 + source = "registry+https://github.com/rust-lang/crates.io-index" 1110 + checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 1111 + dependencies = [ 1112 + "bitflags", 1113 + "bytes", 1114 + "futures-util", 1115 + "http", 1116 + "http-body", 1117 + "iri-string", 1118 + "pin-project-lite", 1119 + "tower", 1120 + "tower-layer", 1121 + "tower-service", 1122 + ] 1123 + 1124 + [[package]] 1125 + name = "tower-layer" 1126 + version = "0.3.3" 1127 + source = "registry+https://github.com/rust-lang/crates.io-index" 1128 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1129 + 1130 + [[package]] 1131 + name = "tower-service" 1132 + version = "0.3.3" 1133 + source = "registry+https://github.com/rust-lang/crates.io-index" 1134 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1135 + 1136 + [[package]] 1137 + name = "tracing" 1138 + version = "0.1.44" 1139 + source = "registry+https://github.com/rust-lang/crates.io-index" 1140 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1141 + dependencies = [ 1142 + "pin-project-lite", 1143 + "tracing-attributes", 1144 + "tracing-core", 1145 + ] 1146 + 1147 + [[package]] 1148 + name = "tracing-attributes" 1149 + version = "0.1.31" 1150 + source = "registry+https://github.com/rust-lang/crates.io-index" 1151 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 1152 + dependencies = [ 1153 + "proc-macro2", 1154 + "quote", 1155 + "syn", 1156 + ] 1157 + 1158 + [[package]] 1159 + name = "tracing-core" 1160 + version = "0.1.36" 1161 + source = "registry+https://github.com/rust-lang/crates.io-index" 1162 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 1163 + dependencies = [ 1164 + "once_cell", 1165 + ] 1166 + 1167 + [[package]] 1168 + name = "try-lock" 1169 + version = "0.2.5" 1170 + source = "registry+https://github.com/rust-lang/crates.io-index" 1171 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1172 + 1173 + [[package]] 1174 + name = "unicode-ident" 1175 + version = "1.0.24" 1176 + source = "registry+https://github.com/rust-lang/crates.io-index" 1177 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 1178 + 1179 + [[package]] 1180 + name = "unicode-xid" 1181 + version = "0.2.6" 1182 + source = "registry+https://github.com/rust-lang/crates.io-index" 1183 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 1184 + 1185 + [[package]] 1186 + name = "untrusted" 1187 + version = "0.9.0" 1188 + source = "registry+https://github.com/rust-lang/crates.io-index" 1189 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 1190 + 1191 + [[package]] 1192 + name = "url" 1193 + version = "2.5.8" 1194 + source = "registry+https://github.com/rust-lang/crates.io-index" 1195 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 1196 + dependencies = [ 1197 + "form_urlencoded", 1198 + "idna", 1199 + "percent-encoding", 1200 + "serde", 1201 + ] 1202 + 1203 + [[package]] 1204 + name = "utf8_iter" 1205 + version = "1.0.4" 1206 + source = "registry+https://github.com/rust-lang/crates.io-index" 1207 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1208 + 1209 + [[package]] 1210 + name = "want" 1211 + version = "0.3.1" 1212 + source = "registry+https://github.com/rust-lang/crates.io-index" 1213 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 1214 + dependencies = [ 1215 + "try-lock", 1216 + ] 1217 + 1218 + [[package]] 1219 + name = "wasi" 1220 + version = "0.11.1+wasi-snapshot-preview1" 1221 + source = "registry+https://github.com/rust-lang/crates.io-index" 1222 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1223 + 1224 + [[package]] 1225 + name = "wasip2" 1226 + version = "1.0.2+wasi-0.2.9" 1227 + source = "registry+https://github.com/rust-lang/crates.io-index" 1228 + checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" 1229 + dependencies = [ 1230 + "wit-bindgen", 1231 + ] 1232 + 1233 + [[package]] 1234 + name = "wasip3" 1235 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 1236 + source = "registry+https://github.com/rust-lang/crates.io-index" 1237 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 1238 + dependencies = [ 1239 + "wit-bindgen", 1240 + ] 1241 + 1242 + [[package]] 1243 + name = "wasm-bindgen" 1244 + version = "0.2.111" 1245 + source = "registry+https://github.com/rust-lang/crates.io-index" 1246 + checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" 1247 + dependencies = [ 1248 + "cfg-if", 1249 + "once_cell", 1250 + "rustversion", 1251 + "wasm-bindgen-macro", 1252 + "wasm-bindgen-shared", 1253 + ] 1254 + 1255 + [[package]] 1256 + name = "wasm-bindgen-futures" 1257 + version = "0.4.61" 1258 + source = "registry+https://github.com/rust-lang/crates.io-index" 1259 + checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" 1260 + dependencies = [ 1261 + "cfg-if", 1262 + "futures-util", 1263 + "js-sys", 1264 + "once_cell", 1265 + "wasm-bindgen", 1266 + "web-sys", 1267 + ] 1268 + 1269 + [[package]] 1270 + name = "wasm-bindgen-macro" 1271 + version = "0.2.111" 1272 + source = "registry+https://github.com/rust-lang/crates.io-index" 1273 + checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" 1274 + dependencies = [ 1275 + "quote", 1276 + "wasm-bindgen-macro-support", 1277 + ] 1278 + 1279 + [[package]] 1280 + name = "wasm-bindgen-macro-support" 1281 + version = "0.2.111" 1282 + source = "registry+https://github.com/rust-lang/crates.io-index" 1283 + checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" 1284 + dependencies = [ 1285 + "bumpalo", 1286 + "proc-macro2", 1287 + "quote", 1288 + "syn", 1289 + "wasm-bindgen-shared", 1290 + ] 1291 + 1292 + [[package]] 1293 + name = "wasm-bindgen-shared" 1294 + version = "0.2.111" 1295 + source = "registry+https://github.com/rust-lang/crates.io-index" 1296 + checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" 1297 + dependencies = [ 1298 + "unicode-ident", 1299 + ] 1300 + 1301 + [[package]] 1302 + name = "wasm-encoder" 1303 + version = "0.244.0" 1304 + source = "registry+https://github.com/rust-lang/crates.io-index" 1305 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 1306 + dependencies = [ 1307 + "leb128fmt", 1308 + "wasmparser", 1309 + ] 1310 + 1311 + [[package]] 1312 + name = "wasm-metadata" 1313 + version = "0.244.0" 1314 + source = "registry+https://github.com/rust-lang/crates.io-index" 1315 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 1316 + dependencies = [ 1317 + "anyhow", 1318 + "indexmap", 1319 + "wasm-encoder", 1320 + "wasmparser", 1321 + ] 1322 + 1323 + [[package]] 1324 + name = "wasmparser" 1325 + version = "0.244.0" 1326 + source = "registry+https://github.com/rust-lang/crates.io-index" 1327 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 1328 + dependencies = [ 1329 + "bitflags", 1330 + "hashbrown 0.15.5", 1331 + "indexmap", 1332 + "semver", 1333 + ] 1334 + 1335 + [[package]] 1336 + name = "web-sys" 1337 + version = "0.3.88" 1338 + source = "registry+https://github.com/rust-lang/crates.io-index" 1339 + checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" 1340 + dependencies = [ 1341 + "js-sys", 1342 + "wasm-bindgen", 1343 + ] 1344 + 1345 + [[package]] 1346 + name = "web-time" 1347 + version = "1.1.0" 1348 + source = "registry+https://github.com/rust-lang/crates.io-index" 1349 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 1350 + dependencies = [ 1351 + "js-sys", 1352 + "wasm-bindgen", 1353 + ] 1354 + 1355 + [[package]] 1356 + name = "webpki-roots" 1357 + version = "1.0.6" 1358 + source = "registry+https://github.com/rust-lang/crates.io-index" 1359 + checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" 1360 + dependencies = [ 1361 + "rustls-pki-types", 1362 + ] 1363 + 1364 + [[package]] 1365 + name = "windows-link" 1366 + version = "0.2.1" 1367 + source = "registry+https://github.com/rust-lang/crates.io-index" 1368 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1369 + 1370 + [[package]] 1371 + name = "windows-sys" 1372 + version = "0.52.0" 1373 + source = "registry+https://github.com/rust-lang/crates.io-index" 1374 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1375 + dependencies = [ 1376 + "windows-targets 0.52.6", 1377 + ] 1378 + 1379 + [[package]] 1380 + name = "windows-sys" 1381 + version = "0.60.2" 1382 + source = "registry+https://github.com/rust-lang/crates.io-index" 1383 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1384 + dependencies = [ 1385 + "windows-targets 0.53.5", 1386 + ] 1387 + 1388 + [[package]] 1389 + name = "windows-sys" 1390 + version = "0.61.2" 1391 + source = "registry+https://github.com/rust-lang/crates.io-index" 1392 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 1393 + dependencies = [ 1394 + "windows-link", 1395 + ] 1396 + 1397 + [[package]] 1398 + name = "windows-targets" 1399 + version = "0.52.6" 1400 + source = "registry+https://github.com/rust-lang/crates.io-index" 1401 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1402 + dependencies = [ 1403 + "windows_aarch64_gnullvm 0.52.6", 1404 + "windows_aarch64_msvc 0.52.6", 1405 + "windows_i686_gnu 0.52.6", 1406 + "windows_i686_gnullvm 0.52.6", 1407 + "windows_i686_msvc 0.52.6", 1408 + "windows_x86_64_gnu 0.52.6", 1409 + "windows_x86_64_gnullvm 0.52.6", 1410 + "windows_x86_64_msvc 0.52.6", 1411 + ] 1412 + 1413 + [[package]] 1414 + name = "windows-targets" 1415 + version = "0.53.5" 1416 + source = "registry+https://github.com/rust-lang/crates.io-index" 1417 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 1418 + dependencies = [ 1419 + "windows-link", 1420 + "windows_aarch64_gnullvm 0.53.1", 1421 + "windows_aarch64_msvc 0.53.1", 1422 + "windows_i686_gnu 0.53.1", 1423 + "windows_i686_gnullvm 0.53.1", 1424 + "windows_i686_msvc 0.53.1", 1425 + "windows_x86_64_gnu 0.53.1", 1426 + "windows_x86_64_gnullvm 0.53.1", 1427 + "windows_x86_64_msvc 0.53.1", 1428 + ] 1429 + 1430 + [[package]] 1431 + name = "windows_aarch64_gnullvm" 1432 + version = "0.52.6" 1433 + source = "registry+https://github.com/rust-lang/crates.io-index" 1434 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1435 + 1436 + [[package]] 1437 + name = "windows_aarch64_gnullvm" 1438 + version = "0.53.1" 1439 + source = "registry+https://github.com/rust-lang/crates.io-index" 1440 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 1441 + 1442 + [[package]] 1443 + name = "windows_aarch64_msvc" 1444 + version = "0.52.6" 1445 + source = "registry+https://github.com/rust-lang/crates.io-index" 1446 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1447 + 1448 + [[package]] 1449 + name = "windows_aarch64_msvc" 1450 + version = "0.53.1" 1451 + source = "registry+https://github.com/rust-lang/crates.io-index" 1452 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 1453 + 1454 + [[package]] 1455 + name = "windows_i686_gnu" 1456 + version = "0.52.6" 1457 + source = "registry+https://github.com/rust-lang/crates.io-index" 1458 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1459 + 1460 + [[package]] 1461 + name = "windows_i686_gnu" 1462 + version = "0.53.1" 1463 + source = "registry+https://github.com/rust-lang/crates.io-index" 1464 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 1465 + 1466 + [[package]] 1467 + name = "windows_i686_gnullvm" 1468 + version = "0.52.6" 1469 + source = "registry+https://github.com/rust-lang/crates.io-index" 1470 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1471 + 1472 + [[package]] 1473 + name = "windows_i686_gnullvm" 1474 + version = "0.53.1" 1475 + source = "registry+https://github.com/rust-lang/crates.io-index" 1476 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 1477 + 1478 + [[package]] 1479 + name = "windows_i686_msvc" 1480 + version = "0.52.6" 1481 + source = "registry+https://github.com/rust-lang/crates.io-index" 1482 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1483 + 1484 + [[package]] 1485 + name = "windows_i686_msvc" 1486 + version = "0.53.1" 1487 + source = "registry+https://github.com/rust-lang/crates.io-index" 1488 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 1489 + 1490 + [[package]] 1491 + name = "windows_x86_64_gnu" 1492 + version = "0.52.6" 1493 + source = "registry+https://github.com/rust-lang/crates.io-index" 1494 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1495 + 1496 + [[package]] 1497 + name = "windows_x86_64_gnu" 1498 + version = "0.53.1" 1499 + source = "registry+https://github.com/rust-lang/crates.io-index" 1500 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 1501 + 1502 + [[package]] 1503 + name = "windows_x86_64_gnullvm" 1504 + version = "0.52.6" 1505 + source = "registry+https://github.com/rust-lang/crates.io-index" 1506 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1507 + 1508 + [[package]] 1509 + name = "windows_x86_64_gnullvm" 1510 + version = "0.53.1" 1511 + source = "registry+https://github.com/rust-lang/crates.io-index" 1512 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 1513 + 1514 + [[package]] 1515 + name = "windows_x86_64_msvc" 1516 + version = "0.52.6" 1517 + source = "registry+https://github.com/rust-lang/crates.io-index" 1518 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1519 + 1520 + [[package]] 1521 + name = "windows_x86_64_msvc" 1522 + version = "0.53.1" 1523 + source = "registry+https://github.com/rust-lang/crates.io-index" 1524 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 1525 + 1526 + [[package]] 1527 + name = "wit-bindgen" 1528 + version = "0.51.0" 1529 + source = "registry+https://github.com/rust-lang/crates.io-index" 1530 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 1531 + dependencies = [ 1532 + "wit-bindgen-rust-macro", 1533 + ] 1534 + 1535 + [[package]] 1536 + name = "wit-bindgen-core" 1537 + version = "0.51.0" 1538 + source = "registry+https://github.com/rust-lang/crates.io-index" 1539 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 1540 + dependencies = [ 1541 + "anyhow", 1542 + "heck", 1543 + "wit-parser", 1544 + ] 1545 + 1546 + [[package]] 1547 + name = "wit-bindgen-rust" 1548 + version = "0.51.0" 1549 + source = "registry+https://github.com/rust-lang/crates.io-index" 1550 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 1551 + dependencies = [ 1552 + "anyhow", 1553 + "heck", 1554 + "indexmap", 1555 + "prettyplease", 1556 + "syn", 1557 + "wasm-metadata", 1558 + "wit-bindgen-core", 1559 + "wit-component", 1560 + ] 1561 + 1562 + [[package]] 1563 + name = "wit-bindgen-rust-macro" 1564 + version = "0.51.0" 1565 + source = "registry+https://github.com/rust-lang/crates.io-index" 1566 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 1567 + dependencies = [ 1568 + "anyhow", 1569 + "prettyplease", 1570 + "proc-macro2", 1571 + "quote", 1572 + "syn", 1573 + "wit-bindgen-core", 1574 + "wit-bindgen-rust", 1575 + ] 1576 + 1577 + [[package]] 1578 + name = "wit-component" 1579 + version = "0.244.0" 1580 + source = "registry+https://github.com/rust-lang/crates.io-index" 1581 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 1582 + dependencies = [ 1583 + "anyhow", 1584 + "bitflags", 1585 + "indexmap", 1586 + "log", 1587 + "serde", 1588 + "serde_derive", 1589 + "serde_json", 1590 + "wasm-encoder", 1591 + "wasm-metadata", 1592 + "wasmparser", 1593 + "wit-parser", 1594 + ] 1595 + 1596 + [[package]] 1597 + name = "wit-parser" 1598 + version = "0.244.0" 1599 + source = "registry+https://github.com/rust-lang/crates.io-index" 1600 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 1601 + dependencies = [ 1602 + "anyhow", 1603 + "id-arena", 1604 + "indexmap", 1605 + "log", 1606 + "semver", 1607 + "serde", 1608 + "serde_derive", 1609 + "serde_json", 1610 + "unicode-xid", 1611 + "wasmparser", 1612 + ] 1613 + 1614 + [[package]] 1615 + name = "writeable" 1616 + version = "0.6.2" 1617 + source = "registry+https://github.com/rust-lang/crates.io-index" 1618 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 1619 + 1620 + [[package]] 1621 + name = "yoke" 1622 + version = "0.8.1" 1623 + source = "registry+https://github.com/rust-lang/crates.io-index" 1624 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 1625 + dependencies = [ 1626 + "stable_deref_trait", 1627 + "yoke-derive", 1628 + "zerofrom", 1629 + ] 1630 + 1631 + [[package]] 1632 + name = "yoke-derive" 1633 + version = "0.8.1" 1634 + source = "registry+https://github.com/rust-lang/crates.io-index" 1635 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 1636 + dependencies = [ 1637 + "proc-macro2", 1638 + "quote", 1639 + "syn", 1640 + "synstructure", 1641 + ] 1642 + 1643 + [[package]] 1644 + name = "zerocopy" 1645 + version = "0.8.39" 1646 + source = "registry+https://github.com/rust-lang/crates.io-index" 1647 + checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" 1648 + dependencies = [ 1649 + "zerocopy-derive", 1650 + ] 1651 + 1652 + [[package]] 1653 + name = "zerocopy-derive" 1654 + version = "0.8.39" 1655 + source = "registry+https://github.com/rust-lang/crates.io-index" 1656 + checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" 1657 + dependencies = [ 1658 + "proc-macro2", 1659 + "quote", 1660 + "syn", 1661 + ] 1662 + 1663 + [[package]] 1664 + name = "zerofrom" 1665 + version = "0.1.6" 1666 + source = "registry+https://github.com/rust-lang/crates.io-index" 1667 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 1668 + dependencies = [ 1669 + "zerofrom-derive", 1670 + ] 1671 + 1672 + [[package]] 1673 + name = "zerofrom-derive" 1674 + version = "0.1.6" 1675 + source = "registry+https://github.com/rust-lang/crates.io-index" 1676 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 1677 + dependencies = [ 1678 + "proc-macro2", 1679 + "quote", 1680 + "syn", 1681 + "synstructure", 1682 + ] 1683 + 1684 + [[package]] 1685 + name = "zeroize" 1686 + version = "1.8.2" 1687 + source = "registry+https://github.com/rust-lang/crates.io-index" 1688 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 1689 + 1690 + [[package]] 1691 + name = "zerotrie" 1692 + version = "0.2.3" 1693 + source = "registry+https://github.com/rust-lang/crates.io-index" 1694 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 1695 + dependencies = [ 1696 + "displaydoc", 1697 + "yoke", 1698 + "zerofrom", 1699 + ] 1700 + 1701 + [[package]] 1702 + name = "zerovec" 1703 + version = "0.11.5" 1704 + source = "registry+https://github.com/rust-lang/crates.io-index" 1705 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 1706 + dependencies = [ 1707 + "yoke", 1708 + "zerofrom", 1709 + "zerovec-derive", 1710 + ] 1711 + 1712 + [[package]] 1713 + name = "zerovec-derive" 1714 + version = "0.11.2" 1715 + source = "registry+https://github.com/rust-lang/crates.io-index" 1716 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 1717 + dependencies = [ 1718 + "proc-macro2", 1719 + "quote", 1720 + "syn", 1721 + ] 1722 + 1723 + [[package]] 1724 + name = "zmij" 1725 + version = "1.0.21" 1726 + source = "registry+https://github.com/rust-lang/crates.io-index" 1727 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+17
Cargo.toml
··· 1 + [package] 2 + name = "pds-git-remote" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [workspace] 7 + 8 + [dependencies] 9 + reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } 10 + serde = { version = "1.0", features = ["derive"] } 11 + serde_json = "1.0" 12 + tempfile = "3.13.0" 13 + tokio = { version = "1", features = ["full"] } 14 + tracing = "0.1" 15 + 16 + [dev-dependencies] 17 + tempfile = "3.13.0"
+186
plan.md
··· 1 + # pds-git-remote 2 + 3 + A Rust crate providing PDS-backed git remote functionality — uses AT Protocol PDS as a git backup backend via incremental bundles. 4 + 5 + See `pds-git-plan.md` for the full architectural rationale and design document. 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + `pds-git-remote` is a new crate in the lichen workspace (`crates/pds-git-remote`). It provides: 12 + 13 + 1. **A library** — core types, PDS API client, bundle operations, and push/pull logic that lichen-cms (and other crates) can call directly 14 + 2. **A git remote helper binary** (`git-remote-pds`) — so developers can `git push pds main` from the CLI 15 + 16 + The crate shells out to the `git` binary for bundle creation/application (consistent with how `lichen-git` already works). PDS interaction uses HTTP via `reqwest`. 17 + 18 + --- 19 + 20 + ## Phase 1: Core types and PDS client 21 + 22 + Foundation layer — types that model the PDS state record and an HTTP client for the PDS XRPC API. 23 + 24 + - [x] create crate skeleton (`crates/pds-git-remote`, add to workspace `Cargo.toml`) 25 + - [x] define core types in `types.rs`: 26 + - `GitRef { name, sha }` 27 + - `BundleEntry { parts (blob CIDs), prerequisites, tips, total_size, created_at }` 28 + - `RepoState { name, refs, bundles, updated_at }` — mirrors the `sh.pdsbackup.git.state` lexicon 29 + - [x] implement PDS XRPC client in `pds_client.rs`: 30 + - `get_record(did, collection, rkey)` → fetch a record 31 + - `put_record(did, collection, rkey, record)` → create or update a record 32 + - `upload_blob(data)` → upload bytes, return blob ref/CID 33 + - `get_blob(did, cid)` → download blob bytes (unauthenticated) 34 + - auth: accept a bearer token (access token), with a builder pattern for optional auth 35 + - [x] implement AT Protocol identity resolution in `identity.rs`: 36 + - resolve handle → DID (via `com.atproto.identity.resolveHandle` or DNS) 37 + - resolve DID → PDS endpoint (via DID document / PLC directory) 38 + - [x] add unit tests for types serialization and identity resolution 39 + 40 + **Dependencies to add:** `reqwest`, `serde`, `serde_json`, `tokio` 41 + 42 + --- 43 + 44 + ## Phase 2: Bundle operations 45 + 46 + Wrap git bundle commands and handle chunking for large bundles. 47 + 48 + - [x] implement bundle operations in `bundle.rs`: 49 + - `create_full_bundle(repo_path)` → create a full bundle of the entire repo, return bytes 50 + - `create_incremental_bundle(repo_path, refs, since_commits)` → create bundle with only new commits 51 + - `apply_bundle(repo_path, bundle_bytes)` → unbundle into a repo 52 + - `verify_bundle(repo_path, bundle_bytes)` → check bundle prerequisites are satisfied 53 + - [x] implement chunking in `chunk.rs`: 54 + - `chunk_bytes(data, chunk_size)` → split into <=40MB parts 55 + - `reassemble_chunks(parts)` → concatenate in order 56 + - [x] add integration tests (create temp repos, make commits, bundle/unbundle, verify content) 57 + 58 + --- 59 + 60 + ## Phase 3: Push flow 61 + 62 + Combine the PDS client and bundle operations into a complete push. 63 + 64 + - [ ] implement push logic in `push.rs`: 65 + - read current state record from PDS (or handle first-push where none exists) 66 + - determine what's new: compare local refs to remote refs 67 + - create incremental bundle (or full bundle on first push) 68 + - chunk if needed, upload blob(s) via `upload_blob` 69 + - append new `BundleEntry` to state, update refs 70 + - write updated state record via `put_record` 71 + - [ ] handle edge cases: 72 + - first push (no existing state record) → create full bundle + new record 73 + - nothing to push (refs match) → no-op 74 + - non-fast-forward → reject with error (no force push for now) 75 + - [ ] add integration tests with a mock PDS server (or test against local PDS) 76 + 77 + --- 78 + 79 + ## Phase 3.1: Local PDS test environment 80 + 81 + Set up a local PDS server via Docker for integration testing. Scripts live in `scripts/pds-dev/`. 82 + 83 + - [ ] create `scripts/pds-dev/compose.yaml`: 84 + - single service: `ghcr.io/bluesky-social/pds:0.4` on port 3000 85 + - volume mount `./pds-data:/pds` for persistence 86 + - env_file pointing to `pds.env` 87 + - [ ] create `scripts/pds-dev/setup.sh`: 88 + - generate secrets (`PDS_JWT_SECRET`, `PDS_ADMIN_PASSWORD`, rotation key) 89 + - write `pds.env` with local-dev defaults (`PDS_HOSTNAME=localhost`, `PDS_DEV_MODE=true`, `PDS_INVITE_REQUIRED=false`) 90 + - create data directory 91 + - print admin password for reference 92 + - [ ] create `scripts/pds-dev/start.sh`: 93 + - run `setup.sh` if `pds.env` doesn't exist yet 94 + - `docker compose up -d` 95 + - wait for health check (`/xrpc/_health`) with timeout 96 + - [ ] create `scripts/pds-dev/create-account.sh`: 97 + - accept handle and password as args (defaults: `test.localhost` / `test-password-123`) 98 + - call `com.atproto.server.createAccount` XRPC endpoint 99 + - print the DID and access token 100 + - [ ] create `scripts/pds-dev/login.sh`: 101 + - call `com.atproto.server.createSession` for a given handle/password 102 + - print `accessJwt` for use in manual testing or piping to other scripts 103 + - [ ] create `scripts/pds-dev/stop.sh`: 104 + - `docker compose down` 105 + - [ ] create `scripts/pds-dev/reset.sh`: 106 + - `docker compose down -v` and `rm -rf pds-data` to wipe all state 107 + - [ ] add `scripts/pds-dev/README.md` with quick-start instructions 108 + - [ ] add `scripts/pds-dev/` to `.gitignore` for `pds-data/` and `pds.env` (generated secrets) 109 + 110 + --- 111 + 112 + ## Phase 4: Clone and fetch flow 113 + 114 + Download bundle chain from PDS and apply to local repo. 115 + 116 + - [ ] implement fetch/clone logic in `fetch.rs`: 117 + - resolve `pds://handle/repo-name` into DID + PDS endpoint + rkey 118 + - read state record → get bundle chain and refs 119 + - for clone: download all bundles in order (oldest first), apply each 120 + - for fetch: compare local refs to remote, skip bundles whose tips we already have, download and apply only new ones 121 + - set up local refs from state record 122 + - [ ] handle chunked bundles (reassemble parts before unbundling) 123 + - [ ] add integration tests 124 + 125 + --- 126 + 127 + ## Phase 5: Git remote helper binary 128 + 129 + Implement the `git-remote-pds` binary that speaks git's remote helper protocol on stdin/stdout. 130 + 131 + - [ ] add `[[bin]]` target to `Cargo.toml` for `git-remote-pds` 132 + - [ ] implement remote helper protocol in `remote_helper.rs`: 133 + - `capabilities` → respond with `push` and `fetch` 134 + - `list` / `list for-push` → read refs from PDS state record 135 + - `fetch <sha> <ref>` → download and apply bundle chain 136 + - `push <src>:<dst>` → create bundle, upload, update state 137 + - [ ] implement CLI auth in `auth.rs`: 138 + - `pds-git auth login` → open browser for atproto OAuth, cache token locally 139 + - token storage in `~/.config/pds-git-remote/` or platform-appropriate config dir 140 + - token refresh on expiry 141 + - [ ] add `clap`-based CLI for auth subcommands 142 + - [ ] end-to-end test: init repo, add remote, push, clone elsewhere, verify content matches 143 + 144 + --- 145 + 146 + ## Phase 6: Library interface for lichen-cms 147 + 148 + Expose a high-level API that lichen-cms can call with an existing OAuth session. 149 + 150 + - [ ] implement library API in `lib.rs` (public interface): 151 + - `PdsBackup::new(pds_endpoint, auth_token, repo_path, repo_name)` — constructor 152 + - `push()` — push new commits to PDS 153 + - `pull()` — pull latest from PDS into local repo 154 + - `status()` → `{ ahead, behind, last_push }` — compare local vs remote state 155 + - `compact()` — replace bundle chain with single full bundle 156 + - [ ] integrate with `lichen-git`: 157 + - `git_push` placeholder in `lichen-git/src/git.rs` can call into `pds-git-remote` 158 + - auto-commit flow triggers `PdsBackup::push()` on a debounce timer 159 + - [ ] add settings support: 160 + - extend `lichen-git` settings or add `[pds_backup]` section to site config 161 + - fields: `handle`, `repo_name`, `enabled` 162 + 163 + --- 164 + 165 + ## Phase 7: Compaction and polish 166 + 167 + - [ ] implement bundle chain compaction: 168 + - `compact()` → create full bundle (`git bundle create --all`), upload, replace state 169 + - auto-compaction trigger when chain length exceeds threshold (e.g. 25 entries) 170 + - [ ] error recovery: 171 + - resume interrupted uploads (track which blobs were uploaded before record write) 172 + - handle PDS downtime gracefully (queue pushes, retry with backoff) 173 + - [ ] progress reporting: 174 + - callback/channel-based progress for UI integration (upload %, bundle creation, etc.) 175 + - [ ] documentation and examples 176 + 177 + --- 178 + 179 + ## Open questions 180 + 181 + Carried forward from pds-git-plan.md — to be resolved as we go: 182 + 183 + - **Lexicon NSID**: `sh.pdsbackup.git.state` is a placeholder. Needs a domain we control for the real NSID. 184 + - **Private repos**: all PDS data is currently public. Only suitable for public repos until atproto ships private data. 185 + - **Force push**: currently planned as "reject". May revisit (full re-upload approach). 186 + - **Multiple branches**: current design tracks all refs in one state record. Should work naturally but needs testing.
+206
src/bundle.rs
··· 1 + //! Git bundle operations. 2 + //! 3 + //! Wraps `git bundle` CLI commands to create and apply bundles. 4 + //! Bundles are the serialization format used to store repository 5 + //! snapshots as PDS blobs. 6 + 7 + use std::path::Path; 8 + use tokio::process::Command; 9 + 10 + /// Result of creating a bundle, including the raw bytes and metadata 11 + /// needed to build a `BundleEntry`. 12 + #[derive(Debug)] 13 + pub struct CreatedBundle { 14 + /// raw bundle file bytes 15 + pub data: Vec<u8>, 16 + /// commit SHAs that the receiver must already have 17 + pub prerequisites: Vec<String>, 18 + /// commit SHAs this bundle provides up to (the tip commits) 19 + pub tips: Vec<String>, 20 + } 21 + 22 + /// Creates a full bundle containing the entire repository history. 23 + /// 24 + /// Equivalent to `git bundle create <file> --all`. The bundle includes 25 + /// all branches and tags, and has no prerequisites. 26 + pub async fn create_full_bundle(repo_path: &Path) -> Result<CreatedBundle, String> { 27 + let tmp = 28 + tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; 29 + let bundle_path = tmp.path(); 30 + 31 + // create a full bundle with all refs 32 + let output = Command::new("git") 33 + .args(["bundle", "create", bundle_path.to_str().unwrap(), "--all"]) 34 + .current_dir(repo_path) 35 + .output() 36 + .await 37 + .map_err(|e| format!("failed to run git bundle create: {}", e))?; 38 + 39 + if !output.status.success() { 40 + let stderr = String::from_utf8_lossy(&output.stderr); 41 + return Err(format!("git bundle create failed: {}", stderr.trim())); 42 + } 43 + 44 + let data = tokio::fs::read(bundle_path) 45 + .await 46 + .map_err(|e| format!("failed to read bundle file: {}", e))?; 47 + 48 + // get the tip commits (all branch heads) 49 + let tips = get_branch_tips(repo_path).await?; 50 + 51 + Ok(CreatedBundle { 52 + data, 53 + prerequisites: vec![], 54 + tips, 55 + }) 56 + } 57 + 58 + /// Creates an incremental bundle containing only commits since the given SHAs. 59 + /// 60 + /// `refs` is the list of refs to include (e.g. `["refs/heads/main"]`). 61 + /// `since_commits` are the prerequisite commits the receiver must already 62 + /// have — the bundle contains everything reachable from `refs` that is 63 + /// not reachable from `since_commits`. 64 + pub async fn create_incremental_bundle( 65 + repo_path: &Path, 66 + refs: &[&str], 67 + since_commits: &[&str], 68 + ) -> Result<CreatedBundle, String> { 69 + let tmp = 70 + tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; 71 + let bundle_path = tmp.path(); 72 + 73 + // build args: git bundle create <file> <refs...> ^<since...> 74 + let mut args = vec![ 75 + "bundle".to_string(), 76 + "create".to_string(), 77 + bundle_path.to_str().unwrap().to_string(), 78 + ]; 79 + 80 + for r in refs { 81 + args.push(r.to_string()); 82 + } 83 + for sha in since_commits { 84 + args.push(format!("^{}", sha)); 85 + } 86 + 87 + let output = Command::new("git") 88 + .args(&args) 89 + .current_dir(repo_path) 90 + .output() 91 + .await 92 + .map_err(|e| format!("failed to run git bundle create: {}", e))?; 93 + 94 + if !output.status.success() { 95 + let stderr = String::from_utf8_lossy(&output.stderr); 96 + return Err(format!( 97 + "git bundle create (incremental) failed: {}", 98 + stderr.trim() 99 + )); 100 + } 101 + 102 + let data = tokio::fs::read(bundle_path) 103 + .await 104 + .map_err(|e| format!("failed to read bundle file: {}", e))?; 105 + 106 + // resolve ref names to their current SHAs for the tips 107 + let tips = resolve_refs_to_shas(repo_path, refs).await?; 108 + 109 + Ok(CreatedBundle { 110 + data, 111 + prerequisites: since_commits.iter().map(|s| s.to_string()).collect(), 112 + tips, 113 + }) 114 + } 115 + 116 + /// Applies a bundle to a repository. 117 + /// 118 + /// Writes the bundle bytes to a temp file and runs `git bundle unbundle`. 119 + /// The caller should set up refs afterward if needed. 120 + pub async fn apply_bundle(repo_path: &Path, bundle_bytes: &[u8]) -> Result<(), String> { 121 + let tmp = 122 + tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; 123 + let bundle_path = tmp.path(); 124 + 125 + tokio::fs::write(bundle_path, bundle_bytes) 126 + .await 127 + .map_err(|e| format!("failed to write bundle to temp file: {}", e))?; 128 + 129 + let output = Command::new("git") 130 + .args(["bundle", "unbundle", bundle_path.to_str().unwrap()]) 131 + .current_dir(repo_path) 132 + .output() 133 + .await 134 + .map_err(|e| format!("failed to run git bundle unbundle: {}", e))?; 135 + 136 + if !output.status.success() { 137 + let stderr = String::from_utf8_lossy(&output.stderr); 138 + return Err(format!("git bundle unbundle failed: {}", stderr.trim())); 139 + } 140 + 141 + Ok(()) 142 + } 143 + 144 + /// Verifies that a bundle's prerequisites are satisfied by the local repo. 145 + /// 146 + /// Returns Ok(true) if the bundle can be applied, Ok(false) if 147 + /// prerequisites are missing. 148 + pub async fn verify_bundle(repo_path: &Path, bundle_bytes: &[u8]) -> Result<bool, String> { 149 + let tmp = 150 + tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; 151 + let bundle_path = tmp.path(); 152 + 153 + tokio::fs::write(bundle_path, bundle_bytes) 154 + .await 155 + .map_err(|e| format!("failed to write bundle to temp file: {}", e))?; 156 + 157 + let output = Command::new("git") 158 + .args(["bundle", "verify", bundle_path.to_str().unwrap()]) 159 + .current_dir(repo_path) 160 + .output() 161 + .await 162 + .map_err(|e| format!("failed to run git bundle verify: {}", e))?; 163 + 164 + Ok(output.status.success()) 165 + } 166 + 167 + /// Returns the current tip commit SHAs for all local branches. 168 + async fn get_branch_tips(repo_path: &Path) -> Result<Vec<String>, String> { 169 + let output = Command::new("git") 170 + .args(["for-each-ref", "--format=%(objectname)", "refs/heads/"]) 171 + .current_dir(repo_path) 172 + .output() 173 + .await 174 + .map_err(|e| format!("failed to run git for-each-ref: {}", e))?; 175 + 176 + if !output.status.success() { 177 + let stderr = String::from_utf8_lossy(&output.stderr); 178 + return Err(format!("git for-each-ref failed: {}", stderr.trim())); 179 + } 180 + 181 + let stdout = String::from_utf8_lossy(&output.stdout); 182 + let tips: Vec<String> = stdout.lines().map(|l| l.trim().to_string()).collect(); 183 + Ok(tips) 184 + } 185 + 186 + /// Resolves ref names to their current commit SHAs. 187 + async fn resolve_refs_to_shas(repo_path: &Path, refs: &[&str]) -> Result<Vec<String>, String> { 188 + let mut shas = Vec::with_capacity(refs.len()); 189 + for r in refs { 190 + let output = Command::new("git") 191 + .args(["rev-parse", r]) 192 + .current_dir(repo_path) 193 + .output() 194 + .await 195 + .map_err(|e| format!("failed to run git rev-parse {}: {}", r, e))?; 196 + 197 + if !output.status.success() { 198 + let stderr = String::from_utf8_lossy(&output.stderr); 199 + return Err(format!("git rev-parse {} failed: {}", r, stderr.trim())); 200 + } 201 + 202 + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); 203 + shas.push(sha); 204 + } 205 + Ok(shas) 206 + }
+91
src/chunk.rs
··· 1 + //! Bundle chunking for large blobs. 2 + //! 3 + //! PDS blob uploads have a size limit (~50MB on Bluesky's PDS). 4 + //! Bundles larger than the chunk size are split into parts, uploaded 5 + //! as separate blobs, and reassembled on download. 6 + 7 + /// Default chunk size: 40MB (headroom under the 50MB PDS limit). 8 + pub const DEFAULT_CHUNK_SIZE: usize = 40 * 1024 * 1024; 9 + 10 + /// Splits data into chunks of at most `chunk_size` bytes. 11 + /// 12 + /// Returns a vec of byte slices. If the data fits in one chunk, 13 + /// returns a single-element vec (no overhead for the common case). 14 + pub fn chunk_bytes(data: &[u8], chunk_size: usize) -> Vec<&[u8]> { 15 + if data.is_empty() { 16 + return vec![data]; 17 + } 18 + data.chunks(chunk_size).collect() 19 + } 20 + 21 + /// Reassembles chunks into a single contiguous byte vector. 22 + /// 23 + /// Chunks must be in order (as returned by `chunk_bytes`). 24 + pub fn reassemble_chunks(parts: &[Vec<u8>]) -> Vec<u8> { 25 + let total: usize = parts.iter().map(|p| p.len()).sum(); 26 + let mut result = Vec::with_capacity(total); 27 + for part in parts { 28 + result.extend_from_slice(part); 29 + } 30 + result 31 + } 32 + 33 + /// Returns true if the data exceeds the given chunk size and needs splitting. 34 + pub fn needs_chunking(data: &[u8], chunk_size: usize) -> bool { 35 + data.len() > chunk_size 36 + } 37 + 38 + #[cfg(test)] 39 + mod tests { 40 + use super::*; 41 + 42 + #[test] 43 + fn small_data_single_chunk() { 44 + let data = b"hello world"; 45 + let chunks = chunk_bytes(data, DEFAULT_CHUNK_SIZE); 46 + assert_eq!(chunks.len(), 1); 47 + assert_eq!(chunks[0], b"hello world"); 48 + } 49 + 50 + #[test] 51 + fn exact_chunk_size() { 52 + let data = vec![0u8; 100]; 53 + let chunks = chunk_bytes(&data, 100); 54 + assert_eq!(chunks.len(), 1); 55 + assert_eq!(chunks[0].len(), 100); 56 + } 57 + 58 + #[test] 59 + fn splits_into_multiple_chunks() { 60 + let data = vec![0u8; 250]; 61 + let chunks = chunk_bytes(&data, 100); 62 + assert_eq!(chunks.len(), 3); 63 + assert_eq!(chunks[0].len(), 100); 64 + assert_eq!(chunks[1].len(), 100); 65 + assert_eq!(chunks[2].len(), 50); 66 + } 67 + 68 + #[test] 69 + fn reassemble_round_trip() { 70 + let original = (0..255u8).cycle().take(1000).collect::<Vec<_>>(); 71 + let chunks = chunk_bytes(&original, 300); 72 + let parts: Vec<Vec<u8>> = chunks.iter().map(|c| c.to_vec()).collect(); 73 + let reassembled = reassemble_chunks(&parts); 74 + assert_eq!(original, reassembled); 75 + } 76 + 77 + #[test] 78 + fn empty_data() { 79 + let data = b""; 80 + let chunks = chunk_bytes(data, DEFAULT_CHUNK_SIZE); 81 + assert_eq!(chunks.len(), 1); 82 + assert_eq!(chunks[0].len(), 0); 83 + } 84 + 85 + #[test] 86 + fn needs_chunking_check() { 87 + assert!(!needs_chunking(b"small", DEFAULT_CHUNK_SIZE)); 88 + let big = vec![0u8; DEFAULT_CHUNK_SIZE + 1]; 89 + assert!(needs_chunking(&big, DEFAULT_CHUNK_SIZE)); 90 + } 91 + }
+201
src/identity.rs
··· 1 + //! AT Protocol identity resolution. 2 + //! 3 + //! Resolves atproto handles to DIDs and DIDs to PDS service endpoints, 4 + //! following the standard AT Protocol identity resolution flow. 5 + 6 + use serde::Deserialize; 7 + 8 + /// Resolved identity: a DID and the PDS endpoint that hosts it. 9 + #[derive(Debug, Clone)] 10 + pub struct ResolvedIdentity { 11 + pub did: String, 12 + pub pds_url: String, 13 + } 14 + 15 + /// Response from `com.atproto.identity.resolveHandle`. 16 + #[derive(Debug, Deserialize)] 17 + struct ResolveHandleResponse { 18 + did: String, 19 + } 20 + 21 + /// A DID document returned by the PLC directory. 22 + #[derive(Debug, Deserialize)] 23 + struct DidDocument { 24 + #[serde(default)] 25 + service: Vec<DidService>, 26 + } 27 + 28 + /// A service entry in a DID document. 29 + #[derive(Debug, Deserialize)] 30 + struct DidService { 31 + id: String, 32 + #[serde(rename = "type")] 33 + service_type: String, 34 + #[serde(rename = "serviceEndpoint")] 35 + service_endpoint: String, 36 + } 37 + 38 + /// Default PLC directory URL. 39 + const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory"; 40 + 41 + /// Resolves an AT Protocol handle to a DID. 42 + /// 43 + /// Uses the `com.atproto.identity.resolveHandle` XRPC endpoint on the 44 + /// given PDS (or a public one). If `pds_url` is None, uses the handle's 45 + /// own domain as the resolution source. 46 + pub async fn resolve_handle(handle: &str, pds_url: Option<&str>) -> Result<String, String> { 47 + let http = reqwest::Client::new(); 48 + 49 + // try resolving via the provided PDS, or fall back to the handle's domain 50 + let base = match pds_url { 51 + Some(url) => url.trim_end_matches('/').to_string(), 52 + None => format!("https://{}", handle), 53 + }; 54 + 55 + let url = format!( 56 + "{}/xrpc/com.atproto.identity.resolveHandle?handle={}", 57 + base, handle 58 + ); 59 + 60 + let resp = http 61 + .get(&url) 62 + .send() 63 + .await 64 + .map_err(|e| format!("resolveHandle request failed: {}", e))?; 65 + 66 + if !resp.status().is_success() { 67 + let status = resp.status(); 68 + let body = resp.text().await.unwrap_or_default(); 69 + return Err(format!("resolveHandle failed ({}): {}", status, body)); 70 + } 71 + 72 + let parsed: ResolveHandleResponse = resp 73 + .json() 74 + .await 75 + .map_err(|e| format!("failed to parse resolveHandle response: {}", e))?; 76 + 77 + Ok(parsed.did) 78 + } 79 + 80 + /// Resolves a DID to its PDS service endpoint. 81 + /// 82 + /// Looks up the DID document from the PLC directory (for `did:plc`) or 83 + /// fetches the DID document directly (for `did:web`), then extracts the 84 + /// `#atproto_pds` service endpoint. 85 + pub async fn resolve_pds_endpoint( 86 + did: &str, 87 + plc_directory: Option<&str>, 88 + ) -> Result<String, String> { 89 + let http = reqwest::Client::new(); 90 + let doc = fetch_did_document(&http, did, plc_directory).await?; 91 + 92 + // find the atproto PDS service endpoint 93 + let pds_service = doc 94 + .service 95 + .iter() 96 + .find(|s| s.id == "#atproto_pds" && s.service_type == "AtprotoPersonalDataServer") 97 + .ok_or_else(|| format!("no #atproto_pds service found in DID document for {}", did))?; 98 + 99 + Ok(pds_service 100 + .service_endpoint 101 + .trim_end_matches('/') 102 + .to_string()) 103 + } 104 + 105 + /// Resolves a handle to both its DID and PDS endpoint in one call. 106 + pub async fn resolve_identity( 107 + handle: &str, 108 + pds_url: Option<&str>, 109 + plc_directory: Option<&str>, 110 + ) -> Result<ResolvedIdentity, String> { 111 + let did = resolve_handle(handle, pds_url).await?; 112 + let pds_url = resolve_pds_endpoint(&did, plc_directory).await?; 113 + Ok(ResolvedIdentity { did, pds_url }) 114 + } 115 + 116 + /// Fetches a DID document for the given DID. 117 + async fn fetch_did_document( 118 + http: &reqwest::Client, 119 + did: &str, 120 + plc_directory: Option<&str>, 121 + ) -> Result<DidDocument, String> { 122 + let url = if did.starts_with("did:plc:") { 123 + let plc = plc_directory.unwrap_or(DEFAULT_PLC_DIRECTORY); 124 + format!("{}/{}", plc.trim_end_matches('/'), did) 125 + } else if did.starts_with("did:web:") { 126 + // did:web:example.com → https://example.com/.well-known/did.json 127 + let domain = did.strip_prefix("did:web:").unwrap(); 128 + format!("https://{}/.well-known/did.json", domain) 129 + } else { 130 + return Err(format!("unsupported DID method: {}", did)); 131 + }; 132 + 133 + let resp = http 134 + .get(&url) 135 + .send() 136 + .await 137 + .map_err(|e| format!("failed to fetch DID document for {}: {}", did, e))?; 138 + 139 + if !resp.status().is_success() { 140 + let status = resp.status(); 141 + let body = resp.text().await.unwrap_or_default(); 142 + return Err(format!( 143 + "failed to fetch DID document for {} ({}): {}", 144 + did, status, body 145 + )); 146 + } 147 + 148 + resp.json() 149 + .await 150 + .map_err(|e| format!("failed to parse DID document for {}: {}", did, e)) 151 + } 152 + 153 + #[cfg(test)] 154 + mod tests { 155 + use super::*; 156 + 157 + #[test] 158 + fn did_plc_url_construction() { 159 + let did = "did:plc:abc123"; 160 + let url = format!("{}/{}", DEFAULT_PLC_DIRECTORY, did); 161 + assert_eq!(url, "https://plc.directory/did:plc:abc123"); 162 + } 163 + 164 + #[test] 165 + fn did_web_url_construction() { 166 + let did = "did:web:example.com"; 167 + let domain = did.strip_prefix("did:web:").unwrap(); 168 + let url = format!("https://{}/.well-known/did.json", domain); 169 + assert_eq!(url, "https://example.com/.well-known/did.json"); 170 + } 171 + 172 + #[test] 173 + fn parse_did_document_with_pds_service() { 174 + let json = serde_json::json!({ 175 + "id": "did:plc:abc123", 176 + "service": [ 177 + { 178 + "id": "#atproto_pds", 179 + "type": "AtprotoPersonalDataServer", 180 + "serviceEndpoint": "https://pds.example.com" 181 + } 182 + ] 183 + }); 184 + 185 + let doc: DidDocument = serde_json::from_value(json).unwrap(); 186 + assert_eq!(doc.service.len(), 1); 187 + assert_eq!(doc.service[0].id, "#atproto_pds"); 188 + assert_eq!(doc.service[0].service_endpoint, "https://pds.example.com"); 189 + } 190 + 191 + #[test] 192 + fn parse_did_document_without_pds_service() { 193 + let json = serde_json::json!({ 194 + "id": "did:plc:abc123", 195 + "service": [] 196 + }); 197 + 198 + let doc: DidDocument = serde_json::from_value(json).unwrap(); 199 + assert!(doc.service.is_empty()); 200 + } 201 + }
+11
src/lib.rs
··· 1 + //! pds-git-remote: PDS-backed git remote via incremental bundles. 2 + //! 3 + //! Uses AT Protocol PDS as a git backup backend. Stores repositories 4 + //! as chains of incremental git bundles uploaded as PDS blobs, tracked 5 + //! by a single mutable state record. 6 + 7 + pub mod bundle; 8 + pub mod chunk; 9 + pub mod identity; 10 + pub mod pds_client; 11 + pub mod types;
+324
src/pds_client.rs
··· 1 + //! HTTP client for the PDS XRPC API. 2 + //! 3 + //! Wraps the AT Protocol XRPC endpoints needed for git backup: 4 + //! record CRUD, blob upload/download, and session creation. 5 + 6 + use crate::types::{BlobRef, CidLink}; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + /// Client for interacting with a PDS server over XRPC. 10 + /// 11 + /// Supports both authenticated (push) and unauthenticated (clone/fetch) 12 + /// operations. Use `PdsClient::new` for unauthenticated access and 13 + /// `PdsClient::with_auth` when a bearer token is available. 14 + #[derive(Debug, Clone)] 15 + pub struct PdsClient { 16 + /// base URL of the PDS, e.g. "https://bsky.social" 17 + base_url: String, 18 + /// bearer token for authenticated requests (access JWT) 19 + auth_token: Option<String>, 20 + http: reqwest::Client, 21 + } 22 + 23 + /// Response from `com.atproto.repo.getRecord`. 24 + #[derive(Debug, Deserialize)] 25 + pub struct GetRecordResponse { 26 + pub uri: String, 27 + pub cid: Option<String>, 28 + pub value: serde_json::Value, 29 + } 30 + 31 + /// Response from `com.atproto.repo.uploadBlob`. 32 + #[derive(Debug, Deserialize)] 33 + pub struct UploadBlobResponse { 34 + pub blob: UploadedBlob, 35 + } 36 + 37 + /// The blob object returned inside an `uploadBlob` response. 38 + #[derive(Debug, Deserialize)] 39 + pub struct UploadedBlob { 40 + #[serde(rename = "$type")] 41 + pub blob_type: String, 42 + #[serde(rename = "ref")] 43 + pub link: CidLink, 44 + #[serde(rename = "mimeType")] 45 + pub mime_type: String, 46 + pub size: u64, 47 + } 48 + 49 + /// Request body for `com.atproto.repo.putRecord`. 50 + #[derive(Debug, Serialize)] 51 + pub struct PutRecordRequest { 52 + pub repo: String, 53 + pub collection: String, 54 + pub rkey: String, 55 + pub record: serde_json::Value, 56 + /// swap with the current CID to prevent race conditions 57 + #[serde(rename = "swapRecord", skip_serializing_if = "Option::is_none")] 58 + pub swap_record: Option<String>, 59 + } 60 + 61 + /// Response from `com.atproto.repo.putRecord`. 62 + #[derive(Debug, Deserialize)] 63 + pub struct PutRecordResponse { 64 + pub uri: String, 65 + pub cid: String, 66 + } 67 + 68 + /// Response from `com.atproto.server.createSession`. 69 + #[derive(Debug, Deserialize)] 70 + pub struct CreateSessionResponse { 71 + pub did: String, 72 + #[serde(rename = "accessJwt")] 73 + pub access_jwt: String, 74 + #[serde(rename = "refreshJwt")] 75 + pub refresh_jwt: String, 76 + pub handle: String, 77 + } 78 + 79 + /// Error response from PDS XRPC endpoints. 80 + #[derive(Debug, Deserialize)] 81 + pub struct XrpcError { 82 + pub error: String, 83 + #[serde(default)] 84 + pub message: String, 85 + } 86 + 87 + impl std::fmt::Display for XrpcError { 88 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 89 + write!(f, "{}: {}", self.error, self.message) 90 + } 91 + } 92 + 93 + impl PdsClient { 94 + /// Creates an unauthenticated client for the given PDS base URL. 95 + pub fn new(base_url: impl Into<String>) -> Self { 96 + Self { 97 + base_url: base_url.into().trim_end_matches('/').to_string(), 98 + auth_token: None, 99 + http: reqwest::Client::new(), 100 + } 101 + } 102 + 103 + /// Creates an authenticated client with a bearer token. 104 + pub fn with_auth(base_url: impl Into<String>, token: impl Into<String>) -> Self { 105 + Self { 106 + base_url: base_url.into().trim_end_matches('/').to_string(), 107 + auth_token: Some(token.into()), 108 + http: reqwest::Client::new(), 109 + } 110 + } 111 + 112 + /// Sets or replaces the auth token. 113 + pub fn set_auth(&mut self, token: impl Into<String>) { 114 + self.auth_token = Some(token.into()); 115 + } 116 + 117 + /// Returns the base URL of the PDS. 118 + pub fn base_url(&self) -> &str { 119 + &self.base_url 120 + } 121 + 122 + /// Logs in with handle/password and stores the resulting access token. 123 + /// 124 + /// Calls `com.atproto.server.createSession`. 125 + pub async fn login( 126 + &mut self, 127 + identifier: &str, 128 + password: &str, 129 + ) -> Result<CreateSessionResponse, String> { 130 + let url = format!("{}/xrpc/com.atproto.server.createSession", self.base_url); 131 + 132 + let body = serde_json::json!({ 133 + "identifier": identifier, 134 + "password": password, 135 + }); 136 + 137 + let resp = self 138 + .http 139 + .post(&url) 140 + .json(&body) 141 + .send() 142 + .await 143 + .map_err(|e| format!("createSession request failed: {}", e))?; 144 + 145 + if !resp.status().is_success() { 146 + let err = parse_xrpc_error(resp).await; 147 + return Err(format!("createSession failed: {}", err)); 148 + } 149 + 150 + let session: CreateSessionResponse = resp 151 + .json() 152 + .await 153 + .map_err(|e| format!("failed to parse createSession response: {}", e))?; 154 + 155 + self.auth_token = Some(session.access_jwt.clone()); 156 + Ok(session) 157 + } 158 + 159 + /// Fetches a record from the PDS. 160 + /// 161 + /// Calls `com.atproto.repo.getRecord`. Returns `Ok(None)` if the 162 + /// record does not exist (RecordNotFound). 163 + pub async fn get_record( 164 + &self, 165 + did: &str, 166 + collection: &str, 167 + rkey: &str, 168 + ) -> Result<Option<GetRecordResponse>, String> { 169 + let url = format!( 170 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 171 + self.base_url, did, collection, rkey 172 + ); 173 + 174 + let resp = self 175 + .http 176 + .get(&url) 177 + .send() 178 + .await 179 + .map_err(|e| format!("getRecord request failed: {}", e))?; 180 + 181 + // 400 with RecordNotFound means the record doesn't exist yet 182 + if resp.status() == reqwest::StatusCode::BAD_REQUEST { 183 + let body = resp.text().await.unwrap_or_default(); 184 + if body.contains("RecordNotFound") { 185 + return Ok(None); 186 + } 187 + return Err(format!("getRecord failed: {}", body)); 188 + } 189 + 190 + if !resp.status().is_success() { 191 + let err = parse_xrpc_error(resp).await; 192 + return Err(format!("getRecord failed: {}", err)); 193 + } 194 + 195 + let record: GetRecordResponse = resp 196 + .json() 197 + .await 198 + .map_err(|e| format!("failed to parse getRecord response: {}", e))?; 199 + 200 + Ok(Some(record)) 201 + } 202 + 203 + /// Creates or updates a record on the PDS. 204 + /// 205 + /// Calls `com.atproto.repo.putRecord`. Requires authentication. 206 + pub async fn put_record( 207 + &self, 208 + did: &str, 209 + collection: &str, 210 + rkey: &str, 211 + record: serde_json::Value, 212 + swap_record: Option<String>, 213 + ) -> Result<PutRecordResponse, String> { 214 + let token = self 215 + .auth_token 216 + .as_ref() 217 + .ok_or("putRecord requires authentication")?; 218 + 219 + let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url); 220 + 221 + let body = PutRecordRequest { 222 + repo: did.to_string(), 223 + collection: collection.to_string(), 224 + rkey: rkey.to_string(), 225 + record, 226 + swap_record, 227 + }; 228 + 229 + let resp = self 230 + .http 231 + .post(&url) 232 + .bearer_auth(token) 233 + .json(&body) 234 + .send() 235 + .await 236 + .map_err(|e| format!("putRecord request failed: {}", e))?; 237 + 238 + if !resp.status().is_success() { 239 + let err = parse_xrpc_error(resp).await; 240 + return Err(format!("putRecord failed: {}", err)); 241 + } 242 + 243 + resp.json() 244 + .await 245 + .map_err(|e| format!("failed to parse putRecord response: {}", e)) 246 + } 247 + 248 + /// Uploads a blob to the PDS. 249 + /// 250 + /// Calls `com.atproto.repo.uploadBlob`. Requires authentication. 251 + /// Returns a `BlobRef` that can be embedded in a record. 252 + pub async fn upload_blob(&self, data: Vec<u8>) -> Result<BlobRef, String> { 253 + let token = self 254 + .auth_token 255 + .as_ref() 256 + .ok_or("uploadBlob requires authentication")?; 257 + 258 + let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url); 259 + 260 + let resp = self 261 + .http 262 + .post(&url) 263 + .bearer_auth(token) 264 + .header("Content-Type", "application/octet-stream") 265 + .body(data) 266 + .send() 267 + .await 268 + .map_err(|e| format!("uploadBlob request failed: {}", e))?; 269 + 270 + if !resp.status().is_success() { 271 + let err = parse_xrpc_error(resp).await; 272 + return Err(format!("uploadBlob failed: {}", err)); 273 + } 274 + 275 + let upload: UploadBlobResponse = resp 276 + .json() 277 + .await 278 + .map_err(|e| format!("failed to parse uploadBlob response: {}", e))?; 279 + 280 + Ok(BlobRef { 281 + blob_type: upload.blob.blob_type, 282 + link: upload.blob.link, 283 + mime_type: upload.blob.mime_type, 284 + size: upload.blob.size, 285 + }) 286 + } 287 + 288 + /// Downloads a blob from the PDS. 289 + /// 290 + /// Calls `com.atproto.sync.getBlob`. This is unauthenticated — 291 + /// anyone can download blobs if they know the DID and CID. 292 + pub async fn get_blob(&self, did: &str, cid: &str) -> Result<Vec<u8>, String> { 293 + let url = format!( 294 + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", 295 + self.base_url, did, cid 296 + ); 297 + 298 + let resp = self 299 + .http 300 + .get(&url) 301 + .send() 302 + .await 303 + .map_err(|e| format!("getBlob request failed: {}", e))?; 304 + 305 + if !resp.status().is_success() { 306 + let err = parse_xrpc_error(resp).await; 307 + return Err(format!("getBlob failed: {}", err)); 308 + } 309 + 310 + resp.bytes() 311 + .await 312 + .map(|b| b.to_vec()) 313 + .map_err(|e| format!("failed to read getBlob response body: {}", e)) 314 + } 315 + } 316 + 317 + /// Extracts an error message from a non-success XRPC response. 318 + async fn parse_xrpc_error(resp: reqwest::Response) -> String { 319 + let status = resp.status(); 320 + match resp.json::<XrpcError>().await { 321 + Ok(err) => format!("{} ({})", err, status), 322 + Err(_) => format!("HTTP {}", status), 323 + } 324 + }
+172
src/types.rs
··· 1 + //! Core types mirroring the `sh.pdsbackup.git.state` lexicon. 2 + //! 3 + //! These types represent the PDS record and its nested structures for 4 + //! tracking git repository backup state. 5 + 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// A git ref (branch or tag) with its current commit SHA. 9 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 10 + pub struct GitRef { 11 + /// full ref name, e.g. "refs/heads/main" 12 + pub name: String, 13 + /// commit SHA the ref points to 14 + pub sha: String, 15 + } 16 + 17 + /// A reference to a blob stored on the PDS. 18 + /// 19 + /// When reading from the PDS, the `ref` field contains a `$link` (CID). 20 + /// When writing, we include the blob reference returned by `uploadBlob`. 21 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 22 + pub struct BlobRef { 23 + #[serde(rename = "$type")] 24 + pub blob_type: String, 25 + #[serde(rename = "ref")] 26 + pub link: CidLink, 27 + #[serde(rename = "mimeType")] 28 + pub mime_type: String, 29 + pub size: u64, 30 + } 31 + 32 + /// A CID link as used in AT Protocol blob references. 33 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 34 + pub struct CidLink { 35 + #[serde(rename = "$link")] 36 + pub link: String, 37 + } 38 + 39 + /// A single entry in the bundle chain. 40 + /// 41 + /// Each push appends one of these. The chain is ordered oldest-first. 42 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 43 + pub struct BundleEntry { 44 + /// bundle blob(s) — usually one, multiple if chunked (>40MB) 45 + pub parts: Vec<BlobRef>, 46 + /// commit SHAs the receiver must have before applying this bundle 47 + pub prerequisites: Vec<String>, 48 + /// commit SHAs this bundle provides up to 49 + pub tips: Vec<String>, 50 + /// total size in bytes across all parts 51 + #[serde(rename = "totalSize", skip_serializing_if = "Option::is_none")] 52 + pub total_size: Option<u64>, 53 + /// when this bundle was created 54 + #[serde(rename = "createdAt")] 55 + pub created_at: String, 56 + } 57 + 58 + /// Top-level state record for a git repository backup on PDS. 59 + /// 60 + /// Stored at `sh.pdsbackup.git.state/<repo-name>`. 61 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 62 + pub struct RepoState { 63 + /// human-readable repo name, e.g. "my-site" 64 + #[serde(skip_serializing_if = "Option::is_none")] 65 + pub name: Option<String>, 66 + /// current branch and tag refs 67 + pub refs: Vec<GitRef>, 68 + /// ordered bundle chain, oldest first 69 + pub bundles: Vec<BundleEntry>, 70 + /// when the record was last updated 71 + #[serde(rename = "updatedAt")] 72 + pub updated_at: String, 73 + } 74 + 75 + /// The lexicon collection NSID for git backup state records. 76 + pub const COLLECTION: &str = "sh.pdsbackup.git.state"; 77 + 78 + impl BlobRef { 79 + /// Creates a new blob reference from an upload response. 80 + pub fn new(cid: String, mime_type: String, size: u64) -> Self { 81 + Self { 82 + blob_type: "blob".to_string(), 83 + link: CidLink { link: cid }, 84 + mime_type, 85 + size, 86 + } 87 + } 88 + 89 + /// Returns the CID string for this blob. 90 + pub fn cid(&self) -> &str { 91 + &self.link.link 92 + } 93 + } 94 + 95 + impl GitRef { 96 + /// Creates a new git ref. 97 + pub fn new(name: impl Into<String>, sha: impl Into<String>) -> Self { 98 + Self { 99 + name: name.into(), 100 + sha: sha.into(), 101 + } 102 + } 103 + } 104 + 105 + #[cfg(test)] 106 + mod tests { 107 + use super::*; 108 + 109 + #[test] 110 + fn repo_state_round_trip() { 111 + let state = RepoState { 112 + name: Some("my-site".to_string()), 113 + refs: vec![ 114 + GitRef::new("refs/heads/main", "abc123"), 115 + GitRef::new("refs/heads/draft", "def456"), 116 + ], 117 + bundles: vec![BundleEntry { 118 + parts: vec![BlobRef::new( 119 + "bafkreiabc".to_string(), 120 + "application/octet-stream".to_string(), 121 + 1024, 122 + )], 123 + prerequisites: vec![], 124 + tips: vec!["abc123".to_string()], 125 + total_size: Some(1024), 126 + created_at: "2026-02-11T00:00:00Z".to_string(), 127 + }], 128 + updated_at: "2026-02-11T00:00:00Z".to_string(), 129 + }; 130 + 131 + let json = serde_json::to_string(&state).unwrap(); 132 + let parsed: RepoState = serde_json::from_str(&json).unwrap(); 133 + assert_eq!(state, parsed); 134 + } 135 + 136 + #[test] 137 + fn blob_ref_serializes_with_dollar_fields() { 138 + let blob = BlobRef::new( 139 + "bafkreixyz".to_string(), 140 + "application/octet-stream".to_string(), 141 + 2048, 142 + ); 143 + 144 + let json = serde_json::to_value(&blob).unwrap(); 145 + assert_eq!(json["$type"], "blob"); 146 + assert_eq!(json["ref"]["$link"], "bafkreixyz"); 147 + assert_eq!(json["mimeType"], "application/octet-stream"); 148 + assert_eq!(json["size"], 2048); 149 + } 150 + 151 + #[test] 152 + fn git_ref_round_trip() { 153 + let r = GitRef::new("refs/heads/main", "deadbeef"); 154 + let json = serde_json::to_string(&r).unwrap(); 155 + let parsed: GitRef = serde_json::from_str(&json).unwrap(); 156 + assert_eq!(r, parsed); 157 + } 158 + 159 + #[test] 160 + fn bundle_entry_omits_none_total_size() { 161 + let entry = BundleEntry { 162 + parts: vec![], 163 + prerequisites: vec![], 164 + tips: vec![], 165 + total_size: None, 166 + created_at: "2026-02-11T00:00:00Z".to_string(), 167 + }; 168 + 169 + let json = serde_json::to_value(&entry).unwrap(); 170 + assert!(!json.as_object().unwrap().contains_key("totalSize")); 171 + } 172 + }
+293
tests/bundle_tests.rs
··· 1 + //! Integration tests for bundle creation, application, and chunking. 2 + //! 3 + //! Each test creates temporary git repos, makes commits, creates bundles, 4 + //! and verifies that applying them reproduces the correct content. 5 + 6 + use std::path::Path; 7 + use tokio::fs; 8 + 9 + use pds_git_remote::bundle::{ 10 + apply_bundle, create_full_bundle, create_incremental_bundle, verify_bundle, 11 + }; 12 + use pds_git_remote::chunk::{chunk_bytes, reassemble_chunks}; 13 + 14 + /// Helper: write a file inside a directory. 15 + async fn write_file(dir: &Path, name: &str, content: &str) { 16 + let path = dir.join(name); 17 + if let Some(parent) = path.parent() { 18 + fs::create_dir_all(parent).await.unwrap(); 19 + } 20 + fs::write(&path, content).await.unwrap(); 21 + } 22 + 23 + /// Helper: read a file, returning None if missing. 24 + async fn read_file(dir: &Path, name: &str) -> Option<String> { 25 + fs::read_to_string(dir.join(name)).await.ok() 26 + } 27 + 28 + /// Helper: configure git author so commits work in CI. 29 + async fn configure_git(dir: &Path) { 30 + tokio::process::Command::new("git") 31 + .args(["config", "user.email", "test@test.com"]) 32 + .current_dir(dir) 33 + .output() 34 + .await 35 + .unwrap(); 36 + tokio::process::Command::new("git") 37 + .args(["config", "user.name", "Test"]) 38 + .current_dir(dir) 39 + .output() 40 + .await 41 + .unwrap(); 42 + } 43 + 44 + /// Helper: init a git repo in a temp dir. 45 + async fn init_repo() -> tempfile::TempDir { 46 + let tmp = tempfile::tempdir().unwrap(); 47 + tokio::process::Command::new("git") 48 + .args(["init"]) 49 + .current_dir(tmp.path()) 50 + .output() 51 + .await 52 + .unwrap(); 53 + configure_git(tmp.path()).await; 54 + tmp 55 + } 56 + 57 + /// Helper: stage all and commit. 58 + async fn commit(dir: &Path, message: &str) { 59 + tokio::process::Command::new("git") 60 + .args(["add", "-A"]) 61 + .current_dir(dir) 62 + .output() 63 + .await 64 + .unwrap(); 65 + let output = tokio::process::Command::new("git") 66 + .args(["commit", "-m", message]) 67 + .current_dir(dir) 68 + .output() 69 + .await 70 + .unwrap(); 71 + assert!( 72 + output.status.success(), 73 + "commit failed: {}", 74 + String::from_utf8_lossy(&output.stderr) 75 + ); 76 + } 77 + 78 + /// Helper: get the HEAD commit SHA. 79 + async fn head_sha(dir: &Path) -> String { 80 + let output = tokio::process::Command::new("git") 81 + .args(["rev-parse", "HEAD"]) 82 + .current_dir(dir) 83 + .output() 84 + .await 85 + .unwrap(); 86 + String::from_utf8_lossy(&output.stdout).trim().to_string() 87 + } 88 + 89 + /// Helper: get the current branch name (e.g. "master" or "main"). 90 + async fn current_branch(dir: &Path) -> String { 91 + let output = tokio::process::Command::new("git") 92 + .args(["rev-parse", "--abbrev-ref", "HEAD"]) 93 + .current_dir(dir) 94 + .output() 95 + .await 96 + .unwrap(); 97 + String::from_utf8_lossy(&output.stdout).trim().to_string() 98 + } 99 + 100 + /// Helper: init a bare repo for receiving bundles. 101 + async fn init_bare_repo() -> tempfile::TempDir { 102 + let tmp = tempfile::tempdir().unwrap(); 103 + tokio::process::Command::new("git") 104 + .args(["init", "--bare"]) 105 + .current_dir(tmp.path()) 106 + .output() 107 + .await 108 + .unwrap(); 109 + tmp 110 + } 111 + 112 + /// Helper: clone a bare repo into a working directory so we can inspect files. 113 + async fn clone_bare_to_working(bare_path: &Path) -> tempfile::TempDir { 114 + let tmp = tempfile::tempdir().unwrap(); 115 + let output = tokio::process::Command::new("git") 116 + .args([ 117 + "clone", 118 + bare_path.to_str().unwrap(), 119 + tmp.path().to_str().unwrap(), 120 + ]) 121 + .output() 122 + .await 123 + .unwrap(); 124 + assert!( 125 + output.status.success(), 126 + "clone failed: {}", 127 + String::from_utf8_lossy(&output.stderr) 128 + ); 129 + tmp 130 + } 131 + 132 + #[tokio::test] 133 + async fn full_bundle_round_trip() { 134 + let src = init_repo().await; 135 + 136 + // make a couple of commits 137 + write_file(src.path(), "readme.md", "# Hello").await; 138 + commit(src.path(), "initial commit").await; 139 + 140 + write_file(src.path(), "page.md", "content here").await; 141 + commit(src.path(), "add page").await; 142 + 143 + let branch = current_branch(src.path()).await; 144 + 145 + // create full bundle 146 + let bundle = create_full_bundle(src.path()).await.unwrap(); 147 + assert!(!bundle.data.is_empty()); 148 + assert!(bundle.prerequisites.is_empty()); 149 + assert!(!bundle.tips.is_empty()); 150 + 151 + // apply to a fresh bare repo 152 + let dst = init_bare_repo().await; 153 + apply_bundle(dst.path(), &bundle.data).await.unwrap(); 154 + 155 + // update the branch ref so we can clone it 156 + let tip = &bundle.tips[0]; 157 + let refname = format!("refs/heads/{}", branch); 158 + tokio::process::Command::new("git") 159 + .args(["update-ref", &refname, tip]) 160 + .current_dir(dst.path()) 161 + .output() 162 + .await 163 + .unwrap(); 164 + 165 + // clone the bare repo and verify content 166 + let working = clone_bare_to_working(dst.path()).await; 167 + assert_eq!( 168 + read_file(working.path(), "readme.md").await.unwrap(), 169 + "# Hello" 170 + ); 171 + assert_eq!( 172 + read_file(working.path(), "page.md").await.unwrap(), 173 + "content here" 174 + ); 175 + } 176 + 177 + #[tokio::test] 178 + async fn incremental_bundle_round_trip() { 179 + let src = init_repo().await; 180 + 181 + // first commit 182 + write_file(src.path(), "file1.txt", "version 1").await; 183 + commit(src.path(), "first").await; 184 + let sha_after_first = head_sha(src.path()).await; 185 + let branch = current_branch(src.path()).await; 186 + let refspec = format!("refs/heads/{}", branch); 187 + 188 + // create a full bundle of the initial state 189 + let full_bundle = create_full_bundle(src.path()).await.unwrap(); 190 + 191 + // second commit 192 + write_file(src.path(), "file2.txt", "new file").await; 193 + commit(src.path(), "second").await; 194 + 195 + // third commit 196 + write_file(src.path(), "file1.txt", "version 2").await; 197 + commit(src.path(), "third").await; 198 + 199 + // create incremental bundle since the first commit 200 + let incr_bundle = create_incremental_bundle(src.path(), &[&refspec], &[&sha_after_first]) 201 + .await 202 + .unwrap(); 203 + assert!(!incr_bundle.data.is_empty()); 204 + assert_eq!(incr_bundle.prerequisites, vec![sha_after_first.clone()]); 205 + assert!(!incr_bundle.tips.is_empty()); 206 + 207 + // apply both bundles to a fresh bare repo 208 + let dst = init_bare_repo().await; 209 + apply_bundle(dst.path(), &full_bundle.data).await.unwrap(); 210 + apply_bundle(dst.path(), &incr_bundle.data).await.unwrap(); 211 + 212 + // set the branch ref to the incremental tip 213 + let tip = &incr_bundle.tips[0]; 214 + tokio::process::Command::new("git") 215 + .args(["update-ref", &refspec, tip]) 216 + .current_dir(dst.path()) 217 + .output() 218 + .await 219 + .unwrap(); 220 + 221 + // clone and verify 222 + let working = clone_bare_to_working(dst.path()).await; 223 + assert_eq!( 224 + read_file(working.path(), "file1.txt").await.unwrap(), 225 + "version 2" 226 + ); 227 + assert_eq!( 228 + read_file(working.path(), "file2.txt").await.unwrap(), 229 + "new file" 230 + ); 231 + } 232 + 233 + #[tokio::test] 234 + async fn verify_bundle_checks_prerequisites() { 235 + let src = init_repo().await; 236 + 237 + // two commits 238 + write_file(src.path(), "a.txt", "aaa").await; 239 + commit(src.path(), "first").await; 240 + let sha1 = head_sha(src.path()).await; 241 + let branch = current_branch(src.path()).await; 242 + let refspec = format!("refs/heads/{}", branch); 243 + 244 + write_file(src.path(), "b.txt", "bbb").await; 245 + commit(src.path(), "second").await; 246 + 247 + // incremental bundle requiring sha1 248 + let incr = create_incremental_bundle(src.path(), &[&refspec], &[&sha1]) 249 + .await 250 + .unwrap(); 251 + 252 + // empty bare repo should fail verification (missing prerequisite) 253 + let empty = init_bare_repo().await; 254 + let ok = verify_bundle(empty.path(), &incr.data).await.unwrap(); 255 + assert!(!ok, "should fail: prerequisite not present"); 256 + 257 + // after applying the full bundle, verification should pass 258 + let full = create_full_bundle(src.path()).await.unwrap(); 259 + let dst = init_bare_repo().await; 260 + apply_bundle(dst.path(), &full.data).await.unwrap(); 261 + let ok = verify_bundle(dst.path(), &incr.data).await.unwrap(); 262 + assert!(ok, "should pass: prerequisite is present"); 263 + } 264 + 265 + #[tokio::test] 266 + async fn full_bundle_on_empty_repo_fails() { 267 + let empty = init_repo().await; 268 + // no commits — bundle create should fail 269 + let result = create_full_bundle(empty.path()).await; 270 + assert!(result.is_err()); 271 + } 272 + 273 + #[tokio::test] 274 + async fn chunk_and_reassemble_bundle_data() { 275 + let src = init_repo().await; 276 + write_file(src.path(), "f.txt", "data").await; 277 + commit(src.path(), "commit").await; 278 + 279 + let bundle = create_full_bundle(src.path()).await.unwrap(); 280 + 281 + // chunk at a very small size to force splitting 282 + let chunks = chunk_bytes(&bundle.data, 64); 283 + assert!(chunks.len() > 1, "should split into multiple chunks"); 284 + 285 + // reassemble 286 + let parts: Vec<Vec<u8>> = chunks.iter().map(|c| c.to_vec()).collect(); 287 + let reassembled = reassemble_chunks(&parts); 288 + assert_eq!(bundle.data, reassembled); 289 + 290 + // the reassembled data should still be a valid bundle 291 + let dst = init_bare_repo().await; 292 + apply_bundle(dst.path(), &reassembled).await.unwrap(); 293 + }