max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+1558 insertions,
-0 deletions
| @@ -1,0 +1,492 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "android_system_properties" | |
| 7 | + | version = "0.1.6" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" | |
| 10 | + | dependencies = [ | |
| 11 | + | "libc", | |
| 12 | + | ] | |
| 13 | + | ||
| 14 | + | [[package]] | |
| 15 | + | name = "autocfg" | |
| 16 | + | version = "1.5.1" | |
| 17 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 19 | + | ||
| 20 | + | [[package]] | |
| 21 | + | name = "block-buffer" | |
| 22 | + | version = "0.10.4" | |
| 23 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 25 | + | dependencies = [ | |
| 26 | + | "generic-array", | |
| 27 | + | ] | |
| 28 | + | ||
| 29 | + | [[package]] | |
| 30 | + | name = "bumpalo" | |
| 31 | + | version = "3.20.3" | |
| 32 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 33 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 34 | + | ||
| 35 | + | [[package]] | |
| 36 | + | name = "cc" | |
| 37 | + | version = "1.4.0" | |
| 38 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | + | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 40 | + | dependencies = [ | |
| 41 | + | "find-msvc-tools", | |
| 42 | + | "shlex", | |
| 43 | + | ] | |
| 44 | + | ||
| 45 | + | [[package]] | |
| 46 | + | name = "cfg-if" | |
| 47 | + | version = "1.0.4" | |
| 48 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 49 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 50 | + | ||
| 51 | + | [[package]] | |
| 52 | + | name = "chrono" | |
| 53 | + | version = "0.4.45" | |
| 54 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 55 | + | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 56 | + | dependencies = [ | |
| 57 | + | "iana-time-zone", | |
| 58 | + | "js-sys", | |
| 59 | + | "num-traits", | |
| 60 | + | "serde", | |
| 61 | + | "wasm-bindgen", | |
| 62 | + | "windows-link", | |
| 63 | + | ] | |
| 64 | + | ||
| 65 | + | [[package]] | |
| 66 | + | name = "core-foundation-sys" | |
| 67 | + | version = "0.8.7" | |
| 68 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 69 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 70 | + | ||
| 71 | + | [[package]] | |
| 72 | + | name = "cpufeatures" | |
| 73 | + | version = "0.2.17" | |
| 74 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 75 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 76 | + | dependencies = [ | |
| 77 | + | "libc", | |
| 78 | + | ] | |
| 79 | + | ||
| 80 | + | [[package]] | |
| 81 | + | name = "crypto-common" | |
| 82 | + | version = "0.1.7" | |
| 83 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 84 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 85 | + | dependencies = [ | |
| 86 | + | "generic-array", | |
| 87 | + | "typenum", | |
| 88 | + | ] | |
| 89 | + | ||
| 90 | + | [[package]] | |
| 91 | + | name = "digest" | |
| 92 | + | version = "0.10.7" | |
| 93 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 94 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 95 | + | dependencies = [ | |
| 96 | + | "block-buffer", | |
| 97 | + | "crypto-common", | |
| 98 | + | ] | |
| 99 | + | ||
| 100 | + | [[package]] | |
| 101 | + | name = "find-msvc-tools" | |
| 102 | + | version = "0.1.9" | |
| 103 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 104 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 105 | + | ||
| 106 | + | [[package]] | |
| 107 | + | name = "futures-core" | |
| 108 | + | version = "0.3.33" | |
| 109 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 110 | + | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 111 | + | ||
| 112 | + | [[package]] | |
| 113 | + | name = "futures-task" | |
| 114 | + | version = "0.3.33" | |
| 115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 116 | + | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 117 | + | ||
| 118 | + | [[package]] | |
| 119 | + | name = "futures-util" | |
| 120 | + | version = "0.3.33" | |
| 121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 122 | + | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 123 | + | dependencies = [ | |
| 124 | + | "futures-core", | |
| 125 | + | "futures-task", | |
| 126 | + | "pin-project-lite", | |
| 127 | + | "slab", | |
| 128 | + | ] | |
| 129 | + | ||
| 130 | + | [[package]] | |
| 131 | + | name = "generic-array" | |
| 132 | + | version = "0.14.7" | |
| 133 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 134 | + | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | |
| 135 | + | dependencies = [ | |
| 136 | + | "typenum", | |
| 137 | + | "version_check", | |
| 138 | + | ] | |
| 139 | + | ||
| 140 | + | [[package]] | |
| 141 | + | name = "iana-time-zone" | |
| 142 | + | version = "0.1.65" | |
| 143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | + | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | |
| 145 | + | dependencies = [ | |
| 146 | + | "android_system_properties", | |
| 147 | + | "core-foundation-sys", | |
| 148 | + | "iana-time-zone-haiku", | |
| 149 | + | "js-sys", | |
| 150 | + | "log", | |
| 151 | + | "wasm-bindgen", | |
| 152 | + | "windows-core", | |
| 153 | + | ] | |
| 154 | + | ||
| 155 | + | [[package]] | |
| 156 | + | name = "iana-time-zone-haiku" | |
| 157 | + | version = "0.1.2" | |
| 158 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 159 | + | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | |
| 160 | + | dependencies = [ | |
| 161 | + | "cc", | |
| 162 | + | ] | |
| 163 | + | ||
| 164 | + | [[package]] | |
| 165 | + | name = "itoa" | |
| 166 | + | version = "1.0.18" | |
| 167 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 168 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 169 | + | ||
| 170 | + | [[package]] | |
| 171 | + | name = "js-sys" | |
| 172 | + | version = "0.3.103" | |
| 173 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 174 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" | |
| 175 | + | dependencies = [ | |
| 176 | + | "cfg-if", | |
| 177 | + | "futures-util", | |
| 178 | + | "wasm-bindgen", | |
| 179 | + | ] | |
| 180 | + | ||
| 181 | + | [[package]] | |
| 182 | + | name = "libc" | |
| 183 | + | version = "0.2.189" | |
| 184 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 185 | + | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 186 | + | ||
| 187 | + | [[package]] | |
| 188 | + | name = "log" | |
| 189 | + | version = "0.4.33" | |
| 190 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 191 | + | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 192 | + | ||
| 193 | + | [[package]] | |
| 194 | + | name = "memchr" | |
| 195 | + | version = "2.8.3" | |
| 196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 197 | + | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 198 | + | ||
| 199 | + | [[package]] | |
| 200 | + | name = "num-traits" | |
| 201 | + | version = "0.2.19" | |
| 202 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 203 | + | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 204 | + | dependencies = [ | |
| 205 | + | "autocfg", | |
| 206 | + | ] | |
| 207 | + | ||
| 208 | + | [[package]] | |
| 209 | + | name = "once_cell" | |
| 210 | + | version = "1.21.4" | |
| 211 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 212 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 213 | + | ||
| 214 | + | [[package]] | |
| 215 | + | name = "ops-artifact" | |
| 216 | + | version = "0.1.0" | |
| 217 | + | dependencies = [ | |
| 218 | + | "chrono", | |
| 219 | + | "serde", | |
| 220 | + | "serde_json", | |
| 221 | + | "sha2", | |
| 222 | + | ] | |
| 223 | + | ||
| 224 | + | [[package]] | |
| 225 | + | name = "pin-project-lite" | |
| 226 | + | version = "0.2.17" | |
| 227 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 228 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 229 | + | ||
| 230 | + | [[package]] | |
| 231 | + | name = "proc-macro2" | |
| 232 | + | version = "1.0.107" | |
| 233 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 234 | + | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 235 | + | dependencies = [ | |
| 236 | + | "unicode-ident", | |
| 237 | + | ] | |
| 238 | + | ||
| 239 | + | [[package]] | |
| 240 | + | name = "quote" | |
| 241 | + | version = "1.0.47" | |
| 242 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 243 | + | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 244 | + | dependencies = [ | |
| 245 | + | "proc-macro2", | |
| 246 | + | ] | |
| 247 | + | ||
| 248 | + | [[package]] | |
| 249 | + | name = "rustversion" | |
| 250 | + | version = "1.0.23" | |
| 251 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 252 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 253 | + | ||
| 254 | + | [[package]] | |
| 255 | + | name = "serde" | |
| 256 | + | version = "1.0.229" | |
| 257 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 258 | + | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 259 | + | dependencies = [ | |
| 260 | + | "serde_core", | |
| 261 | + | "serde_derive", | |
| 262 | + | ] | |
| 263 | + | ||
| 264 | + | [[package]] | |
| 265 | + | name = "serde_core" | |
| 266 | + | version = "1.0.229" | |
| 267 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 268 | + | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 269 | + | dependencies = [ | |
| 270 | + | "serde_derive", | |
| 271 | + | ] | |
| 272 | + | ||
| 273 | + | [[package]] | |
| 274 | + | name = "serde_derive" | |
| 275 | + | version = "1.0.229" | |
| 276 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 277 | + | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 278 | + | dependencies = [ | |
| 279 | + | "proc-macro2", | |
| 280 | + | "quote", | |
| 281 | + | "syn 3.0.3", | |
| 282 | + | ] | |
| 283 | + | ||
| 284 | + | [[package]] | |
| 285 | + | name = "serde_json" | |
| 286 | + | version = "1.0.151" | |
| 287 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 288 | + | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | |
| 289 | + | dependencies = [ | |
| 290 | + | "itoa", | |
| 291 | + | "memchr", | |
| 292 | + | "serde", | |
| 293 | + | "serde_core", | |
| 294 | + | "zmij", | |
| 295 | + | ] | |
| 296 | + | ||
| 297 | + | [[package]] | |
| 298 | + | name = "sha2" | |
| 299 | + | version = "0.10.9" | |
| 300 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 301 | + | checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" | |
| 302 | + | dependencies = [ | |
| 303 | + | "cfg-if", | |
| 304 | + | "cpufeatures", | |
| 305 | + | "digest", | |
| 306 | + | ] | |
| 307 | + | ||
| 308 | + | [[package]] | |
| 309 | + | name = "shlex" | |
| 310 | + | version = "2.0.1" | |
| 311 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 312 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 313 | + | ||
| 314 | + | [[package]] | |
| 315 | + | name = "slab" | |
| 316 | + | version = "0.4.12" | |
| 317 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 318 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 319 | + | ||
| 320 | + | [[package]] | |
| 321 | + | name = "syn" | |
| 322 | + | version = "2.0.119" | |
| 323 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 324 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 325 | + | dependencies = [ | |
| 326 | + | "proc-macro2", | |
| 327 | + | "quote", | |
| 328 | + | "unicode-ident", | |
| 329 | + | ] | |
| 330 | + | ||
| 331 | + | [[package]] | |
| 332 | + | name = "syn" | |
| 333 | + | version = "3.0.3" | |
| 334 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 335 | + | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" | |
| 336 | + | dependencies = [ | |
| 337 | + | "proc-macro2", | |
| 338 | + | "quote", | |
| 339 | + | "unicode-ident", | |
| 340 | + | ] | |
| 341 | + | ||
| 342 | + | [[package]] | |
| 343 | + | name = "typenum" | |
| 344 | + | version = "1.20.1" | |
| 345 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 346 | + | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 347 | + | ||
| 348 | + | [[package]] | |
| 349 | + | name = "unicode-ident" | |
| 350 | + | version = "1.0.24" | |
| 351 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 352 | + | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 353 | + | ||
| 354 | + | [[package]] | |
| 355 | + | name = "version_check" | |
| 356 | + | version = "0.9.5" | |
| 357 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 358 | + | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 359 | + | ||
| 360 | + | [[package]] | |
| 361 | + | name = "wasm-bindgen" | |
| 362 | + | version = "0.2.126" | |
| 363 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 364 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" | |
| 365 | + | dependencies = [ | |
| 366 | + | "cfg-if", | |
| 367 | + | "once_cell", | |
| 368 | + | "rustversion", | |
| 369 | + | "wasm-bindgen-macro", | |
| 370 | + | "wasm-bindgen-shared", | |
| 371 | + | ] | |
| 372 | + | ||
| 373 | + | [[package]] | |
| 374 | + | name = "wasm-bindgen-macro" | |
| 375 | + | version = "0.2.126" | |
| 376 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 377 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" | |
| 378 | + | dependencies = [ | |
| 379 | + | "quote", | |
| 380 | + | "wasm-bindgen-macro-support", | |
| 381 | + | ] | |
| 382 | + | ||
| 383 | + | [[package]] | |
| 384 | + | name = "wasm-bindgen-macro-support" | |
| 385 | + | version = "0.2.126" | |
| 386 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 387 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" | |
| 388 | + | dependencies = [ | |
| 389 | + | "bumpalo", | |
| 390 | + | "proc-macro2", | |
| 391 | + | "quote", | |
| 392 | + | "syn 2.0.119", | |
| 393 | + | "wasm-bindgen-shared", | |
| 394 | + | ] | |
| 395 | + | ||
| 396 | + | [[package]] | |
| 397 | + | name = "wasm-bindgen-shared" | |
| 398 | + | version = "0.2.126" | |
| 399 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 400 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" | |
| 401 | + | dependencies = [ | |
| 402 | + | "unicode-ident", | |
| 403 | + | ] | |
| 404 | + | ||
| 405 | + | [[package]] | |
| 406 | + | name = "windows-core" | |
| 407 | + | version = "0.62.2" | |
| 408 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 409 | + | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 410 | + | dependencies = [ | |
| 411 | + | "windows-implement", | |
| 412 | + | "windows-interface", | |
| 413 | + | "windows-link", | |
| 414 | + | "windows-result", | |
| 415 | + | "windows-strings", | |
| 416 | + | ] | |
| 417 | + | ||
| 418 | + | [[package]] | |
| 419 | + | name = "windows-implement" | |
| 420 | + | version = "0.60.2" | |
| 421 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 422 | + | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 423 | + | dependencies = [ | |
| 424 | + | "proc-macro2", | |
| 425 | + | "quote", | |
| 426 | + | "syn 2.0.119", | |
| 427 | + | ] | |
| 428 | + | ||
| 429 | + | [[package]] | |
| 430 | + | name = "windows-interface" | |
| 431 | + | version = "0.59.3" | |
| 432 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 433 | + | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 434 | + | dependencies = [ | |
| 435 | + | "proc-macro2", | |
| 436 | + | "quote", | |
| 437 | + | "syn 2.0.119", | |
| 438 | + | ] | |
| 439 | + | ||
| 440 | + | [[package]] | |
| 441 | + | name = "windows-link" | |
| 442 | + | version = "0.2.1" | |
| 443 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 444 | + | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 445 | + | ||
| 446 | + | [[package]] | |
| 447 | + | name = "windows-result" | |
| 448 | + | version = "0.4.1" | |
| 449 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 450 | + | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 451 | + | dependencies = [ | |
| 452 | + | "windows-link", | |
| 453 | + | ] | |
| 454 | + | ||
| 455 | + | [[package]] | |
| 456 | + | name = "windows-strings" | |
| 457 | + | version = "0.5.1" | |
| 458 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 459 | + | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 460 | + | dependencies = [ | |
| 461 | + | "windows-link", | |
| 462 | + | ] | |
| 463 | + | ||
| 464 | + | [[package]] | |
| 465 | + | name = "zmij" | |
| 466 | + | version = "1.0.23" | |
| 467 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 468 | + | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 469 | + | ||
| 470 | + | [[patch.unused]] | |
| 471 | + | name = "synckit-client" | |
| 472 | + | version = "0.7.0" | |
| 473 | + | ||
| 474 | + | [[patch.unused]] | |
| 475 | + | name = "synckit-config" | |
| 476 | + | version = "0.1.2" | |
| 477 | + | ||
| 478 | + | [[patch.unused]] | |
| 479 | + | name = "docengine" | |
| 480 | + | version = "0.4.0" | |
| 481 | + | ||
| 482 | + | [[patch.unused]] | |
| 483 | + | name = "kberg" | |
| 484 | + | version = "0.1.0" | |
| 485 | + | ||
| 486 | + | [[patch.unused]] | |
| 487 | + | name = "painhours" | |
| 488 | + | version = "0.1.0" | |
| 489 | + | ||
| 490 | + | [[patch.unused]] | |
| 491 | + | name = "tagtree" | |
| 492 | + | version = "0.4.0" |
| @@ -1,0 +1,44 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "ops-artifact" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | license = "MIT" | |
| 6 | + | description = "The artifact and evidence contract between the system that builds a release and the system that advances it. Names what bytes exist, what source they came from, and what has been proven about them." | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 10 | + | serde_json = "1" | |
| 11 | + | chrono = { version = "0.4", features = ["serde"] } | |
| 12 | + | sha2 = "0.10" | |
| 13 | + | ||
| 14 | + | [lints.rust] | |
| 15 | + | unused = "warn" | |
| 16 | + | unreachable_pub = "warn" | |
| 17 | + | ||
| 18 | + | [lints.clippy] | |
| 19 | + | pedantic = { level = "warn", priority = -1 } | |
| 20 | + | # Allow-list tuned from a measured breakdown across server/multithreaded/pter | |
| 21 | + | # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything | |
| 22 | + | # else in `pedantic` stays a warning. Keep this block identical across repos. | |
| 23 | + | module_name_repetitions = "allow" | |
| 24 | + | # Doc lints. No docs-completeness push is underway. | |
| 25 | + | missing_errors_doc = "allow" | |
| 26 | + | missing_panics_doc = "allow" | |
| 27 | + | doc_markdown = "allow" | |
| 28 | + | # Numeric casts. Endemic and mostly intentional in size and byte math. | |
| 29 | + | cast_possible_truncation = "allow" | |
| 30 | + | cast_sign_loss = "allow" | |
| 31 | + | cast_precision_loss = "allow" | |
| 32 | + | cast_possible_wrap = "allow" | |
| 33 | + | cast_lossless = "allow" | |
| 34 | + | # Subjective structure and style nags. High churn, low signal. | |
| 35 | + | must_use_candidate = "allow" | |
| 36 | + | too_many_lines = "allow" | |
| 37 | + | struct_excessive_bools = "allow" | |
| 38 | + | similar_names = "allow" | |
| 39 | + | items_after_statements = "allow" | |
| 40 | + | single_match_else = "allow" | |
| 41 | + | # Frequent false-positives in TUI and router-heavy code. | |
| 42 | + | match_same_arms = "allow" | |
| 43 | + | unnecessary_wraps = "allow" | |
| 44 | + | type_complexity = "allow" |
| @@ -1,0 +1,166 @@ | |||
| 1 | + | //! What has been proven about the bytes, and by whom. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | ||
| 5 | + | /// What a gate's evidence is *about*. | |
| 6 | + | /// | |
| 7 | + | /// This is the boundary between the two controllers, expressed as a type | |
| 8 | + | /// rather than as a convention. A build host can prove things about an | |
| 9 | + | /// artifact: it compiled, its tests passed, it is signed and notarized. It | |
| 10 | + | /// cannot prove anything about that artifact in production, because it does | |
| 11 | + | /// not have production: no restored dump to migrate, no nodes to probe, no | |
| 12 | + | /// clock that has been running for 48 hours. | |
| 13 | + | /// | |
| 14 | + | /// Keeping the two apart in the type means an evaluator never has to decide | |
| 15 | + | /// whether `boot_smoke` from a build host counts. It cannot be handed one. | |
| 16 | + | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 17 | + | #[serde(tag = "kind", rename_all = "snake_case")] | |
| 18 | + | pub enum Scope { | |
| 19 | + | /// About the artifact itself: clippy, unit tests, `cargo_audit`, signing, | |
| 20 | + | /// notarization, Gatekeeper. True wherever the bytes go. | |
| 21 | + | Artifact, | |
| 22 | + | /// About the artifact in one environment: `migration_dry_run`, | |
| 23 | + | /// `boot_smoke` against a restored dump, `node_health`, `burn_in`. | |
| 24 | + | /// Says nothing about the same bytes anywhere else, so it names where. | |
| 25 | + | Environment { env: String }, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Passed, failed, or could not run. | |
| 29 | + | /// | |
| 30 | + | /// `Blocked` is not a third flavour of failure. The gate did not run because it | |
| 31 | + | /// owes a precondition somebody can satisfy out of band (no backup fetched, the | |
| 32 | + | /// burn-in clock never started). Collapsing it into `Failed` turns "not yet" | |
| 33 | + | /// into "no", which is how an operator learns to route around a gate. | |
| 34 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 35 | + | #[serde(rename_all = "snake_case")] | |
| 36 | + | pub enum Verdict { | |
| 37 | + | Passed, | |
| 38 | + | Failed, | |
| 39 | + | Blocked, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl Verdict { | |
| 43 | + | pub fn is_passed(self) -> bool { | |
| 44 | + | matches!(self, Verdict::Passed) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// One gate, run once, against one artifact. | |
| 49 | + | /// | |
| 50 | + | /// `detail` is deliberately untyped. Both controllers already have rich, | |
| 51 | + | /// divergent failure vocabularies (Sando's `GateFailure` names the migration | |
| 52 | + | /// that drifted), and hoisting either into this crate would make the contract | |
| 53 | + | /// the union of two daemons' internals and force a coordinated change every | |
| 54 | + | /// time one of them learns a new failure mode. The envelope is shared; what a | |
| 55 | + | /// producer puts inside stays its own. A reader that does not know the producer | |
| 56 | + | /// still gets `verdict` and `summary`, which is what deciding requires. | |
| 57 | + | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 58 | + | pub struct GateRecord { | |
| 59 | + | /// The gate's name in its producer's vocabulary (`cargo_test`, `boot_smoke`). | |
| 60 | + | pub gate: String, | |
| 61 | + | pub scope: Scope, | |
| 62 | + | pub verdict: Verdict, | |
| 63 | + | /// One line, human-facing. `12 test(s) failed; first panic: ...` | |
| 64 | + | pub summary: String, | |
| 65 | + | pub ran_at: DateTime<Utc>, | |
| 66 | + | /// Where the producer kept the full output, in the producer's own terms. | |
| 67 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 68 | + | pub log_ref: Option<String>, | |
| 69 | + | /// The producer's own typed outcome, verbatim. | |
| 70 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 71 | + | pub detail: Option<serde_json::Value>, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl GateRecord { | |
| 75 | + | pub fn new( | |
| 76 | + | gate: impl Into<String>, | |
| 77 | + | scope: Scope, | |
| 78 | + | verdict: Verdict, | |
| 79 | + | summary: impl Into<String>, | |
| 80 | + | ran_at: DateTime<Utc>, | |
| 81 | + | ) -> Self { | |
| 82 | + | Self { | |
| 83 | + | gate: gate.into(), | |
| 84 | + | scope, | |
| 85 | + | verdict, | |
| 86 | + | summary: summary.into(), | |
| 87 | + | ran_at, | |
| 88 | + | log_ref: None, | |
| 89 | + | detail: None, | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[must_use] | |
| 94 | + | pub fn with_log_ref(mut self, log_ref: impl Into<String>) -> Self { | |
| 95 | + | self.log_ref = Some(log_ref.into()); | |
| 96 | + | self | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[must_use] | |
| 100 | + | pub fn with_detail(mut self, detail: serde_json::Value) -> Self { | |
| 101 | + | self.detail = Some(detail); | |
| 102 | + | self | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[cfg(test)] | |
| 107 | + | mod tests { | |
| 108 | + | use super::*; | |
| 109 | + | ||
| 110 | + | fn at() -> DateTime<Utc> { | |
| 111 | + | DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap() | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[test] | |
| 115 | + | fn an_environment_scope_names_its_environment() { | |
| 116 | + | let g = GateRecord::new( | |
| 117 | + | "boot_smoke", | |
| 118 | + | Scope::Environment { | |
| 119 | + | env: "tier:a".into(), | |
| 120 | + | }, | |
| 121 | + | Verdict::Passed, | |
| 122 | + | "served /health in 340ms", | |
| 123 | + | at(), | |
| 124 | + | ); | |
| 125 | + | let v = serde_json::to_value(&g).unwrap(); | |
| 126 | + | assert_eq!(v["scope"]["kind"], "environment"); | |
| 127 | + | assert_eq!(v["scope"]["env"], "tier:a"); | |
| 128 | + | assert_eq!(v["verdict"], "passed"); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[test] | |
| 132 | + | fn a_producers_own_failure_type_survives_verbatim() { | |
| 133 | + | // Sando's GateFailure, serialized by Sando, carried through untouched. | |
| 134 | + | let detail = serde_json::json!({ | |
| 135 | + | "kind": "migration_drift", | |
| 136 | + | "migration": "0047_widgets", | |
| 137 | + | }); | |
| 138 | + | let g = GateRecord::new( | |
| 139 | + | "migration_dry_run", | |
| 140 | + | Scope::Environment { | |
| 141 | + | env: "tier:a".into(), | |
| 142 | + | }, | |
| 143 | + | Verdict::Failed, | |
| 144 | + | "migration 0047_widgets previously applied but missing", | |
| 145 | + | at(), | |
| 146 | + | ) | |
| 147 | + | .with_detail(detail.clone()); | |
| 148 | + | let back: GateRecord = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap(); | |
| 149 | + | assert_eq!(back.detail.unwrap(), detail); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | #[test] | |
| 153 | + | fn optional_fields_are_absent_rather_than_null() { | |
| 154 | + | let g = GateRecord::new("clippy", Scope::Artifact, Verdict::Passed, "clean", at()); | |
| 155 | + | let v = serde_json::to_value(&g).unwrap(); | |
| 156 | + | assert!(v.get("log_ref").is_none()); | |
| 157 | + | assert!(v.get("detail").is_none()); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn blocked_is_not_passed() { | |
| 162 | + | assert!(!Verdict::Blocked.is_passed()); | |
| 163 | + | assert!(!Verdict::Failed.is_passed()); | |
| 164 | + | assert!(Verdict::Passed.is_passed()); | |
| 165 | + | } | |
| 166 | + | } |
| @@ -1,0 +1,131 @@ | |||
| 1 | + | //! The artifact and evidence contract between the system that builds a release | |
| 2 | + | //! and the system that advances it. | |
| 3 | + | //! | |
| 4 | + | //! Design + rationale: maintainer wiki. | |
| 5 | + | //! <!-- wiki: sando-bento-boundary --> | |
| 6 | + | //! | |
| 7 | + | //! Sando and Bento independently grew the same defect: gates vouch for one | |
| 8 | + | //! thing, the deploy ships another, and nothing guarantees they match. Both had | |
| 9 | + | //! to remember to bind evidence to bytes, and both forgot. Two systems that | |
| 10 | + | //! forgot the same thing separately will forget it again, so this crate makes | |
| 11 | + | //! the binding the wire format between them rather than a discipline inside | |
| 12 | + | //! each. A builder cannot hand over an artifact without a digest and a | |
| 13 | + | //! provenance; an evaluator cannot evaluate one it was not handed. There is no | |
| 14 | + | //! second channel. | |
| 15 | + | //! | |
| 16 | + | //! # The documents | |
| 17 | + | //! | |
| 18 | + | //! - [`ArtifactRecord`] — the handover. These bytes ([`Manifest`], | |
| 19 | + | //! [`BundleDigest`]), from this source ([`Provenance`]), and what the builder | |
| 20 | + | //! proved about them. | |
| 21 | + | //! - [`EvidenceRecord`] — an append. What a later producer proved about a | |
| 22 | + | //! bundle it did not build, naming it by digest. | |
| 23 | + | //! | |
| 24 | + | //! Both sit **beside** a bundle, never inside it. Evidence names the digest of | |
| 25 | + | //! what it vouches for, and the digest covers every file in the bundle, so | |
| 26 | + | //! evidence stored inside would change the digest it names. That circularity is | |
| 27 | + | //! the short argument; the longer one is that evidence keeps arriving after the | |
| 28 | + | //! build, and if it lived in the bundle then vouching for an artifact would | |
| 29 | + | //! change the artifact's identity, so the thing that passed would never be the | |
| 30 | + | //! thing that ships. | |
| 31 | + | //! | |
| 32 | + | //! # What this crate does not do | |
| 33 | + | //! | |
| 34 | + | //! It has no opinion on where the documents land, and no I/O. Bento's dist tree | |
| 35 | + | //! and Sando's content-addressed release directories are laid out differently | |
| 36 | + | //! and neither has to move for this to be shared. It also does not model either | |
| 37 | + | //! daemon's failure vocabulary: [`GateRecord::detail`] carries a producer's own | |
| 38 | + | //! typed outcome verbatim, so learning a new failure mode stays a one-repo | |
| 39 | + | //! change. | |
| 40 | + | ||
| 41 | + | mod evidence; | |
| 42 | + | mod manifest; | |
| 43 | + | mod provenance; | |
| 44 | + | mod record; | |
| 45 | + | ||
| 46 | + | pub use evidence::{GateRecord, Scope, Verdict}; | |
| 47 | + | pub use manifest::{BundleDigest, Manifest, ManifestEntry}; | |
| 48 | + | pub use provenance::Provenance; | |
| 49 | + | pub use record::{ArtifactRecord, CONTRACT_VERSION, EvidenceRecord}; | |
| 50 | + | ||
| 51 | + | /// Every way a document can fail to be one. | |
| 52 | + | #[derive(Debug)] | |
| 53 | + | pub enum ContractError { | |
| 54 | + | /// A hash field was not 64 lowercase hex characters. | |
| 55 | + | NotASha256(String), | |
| 56 | + | /// A provenance git sha was not 40 lowercase hex characters. Abbreviated | |
| 57 | + | /// shas compare unequal to full ones without being wrong, which turns a | |
| 58 | + | /// mismatch into a non-answer. | |
| 59 | + | NotAGitSha(String), | |
| 60 | + | /// A manifest path was absolute, backslashed, or walked upward. | |
| 61 | + | UnsafePath(String), | |
| 62 | + | /// Two manifest entries named the same path. | |
| 63 | + | DuplicatePath(String), | |
| 64 | + | /// A manifest with no files. It hashes fine, which is the problem: it is a | |
| 65 | + | /// stable identity for nothing, shared by every empty bundle. | |
| 66 | + | EmptyManifest, | |
| 67 | + | /// A `MANIFEST` line was not `<sha256> <path>`. Carries the 1-based line. | |
| 68 | + | MalformedManifestLine(usize), | |
| 69 | + | /// A required provenance field was empty. | |
| 70 | + | MissingProvenance(&'static str), | |
| 71 | + | /// The record's digest is not the digest of the manifest it carries. | |
| 72 | + | DigestMismatch { claimed: String, computed: String }, | |
| 73 | + | /// A builder handed over evidence about an environment it does not have. | |
| 74 | + | EnvironmentEvidenceFromBuilder { gate: String, env: String }, | |
| 75 | + | /// A document written against a contract version this reader does not know. | |
| 76 | + | UnknownContractVersion(u32), | |
| 77 | + | /// The JSON did not parse as the document it claimed to be. | |
| 78 | + | Malformed(serde_json::Error), | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | impl std::fmt::Display for ContractError { | |
| 82 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 83 | + | match self { | |
| 84 | + | ContractError::NotASha256(s) => { | |
| 85 | + | write!(f, "`{s}` is not a sha256 (64 lowercase hex characters)") | |
| 86 | + | } | |
| 87 | + | ContractError::NotAGitSha(s) => { | |
| 88 | + | write!( | |
| 89 | + | f, | |
| 90 | + | "`{s}` is not a full git sha (40 lowercase hex characters)" | |
| 91 | + | ) | |
| 92 | + | } | |
| 93 | + | ContractError::UnsafePath(p) => write!( | |
| 94 | + | f, | |
| 95 | + | "manifest path `{p}` must be relative, forward-slashed, and stay inside the bundle" | |
| 96 | + | ), | |
| 97 | + | ContractError::DuplicatePath(p) => { | |
| 98 | + | write!(f, "manifest lists `{p}` twice, so it describes two bundles") | |
| 99 | + | } | |
| 100 | + | ContractError::EmptyManifest => write!(f, "manifest lists no files"), | |
| 101 | + | ContractError::MalformedManifestLine(n) => { | |
| 102 | + | write!(f, "manifest line {n} is not `<sha256> <path>`") | |
| 103 | + | } | |
| 104 | + | ContractError::MissingProvenance(field) => write!(f, "`{field}` is empty"), | |
| 105 | + | ContractError::DigestMismatch { claimed, computed } => write!( | |
| 106 | + | f, | |
| 107 | + | "record claims digest {claimed} but its manifest hashes to {computed}" | |
| 108 | + | ), | |
| 109 | + | ContractError::EnvironmentEvidenceFromBuilder { gate, env } => write!( | |
| 110 | + | f, | |
| 111 | + | "gate `{gate}` is scoped to environment `{env}`, which a build host does not have" | |
| 112 | + | ), | |
| 113 | + | ContractError::UnknownContractVersion(v) => { | |
| 114 | + | write!( | |
| 115 | + | f, | |
| 116 | + | "contract version {v}, this reader knows {CONTRACT_VERSION}" | |
| 117 | + | ) | |
| 118 | + | } | |
| 119 | + | ContractError::Malformed(e) => write!(f, "malformed document: {e}"), | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | impl std::error::Error for ContractError { | |
| 125 | + | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { | |
| 126 | + | match self { | |
| 127 | + | ContractError::Malformed(e) => Some(e), | |
| 128 | + | _ => None, | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } |
| @@ -1,0 +1,293 @@ | |||
| 1 | + | //! What bytes exist: the per-file manifest and the digest that names it. | |
| 2 | + | ||
| 3 | + | use crate::ContractError; | |
| 4 | + | use sha2::{Digest, Sha256}; | |
| 5 | + | ||
| 6 | + | /// One file in a bundle: its sha256 and its path relative to the bundle root. | |
| 7 | + | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 8 | + | pub struct ManifestEntry { | |
| 9 | + | pub sha256: String, | |
| 10 | + | pub path: String, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | /// Every file in a bundle, sorted by path. | |
| 14 | + | /// | |
| 15 | + | /// Sorted, and constructible only through checks: a manifest is what the digest | |
| 16 | + | /// is computed over, so two producers listing the same files must produce the | |
| 17 | + | /// same text or the digest stops being an identity. Sorting is the ordering | |
| 18 | + | /// half of that. Rejecting duplicate paths is the other half, since a repeated | |
| 19 | + | /// path with two different hashes is a manifest that describes two bundles. | |
| 20 | + | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 21 | + | #[serde(transparent)] | |
| 22 | + | pub struct Manifest { | |
| 23 | + | entries: Vec<ManifestEntry>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | impl Manifest { | |
| 27 | + | /// Build a manifest from `(path, sha256)` pairs in any order. | |
| 28 | + | pub fn new<I, P, S>(files: I) -> Result<Self, ContractError> | |
| 29 | + | where | |
| 30 | + | I: IntoIterator<Item = (P, S)>, | |
| 31 | + | P: Into<String>, | |
| 32 | + | S: Into<String>, | |
| 33 | + | { | |
| 34 | + | let mut entries: Vec<ManifestEntry> = files | |
| 35 | + | .into_iter() | |
| 36 | + | .map(|(path, sha256)| ManifestEntry { | |
| 37 | + | path: path.into(), | |
| 38 | + | sha256: sha256.into(), | |
| 39 | + | }) | |
| 40 | + | .collect(); | |
| 41 | + | for e in &entries { | |
| 42 | + | check_sha256(&e.sha256)?; | |
| 43 | + | check_path(&e.path)?; | |
| 44 | + | } | |
| 45 | + | entries.sort_by(|a, b| a.path.cmp(&b.path)); | |
| 46 | + | if let Some(dup) = entries.windows(2).find(|w| w[0].path == w[1].path) { | |
| 47 | + | return Err(ContractError::DuplicatePath(dup[0].path.clone())); | |
| 48 | + | } | |
| 49 | + | if entries.is_empty() { | |
| 50 | + | return Err(ContractError::EmptyManifest); | |
| 51 | + | } | |
| 52 | + | Ok(Self { entries }) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn entries(&self) -> &[ManifestEntry] { | |
| 56 | + | &self.entries | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// The `MANIFEST` file's contents: `<sha256> <path>`, one per line, | |
| 60 | + | /// trailing newline. Deliberately the shape `sha256sum -c` reads, so the | |
| 61 | + | /// file is checkable on a node with no tooling of ours installed. | |
| 62 | + | pub fn to_text(&self) -> String { | |
| 63 | + | let mut s = String::new(); | |
| 64 | + | for e in &self.entries { | |
| 65 | + | s.push_str(&e.sha256); | |
| 66 | + | s.push_str(" "); | |
| 67 | + | s.push_str(&e.path); | |
| 68 | + | s.push('\n'); | |
| 69 | + | } | |
| 70 | + | s | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Parse a `MANIFEST` back. Round-trips [`Manifest::to_text`]. | |
| 74 | + | pub fn parse(text: &str) -> Result<Self, ContractError> { | |
| 75 | + | let mut files = Vec::new(); | |
| 76 | + | for (n, line) in text.lines().enumerate() { | |
| 77 | + | if line.trim().is_empty() { | |
| 78 | + | continue; | |
| 79 | + | } | |
| 80 | + | // Split on the two-space separator rather than on whitespace: a path | |
| 81 | + | // may contain single spaces, and splitting greedily would silently | |
| 82 | + | // truncate one. `sha256sum` has the same convention. | |
| 83 | + | let (sha, path) = line | |
| 84 | + | .split_once(" ") | |
| 85 | + | .ok_or(ContractError::MalformedManifestLine(n + 1))?; | |
| 86 | + | files.push((path.to_string(), sha.to_string())); | |
| 87 | + | } | |
| 88 | + | Self::new(files) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// The bundle digest: sha256 of the manifest text. | |
| 92 | + | /// | |
| 93 | + | /// Hashing the manifest rather than the primary binary is the point. Two | |
| 94 | + | /// bundles whose binaries are identical and whose assets differ hash the | |
| 95 | + | /// same if you hash only the binary, which is the drift that crash-looped | |
| 96 | + | /// production on a missing `CDN_BASE_URL` (postmortem 2026-07-09 #2). | |
| 97 | + | pub fn digest(&self) -> BundleDigest { | |
| 98 | + | let mut hasher = Sha256::new(); | |
| 99 | + | hasher.update(self.to_text().as_bytes()); | |
| 100 | + | BundleDigest(hex_lower(&hasher.finalize())) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// The identity of a set of bytes: 64 lowercase hex, the sha256 of a manifest. | |
| 105 | + | /// | |
| 106 | + | /// Not a substitute for the git sha, and neither is a substitute for it. The | |
| 107 | + | /// sha says what source; the digest says what bytes. Cargo builds are not | |
| 108 | + | /// bit-reproducible by default, so one commit built twice gives two digests: | |
| 109 | + | /// a digest match across hosts proves more than a sha match, and a digest | |
| 110 | + | /// cannot answer "have we already built this commit". | |
| 111 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] | |
| 112 | + | #[serde(transparent)] | |
| 113 | + | pub struct BundleDigest(String); | |
| 114 | + | ||
| 115 | + | impl BundleDigest { | |
| 116 | + | pub fn parse(s: &str) -> Result<Self, ContractError> { | |
| 117 | + | check_sha256(s)?; | |
| 118 | + | Ok(Self(s.to_string())) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | pub fn as_str(&self) -> &str { | |
| 122 | + | &self.0 | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// The first 16 hex characters, which is what a content-addressed directory | |
| 126 | + | /// is named. The full 64 is what gets stored and compared. | |
| 127 | + | pub fn short(&self) -> &str { | |
| 128 | + | &self.0[..16] | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | impl std::fmt::Display for BundleDigest { | |
| 133 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 134 | + | self.0.fmt(f) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | fn check_sha256(s: &str) -> Result<(), ContractError> { | |
| 139 | + | if s.len() == 64 | |
| 140 | + | && s.bytes() | |
| 141 | + | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) | |
| 142 | + | { | |
| 143 | + | Ok(()) | |
| 144 | + | } else { | |
| 145 | + | Err(ContractError::NotASha256(s.to_string())) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// A manifest path is relative, forward-slashed, and does not walk upward. | |
| 150 | + | /// | |
| 151 | + | /// The node verifies a bundle by joining these onto a release directory, so a | |
| 152 | + | /// path that escapes it would have the verifier hash a file the bundle does not | |
| 153 | + | /// contain, and a deploy write one there. | |
| 154 | + | fn check_path(p: &str) -> Result<(), ContractError> { | |
| 155 | + | let bad = p.is_empty() | |
| 156 | + | || p.starts_with('/') | |
| 157 | + | || p.contains('\\') | |
| 158 | + | || p.split('/').any(|c| c == ".." || c == ".") | |
| 159 | + | || p.contains('\n'); | |
| 160 | + | if bad { | |
| 161 | + | return Err(ContractError::UnsafePath(p.to_string())); | |
| 162 | + | } | |
| 163 | + | Ok(()) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn hex_lower(bytes: &[u8]) -> String { | |
| 167 | + | use std::fmt::Write as _; | |
| 168 | + | let mut s = String::with_capacity(bytes.len() * 2); | |
| 169 | + | for b in bytes { | |
| 170 | + | let _ = write!(s, "{b:02x}"); | |
| 171 | + | } | |
| 172 | + | s | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[cfg(test)] | |
| 176 | + | mod tests { | |
| 177 | + | use super::*; | |
| 178 | + | ||
| 179 | + | fn sha(byte: u8) -> String { | |
| 180 | + | std::iter::repeat_n(format!("{byte:02x}"), 32).collect() | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn entries_sort_by_path_so_input_order_cannot_change_the_digest() { | |
| 185 | + | let a = Manifest::new([ | |
| 186 | + | ("static/app.css", sha(1)), | |
| 187 | + | ("makenotwork", sha(2)), | |
| 188 | + | ("companions/mnw-cli", sha(3)), | |
| 189 | + | ]) | |
| 190 | + | .unwrap(); | |
| 191 | + | let b = Manifest::new([ | |
| 192 | + | ("companions/mnw-cli", sha(3)), | |
| 193 | + | ("static/app.css", sha(1)), | |
| 194 | + | ("makenotwork", sha(2)), | |
| 195 | + | ]) | |
| 196 | + | .unwrap(); | |
| 197 | + | assert_eq!(a.to_text(), b.to_text()); | |
| 198 | + | assert_eq!(a.digest(), b.digest()); | |
| 199 | + | let paths: Vec<&str> = a.entries().iter().map(|e| e.path.as_str()).collect(); | |
| 200 | + | assert_eq!( | |
| 201 | + | paths, | |
| 202 | + | ["companions/mnw-cli", "makenotwork", "static/app.css"] | |
| 203 | + | ); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn text_round_trips() { | |
| 208 | + | let m = Manifest::new([("docs/index.html", sha(9)), ("makenotwork", sha(10))]).unwrap(); | |
| 209 | + | let back = Manifest::parse(&m.to_text()).unwrap(); | |
| 210 | + | assert_eq!(m, back); | |
| 211 | + | assert_eq!(m.digest(), back.digest()); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn a_path_containing_a_space_survives_the_round_trip() { | |
| 216 | + | // Splitting on whitespace rather than on the two-space separator would | |
| 217 | + | // truncate this to "Release" and quietly describe a different file. | |
| 218 | + | let m = Manifest::new([("docs/Release Notes.html", sha(4))]).unwrap(); | |
| 219 | + | let back = Manifest::parse(&m.to_text()).unwrap(); | |
| 220 | + | assert_eq!(back.entries()[0].path, "docs/Release Notes.html"); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[test] | |
| 224 | + | fn the_digest_is_the_sha256_of_the_manifest_text() { | |
| 225 | + | let m = Manifest::new([("a", sha(0))]).unwrap(); | |
| 226 | + | let mut hasher = Sha256::new(); | |
| 227 | + | hasher.update(m.to_text().as_bytes()); | |
| 228 | + | assert_eq!(m.digest().as_str(), hex_lower(&hasher.finalize())); | |
| 229 | + | assert_eq!(m.digest().short().len(), 16); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn a_changed_asset_changes_the_digest_even_with_the_binary_untouched() { | |
| 234 | + | // The CDN_BASE_URL postmortem in one assertion: hashing only the binary | |
| 235 | + | // would call these two bundles the same thing. | |
| 236 | + | let before = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(1))]).unwrap(); | |
| 237 | + | let after = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(2))]).unwrap(); | |
| 238 | + | assert_ne!(before.digest(), after.digest()); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn a_duplicate_path_is_refused() { | |
| 243 | + | let err = Manifest::new([("makenotwork", sha(1)), ("makenotwork", sha(2))]).unwrap_err(); | |
| 244 | + | assert!(matches!(err, ContractError::DuplicatePath(p) if p == "makenotwork")); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn an_empty_manifest_is_refused() { | |
| 249 | + | // A bundle with no files has a perfectly good digest (the hash of the | |
| 250 | + | // empty string), which is the problem: it is a stable identity for | |
| 251 | + | // nothing at all, and every empty bundle shares it. | |
| 252 | + | let files: Vec<(String, String)> = Vec::new(); | |
| 253 | + | assert!(matches!( | |
| 254 | + | Manifest::new(files).unwrap_err(), | |
| 255 | + | ContractError::EmptyManifest | |
| 256 | + | )); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn paths_that_escape_the_bundle_are_refused() { | |
| 261 | + | for p in ["/etc/passwd", "../secrets", "a/../../b", "c:\\windows"] { | |
| 262 | + | assert!( | |
| 263 | + | matches!( | |
| 264 | + | Manifest::new([(p, sha(1))]).unwrap_err(), | |
| 265 | + | ContractError::UnsafePath(_) | |
| 266 | + | ), | |
| 267 | + | "{p} should be refused" | |
| 268 | + | ); | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn a_hash_that_is_not_a_sha256_is_refused() { | |
| 274 | + | for h in ["", "abc", &"g".repeat(64), &"AB".repeat(32)] { | |
| 275 | + | assert!( | |
| 276 | + | matches!( | |
| 277 | + | Manifest::new([("a", h)]).unwrap_err(), | |
| 278 | + | ContractError::NotASha256(_) | |
| 279 | + | ), | |
| 280 | + | "{h} should be refused" | |
| 281 | + | ); | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[test] | |
| 286 | + | fn a_malformed_line_names_its_line_number() { | |
| 287 | + | let text = format!("{} ok\nnot-a-manifest-line\n", sha(1)); | |
| 288 | + | assert!(matches!( | |
| 289 | + | Manifest::parse(&text).unwrap_err(), | |
| 290 | + | ContractError::MalformedManifestLine(2) | |
| 291 | + | )); | |
| 292 | + | } | |
| 293 | + | } |
| @@ -1,0 +1,126 @@ | |||
| 1 | + | //! What source the bytes came from, and which machine turned one into the other. | |
| 2 | + | ||
| 3 | + | use crate::ContractError; | |
| 4 | + | use chrono::{DateTime, Utc}; | |
| 5 | + | ||
| 6 | + | /// Everything needed to rebuild the artifact, or to prove it was not rebuilt. | |
| 7 | + | /// | |
| 8 | + | /// `git_sha` is the commit the build host was pinned to, not the branch tip at | |
| 9 | + | /// pull time. That distinction is the whole reason this field is here: a | |
| 10 | + | /// multi-arch release whose hosts each pulled independently ships several | |
| 11 | + | /// commits under one version label, and nothing downstream can tell. | |
| 12 | + | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 13 | + | pub struct Provenance { | |
| 14 | + | /// The app this release is of, as the controller keys it (`goingson`). | |
| 15 | + | pub app: String, | |
| 16 | + | /// The release version. A label, not an identity: it does not change per | |
| 17 | + | /// commit, and two builds can share it. The digest is the identity. | |
| 18 | + | pub version: String, | |
| 19 | + | /// The release tag the build was pinned to (`v0.4.1`, `pom-v0.4.1`). | |
| 20 | + | pub tag: String, | |
| 21 | + | /// The commit that tag resolved to, full 40 hex. | |
| 22 | + | pub git_sha: String, | |
| 23 | + | /// `os/arch` as the topology names it (`linux/x86_64`, `macos/aarch64`). | |
| 24 | + | pub target: String, | |
| 25 | + | /// The host that compiled it. A release is native per architecture, so this | |
| 26 | + | /// also says which machine to look at when only one target is wrong. | |
| 27 | + | pub build_host: String, | |
| 28 | + | /// `rustc --version` verbatim from the build host. | |
| 29 | + | pub toolchain: String, | |
| 30 | + | pub built_at: DateTime<Utc>, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | impl Provenance { | |
| 34 | + | /// Reject a provenance that cannot identify what it describes. | |
| 35 | + | /// | |
| 36 | + | /// Every field here is load-bearing at intake, and an empty one is worse | |
| 37 | + | /// than a missing document: it satisfies a shape check while telling the | |
| 38 | + | /// evaluator nothing. The git sha is length-checked because a short sha | |
| 39 | + | /// silently stops matching a full one, and comparing provenance across | |
| 40 | + | /// hosts is the point. | |
| 41 | + | pub fn validate(&self) -> Result<(), ContractError> { | |
| 42 | + | for (name, value) in [ | |
| 43 | + | ("app", &self.app), | |
| 44 | + | ("version", &self.version), | |
| 45 | + | ("tag", &self.tag), | |
| 46 | + | ("target", &self.target), | |
| 47 | + | ("build_host", &self.build_host), | |
| 48 | + | ("toolchain", &self.toolchain), | |
| 49 | + | ] { | |
| 50 | + | if value.trim().is_empty() { | |
| 51 | + | return Err(ContractError::MissingProvenance(name)); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | let sha_ok = self.git_sha.len() == 40 | |
| 55 | + | && self | |
| 56 | + | .git_sha | |
| 57 | + | .bytes() | |
| 58 | + | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); | |
| 59 | + | if !sha_ok { | |
| 60 | + | return Err(ContractError::NotAGitSha(self.git_sha.clone())); | |
| 61 | + | } | |
| 62 | + | Ok(()) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[cfg(test)] | |
| 67 | + | mod tests { | |
| 68 | + | use super::*; | |
| 69 | + | ||
| 70 | + | fn good() -> Provenance { | |
| 71 | + | Provenance { | |
| 72 | + | app: "goingson".into(), | |
| 73 | + | version: "0.4.1".into(), | |
| 74 | + | tag: "v0.4.1".into(), | |
| 75 | + | git_sha: "a".repeat(40), | |
| 76 | + | target: "linux/x86_64".into(), | |
| 77 | + | build_host: "fw13".into(), | |
| 78 | + | toolchain: "rustc 1.97.0 (deadbeef 2026-07-01)".into(), | |
| 79 | + | built_at: DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap(), | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | #[test] | |
| 84 | + | fn a_complete_provenance_validates() { | |
| 85 | + | good().validate().unwrap(); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn each_empty_field_is_named() { | |
| 90 | + | let cases: [(&str, fn(&mut Provenance)); 6] = [ | |
| 91 | + | ("app", |p| p.app = String::new()), | |
| 92 | + | ("version", |p| p.version = String::new()), | |
| 93 | + | ("tag", |p| p.tag = String::new()), | |
| 94 | + | ("target", |p| p.target = String::new()), | |
| 95 | + | ("build_host", |p| p.build_host = " ".into()), | |
| 96 | + | ("toolchain", |p| p.toolchain = String::new()), | |
| 97 | + | ]; | |
| 98 | + | for (field, break_it) in cases { | |
| 99 | + | let mut p = good(); | |
| 100 | + | break_it(&mut p); | |
| 101 | + | assert!( | |
| 102 | + | matches!(p.validate().unwrap_err(), ContractError::MissingProvenance(f) if f == field), | |
| 103 | + | "{field} should be reported by name" | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[test] | |
| 109 | + | fn a_short_sha_is_refused() { | |
| 110 | + | // Abbreviated shas compare unequal to full ones without being wrong, | |
| 111 | + | // which turns a provenance mismatch into a provenance non-answer. | |
| 112 | + | let mut p = good(); | |
| 113 | + | p.git_sha = "a".repeat(12); | |
| 114 | + | assert!(matches!( | |
| 115 | + | p.validate().unwrap_err(), | |
| 116 | + | ContractError::NotAGitSha(_) | |
| 117 | + | )); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn round_trips_through_json() { | |
| 122 | + | let p = good(); | |
| 123 | + | let back: Provenance = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); | |
| 124 | + | assert_eq!(p, back); | |
| 125 | + | } | |
| 126 | + | } |
| @@ -1,0 +1,306 @@ | |||
| 1 | + | //! The two documents that cross the boundary. | |
| 2 | + | ||
| 3 | + | use crate::{BundleDigest, ContractError, GateRecord, Manifest, Provenance, Scope}; | |
| 4 | + | ||
| 5 | + | /// The version of this contract. Bumped when a change would make an older | |
| 6 | + | /// reader misread a newer document, not when a field is added. | |
| 7 | + | pub const CONTRACT_VERSION: u32 = 1; | |
| 8 | + | ||
| 9 | + | /// What the builder hands over: these bytes, from this source, and here is what | |
| 10 | + | /// I proved about them. | |
| 11 | + | /// | |
| 12 | + | /// Written **beside** the bundle, never inside it. Evidence names the digest of | |
| 13 | + | /// what it vouches for, and the digest covers every file in the bundle, so | |
| 14 | + | /// evidence stored inside would change the digest it names. Beyond the | |
| 15 | + | /// circularity, the evaluator appends its own evidence later; if evidence lived | |
| 16 | + | /// in the bundle, vouching for an artifact would alter the artifact's identity, | |
| 17 | + | /// and the thing that passed would no longer be the thing that ships. | |
| 18 | + | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 19 | + | pub struct ArtifactRecord { | |
| 20 | + | pub contract: u32, | |
| 21 | + | /// Which system produced this (`bento`). | |
| 22 | + | pub producer: String, | |
| 23 | + | /// Identity of the bundle. Always equal to `manifest.digest()`; carried | |
| 24 | + | /// explicitly so a reader can check the manifest it was handed is the one | |
| 25 | + | /// the digest was computed from. | |
| 26 | + | pub digest: BundleDigest, | |
| 27 | + | pub manifest: Manifest, | |
| 28 | + | pub provenance: Provenance, | |
| 29 | + | /// Gates the builder ran. Artifact-scoped by construction. | |
| 30 | + | pub gates: Vec<GateRecord>, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | impl ArtifactRecord { | |
| 34 | + | pub fn new( | |
| 35 | + | producer: impl Into<String>, | |
| 36 | + | manifest: Manifest, | |
| 37 | + | provenance: Provenance, | |
| 38 | + | gates: Vec<GateRecord>, | |
| 39 | + | ) -> Result<Self, ContractError> { | |
| 40 | + | let record = Self { | |
| 41 | + | contract: CONTRACT_VERSION, | |
| 42 | + | producer: producer.into(), | |
| 43 | + | digest: manifest.digest(), | |
| 44 | + | manifest, | |
| 45 | + | provenance, | |
| 46 | + | gates, | |
| 47 | + | }; | |
| 48 | + | record.validate()?; | |
| 49 | + | Ok(record) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Everything an intake has to check before it may believe the document. | |
| 53 | + | /// | |
| 54 | + | /// Run on write as well as on read. A producer that emits a record it would | |
| 55 | + | /// itself reject has a bug now, not at the far end of a transfer where the | |
| 56 | + | /// error reads as corruption. | |
| 57 | + | pub fn validate(&self) -> Result<(), ContractError> { | |
| 58 | + | if self.contract != CONTRACT_VERSION { | |
| 59 | + | return Err(ContractError::UnknownContractVersion(self.contract)); | |
| 60 | + | } | |
| 61 | + | if self.producer.trim().is_empty() { | |
| 62 | + | return Err(ContractError::MissingProvenance("producer")); | |
| 63 | + | } | |
| 64 | + | if self.digest != self.manifest.digest() { | |
| 65 | + | return Err(ContractError::DigestMismatch { | |
| 66 | + | claimed: self.digest.to_string(), | |
| 67 | + | computed: self.manifest.digest().to_string(), | |
| 68 | + | }); | |
| 69 | + | } | |
| 70 | + | self.provenance.validate()?; | |
| 71 | + | // A builder has no environment, so it cannot have observed one. This is | |
| 72 | + | // the boundary refusing to be crossed by accident rather than a style | |
| 73 | + | // rule: an environment-scoped gate from a build host is either | |
| 74 | + | // mislabelled or measures a machine nothing will run on. | |
| 75 | + | for g in &self.gates { | |
| 76 | + | if let Scope::Environment { env } = &g.scope { | |
| 77 | + | return Err(ContractError::EnvironmentEvidenceFromBuilder { | |
| 78 | + | gate: g.gate.clone(), | |
| 79 | + | env: env.clone(), | |
| 80 | + | }); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | Ok(()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// True iff every gate the builder ran passed. Not a decision: whether the | |
| 87 | + | /// artifact advances is the evaluator's call, and it will have gates of its | |
| 88 | + | /// own that this record cannot speak to. | |
| 89 | + | pub fn all_gates_passed(&self) -> bool { | |
| 90 | + | self.gates.iter().all(|g| g.verdict.is_passed()) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | pub fn to_json(&self) -> String { | |
| 94 | + | // Pretty, with a trailing newline: these land on disk next to a bundle | |
| 95 | + | // and get read by a person before they are ever read by a program. | |
| 96 | + | let mut s = serde_json::to_string_pretty(self).unwrap_or_default(); | |
| 97 | + | s.push('\n'); | |
| 98 | + | s | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | pub fn parse(json: &str) -> Result<Self, ContractError> { | |
| 102 | + | let record: Self = serde_json::from_str(json).map_err(ContractError::Malformed)?; | |
| 103 | + | record.validate()?; | |
| 104 | + | Ok(record) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// What a later producer adds: I did not build this, and here is what I proved | |
| 109 | + | /// about it. | |
| 110 | + | /// | |
| 111 | + | /// Separate from [`ArtifactRecord`] so that appending evidence never rewrites | |
| 112 | + | /// the handover. Each producer owns one document; nobody edits anybody else's. | |
| 113 | + | /// `subject` is the only link, which is what makes the binding between evidence | |
| 114 | + | /// and bytes structural rather than a discipline each system has to remember. | |
| 115 | + | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 116 | + | pub struct EvidenceRecord { | |
| 117 | + | pub contract: u32, | |
| 118 | + | /// Which system produced this (`sando`). | |
| 119 | + | pub producer: String, | |
| 120 | + | /// The bundle this evidence is about. | |
| 121 | + | pub subject: BundleDigest, | |
| 122 | + | pub gates: Vec<GateRecord>, | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | impl EvidenceRecord { | |
| 126 | + | pub fn new( | |
| 127 | + | producer: impl Into<String>, | |
| 128 | + | subject: BundleDigest, | |
| 129 | + | gates: Vec<GateRecord>, | |
| 130 | + | ) -> Result<Self, ContractError> { | |
| 131 | + | let record = Self { | |
| 132 | + | contract: CONTRACT_VERSION, | |
| 133 | + | producer: producer.into(), | |
| 134 | + | subject, | |
| 135 | + | gates, | |
| 136 | + | }; | |
| 137 | + | record.validate()?; | |
| 138 | + | Ok(record) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | pub fn validate(&self) -> Result<(), ContractError> { | |
| 142 | + | if self.contract != CONTRACT_VERSION { | |
| 143 | + | return Err(ContractError::UnknownContractVersion(self.contract)); | |
| 144 | + | } | |
| 145 | + | if self.producer.trim().is_empty() { | |
| 146 | + | return Err(ContractError::MissingProvenance("producer")); | |
| 147 | + | } | |
| 148 | + | Ok(()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | pub fn to_json(&self) -> String { | |
| 152 | + | let mut s = serde_json::to_string_pretty(self).unwrap_or_default(); | |
| 153 | + | s.push('\n'); | |
| 154 | + | s | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | pub fn parse(json: &str) -> Result<Self, ContractError> { | |
| 158 | + | let record: Self = serde_json::from_str(json).map_err(ContractError::Malformed)?; | |
| 159 | + | record.validate()?; | |
| 160 | + | Ok(record) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | #[cfg(test)] | |
| 165 | + | mod tests { | |
| 166 | + | use super::*; | |
| 167 | + | use crate::Verdict; | |
| 168 | + | use chrono::{DateTime, Utc}; | |
| 169 | + | ||
| 170 | + | fn at() -> DateTime<Utc> { | |
| 171 | + | DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap() | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | fn manifest() -> Manifest { | |
| 175 | + | Manifest::new([ | |
| 176 | + | ("GoingsOn.AppImage", "1".repeat(64)), | |
| 177 | + | ("GoingsOn.AppImage.minisig", "2".repeat(64)), | |
| 178 | + | ]) | |
| 179 | + | .unwrap() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | fn provenance() -> Provenance { | |
| 183 | + | Provenance { | |
| 184 | + | app: "goingson".into(), | |
| 185 | + | version: "0.4.1".into(), | |
| 186 | + | tag: "v0.4.1".into(), | |
| 187 | + | git_sha: "a".repeat(40), | |
| 188 | + | target: "linux/x86_64".into(), | |
| 189 | + | build_host: "fw13".into(), | |
| 190 | + | toolchain: "rustc 1.97.0".into(), | |
| 191 | + | built_at: at(), | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn gate(name: &str, verdict: Verdict) -> GateRecord { | |
| 196 | + | GateRecord::new(name, Scope::Artifact, verdict, "ok", at()) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | fn record() -> ArtifactRecord { | |
| 200 | + | ArtifactRecord::new( | |
| 201 | + | "bento", | |
| 202 | + | manifest(), | |
| 203 | + | provenance(), | |
| 204 | + | vec![ | |
| 205 | + | gate("clippy", Verdict::Passed), | |
| 206 | + | gate("cargo_test", Verdict::Passed), | |
| 207 | + | ], | |
| 208 | + | ) | |
| 209 | + | .unwrap() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn the_digest_is_the_manifests_and_the_record_round_trips() { | |
| 214 | + | let r = record(); | |
| 215 | + | assert_eq!(r.digest, manifest().digest()); | |
| 216 | + | assert!(r.all_gates_passed()); | |
| 217 | + | let back = ArtifactRecord::parse(&r.to_json()).unwrap(); | |
| 218 | + | assert_eq!(r, back); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn a_record_whose_digest_does_not_match_its_manifest_is_refused() { | |
| 223 | + | // The whole point of the handover: a document claiming to describe | |
| 224 | + | // bytes it does not describe must not survive a read. | |
| 225 | + | let mut r = record(); | |
| 226 | + | r.digest = BundleDigest::parse(&"f".repeat(64)).unwrap(); | |
| 227 | + | assert!(matches!( | |
| 228 | + | r.validate().unwrap_err(), | |
| 229 | + | ContractError::DigestMismatch { .. } | |
| 230 | + | )); | |
| 231 | + | assert!(ArtifactRecord::parse(&serde_json::to_string(&r).unwrap()).is_err()); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[test] | |
| 235 | + | fn a_builder_cannot_hand_over_environment_evidence() { | |
| 236 | + | let g = GateRecord::new( | |
| 237 | + | "boot_smoke", | |
| 238 | + | Scope::Environment { | |
| 239 | + | env: "tier:a".into(), | |
| 240 | + | }, | |
| 241 | + | Verdict::Passed, | |
| 242 | + | "served /health", | |
| 243 | + | at(), | |
| 244 | + | ); | |
| 245 | + | let err = ArtifactRecord::new("bento", manifest(), provenance(), vec![g]).unwrap_err(); | |
| 246 | + | assert!( | |
| 247 | + | matches!(err, ContractError::EnvironmentEvidenceFromBuilder { gate, env } | |
| 248 | + | if gate == "boot_smoke" && env == "tier:a") | |
| 249 | + | ); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[test] | |
| 253 | + | fn a_failing_gate_does_not_invalidate_the_record() { | |
| 254 | + | // A record of a failed build is a legitimate document. Refusing to emit | |
| 255 | + | // one would leave the only evidence of the failure in a log. | |
| 256 | + | let r = ArtifactRecord::new( | |
| 257 | + | "bento", | |
| 258 | + | manifest(), | |
| 259 | + | provenance(), | |
| 260 | + | vec![gate("cargo_test", Verdict::Failed)], | |
| 261 | + | ) | |
| 262 | + | .unwrap(); | |
| 263 | + | assert!(!r.all_gates_passed()); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn a_future_contract_version_is_refused_rather_than_guessed_at() { | |
| 268 | + | let mut r = record(); | |
| 269 | + | r.contract = CONTRACT_VERSION + 1; | |
| 270 | + | assert!(matches!( | |
| 271 | + | r.validate().unwrap_err(), | |
| 272 | + | ContractError::UnknownContractVersion(_) | |
| 273 | + | )); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn evidence_names_the_bundle_it_is_about_and_may_be_environment_scoped() { | |
| 278 | + | let e = EvidenceRecord::new( | |
| 279 | + | "sando", | |
| 280 | + | manifest().digest(), | |
| 281 | + | vec![GateRecord::new( | |
| 282 | + | "burn_in", | |
| 283 | + | Scope::Environment { | |
| 284 | + | env: "tier:a".into(), | |
| 285 | + | }, | |
| 286 | + | Verdict::Blocked, | |
| 287 | + | "12 hours remaining of 48", | |
| 288 | + | at(), | |
| 289 | + | )], | |
| 290 | + | ) | |
| 291 | + | .unwrap(); | |
| 292 | + | let back = EvidenceRecord::parse(&e.to_json()).unwrap(); | |
| 293 | + | assert_eq!(back.subject, manifest().digest()); | |
| 294 | + | assert_eq!(back, e); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[test] | |
| 298 | + | fn appending_evidence_does_not_touch_the_handover() { | |
| 299 | + | // Two documents, one subject. The evaluator's write cannot change the | |
| 300 | + | // builder's document, and neither can change the digest. | |
| 301 | + | let handover = record(); | |
| 302 | + | let _ = EvidenceRecord::new("sando", handover.digest.clone(), vec![]).unwrap(); | |
| 303 | + | assert_eq!(handover, record()); | |
| 304 | + | assert_eq!(handover.digest, manifest().digest()); | |
| 305 | + | } | |
| 306 | + | } |