max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+1274 insertions,
-68 deletions
| @@ -34,6 +34,7 @@ | |||
| 34 | 34 | "sha2 0.10.9", | |
| 35 | 35 | "toml", | |
| 36 | 36 | "toml_edit", | |
| 37 | + | "zbus", | |
| 37 | 38 | ] | |
| 38 | 39 | ||
| 39 | 40 | [[package]] | |
| @@ -113,6 +114,137 @@ | |||
| 113 | 114 | "num-traits", | |
| 114 | 115 | ] | |
| 115 | 116 | ||
| 117 | + | [[package]] | |
| 118 | + | name = "async-broadcast" | |
| 119 | + | version = "0.7.2" | |
| 120 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 121 | + | checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" | |
| 122 | + | dependencies = [ | |
| 123 | + | "event-listener", | |
| 124 | + | "event-listener-strategy", | |
| 125 | + | "futures-core", | |
| 126 | + | "pin-project-lite", | |
| 127 | + | ] | |
| 128 | + | ||
| 129 | + | [[package]] | |
| 130 | + | name = "async-channel" | |
| 131 | + | version = "2.5.0" | |
| 132 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 133 | + | checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" | |
| 134 | + | dependencies = [ | |
| 135 | + | "concurrent-queue", | |
| 136 | + | "event-listener-strategy", | |
| 137 | + | "futures-core", | |
| 138 | + | "pin-project-lite", | |
| 139 | + | ] | |
| 140 | + | ||
| 141 | + | [[package]] | |
| 142 | + | name = "async-executor" | |
| 143 | + | version = "1.14.0" | |
| 144 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 145 | + | checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" | |
| 146 | + | dependencies = [ | |
| 147 | + | "async-task", | |
| 148 | + | "concurrent-queue", | |
| 149 | + | "fastrand", | |
| 150 | + | "futures-lite", | |
| 151 | + | "pin-project-lite", | |
| 152 | + | "slab", | |
| 153 | + | ] | |
| 154 | + | ||
| 155 | + | [[package]] | |
| 156 | + | name = "async-io" | |
| 157 | + | version = "2.6.0" | |
| 158 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 159 | + | checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" | |
| 160 | + | dependencies = [ | |
| 161 | + | "autocfg", | |
| 162 | + | "cfg-if", | |
| 163 | + | "concurrent-queue", | |
| 164 | + | "futures-io", | |
| 165 | + | "futures-lite", | |
| 166 | + | "parking", | |
| 167 | + | "polling", | |
| 168 | + | "rustix", | |
| 169 | + | "slab", | |
| 170 | + | "windows-sys", | |
| 171 | + | ] | |
| 172 | + | ||
| 173 | + | [[package]] | |
| 174 | + | name = "async-lock" | |
| 175 | + | version = "3.4.2" | |
| 176 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 177 | + | checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" | |
| 178 | + | dependencies = [ | |
| 179 | + | "event-listener", | |
| 180 | + | "event-listener-strategy", | |
| 181 | + | "pin-project-lite", | |
| 182 | + | ] | |
| 183 | + | ||
| 184 | + | [[package]] | |
| 185 | + | name = "async-process" | |
| 186 | + | version = "2.5.0" | |
| 187 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 188 | + | checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" | |
| 189 | + | dependencies = [ | |
| 190 | + | "async-channel", | |
| 191 | + | "async-io", | |
| 192 | + | "async-lock", | |
| 193 | + | "async-signal", | |
| 194 | + | "async-task", | |
| 195 | + | "blocking", | |
| 196 | + | "cfg-if", | |
| 197 | + | "event-listener", | |
| 198 | + | "futures-lite", | |
| 199 | + | "rustix", | |
| 200 | + | ] | |
| 201 | + | ||
| 202 | + | [[package]] | |
| 203 | + | name = "async-recursion" | |
| 204 | + | version = "1.1.1" | |
| 205 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 206 | + | checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" | |
| 207 | + | dependencies = [ | |
| 208 | + | "proc-macro2", | |
| 209 | + | "quote", | |
| 210 | + | "syn 2.0.118", | |
| 211 | + | ] | |
| 212 | + | ||
| 213 | + | [[package]] | |
| 214 | + | name = "async-signal" | |
| 215 | + | version = "0.2.14" | |
| 216 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 217 | + | checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" | |
| 218 | + | dependencies = [ | |
| 219 | + | "async-io", | |
| 220 | + | "async-lock", | |
| 221 | + | "atomic-waker", | |
| 222 | + | "cfg-if", | |
| 223 | + | "futures-core", | |
| 224 | + | "futures-io", | |
| 225 | + | "rustix", | |
| 226 | + | "signal-hook-registry", | |
| 227 | + | "slab", | |
| 228 | + | "windows-sys", | |
| 229 | + | ] | |
| 230 | + | ||
| 231 | + | [[package]] | |
| 232 | + | name = "async-task" | |
| 233 | + | version = "4.7.1" | |
| 234 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 235 | + | checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" | |
| 236 | + | ||
| 237 | + | [[package]] | |
| 238 | + | name = "async-trait" | |
| 239 | + | version = "0.1.92" | |
| 240 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 241 | + | checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" | |
| 242 | + | dependencies = [ | |
| 243 | + | "proc-macro2", | |
| 244 | + | "quote", | |
| 245 | + | "syn 3.0.0", | |
| 246 | + | ] | |
| 247 | + | ||
| 116 | 248 | [[package]] | |
| 117 | 249 | name = "atomic" | |
| 118 | 250 | version = "0.6.1" | |
| @@ -122,6 +254,12 @@ | |||
| 122 | 254 | "bytemuck", | |
| 123 | 255 | ] | |
| 124 | 256 | ||
| 257 | + | [[package]] | |
| 258 | + | name = "atomic-waker" | |
| 259 | + | version = "1.1.2" | |
| 260 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 261 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 262 | + | ||
| 125 | 263 | [[package]] | |
| 126 | 264 | name = "autocfg" | |
| 127 | 265 | version = "1.5.1" | |
| @@ -185,6 +323,19 @@ | |||
| 185 | 323 | "hybrid-array", | |
| 186 | 324 | ] | |
| 187 | 325 | ||
| 326 | + | [[package]] | |
| 327 | + | name = "blocking" | |
| 328 | + | version = "1.6.2" | |
| 329 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 330 | + | checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" | |
| 331 | + | dependencies = [ | |
| 332 | + | "async-channel", | |
| 333 | + | "async-task", | |
| 334 | + | "futures-io", | |
| 335 | + | "futures-lite", | |
| 336 | + | "piper", | |
| 337 | + | ] | |
| 338 | + | ||
| 188 | 339 | [[package]] | |
| 189 | 340 | name = "bumpalo" | |
| 190 | 341 | version = "3.20.3" | |
| @@ -300,6 +451,15 @@ | |||
| 300 | 451 | "static_assertions", | |
| 301 | 452 | ] | |
| 302 | 453 | ||
| 454 | + | [[package]] | |
| 455 | + | name = "concurrent-queue" | |
| 456 | + | version = "2.5.0" | |
| 457 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 458 | + | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 459 | + | dependencies = [ | |
| 460 | + | "crossbeam-utils", | |
| 461 | + | ] | |
| 462 | + | ||
| 303 | 463 | [[package]] | |
| 304 | 464 | name = "convert_case" | |
| 305 | 465 | version = "0.10.0" | |
| @@ -333,6 +493,12 @@ | |||
| 333 | 493 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 334 | 494 | checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" | |
| 335 | 495 | ||
| 496 | + | [[package]] | |
| 497 | + | name = "crossbeam-utils" | |
| 498 | + | version = "0.8.22" | |
| 499 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 500 | + | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" | |
| 501 | + | ||
| 336 | 502 | [[package]] | |
| 337 | 503 | name = "crossterm" | |
| 338 | 504 | version = "0.29.0" | |
| @@ -502,6 +668,33 @@ | |||
| 502 | 668 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 503 | 669 | checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" | |
| 504 | 670 | ||
| 671 | + | [[package]] | |
| 672 | + | name = "endi" | |
| 673 | + | version = "1.1.1" | |
| 674 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 675 | + | checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" | |
| 676 | + | ||
| 677 | + | [[package]] | |
| 678 | + | name = "enumflags2" | |
| 679 | + | version = "0.7.12" | |
| 680 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 681 | + | checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" | |
| 682 | + | dependencies = [ | |
| 683 | + | "enumflags2_derive", | |
| 684 | + | "serde", | |
| 685 | + | ] | |
| 686 | + | ||
| 687 | + | [[package]] | |
| 688 | + | name = "enumflags2_derive" | |
| 689 | + | version = "0.7.12" | |
| 690 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 691 | + | checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" | |
| 692 | + | dependencies = [ | |
| 693 | + | "proc-macro2", | |
| 694 | + | "quote", | |
| 695 | + | "syn 2.0.118", | |
| 696 | + | ] | |
| 697 | + | ||
| 505 | 698 | [[package]] | |
| 506 | 699 | name = "equivalent" | |
| 507 | 700 | version = "1.0.2" | |
| @@ -527,6 +720,26 @@ | |||
| 527 | 720 | "num-traits", | |
| 528 | 721 | ] | |
| 529 | 722 | ||
| 723 | + | [[package]] | |
| 724 | + | name = "event-listener" | |
| 725 | + | version = "5.4.2" | |
| 726 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 727 | + | checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" | |
| 728 | + | dependencies = [ | |
| 729 | + | "parking", | |
| 730 | + | "pin-project-lite", | |
| 731 | + | ] | |
| 732 | + | ||
| 733 | + | [[package]] | |
| 734 | + | name = "event-listener-strategy" | |
| 735 | + | version = "0.5.4" | |
| 736 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 737 | + | checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" | |
| 738 | + | dependencies = [ | |
| 739 | + | "event-listener", | |
| 740 | + | "pin-project-lite", | |
| 741 | + | ] | |
| 742 | + | ||
| 530 | 743 | [[package]] | |
| 531 | 744 | name = "fallible-iterator" | |
| 532 | 745 | version = "0.3.0" | |
| @@ -555,6 +768,12 @@ | |||
| 555 | 768 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 556 | 769 | checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" | |
| 557 | 770 | ||
| 771 | + | [[package]] | |
| 772 | + | name = "fastrand" | |
| 773 | + | version = "2.5.0" | |
| 774 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 775 | + | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | |
| 776 | + | ||
| 558 | 777 | [[package]] | |
| 559 | 778 | name = "filedescriptor" | |
| 560 | 779 | version = "0.8.3" | |
| @@ -602,6 +821,25 @@ | |||
| 602 | 821 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 603 | 822 | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | |
| 604 | 823 | ||
| 824 | + | [[package]] | |
| 825 | + | name = "futures-io" | |
| 826 | + | version = "0.3.34" | |
| 827 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 828 | + | checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" | |
| 829 | + | ||
| 830 | + | [[package]] | |
| 831 | + | name = "futures-lite" | |
| 832 | + | version = "2.6.1" | |
| 833 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 834 | + | checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" | |
| 835 | + | dependencies = [ | |
| 836 | + | "fastrand", | |
| 837 | + | "futures-core", | |
| 838 | + | "futures-io", | |
| 839 | + | "parking", | |
| 840 | + | "pin-project-lite", | |
| 841 | + | ] | |
| 842 | + | ||
| 605 | 843 | [[package]] | |
| 606 | 844 | name = "futures-task" | |
| 607 | 845 | version = "0.3.32" | |
| @@ -690,6 +928,12 @@ | |||
| 690 | 928 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 691 | 929 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 692 | 930 | ||
| 931 | + | [[package]] | |
| 932 | + | name = "hermit-abi" | |
| 933 | + | version = "0.5.2" | |
| 934 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 935 | + | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | |
| 936 | + | ||
| 693 | 937 | [[package]] | |
| 694 | 938 | name = "hex" | |
| 695 | 939 | version = "0.4.3" | |
| @@ -1056,6 +1300,16 @@ | |||
| 1056 | 1300 | "num-traits", | |
| 1057 | 1301 | ] | |
| 1058 | 1302 | ||
| 1303 | + | [[package]] | |
| 1304 | + | name = "ordered-stream" | |
| 1305 | + | version = "0.2.0" | |
| 1306 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1307 | + | checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" | |
| 1308 | + | dependencies = [ | |
| 1309 | + | "futures-core", | |
| 1310 | + | "pin-project-lite", | |
| 1311 | + | ] | |
| 1312 | + | ||
| 1059 | 1313 | [[package]] | |
| 1060 | 1314 | name = "palette" | |
| 1061 | 1315 | version = "0.7.6" | |
| @@ -1080,6 +1334,12 @@ | |||
| 1080 | 1334 | "syn 2.0.118", | |
| 1081 | 1335 | ] | |
| 1082 | 1336 | ||
| 1337 | + | [[package]] | |
| 1338 | + | name = "parking" | |
| 1339 | + | version = "2.2.1" | |
| 1340 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1341 | + | checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" | |
| 1342 | + | ||
| 1083 | 1343 | [[package]] | |
| 1084 | 1344 | name = "parking_lot" | |
| 1085 | 1345 | version = "0.12.5" | |
| @@ -1209,12 +1469,37 @@ | |||
| 1209 | 1469 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1210 | 1470 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 1211 | 1471 | ||
| 1472 | + | [[package]] | |
| 1473 | + | name = "piper" | |
| 1474 | + | version = "0.2.5" | |
| 1475 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1476 | + | checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" | |
| 1477 | + | dependencies = [ | |
| 1478 | + | "atomic-waker", | |
| 1479 | + | "fastrand", | |
| 1480 | + | "futures-io", | |
| 1481 | + | ] | |
| 1482 | + | ||
| 1212 | 1483 | [[package]] | |
| 1213 | 1484 | name = "pkg-config" | |
| 1214 | 1485 | version = "0.3.33" | |
| 1215 | 1486 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1216 | 1487 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 1217 | 1488 | ||
| 1489 | + | [[package]] | |
| 1490 | + | name = "polling" | |
| 1491 | + | version = "3.11.0" | |
| 1492 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1493 | + | checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" | |
| 1494 | + | dependencies = [ | |
| 1495 | + | "cfg-if", | |
| 1496 | + | "concurrent-queue", | |
| 1497 | + | "hermit-abi", | |
| 1498 | + | "pin-project-lite", | |
| 1499 | + | "rustix", | |
| 1500 | + | "windows-sys", | |
| 1501 | + | ] | |
| 1502 | + | ||
| 1218 | 1503 | [[package]] | |
| 1219 | 1504 | name = "portable-atomic" | |
| 1220 | 1505 | version = "1.14.0" | |
| @@ -1227,6 +1512,15 @@ | |||
| 1227 | 1512 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1228 | 1513 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | |
| 1229 | 1514 | ||
| 1515 | + | [[package]] | |
| 1516 | + | name = "proc-macro-crate" | |
| 1517 | + | version = "3.5.0" | |
| 1518 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1519 | + | checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" | |
| 1520 | + | dependencies = [ | |
| 1521 | + | "toml_edit", | |
| 1522 | + | ] | |
| 1523 | + | ||
| 1230 | 1524 | [[package]] | |
| 1231 | 1525 | name = "proc-macro2" | |
| 1232 | 1526 | version = "1.0.106" | |
| @@ -1525,6 +1819,17 @@ | |||
| 1525 | 1819 | "zmij", | |
| 1526 | 1820 | ] | |
| 1527 | 1821 | ||
| 1822 | + | [[package]] | |
| 1823 | + | name = "serde_repr" | |
| 1824 | + | version = "0.1.21" | |
| 1825 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1826 | + | checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" | |
| 1827 | + | dependencies = [ | |
| 1828 | + | "proc-macro2", | |
| 1829 | + | "quote", | |
| 1830 | + | "syn 3.0.0", | |
| 1831 | + | ] | |
| 1832 | + | ||
| 1528 | 1833 | [[package]] | |
| 1529 | 1834 | name = "serde_spanned" | |
| 1530 | 1835 | version = "1.1.1" | |
| @@ -1712,6 +2017,19 @@ | |||
| 1712 | 2017 | "unicode-ident", | |
| 1713 | 2018 | ] | |
| 1714 | 2019 | ||
| 2020 | + | [[package]] | |
| 2021 | + | name = "tempfile" | |
| 2022 | + | version = "3.27.0" | |
| 2023 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2024 | + | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 2025 | + | dependencies = [ | |
| 2026 | + | "fastrand", | |
| 2027 | + | "getrandom 0.4.3", | |
| 2028 | + | "once_cell", | |
| 2029 | + | "rustix", | |
| 2030 | + | "windows-sys", | |
| 2031 | + | ] | |
| 2032 | + | ||
| 1715 | 2033 | [[package]] | |
| 1716 | 2034 | name = "termina" | |
| 1717 | 2035 | version = "0.3.3" | |
| @@ -1901,6 +2219,37 @@ | |||
| 1901 | 2219 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1902 | 2220 | checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" | |
| 1903 | 2221 | ||
| 2222 | + | [[package]] | |
| 2223 | + | name = "tracing" | |
| 2224 | + | version = "0.1.44" | |
| 2225 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2226 | + | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | |
| 2227 | + | dependencies = [ | |
| 2228 | + | "pin-project-lite", | |
| 2229 | + | "tracing-attributes", | |
| 2230 | + | "tracing-core", | |
| 2231 | + | ] | |
| 2232 | + | ||
| 2233 | + | [[package]] | |
| 2234 | + | name = "tracing-attributes" | |
| 2235 | + | version = "0.1.31" | |
| 2236 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2237 | + | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 2238 | + | dependencies = [ | |
| 2239 | + | "proc-macro2", | |
| 2240 | + | "quote", | |
| 2241 | + | "syn 2.0.118", | |
| 2242 | + | ] | |
| 2243 | + | ||
| 2244 | + | [[package]] | |
| 2245 | + | name = "tracing-core" | |
| 2246 | + | version = "0.1.36" | |
| 2247 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2248 | + | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | |
| 2249 | + | dependencies = [ | |
| 2250 | + | "once_cell", | |
| 2251 | + | ] | |
| 2252 | + | ||
| 1904 | 2253 | [[package]] | |
| 1905 | 2254 | name = "typenum" | |
| 1906 | 2255 | version = "1.20.1" | |
| @@ -1913,6 +2262,17 @@ | |||
| 1913 | 2262 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1914 | 2263 | checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" | |
| 1915 | 2264 | ||
| 2265 | + | [[package]] | |
| 2266 | + | name = "uds_windows" | |
| 2267 | + | version = "1.2.1" | |
| 2268 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2269 | + | checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" | |
| 2270 | + | dependencies = [ | |
| 2271 | + | "memoffset", | |
| 2272 | + | "tempfile", | |
| 2273 | + | "windows-sys", | |
| 2274 | + | ] | |
| 2275 | + | ||
| 1916 | 2276 | [[package]] | |
| 1917 | 2277 | name = "unicode-ident" | |
| 1918 | 2278 | version = "1.0.24" | |
| @@ -1957,6 +2317,7 @@ | |||
| 1957 | 2317 | "atomic", | |
| 1958 | 2318 | "getrandom 0.4.3", | |
| 1959 | 2319 | "js-sys", | |
| 2320 | + | "serde_core", | |
| 1960 | 2321 | "wasm-bindgen", | |
| 1961 | 2322 | ] | |
| 1962 | 2323 | ||
| @@ -2165,31 +2526,150 @@ | |||
| 2165 | 2526 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2166 | 2527 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" | |
| 2167 | 2528 | ||
| 2529 | + | [[package]] | |
| 2530 | + | name = "zbus" | |
| 2531 | + | version = "5.19.0" | |
| 2532 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2533 | + | checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" | |
| 2534 | + | dependencies = [ | |
| 2535 | + | "async-broadcast", | |
| 2536 | + | "async-executor", | |
| 2537 | + | "async-io", | |
| 2538 | + | "async-lock", |
Lines truncated
| @@ -1897,6 +1897,39 @@ | |||
| 1897 | 1897 | || { echo "no pkttyagent; the console has no way to answer a polkit prompt" >&2; exit 1; }; \ | |
| 1898 | 1898 | echo "pkttyagent: present" | |
| 1899 | 1899 | ||
| 1900 | + | # ===================================================================== | |
| 1901 | + | # polkit-agent-helper-1 — the same question for tier 3. | |
| 1902 | + | # | |
| 1903 | + | # The console registers an authentication agent of its own and draws the | |
| 1904 | + | # prompt in Akari (crates/alloy/src/polkit.rs, `Flow::AuthorizeInline`). | |
| 1905 | + | # What it does *not* do is run the PAM conversation: that is this setuid | |
| 1906 | + | # helper's, deliberately, and it is the only reason an agent drawn by us | |
| 1907 | + | # is not an authentication surface written by us. | |
| 1908 | + | # | |
| 1909 | + | # Checked for the same reason and against the same failure as pkttyagent | |
| 1910 | + | # above — it ships inside the polkit package and a base that split it out | |
| 1911 | + | # is silent at build time. Distinct from that check because it is a path | |
| 1912 | + | # rather than a command on `$PATH`, and because the two are used by two | |
| 1913 | + | # different tiers: losing this one leaves tier 2 working and takes away | |
| 1914 | + | # the only way to join a wifi network, which needs a passphrase on stdin | |
| 1915 | + | # that a suspended child has no pipe for. | |
| 1916 | + | # | |
| 1917 | + | # `-u` as well as `-x`: an agent helper that is not setuid root cannot | |
| 1918 | + | # read the shadow file, so it would run, fail every password, and say | |
| 1919 | + | # nothing about why. | |
| 1920 | + | # ===================================================================== | |
| 1921 | + | RUN set -eux; \ | |
| 1922 | + | helper=; \ | |
| 1923 | + | for candidate in /usr/lib/polkit-1/polkit-agent-helper-1 \ | |
| 1924 | + | /usr/libexec/polkit-1/polkit-agent-helper-1; do \ | |
| 1925 | + | [ -x "$candidate" ] && helper="$candidate" && break; \ | |
| 1926 | + | done; \ | |
| 1927 | + | [ -n "$helper" ] \ | |
| 1928 | + | || { echo "no polkit-agent-helper-1; the console cannot answer polkit without leaving the TUI" >&2; exit 1; }; \ | |
| 1929 | + | [ -u "$helper" ] \ | |
| 1930 | + | || { echo "$helper is not setuid; every password it is given would fail" >&2; exit 1; }; \ | |
| 1931 | + | echo "polkit-agent-helper-1: $helper, setuid" | |
| 1932 | + | ||
| 1900 | 1933 | # ===================================================================== | |
| 1901 | 1934 | # run0 — assert the way to root that Alloy documents is in the image. | |
| 1902 | 1935 | # |
| @@ -143,9 +143,22 @@ | |||
| 143 | 143 | `alloy net` writes as well as reads: connect, disconnect, and the wifi radio. Those three | |
| 144 | 144 | need no privilege at all, which is a fact rather than a design: NetworkManager's shipped | |
| 145 | 145 | policy grants `network-control` and `enable-disable-wifi` to an active session outright. | |
| 146 | - | Joining a *new* network is `settings.modify.system` and does need authorization, so it | |
| 147 | - | waits on the ladder in wiki note `alloy-privilege`. Nothing in the console escalates | |
| 148 | - | anything today. | |
| 146 | + | ||
| 147 | + | Joining a *new* network is `settings.modify.system`, which the shipped rule deliberately | |
| 148 | + | does not grant, so it needs an answer every time. The screen asks three questions in three | |
| 149 | + | modes — which device, which network in range, what is the passphrase — and Esc walks back | |
| 150 | + | through them. The passphrase is never an argument: `nmcli --ask` prompts for it and reads | |
| 151 | + | the prompt from stdin, so it does not appear in `ps` for every user on the machine the way | |
| 152 | + | `device wifi connect SSID password PW` would. | |
| 153 | + | ||
| 154 | + | That one action is what forced tier 3 of wiki `alloy-privilege`, because tier 2 cannot | |
| 155 | + | reach it. Tier 2 answers polkit by suspending the console and running the command under | |
| 156 | + | `pkttyagent`; a suspended child inherits the terminal's stdio, so there is no pipe left to | |
| 157 | + | carry the passphrase on, and a secret and a suspend cannot both be had. So the join runs | |
| 158 | + | beside the event loop with an authentication agent the console registers for its own | |
| 159 | + | process, and polkit's question arrives as a modal in Akari naming the action. The PAM | |
| 160 | + | conversation is still polkit's own setuid helper — Alloy supplies the cookie, draws the | |
| 161 | + | prompt, and hands back what was typed, and decides nothing about whether it was right. | |
| 149 | 162 | ||
| 150 | 163 | `alloy disk` is the one pane that rewrites a partition table, and the only place | |
| 151 | 164 | in the console where a keypress can destroy data that was not already being |
| @@ -45,5 +45,16 @@ | |||
| 45 | 45 | # the crate. | |
| 46 | 46 | rusqlite = { version = "0.40", features = ["bundled"] } | |
| 47 | 47 | ||
| 48 | + | # The system bus, for the polkit authentication agent in `polkit.rs` (tier 3 of | |
| 49 | + | # wiki `alloy-privilege`). Pure Rust rather than FFI into libpolkit-agent-1, | |
| 50 | + | # which is GLib: the console's whole position is that it links no toolkit, and | |
| 51 | + | # a D-Bus client is the one part of an agent that is genuinely mechanical. | |
| 52 | + | # | |
| 53 | + | # `blocking` because the console is a synchronous event loop and always will be. | |
| 54 | + | # The feature still starts an executor thread of its own inside zbus, which is | |
| 55 | + | # where the agent's callbacks are served from; what it avoids is an async colour | |
| 56 | + | # spreading through a TUI that has one thread and one frame at a time. | |
| 57 | + | zbus = { version = "5", default-features = false, features = ["blocking-api", "async-io"] } | |
| 58 | + | ||
| 48 | 59 | [lints] | |
| 49 | 60 | workspace = true |
| @@ -19,6 +19,7 @@ | |||
| 19 | 19 | mod monitors; | |
| 20 | 20 | mod net; | |
| 21 | 21 | mod pkg; | |
| 22 | + | mod polkit; | |
| 22 | 23 | mod profile; | |
| 23 | 24 | mod recovery; | |
| 24 | 25 | mod run; |
| @@ -585,6 +585,13 @@ | |||
| 585 | 585 | /// does. | |
| 586 | 586 | wifi: Option<bool>, | |
| 587 | 587 | mode: Mode, | |
| 588 | + | /// The network a join is in flight for, so its outcome can be reported | |
| 589 | + | /// against a name. | |
| 590 | + | /// | |
| 591 | + | /// Held here rather than read back out of [`Mode`] when the answer arrives, | |
| 592 | + | /// because the mode has moved on by then: the passphrase is gone the moment | |
| 593 | + | /// it becomes a [`Secret`], which is the point. | |
| 594 | + | pending: Option<String>, | |
| 588 | 595 | } | |
| 589 | 596 | ||
| 590 | 597 | impl NetView { | |
| @@ -596,6 +603,7 @@ | |||
| 596 | 603 | error: None, | |
| 597 | 604 | wifi: None, | |
| 598 | 605 | mode: Mode::Devices, | |
| 606 | + | pending: None, | |
| 599 | 607 | }; | |
| 600 | 608 | view.refresh(log); | |
| 601 | 609 | view | |
| @@ -639,77 +647,59 @@ | |||
| 639 | 647 | } | |
| 640 | 648 | ||
| 641 | 649 | /// Act on the selected network: join an open one, ask about a secured one. | |
| 642 | - | fn choose(&mut self, log: &mut CommandLog) { | |
| 650 | + | fn choose(&mut self) -> Flow { | |
| 643 | 651 | let Mode::Networks { networks, cursor } = &self.mode else { | |
| 644 | - | return; | |
| 652 | + | return Flow::Continue; | |
| 645 | 653 | }; | |
| 646 | 654 | let Some(network) = cursor.selected().and_then(|index| networks.get(index)) else { | |
| 647 | - | return; | |
| 655 | + | return Flow::Continue; | |
| 648 | 656 | }; | |
| 649 | 657 | ||
| 650 | 658 | if network.security.is_none() { | |
| 651 | 659 | let ssid = network.ssid.clone(); | |
| 652 | - | self.join(&ssid, None, log); | |
| 653 | - | return; | |
| 660 | + | return self.join(&ssid, None); | |
| 654 | 661 | } | |
| 655 | 662 | self.mode = Mode::Passphrase { | |
| 656 | 663 | ssid: network.ssid.clone(), | |
| 657 | 664 | field: TextField::new(), | |
| 658 | 665 | }; | |
| 666 | + | Flow::Continue | |
| 659 | 667 | } | |
| 660 | 668 | ||
| 661 | 669 | /// Join with what has been typed. | |
| 662 | - | fn submit(&mut self, log: &mut CommandLog) { | |
| 663 | - | let Mode::Passphrase { ssid, field } = &self.mode else { | |
| 664 | - | return; | |
| 670 | + | /// | |
| 671 | + | /// The passphrase leaves the [`TextField`] here and does not go back: the | |
| 672 | + | /// field is emptied in the same breath as the [`Secret`] is built, so a | |
| 673 | + | /// prompt that fails and is asked again starts from nothing rather than | |
| 674 | + | /// from a value still sitting in a widget. | |
| 675 | + | fn submit(&mut self) -> Flow { | |
| 676 | + | let Mode::Passphrase { ssid, field } = &mut self.mode else { | |
| 677 | + | return Flow::Continue; | |
| 665 | 678 | }; | |
| 666 | 679 | let ssid = ssid.clone(); | |
| 667 | 680 | let secret = Secret::new(field.value().as_bytes().to_vec()); | |
| 668 | - | self.join(&ssid, Some(secret), log); | |
| 681 | + | field.set(""); | |
| 682 | + | self.join(&ssid, Some(secret)) | |
| 669 | 683 | } | |
| 670 | 684 | ||
| 671 | - | /// Run the join, and say what happened. | |
| 685 | + | /// Hand the join to the shell, which runs it with an agent to answer polkit. | |
| 672 | 686 | /// | |
| 673 | - | /// Success returns to the device list, because that is where the answer is: | |
| 674 | - | /// the interface the user was looking at now says `connected` and names the | |
| 675 | - | /// network. Staying on the scan would mean reporting the outcome in a | |
| 676 | - | /// sentence beside a list that has not changed. | |
| 677 | - | fn join(&mut self, ssid: &str, passphrase: Option<Secret>, log: &mut CommandLog) { | |
| 687 | + | /// [`Flow::AuthorizeInline`] rather than running it here, and this screen is | |
| 688 | + | /// the reason that flow exists. Joining a network NM has not saved is | |
| 689 | + | /// `settings.modify.system`, which `50-alloy-settings.rules` deliberately | |
| 690 | + | /// does not grant ("saving a new connection is a real administrative act"), | |
| 691 | + | /// so it always wants an answer. Tier 2 cannot give one: it suspends, a | |
| 692 | + | /// suspended child inherits the terminal's stdio, and there is then no pipe | |
| 693 | + | /// for the passphrase. So the command runs beside the event loop with the | |
| 694 | + | /// console's own polkit agent registered, and both questions — polkit's and | |
| 695 | + | /// the passphrase — are asked on screen. | |
| 696 | + | fn join(&mut self, ssid: &str, passphrase: Option<Secret>) -> Flow { | |
| 678 | 697 | let Some(invocation) = self.backend.join(ssid, passphrase) else { | |
| 679 | 698 | self.error = Some(format!("{} cannot join a network", self.backend.name())); | |
| 680 | - | return; | |
| 699 | + | return Flow::Continue; | |
| 681 | 700 | }; | |
| 682 | - | ||
| 683 | - | // stdout is dropped rather than reported, and that is not tidiness. | |
| 684 | - | // Fed a pipe, nmcli's prompt echoes what it reads, so the passphrase | |
| 685 | - | // can be in the output of a command that carried it privately in every | |
| 686 | - | // other respect. `capture` reports failures out of stderr, which the | |
| 687 | - | // echo does not reach, so nothing that surfaces has been near it. | |
| 688 | - | match invocation.run(log).map(drop) { | |
| 689 | - | Ok(()) => { | |
| 690 | - | self.mode = Mode::Devices; | |
| 691 | - | self.error = None; | |
| 692 | - | log.quiet(|log| self.refresh(log)); | |
| 693 | - | } | |
| 694 | - | Err(err) => { | |
| 695 | - | // Tier 2 of wiki `alloy-privilege` cannot help here, and this | |
| 696 | - | // is the one place in the console where that is true. Joining a | |
| 697 | - | // network NM has not saved is `settings.modify.system`, which | |
| 698 | - | // `50-alloy-settings.rules` deliberately does not grant, so it | |
| 699 | - | // always wants an answer; and `Flow::Authorize` reaches it by | |
| 700 | - | // suspending the TUI and handing the command to `pkttyagent`, | |
| 701 | - | // which inherits stdio and so has no pipe to carry the | |
| 702 | - | // passphrase on. A secret and a suspend cannot both be had. | |
| 703 | - | // That is what tier 3 is for, and until it lands the honest | |
| 704 | - | // thing is to name the wall rather than to suspend into a | |
| 705 | - | // command that would arrive without its passphrase. | |
| 706 | - | self.error = Some(if crate::cli::wants_authentication(&err) { | |
| 707 | - | format!("joining {ssid} needs authorization this console cannot ask for yet") | |
| 708 | - | } else { | |
| 709 | - | err.to_string() | |
| 710 | - | }); | |
| 711 | - | } | |
| 712 | - | } | |
| 701 | + | self.pending = Some(ssid.to_string()); | |
| 702 | + | Flow::AuthorizeInline(invocation) | |
| 713 | 703 | } | |
| 714 | 704 | ||
| 715 | 705 | /// Whether the selected interface has a connect action behind it, which on | |
| @@ -976,6 +966,47 @@ | |||
| 976 | 966 | (self.wifi == Some(false)).then(|| (Severity::Warn, "wifi radio off".to_string())) | |
| 977 | 967 | } | |
| 978 | 968 | ||
| 969 | + | /// The join finished, one way or the other. | |
| 970 | + | /// | |
| 971 | + | /// Success returns to the device list, because that is where the answer is: | |
| 972 | + | /// the interface the user was looking at now says `connected` and names the | |
| 973 | + | /// network. Staying on the scan would mean reporting the outcome in a | |
| 974 | + | /// sentence beside a list that has not changed. | |
| 975 | + | fn authorized(&mut self, outcome: Result<String>, log: &mut CommandLog) { | |
| 976 | + | // Named "the network" rather than left blank when there is no name to | |
| 977 | + | // hand. Nothing reaches this without a join in flight, and a sentence | |
| 978 | + | // with a hole in it is what that assumption looks like when it stops | |
| 979 | + | // being true. | |
| 980 | + | let ssid = self | |
| 981 | + | .pending | |
| 982 | + | .take() | |
| 983 | + | .unwrap_or_else(|| "the network".to_string()); | |
| 984 | + | match outcome { | |
| 985 | + | // The output is dropped rather than reported, and that is not | |
| 986 | + | // tidiness. Fed a pipe, nmcli's `--ask` prompt echoes what it reads, | |
| 987 | + | // so the passphrase can be in the stdout of a command that carried | |
| 988 | + | // it privately in every other respect. Failures are reported out of | |
| 989 | + | // stderr, which the echo does not reach. | |
| 990 | + | Ok(_) => { | |
| 991 | + | self.mode = Mode::Devices; | |
| 992 | + | self.error = None; | |
| 993 | + | log.quiet(|log| self.refresh(log)); | |
| 994 | + | } | |
| 995 | + | Err(err) => { | |
| 996 | + | // Reaching this with an authentication message means the agent | |
| 997 | + | // could not be registered or could not answer — the fallback | |
| 998 | + | // path in `shell::authorize_inline`, which says so in the log | |
| 999 | + | // pane. Worth its own sentence, because "not authorized" and | |
| 1000 | + | // "nobody could be asked" send someone to different places. | |
| 1001 | + | self.error = Some(if crate::cli::wants_authentication(&err) { | |
| 1002 | + | format!("joining {ssid} was not authorized") | |
| 1003 | + | } else { | |
| 1004 | + | err.to_string() | |
| 1005 | + | }); | |
| 1006 | + | } | |
| 1007 | + | } | |
| 1008 | + | } | |
| 1009 | + | ||
| 979 | 1010 | /// True while the passphrase field is open, so `q` and `?` are letters. | |
| 980 | 1011 | /// | |
| 981 | 1012 | /// [`View::text_entry`]'s obligation, and this is the first shipped view to | |
| @@ -1070,7 +1101,7 @@ | |||
| 1070 | 1101 | Mode::Networks { cursor, .. } => match key.code { | |
| 1071 | 1102 | KeyCode::Char('j') | KeyCode::Down => cursor.next(), | |
| 1072 | 1103 | KeyCode::Char('k') | KeyCode::Up => cursor.prev(), | |
| 1073 | - | KeyCode::Enter => self.choose(log), | |
| 1104 | + | KeyCode::Enter => return self.choose(), | |
| 1074 | 1105 | KeyCode::Char('n') => self.scan(log), | |
| 1075 | 1106 | _ => {} | |
| 1076 | 1107 | }, | |
| @@ -1084,7 +1115,7 @@ | |||
| 1084 | 1115 | KeyCode::Right => field.right(), | |
| 1085 | 1116 | KeyCode::Home => field.home(), | |
| 1086 | 1117 | KeyCode::End => field.end(), | |
| 1087 | - | KeyCode::Enter => self.submit(log), | |
| 1118 | + | KeyCode::Enter => return self.submit(), | |
| 1088 | 1119 | _ => {} | |
| 1089 | 1120 | }, | |
| 1090 | 1121 | } | |
| @@ -1220,6 +1251,7 @@ | |||
| 1220 | 1251 | error: None, | |
| 1221 | 1252 | wifi: None, | |
| 1222 | 1253 | mode: Mode::Devices, | |
| 1254 | + | pending: None, | |
| 1223 | 1255 | }; | |
| 1224 | 1256 | view.refresh(&mut log); | |
| 1225 | 1257 | (view, log) | |
| @@ -1530,6 +1562,7 @@ | |||
| 1530 | 1562 | error: None, | |
| 1531 | 1563 | wifi: None, | |
| 1532 | 1564 | mode: Mode::Devices, | |
| 1565 | + | pending: None, | |
| 1533 | 1566 | }; | |
| 1534 | 1567 | view.refresh(&mut log); | |
| 1535 | 1568 | (view, log) | |
| @@ -1553,7 +1586,7 @@ | |||
| 1553 | 1586 | fn a_secured_network_asks_for_a_passphrase_first() { | |
| 1554 | 1587 | let (mut view, mut log) = joinable_view(); | |
| 1555 | 1588 | view.scan(&mut log); | |
| 1556 | - | view.choose(&mut log); | |
| 1589 | + | view.choose(); | |
| 1557 | 1590 | match &view.mode { | |
| 1558 | 1591 | Mode::Passphrase { ssid, field } => { | |
| 1559 | 1592 | assert_eq!(ssid, "Example Network"); | |
| @@ -1571,26 +1604,73 @@ | |||
| 1571 | 1604 | if let Mode::Networks { cursor, .. } = &mut view.mode { | |
| 1572 | 1605 | cursor.move_by(2); | |
| 1573 | 1606 | } | |
| 1574 | - | view.choose(&mut log); | |
| 1575 | - | assert!( | |
| 1576 | - | matches!(view.mode, Mode::Devices), | |
| 1577 | - | "a completed join goes back to the devices, {:?}", | |
| 1578 | - | view.mode, | |
| 1579 | - | ); | |
| 1607 | + | match view.choose() { | |
| 1608 | + | Flow::AuthorizeInline(invocation) => { | |
| 1609 | + | assert_eq!(invocation.display(), "true 'Airport WiFi'"); | |
| 1610 | + | } | |
| 1611 | + | other => panic!("expected the join to be raised, got {other:?}"), | |
| 1612 | + | } | |
| 1613 | + | assert_eq!(view.pending.as_deref(), Some("Airport WiFi")); | |
| 1614 | + | } | |
| 1615 | + | ||
| 1616 | + | // The shell runs the join and hands the answer back. Success lands on the | |
| 1617 | + | // devices, because that is where the evidence is: the interface now says | |
| 1618 | + | // connected and names the network. | |
| 1619 | + | #[test] | |
| 1620 | + | fn a_join_that_worked_returns_to_the_devices() { | |
| 1621 | + | let (mut view, mut log) = joinable_view(); | |
| 1622 | + | view.scan(&mut log); | |
| 1623 | + | view.choose(); | |
| 1624 | + | view.submit(); | |
| 1625 | + | view.authorized(Ok(String::new()), &mut log); | |
| 1626 | + | assert!(matches!(view.mode, Mode::Devices), "{:?}", view.mode); | |
| 1580 | 1627 | assert_eq!(view.error, None); | |
| 1581 | 1628 | } | |
| 1582 | 1629 | ||
| 1630 | + | // "not authorized" and "nobody could be asked" send someone to different | |
| 1631 | + | // places, so they are not the same sentence. | |
| 1632 | + | #[test] | |
| 1633 | + | fn a_refused_join_says_it_was_refused_and_names_the_network() { | |
| 1634 | + | let (mut view, mut log) = joinable_view(); | |
| 1635 | + | view.scan(&mut log); | |
| 1636 | + | view.choose(); | |
| 1637 | + | view.submit(); | |
| 1638 | + | view.authorized(Err(anyhow::anyhow!(crate::cli::INTERACTIVE_AUTH)), &mut log); | |
| 1639 | + | let message = view.error.as_ref().expect("something was said"); | |
| 1640 | + | assert!(message.contains("Example Network"), "{message}"); | |
| 1641 | + | assert!(message.contains("not authorized"), "{message}"); | |
| 1642 | + | } | |
| 1643 | + | ||
| 1644 | + | #[test] | |
| 1645 | + | fn any_other_failure_is_reported_as_nmcli_worded_it() { | |
| 1646 | + | let (mut view, mut log) = joinable_view(); | |
| 1647 | + | view.scan(&mut log); | |
| 1648 | + | view.choose(); | |
| 1649 | + | view.submit(); | |
| 1650 | + | view.authorized(Err(anyhow::anyhow!("No network with SSID found")), &mut log); | |
| 1651 | + | assert_eq!(view.error.as_deref(), Some("No network with SSID found"),); | |
| 1652 | + | } | |
| 1653 | + | ||
| 1583 | 1654 | // The pane's whole promise is that it shows what ran. This is the one value | |
| 1584 | 1655 | // it must show having run *without* showing what it was. | |
| 1585 | 1656 | #[test] | |
| 1586 | 1657 | fn the_passphrase_never_reaches_the_log_pane() { | |
| 1587 | 1658 | let (mut view, mut log) = joinable_view(); | |
| 1588 | 1659 | view.scan(&mut log); | |
| 1589 | - | view.choose(&mut log); | |
| 1660 | + | view.choose(); | |
| 1590 | 1661 | for c in "hunter2-and-a-half".chars() { | |
| 1591 | 1662 | view.handle(KeyEvent::from(KeyCode::Char(c)), &mut log); | |
| 1592 | 1663 | } | |
| 1593 | - | view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 1664 | + | ||
| 1665 | + | // What the shell will show, since it is the shell that logs an | |
| 1666 | + | // authorized command: the argv, and a note that something was piped in. | |
| 1667 | + | let raised = view.handle(KeyEvent::from(KeyCode::Enter), &mut log); | |
| 1668 | + | let Flow::AuthorizeInline(invocation) = raised else { | |
| 1669 | + | panic!("expected the join to be raised, got {raised:?}"); | |
| 1670 | + | }; | |
| 1671 | + | let shown = invocation.display(); | |
| 1672 | + | assert!(!shown.contains("hunter2"), "{shown}"); | |
| 1673 | + | assert!(shown.contains("input withheld"), "{shown}"); | |
| 1594 | 1674 | ||
| 1595 | 1675 | let transcript: String = log | |
| 1596 | 1676 | .entries() | |
| @@ -1599,10 +1679,23 @@ | |||
| 1599 | 1679 | .collect::<Vec<_>>() | |
| 1600 | 1680 | .join("\n"); | |
| 1601 | 1681 | assert!(!transcript.contains("hunter2"), "{transcript}"); | |
| 1602 | - | assert!( | |
| 1603 | - | transcript.contains("input withheld"), | |
| 1604 | - | "the pane still says something was piped in: {transcript}", | |
| 1605 | - | ); | |
| 1682 | + | } | |
| 1683 | + | ||
| 1684 | + | // The field is emptied as the secret is built, so a second prompt after a | |
| 1685 | + | // refusal starts from nothing rather than from what is still in the widget. | |
| 1686 | + | #[test] | |
| 1687 | + | fn submitting_empties_the_field_it_read() { | |
| 1688 | + | let (mut view, mut log) = joinable_view(); | |
| 1689 | + | view.scan(&mut log); | |
| 1690 | + | view.choose(); | |
| 1691 | + | for c in "hunter2".chars() { | |
| 1692 | + | view.handle(KeyEvent::from(KeyCode::Char(c)), &mut log); | |
| 1693 | + | } | |
| 1694 | + | view.submit(); | |
| 1695 | + | match &view.mode { | |
| 1696 | + | Mode::Passphrase { field, .. } => assert_eq!(field.value(), ""), | |
| 1697 | + | other => panic!("still asking, {other:?}"), | |
| 1698 | + | } | |
| 1606 | 1699 | } | |
| 1607 | 1700 | ||
| 1608 | 1701 | // `q` and `?` are letters while a passphrase is open. Without this the | |
| @@ -1613,7 +1706,7 @@ | |||
| 1613 | 1706 | assert!(!view.text_entry(), "the device list does not"); | |
| 1614 | 1707 | view.scan(&mut log); | |
| 1615 | 1708 | assert!(!view.text_entry(), "nor does the network list"); | |
| 1616 | - | view.choose(&mut log); | |
| 1709 | + | view.choose(); | |
| 1617 | 1710 | assert!(view.text_entry()); | |
| 1618 | 1711 | } | |
| 1619 | 1712 | ||
| @@ -1627,7 +1720,7 @@ | |||
| 1627 | 1720 | assert!(matches!(view.mode, Mode::Devices)); | |
| 1628 | 1721 | ||
| 1629 | 1722 | view.scan(&mut log); | |
| 1630 | - | view.choose(&mut log); | |
| 1723 | + | view.choose(); | |
| 1631 | 1724 | assert!(matches!(view.cancel(), Flow::Continue)); | |
| 1632 | 1725 | assert!( | |
| 1633 | 1726 | matches!(view.mode, Mode::Devices), |
| @@ -16,7 +16,7 @@ | |||
| 16 | 16 | }; | |
| 17 | 17 | use anyhow::{Context, Result}; | |
| 18 | 18 | use ratatui::Frame; | |
| 19 | - | use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind}; | |
| 19 | + | use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; | |
| 20 | 20 | use ratatui::layout::Rect; | |
| 21 | 21 | ||
| 22 | 22 | use crate::cli::CommandLog; | |
| @@ -55,6 +55,25 @@ | |||
| 55 | 55 | /// not the seamless one: the screen tears down, polkit's own text prompt | |
| 56 | 56 | /// appears in the terminal's colors, and the console rebuilds after. | |
| 57 | 57 | Authorize(Command), | |
| 58 | + | /// Run a command that polkit will ask about, and answer it in the console. | |
| 59 | + | /// | |
| 60 | + | /// Tier 3. The TUI stays up: an agent of the console's own | |
| 61 | + | /// ([`polkit`](crate::polkit)) is registered for this process, the command | |
| 62 | + | /// runs on a thread beside the event loop, and polkit's question arrives as | |
| 63 | + | /// a modal in Akari naming the action. | |
| 64 | + | /// | |
| 65 | + | /// Takes an [`Invocation`] rather than a [`Command`], which is the | |
| 66 | + | /// difference that forced this tier to exist rather than merely improving | |
| 67 | + | /// on tier 2. `Authorize` suspends, and a suspended child inherits the | |
| 68 | + | /// terminal's stdio, so it has no pipe: an invocation carrying a | |
| 69 | + | /// [`Secret`](crate::cli::Secret) cannot go that way at all. Joining a wifi | |
| 70 | + | /// network is exactly that shape — a passphrase on stdin *and* a polkit | |
| 71 | + | /// action — and it is what `net.rs` raises this for. | |
| 72 | + | /// | |
| 73 | + | /// The outcome goes back to the view through [`View::authorized`] rather | |
| 74 | + | /// than out of the event loop. A wrong password is an ordinary answer to a | |
| 75 | + | /// question the user was asked, not a reason to close the console. | |
| 76 | + | AuthorizeInline(crate::cli::Invocation), | |
| 58 | 77 | } | |
| 59 | 78 | ||
| 60 | 79 | /// A confirmation prompt raised by a view. | |
| @@ -191,6 +210,20 @@ | |||
| 191 | 210 | /// belongs inside [`CommandLog::quiet`]. | |
| 192 | 211 | fn tick(&mut self, _log: &mut CommandLog) {} | |
| 193 | 212 | ||
| 213 | + | /// The command raised with [`Flow::AuthorizeInline`] has finished. | |
| 214 | + | /// | |
| 215 | + | /// Carries what the command said, which is the point: a view that asked for | |
| 216 | + | /// a network to be joined needs to know whether it was, and the failure it | |
| 217 | + | /// most wants to report — a passphrase polkit or NM did not accept — is an | |
| 218 | + | /// ordinary nonzero exit rather than anything the shell should treat as | |
| 219 | + | /// exceptional. | |
| 220 | + | /// | |
| 221 | + | /// stdout arrives as an `Ok` payload nobody is obliged to read, and one | |
| 222 | + | /// caller deliberately drops it: nmcli's `--ask` prompt echoes what it | |
| 223 | + | /// reads from a pipe, so the output of a command carrying a passphrase can | |
| 224 | + | /// contain it. Default is nothing. | |
| 225 | + | fn authorized(&mut self, _outcome: Result<String>, _log: &mut CommandLog) {} | |
| 226 | + | ||
| 194 | 227 | /// The console has just taken the terminal back from a child. | |
| 195 | 228 | /// | |
| 196 | 229 | /// Raised after [`Flow::Suspend`] and [`Flow::Authorize`], where the child | |
| @@ -298,6 +331,9 @@ | |||
| 298 | 331 | Flow::Confirm(raised) => modal = Some(raised), | |
| 299 | 332 | Flow::Suspend(command) => suspend(terminal, view, log, command)?, | |
| 300 | 333 | Flow::Authorize(command) => authorize(terminal, view, log, command)?, | |
| 334 | + | Flow::AuthorizeInline(invocation) => { | |
| 335 | + | authorize_inline(terminal, theme, view, log, invocation)?; | |
| 336 | + | } | |
| 301 | 337 | Flow::Continue => {} | |
| 302 | 338 | } | |
| 303 | 339 | } | |
| @@ -339,6 +375,9 @@ | |||
| 339 | 375 | Flow::Confirm(confirm) => modal = Some(confirm), | |
| 340 | 376 | Flow::Suspend(command) => suspend(terminal, view, log, command)?, | |
| 341 | 377 | Flow::Authorize(command) => authorize(terminal, view, log, command)?, | |
| 378 | + | Flow::AuthorizeInline(invocation) => { | |
| 379 | + | authorize_inline(terminal, theme, view, log, invocation)?; | |
| 380 | + | } | |
| 342 | 381 | } | |
| 343 | 382 | } | |
| 344 | 383 | } | |
| @@ -542,6 +581,151 @@ | |||
| 542 | 581 | outcome | |
| 543 | 582 | } | |
| 544 | 583 | ||
| 584 | + | /// How often the authorization loop wakes to check on the command and the | |
| 585 | + | /// agent. | |
| 586 | + | /// | |
| 587 | + | /// Short, unlike [`TICK`], and for a reason the main loop does not have: there | |
| 588 | + | /// is a person waiting at a prompt, and the two events this is watching for — | |
| 589 | + | /// polkit asking a question, and the command finishing — arrive on other | |
| 590 | + | /// threads and cannot wake the poll themselves. A tenth of a second is under | |
| 591 | + | /// what anyone perceives and costs a few wakeups per second for the seconds | |
| 592 | + | /// this loop is alive. | |
| 593 | + | const AUTHORIZATION_POLL: Duration = Duration::from_millis(100); | |
| 594 | + | ||
| 595 | + | /// Run `invocation` with the console's own polkit agent registered, drawing | |
| 596 | + | /// polkit's question as a modal without leaving the TUI. | |
| 597 | + | /// | |
| 598 | + | /// Three things happen at once here, which is why this is a loop of its own | |
| 599 | + | /// rather than a call: the command runs on a worker thread, zbus serves the | |
| 600 | + | /// agent on another, and this thread keeps drawing. The alternative is running | |
| 601 | + | /// the command inline, which deadlocks on the first prompt — the agent's | |
| 602 | + | /// question would arrive at a thread that is blocked waiting for the command | |
| 603 | + | /// that is waiting for the answer. | |
| 604 | + | /// | |
| 605 | + | /// The view is drawn underneath throughout and its keys are inert while the | |
| 606 | + | /// modal is up, which is the modal rule from the main loop. What is not from | |
| 607 | + | /// the main loop is that the *view* is frozen too: it has a command in flight | |
| 608 | + | /// that it asked for, and letting `j` move a selection under a prompt naming a | |
| 609 | + | /// row is how a confirm ends up applying to something else. | |
| 610 | + | /// | |
| 611 | + | /// Registration failing is not fatal and not silent. The command runs anyway, | |
| 612 | + | /// polkit finds no agent, and it comes back with the same "interactive | |
| 613 | + | /// authentication required" the console would have reported before this | |
| 614 | + | /// existed — so the fallback is the previous behaviour rather than a new | |
| 615 | + | /// failure, and the pane says which of the two happened. | |
| 616 | + | fn authorize_inline( | |
| 617 | + | terminal: &mut ratatui::DefaultTerminal, | |
| 618 | + | theme: &Theme, | |
| 619 | + | view: &mut dyn View, | |
| 620 | + | log: &mut CommandLog, | |
| 621 | + | invocation: crate::cli::Invocation, | |
| 622 | + | ) -> Result<()> { | |
| 623 | + | let display = invocation.display(); | |
| 624 | + | ||
| 625 | + | let agent = match crate::polkit::Agent::register() { | |
| 626 | + | Ok(agent) => Some(agent), | |
| 627 | + | Err(error) => { | |
| 628 | + | log.record( | |
| 629 | + | format!("# no authentication agent: {error}"), | |
| 630 | + | Severity::Warn, | |
| 631 | + | ); | |
| 632 | + | None | |
| 633 | + | } | |
| 634 | + | }; | |
| 635 | + | ||
| 636 | + | // Moved to a worker rather than run here, and it takes its `Secret` with | |
| 637 | + | // it. `capture_quiet` because this thread has the log and the worker does | |
| 638 | + | // not; the invocation is recorded below, once its outcome is known. | |
| 639 | + | let worker = std::thread::spawn(move || invocation.capture_quiet()); | |
| 640 | + | ||
| 641 | + | let mut prompt: Option<(crate::polkit::Prompt, alloy_tui::TextField)> = None; | |
| 642 | + | ||
| 643 | + | let outcome = loop { | |
| 644 | + | // Rebuilt every frame from the prompt and what has been typed, which is | |
| 645 | + | // what lets a `Confirm` — a value with no input in it — carry a text | |
| 646 | + | // field. The modal's own footer already reads `Enter confirm Esc | |
| 647 | + | // cancel`, which is exactly what the two keys do here. | |
| 648 | + | let modal = prompt.as_ref().map(|(prompt, field)| Confirm { | |
| 649 | + | title: prompt.message.clone(), | |
| 650 | + | message: format!( | |
| 651 | + | "{} {}", | |
| 652 | + | prompt.question, | |
| 653 | + | if prompt.echo { | |
| 654 | + | field.value().to_string() | |
| 655 | + | } else { | |
| 656 | + | "•".repeat(field.value().chars().count()) | |
| 657 | + | } | |
| 658 | + | ), | |
| 659 | + | severity: Severity::Warn, | |
| 660 | + | }); | |
| 661 | + | terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref(), false))?; | |
| 662 | + | ||
| 663 | + | if prompt.is_none() | |
| 664 | + | && let Some(agent) = &agent | |
| 665 | + | && let Some(pending) = agent.pending() | |
| 666 | + | { | |
| 667 | + | // Recorded as commentary, the way the mock backends mark a line | |
| 668 | + | // that is not a command. Naming the action is the one thing every | |
| 669 | + | // good version of this prompt does — it is what Omarchy's Quattro | |
| 670 | + | // PR added to theirs (wiki `alloy-privilege`) — and the pane is | |
| 671 | + | // where the console says what is happening. | |
| 672 | + | log.record( | |
| 673 | + | format!("# polkit asks about {}", pending.action_id), | |
| 674 | + | Severity::Warn, | |
| 675 | + | ); | |
| 676 | + | prompt = Some((pending, alloy_tui::TextField::new())); | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | if event::poll(AUTHORIZATION_POLL)? | |
| 680 | + | && let Event::Key(key) = event::read()? | |
| 681 | + | && key.kind == KeyEventKind::Press | |
| 682 | + | && let Some((asked, field)) = prompt.take() | |
| 683 | + | { | |
| 684 | + | match key.code { | |
| 685 | + | KeyCode::Enter => asked.answer(field.value().to_string()), | |
| 686 | + | KeyCode::Esc => asked.dismiss(), | |
| 687 | + | code => { | |
| 688 | + | // Not taken after all: put it back with the key applied. | |
| 689 | + | let mut field = field; | |
| 690 | + | match code { | |
| 691 | + | KeyCode::Char(c) => field.insert(c), | |
| 692 | + | KeyCode::Backspace => field.backspace(), | |
| 693 | + | KeyCode::Delete => field.delete(), | |
| 694 | + | KeyCode::Left => field.left(), | |
| 695 | + | KeyCode::Right => field.right(), | |
| 696 | + | KeyCode::Home => field.home(), | |
| 697 | + | KeyCode::End => field.end(), | |
| 698 | + | _ => {} | |
| 699 | + | } | |
| 700 | + | prompt = Some((asked, field)); | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | if worker.is_finished() { | |
| 706 | + | break worker | |
| 707 | + | .join() | |
| 708 | + | .unwrap_or_else(|_| Err(anyhow::anyhow!("the command's thread panicked"))); | |
| 709 | + | } | |
| 710 | + | }; | |
| 711 | + | ||
| 712 | + | // Answered or abandoned, the question is over: an agent outliving the | |
| 713 | + | // command it was registered for would answer for the next thing to ask. | |
| 714 | + | drop(prompt); | |
| 715 | + | drop(agent); | |
| 716 | + | ||
| 717 | + | log.record( | |
| 718 | + | display, | |
| 719 | + | if outcome.is_ok() { | |
| 720 | + | Severity::Healthy | |
| 721 | + | } else { | |
| 722 | + | Severity::Error | |
| 723 | + | }, | |
| 724 | + | ); | |
| 725 | + | view.authorized(outcome, log); | |
| 726 | + | Ok(()) | |
| 727 | + | } | |
| 728 | + | ||
| 545 | 729 | fn draw( | |
| 546 | 730 | frame: &mut Frame, | |
| 547 | 731 | theme: &Theme, |
| @@ -1,0 +1,712 @@ | |||
| 1 | + | //! Tier 3 of wiki `alloy-privilege`: an authentication agent the console draws | |
| 2 | + | //! itself. | |
| 3 | + | //! | |
| 4 | + | //! Tier 2 ([`Flow::Authorize`](crate::shell::Flow::Authorize)) answers polkit by | |
| 5 | + | //! tearing the TUI down and letting `pkttyagent` prompt on the bare terminal. It | |
| 6 | + | //! is honest and it is jarring, and for one action it is not merely jarring but | |
| 7 | + | //! impossible: joining a wifi network needs a passphrase piped to the child, and | |
| 8 | + | //! a suspended child inherits the terminal's stdio, so there is no pipe. A | |
| 9 | + | //! secret and a suspend cannot both be had (`net.rs`, `NetView::join`). | |
| 10 | + | //! | |
| 11 | + | //! So this speaks polkit's agent protocol directly. The shape is documented | |
| 12 | + | //! rather than reverse-engineered: | |
| 13 | + | //! | |
| 14 | + | //! 1. Register with `Authority.RegisterAuthenticationAgent`, naming a **subject** | |
| 15 | + | //! (this process), a locale, and the object path we serve. | |
| 16 | + | //! 2. polkit calls `AuthenticationAgent.BeginAuthentication` on that object when | |
| 17 | + | //! something the console ran needs an answer. The call does not return until | |
| 18 | + | //! the authentication is over: returning normally means it succeeded, and | |
| 19 | + | //! returning an error means it did not. | |
| 20 | + | //! 3. The PAM conversation itself is **not ours**. It runs in the setuid | |
| 21 | + | //! `polkit-agent-helper-1`, whose protocol is plain text on stdin and stdout. | |
| 22 | + | //! Alloy supplies the cookie, relays prompts to the screen, and hands back | |
| 23 | + | //! what was typed. Nothing here talks to PAM, hashes anything, or decides | |
| 24 | + | //! whether an answer was right. | |
| 25 | + | //! | |
| 26 | + | //! **What is Alloy's here is the drawing, and deliberately only the drawing.** | |
| 27 | + | //! That is the whole of the wedge from wiki `alloy-privilege`: the prompt is an | |
| 28 | + | //! Akari modal naming the action, in the console, instead of a torn-down screen | |
| 29 | + | //! and a block of text in whatever colors the terminal happens to have. Every | |
| 30 | + | //! decision about whether the answer is acceptable stays inside polkit's own | |
| 31 | + | //! audited code, which is the only version of this worth shipping. | |
| 32 | + | //! | |
| 33 | + | //! Registration is scoped to this **process**, the way `pkttyagent --process` | |
| 34 | + | //! does, and not to the session. A session-scoped agent would fight whatever | |
| 35 | + | //! the desktop already registered and would answer for programs the console | |
| 36 | + | //! never ran; a process-scoped one covers exactly the command the console is | |
| 37 | + | //! about to run and the children it spawns. | |
| 38 | + | ||
| 39 | + | use std::collections::HashMap; | |
| 40 | + | use std::io::{BufRead, BufReader, Write}; | |
| 41 | + | use std::path::{Path, PathBuf}; | |
| 42 | + | use std::process::{Child, Command, Stdio}; | |
| 43 | + | use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; | |
| 44 | + | ||
| 45 | + | use anyhow::{Context, Result, anyhow, bail}; | |
| 46 | + | use zbus::blocking::Connection; | |
| 47 | + | use zbus::interface; | |
| 48 | + | use zbus::zvariant::{OwnedValue, Value}; | |
| 49 | + | ||
| 50 | + | /// Where polkit keeps the setuid helper that runs the PAM conversation. | |
| 51 | + | /// | |
| 52 | + | /// Both paths are tried in order. Fedora (and so Alloy) ships `/usr/lib`; | |
| 53 | + | /// Debian-family systems use `/usr/libexec`, which is what a development box | |
| 54 | + | /// running the console outside an image is likely to have. Neither is a | |
| 55 | + | /// configuration knob: a helper somewhere else is a polkit nobody here knows, | |
| 56 | + | /// and guessing further would mean executing an arbitrary path as part of an | |
| 57 | + | /// authentication. | |
| 58 | + | const HELPERS: [&str; 2] = [ | |
| 59 | + | "/usr/lib/polkit-1/polkit-agent-helper-1", | |
| 60 | + | "/usr/libexec/polkit-1/polkit-agent-helper-1", | |
| 61 | + | ]; | |
| 62 | + | ||
| 63 | + | /// The object path this agent serves. Arbitrary, and only has to be ours. | |
| 64 | + | const AGENT_PATH: &str = "/work/makenot/alloy/PolkitAgent"; | |
| 65 | + | ||
| 66 | + | const AUTHORITY_SERVICE: &str = "org.freedesktop.PolicyKit1"; | |
| 67 | + | const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority"; | |
| 68 | + | const AUTHORITY_INTERFACE: &str = "org.freedesktop.PolicyKit1.Authority"; | |
| 69 | + | ||
| 70 | + | /// A question the agent needs the screen to ask. | |
| 71 | + | /// | |
| 72 | + | /// Carries its own reply channel rather than an id the caller has to match up: | |
| 73 | + | /// the D-Bus thread is blocked inside `BeginAuthentication` waiting on exactly | |
| 74 | + | /// this one, so a request that cannot be answered is a request nobody should be | |
| 75 | + | /// able to construct. | |
| 76 | + | pub(crate) struct Prompt { | |
| 77 | + | /// The polkit action, e.g. `org.freedesktop.NetworkManager.settings.modify.system`. | |
| 78 | + | pub action_id: String, | |
| 79 | + | /// polkit's own human-readable line for the action. | |
| 80 | + | pub message: String, | |
| 81 | + | /// What the helper asked for, usually `Password: `. | |
| 82 | + | pub question: String, | |
| 83 | + | /// Whether what is typed should be drawn. False for a password, true for | |
| 84 | + | /// the one-time-code shape (`PAM_PROMPT_ECHO_ON`), which does exist and | |
| 85 | + | /// which a masked field would make unusable. | |
| 86 | + | pub echo: bool, | |
| 87 | + | reply: SyncSender<Option<String>>, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | impl Prompt { | |
| 91 | + | /// Answer the prompt. | |
| 92 | + | /// | |
| 93 | + | /// The answer is a `String` and not a [`Secret`](crate::cli::Secret), | |
| 94 | + | /// because the helper reads it as a line of text and there is nowhere else | |
| 95 | + | /// for it to go: it crosses one channel, is written to one pipe, and is | |
| 96 | + | /// dropped. What a `Secret` buys elsewhere is a scrubbed buffer inside a | |
| 97 | + | /// long-lived [`Invocation`](crate::cli::Invocation); nothing here is | |
| 98 | + | /// long-lived. | |
| 99 | + | pub(crate) fn answer(self, response: String) { | |
| 100 | + | // A send that fails means the D-Bus thread gave up first — polkit | |
| 101 | + | // cancelled, or the command died. Nothing to report: the screen is | |
| 102 | + | // about to be told the same thing by the outcome channel. | |
| 103 | + | let _ = self.reply.send(Some(response)); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Refuse it. polkit is told the authentication failed. | |
| 107 | + | pub(crate) fn dismiss(self) { | |
| 108 | + | let _ = self.reply.send(None); | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// A registered authentication agent. | |
| 113 | + | /// | |
| 114 | + | /// Unregisters on drop, which is not tidiness: an agent left registered keeps | |
| 115 | + | /// answering for a console that has stopped asking, and the next thing to want | |
| 116 | + | /// authorization would block on a prompt nobody is drawing. | |
| 117 | + | pub(crate) struct Agent { | |
| 118 | + | connection: Connection, | |
| 119 | + | subject: Subject, | |
| 120 | + | prompts: Receiver<Prompt>, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | impl Agent { | |
| 124 | + | /// Register an agent for this process. | |
| 125 | + | /// | |
| 126 | + | /// Fails rather than degrading when there is no system bus or polkit does | |
| 127 | + | /// not answer. The caller's fallback is tier 2, which is a decision about | |
| 128 | + | /// what the console does next rather than something to paper over here. | |
| 129 | + | pub(crate) fn register() -> Result<Self> { | |
| 130 | + | let helper = helper_path().context("no polkit-agent-helper-1 to run the conversation")?; | |
| 131 | + | let subject = Subject::this_process()?; | |
| 132 | + | let (sender, prompts) = sync_channel(0); | |
| 133 | + | ||
| 134 | + | let connection = zbus::blocking::connection::Builder::system() | |
| 135 | + | .context("no system bus")? | |
| 136 | + | .serve_at(AGENT_PATH, Listener { helper, sender }) | |
| 137 | + | .context("the agent object path is already in use")? | |
| 138 | + | .build() | |
| 139 | + | .context("the agent could not be served")?; | |
| 140 | + | ||
| 141 | + | connection | |
| 142 | + | .call_method( | |
| 143 | + | Some(AUTHORITY_SERVICE), | |
| 144 | + | AUTHORITY_PATH, | |
| 145 | + | Some(AUTHORITY_INTERFACE), | |
| 146 | + | "RegisterAuthenticationAgent", | |
| 147 | + | // The path is a plain string here, not an `o`. polkit's own | |
| 148 | + | // signature is `((sa{sv})ss)` and it rejects an object path | |
| 149 | + | // outright, which is worth stating: every other D-Bus API in | |
| 150 | + | // sight takes the typed form, and the mismatch is a registration | |
| 151 | + | // that fails at runtime with nothing at compile time to catch it. | |
| 152 | + | &(subject.as_tuple(), locale(), AGENT_PATH), | |
| 153 | + | ) | |
| 154 | + | .context("polkit refused the agent registration")?; | |
| 155 | + | ||
| 156 | + | Ok(Self { | |
| 157 | + | connection, | |
| 158 | + | subject, | |
| 159 | + | prompts, | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// The question waiting to be drawn, if there is one. | |
| 164 | + | /// | |
| 165 | + | /// Non-blocking, because the caller is an event loop that also has a frame | |
| 166 | + | /// to draw and keys to read. Polling rather than a callback for the same | |
| 167 | + | /// reason every other console refresh is a poll: there is one thread that | |
| 168 | + | /// owns the screen and it is not this one. | |
| 169 | + | pub(crate) fn pending(&self) -> Option<Prompt> { | |
| 170 | + | self.prompts.try_recv().ok() | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | impl Drop for Agent { | |
| 175 | + | fn drop(&mut self) { | |
| 176 | + | // Best effort by necessity: a drop cannot report, and the failure modes | |
| 177 | + | // are a bus that has gone away and a polkit that has restarted, in both | |
| 178 | + | // of which the registration is already void. | |
| 179 | + | let _ = self.connection.call_method( | |
| 180 | + | Some(AUTHORITY_SERVICE), | |
| 181 | + | AUTHORITY_PATH, | |
| 182 | + | Some(AUTHORITY_INTERFACE), | |
| 183 | + | "UnregisterAuthenticationAgent", | |
| 184 | + | &(self.subject.as_tuple(), AGENT_PATH), | |
| 185 | + | ); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// The polkit subject this agent covers: one process, pinned by start time. | |
| 190 | + | /// | |
| 191 | + | /// The start time is not decoration. A pid is reused, and an agent registered | |
| 192 | + | /// for pid 4021 would otherwise still be registered for whatever takes that pid | |
| 193 | + | /// next; polkit pairs the two so a subject names one run of one process. | |
| 194 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 195 | + | struct Subject { | |
| 196 | + | pid: u32, | |
| 197 | + | start_time: u64, | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | impl Subject { | |
| 201 | + | fn this_process() -> Result<Self> { | |
| 202 | + | let pid = std::process::id(); | |
| 203 | + | let stat = std::fs::read_to_string("/proc/self/stat").context("no /proc/self/stat")?; | |
| 204 | + | Ok(Self { | |
| 205 | + | pid, | |
| 206 | + | start_time: start_time(&stat).context("no start time in /proc/self/stat")?, | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// The `(sa{sv})` polkit takes: a kind, and details keyed by name. | |
| 211 | + | fn as_tuple(&self) -> (String, HashMap<String, OwnedValue>) { | |
| 212 | + | let mut details: HashMap<String, OwnedValue> = HashMap::new(); | |
| 213 | + | details.insert("pid".into(), Value::from(self.pid).try_into().expect("u32")); | |
| 214 | + | details.insert( | |
| 215 | + | "start-time".into(), | |
| 216 | + | Value::from(self.start_time).try_into().expect("u64"), | |
| 217 | + | ); | |
| 218 | + | ("unix-process".into(), details) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Field 22 of `/proc/<pid>/stat`, the process start time in clock ticks. | |
| 223 | + | /// | |
| 224 | + | /// Parsed from the **last** `)` rather than by splitting on whitespace from the | |
| 225 | + | /// left. Field 2 is the executable name in parentheses and it can contain both | |
| 226 | + | /// spaces and parentheses, since it is the basename of whatever was executed — | |
| 227 | + | /// which is attacker-chosen on a shared machine. Counting fields from the left | |
| 228 | + | /// therefore reads the wrong field for any process willing to be named | |
| 229 | + | /// `foo bar`, and this value exists precisely to make impersonation harder. | |
| 230 | + | fn start_time(stat: &str) -> Option<u64> { | |
| 231 | + | let after_comm = stat.rfind(')').map(|end| &stat[end + 1..])?; | |
| 232 | + | // Fields resume at 3 (state), so start-time (22) is the 20th value here. | |
| 233 | + | after_comm.split_whitespace().nth(19)?.parse().ok() | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// The locale polkit should render its message in. | |
| 237 | + | /// | |
| 238 | + | /// polkit takes the string and hands it to gettext. An empty one is legal and | |
| 239 | + | /// means the default, which is what the console wants when nothing is set: | |
| 240 | + | /// inventing `en_US.UTF-8` would be Alloy deciding the user's language. | |
| 241 | + | fn locale() -> String { | |
| 242 | + | std::env::var("LANG").unwrap_or_default() | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | fn helper_path() -> Option<PathBuf> { | |
| 246 | + | HELPERS | |
| 247 | + | .iter() | |
| 248 | + | .map(Path::new) | |
| 249 | + | .find(|path| path.exists()) | |
| 250 | + | .map(Path::to_path_buf) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// The object polkit calls into. | |
| 254 | + | struct Listener { | |
| 255 | + | helper: PathBuf, | |
| 256 | + | sender: SyncSender<Prompt>, | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Every argument below is owned and several are unread, both of which clippy | |
| 260 | + | // objects to and neither of which is this code's choice: the signature is | |
| 261 | + | // polkit's, the `#[interface]` macro deserializes into it, and an argument | |
| 262 | + | // dropped from the list is a method that no longer matches the interface it | |
| 263 | + | // claims to implement. Named in full and ignored where they are not wanted, | |
| 264 | + | // so the shape of what polkit sends stays legible here. | |
| 265 | + | #[allow(clippy::needless_pass_by_value)] | |
| 266 | + | #[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] | |
| 267 | + | impl Listener { | |
| 268 | + | /// polkit is asking. This call blocks until the answer is known, which is | |
| 269 | + | /// the interface's contract rather than an oversight: returning is success | |
| 270 | + | /// and an error is failure, so there is nothing to return early with. | |
| 271 | + | /// | |
| 272 | + | /// `icon_name` and `details` are taken and dropped on the next line. The | |
| 273 | + | /// console has no icons, and the details map is a set of hints polkit's own | |
| 274 | + | /// GUI agents render beside the message; naming them in the signature | |
| 275 | + | /// rather than dropping them keeps the method matching the interface it | |
| 276 | + | /// claims to implement. Spelled without a leading underscore because the | |
| 277 | + | /// macro reads these names back out, which makes an underscore a lie about | |
| 278 | + | /// whether anything uses them. | |
| 279 | + | #[allow(clippy::too_many_arguments)] | |
| 280 | + | fn begin_authentication( | |
| 281 | + | &self, | |
| 282 | + | action_id: String, | |
| 283 | + | message: String, | |
| 284 | + | icon_name: String, | |
| 285 | + | details: HashMap<String, String>, | |
| 286 | + | cookie: String, | |
| 287 | + | identities: Vec<(String, HashMap<String, OwnedValue>)>, | |
| 288 | + | ) -> zbus::fdo::Result<()> { | |
| 289 | + | drop((icon_name, details)); | |
| 290 | + | ||
| 291 | + | let user = choose_identity(&identities) | |
| 292 | + | .ok_or_else(|| zbus::fdo::Error::Failed("no identity this agent can ask".into()))?; | |
| 293 | + | ||
| 294 | + | converse( | |
| 295 | + | &self.helper, | |
| 296 | + | &user, | |
| 297 | + | &cookie, | |
| 298 | + | &action_id, | |
| 299 | + | &message, | |
| 300 | + | |prompt| { | |
| 301 | + | self.sender | |
| 302 | + | .send(prompt) | |
| 303 | + | .map_err(|_| anyhow!("the console stopped listening")) | |
| 304 | + | }, | |
| 305 | + | ) | |
| 306 | + | .map_err(|error| zbus::fdo::Error::Failed(error.to_string())) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// polkit withdrew the question — the command it was for went away. | |
| 310 | + | /// | |
| 311 | + | /// Nothing to do. The helper is a child of the call that is still blocked | |
| 312 | + | /// in [`begin_authentication`](Listener::begin_authentication), and polkit | |
| 313 | + | /// closes that out itself; killing it from here would race the reply. | |
| 314 | + | #[allow(clippy::unused_self)] | |
| 315 | + | fn cancel_authentication(&self, cookie: String) -> zbus::fdo::Result<()> { | |
| 316 | + | drop(cookie); | |
| 317 | + | Ok(()) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// Which of the identities polkit will accept this agent can actually ask. | |
| 322 | + | /// | |
| 323 | + | /// **This machine's own user first, and that ordering is the security-relevant | |
| 324 | + | /// part.** polkit sends every identity that would satisfy the action, which on | |
| 325 | + | /// a `wheel`-administered box is every administrator. Asking for the *first* | |
| 326 | + | /// one would mean a console at a laptop routinely prompting for root, teaching | |
| 327 | + | /// its owner to type the root password into a screen that could have been | |
| 328 | + | /// anything. | |
| 329 | + | /// | |
| 330 | + | /// Group identities are skipped. The helper takes a user name, so a group is | |
| 331 | + | /// not something this can ask for, and expanding one to its members would mean | |
| 332 | + | /// choosing an administrator on the user's behalf. | |
| 333 | + | fn choose_identity(identities: &[(String, HashMap<String, OwnedValue>)]) -> Option<String> { | |
| 334 | + | let uids: Vec<u32> = identities | |
| 335 | + | .iter() | |
| 336 | + | .filter(|(kind, _)| kind == "unix-user") | |
| 337 | + | .filter_map(|(_, details)| details.get("uid")) | |
| 338 | + | .filter_map(|uid| u32::try_from(uid).ok()) | |
| 339 | + | .collect(); | |
| 340 | + | ||
| 341 | + | let self_uid = self_uid(); | |
| 342 | + | if let Some(uid) = self_uid.filter(|uid| uids.contains(uid)) { | |
| 343 | + | return username_of(uid); | |
| 344 | + | } | |
| 345 | + | uids.first().copied().and_then(username_of) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | /// This process's real uid, from `/proc/self/status`. | |
| 349 | + | /// | |
| 350 | + | /// Read rather than asked of libc, which the console does not link. The `Uid:` | |
| 351 | + | /// line is four values — real, effective, saved, filesystem — and the first is | |
| 352 | + | /// the one that answers "who is sitting here". | |
| 353 | + | fn self_uid() -> Option<u32> { | |
| 354 | + | let status = std::fs::read_to_string("/proc/self/status").ok()?; | |
| 355 | + | status | |
| 356 | + | .lines() | |
| 357 | + | .find_map(|line| line.strip_prefix("Uid:"))? | |
| 358 | + | .split_whitespace() | |
| 359 | + | .next()? | |
| 360 | + | .parse() | |
| 361 | + | .ok() | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /// Resolve a uid to a login name out of `/etc/passwd`. | |
| 365 | + | /// | |
| 366 | + | /// Parsed directly rather than through `getent`, for the same reason the uid is | |
| 367 | + | /// read from `/proc`: this is an authentication path, and a name that decides | |
| 368 | + | /// whose password is being asked for should not depend on a subprocess, a | |
| 369 | + | /// `$PATH`, or the console's command log — which would otherwise show a lookup | |
| 370 | + | /// the user never asked for, in the middle of a prompt. | |
| 371 | + | /// | |
| 372 | + | /// NSS is the cost, and it is a bounded one. A machine whose users live in LDAP | |
| 373 | + | /// or in systemd-homed has entries `/etc/passwd` does not carry, and this | |
| 374 | + | /// returns nothing for them rather than the wrong name. Alloy installs a local | |
| 375 | + | /// account (`install.rs`), so the case is a machine that has been joined to a | |
| 376 | + | /// directory since. | |
| 377 | + | fn username_of(uid: u32) -> Option<String> { | |
| 378 | + | let passwd = std::fs::read_to_string("/etc/passwd").ok()?; | |
| 379 | + | username_in(&passwd, uid) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /// The `/etc/passwd` lookup itself, split out so it can be tested without | |
| 383 | + | /// writing to the real one. | |
| 384 | + | fn username_in(passwd: &str, uid: u32) -> Option<String> { | |
| 385 | + | passwd.lines().find_map(|line| { | |
| 386 | + | let mut fields = line.split(':'); | |
| 387 | + | let name = fields.next()?; | |
| 388 | + | let _password = fields.next()?; | |
| 389 | + | let found: u32 = fields.next()?.parse().ok()?; | |
| 390 | + | (found == uid).then(|| name.to_string()) | |
| 391 | + | }) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// Run the helper's conversation to its end. | |
| 395 | + | /// | |
| 396 | + | /// `ask` is how a question reaches the screen; it returns once the prompt has | |
| 397 | + | /// been handed over, and the answer comes back through the prompt's own reply | |
| 398 | + | /// channel. Taking it as a closure is what keeps this function testable against | |
| 399 | + | /// a scripted helper with no D-Bus and no terminal anywhere near it. | |
| 400 | + | fn converse( | |
| 401 | + | helper: &Path, | |
| 402 | + | user: &str, | |
| 403 | + | cookie: &str, | |
| 404 | + | action_id: &str, | |
| 405 | + | message: &str, | |
| 406 | + | ask: impl Fn(Prompt) -> Result<()>, | |
| 407 | + | ) -> Result<()> { | |
| 408 | + | let mut child = Command::new(helper) | |
| 409 | + | .arg(user) | |
| 410 | + | .stdin(Stdio::piped()) | |
| 411 | + | .stdout(Stdio::piped()) | |
| 412 | + | .stderr(Stdio::null()) | |
| 413 | + | .spawn() | |
| 414 | + | .with_context(|| format!("could not run {}", helper.display()))?; | |
| 415 | + | ||
| 416 | + | let mut stdin = child.stdin.take().context("the helper took no stdin")?; | |
| 417 | + | let stdout = child.stdout.take().context("the helper wrote no stdout")?; | |
| 418 | + | let mut lines = BufReader::new(stdout).lines(); | |
| 419 | + | ||
| 420 | + | // The cookie first, on its own line. It is what polkit gave us and what the | |
| 421 | + | // helper hands back to prove this conversation is the one polkit asked for. | |
| 422 | + | writeln!(stdin, "{cookie}").context("the helper closed before the cookie")?; | |
| 423 | + | ||
| 424 | + | while let Some(line) = lines.next().transpose().context("the helper stopped")? { | |
| 425 | + | match Directive::parse(&line) { | |
| 426 | + | Some(Directive::Prompt { question, echo }) => { | |
| 427 | + | let (reply, answers) = sync_channel(0); | |
| 428 | + | ask(Prompt { | |
| 429 | + | action_id: action_id.to_string(), | |
| 430 | + | message: message.to_string(), | |
| 431 | + | question, | |
| 432 | + | echo, | |
| 433 | + | reply, | |
| 434 | + | })?; | |
| 435 | + | ||
| 436 | + | // A closed channel is the screen going away mid-prompt, which | |
| 437 | + | // is a dismissal rather than an answer. | |
| 438 | + | let answer = answers.recv().unwrap_or(None); | |
| 439 | + | let Some(answer) = answer else { | |
| 440 | + | finish(&mut child); | |
| 441 | + | bail!("dismissed"); | |
| 442 | + | }; | |
| 443 | + | writeln!(stdin, "{answer}").context("the helper closed mid-answer")?; | |
| 444 | + | } | |
| 445 | + | Some(Directive::Success) => { | |
| 446 | + | finish(&mut child); | |
| 447 | + | return Ok(()); | |
| 448 | + | } | |
| 449 | + | Some(Directive::Failure) => { | |
| 450 | + | finish(&mut child); | |
| 451 | + | bail!("not authorized"); | |
| 452 | + | } | |
| 453 | + | // PAM_ERROR_MSG and PAM_TEXT_INFO carry text for the user, and | |
| 454 | + | // anything unrecognized is a helper newer than this code. Neither | |
| 455 | + | // is a reason to abandon a conversation that is still going: the | |
| 456 | + | // helper says SUCCESS or FAILURE either way, and that is what this | |
| 457 | + | // waits for. | |
| 458 | + | None => {} | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | finish(&mut child); | |
| 463 | + | bail!("the helper ended without saying whether it worked") | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /// Close the helper out. | |
| 467 | + | /// | |
| 468 | + | /// Killing rather than waiting politely, because the paths that reach here have | |
| 469 | + | /// already decided the conversation is over and a helper mid-`pam_authenticate` | |
| 470 | + | /// can sit for as long as its PAM stack wants. The wait is what stops it | |
| 471 | + | /// becoming a zombie for the life of the console. | |
| 472 | + | fn finish(child: &mut Child) { | |
| 473 | + | let _ = child.kill(); | |
| 474 | + | let _ = child.wait(); | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | /// One line of the helper's protocol. | |
| 478 | + | #[derive(Debug, PartialEq, Eq)] | |
| 479 | + | enum Directive { | |
| 480 | + | Prompt { question: String, echo: bool }, | |
| 481 | + | Success, | |
| 482 | + | Failure, | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | impl Directive { | |
| 486 | + | fn parse(line: &str) -> Option<Self> { | |
| 487 | + | // Trailing whitespace is significant in the other direction: the prompt | |
| 488 | + | // is usually `Password: ` and the trailing space is part of what a GUI | |
| 489 | + | // agent would draw. It is trimmed here because the console draws its | |
| 490 | + | // own label and the space would land in the middle of a rendered line. | |
| 491 | + | let (verb, rest) = line.split_once(' ').unwrap_or((line, "")); | |
| 492 | + | match verb { | |
| 493 | + | "PAM_PROMPT_ECHO_OFF" => Some(Directive::Prompt { | |
| 494 | + | question: rest.trim_end().to_string(), | |
| 495 | + | echo: false, | |
| 496 | + | }), | |
| 497 | + | "PAM_PROMPT_ECHO_ON" => Some(Directive::Prompt { | |
| 498 | + | question: rest.trim_end().to_string(), | |
| 499 | + | echo: true, | |
| 500 | + | }), |
Lines truncated