├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── default.nix ├── flake.lock ├── flake.nix └── src ├── main.rs └── prepare_env.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /test 3 | /.vscode 4 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.18" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 64 | dependencies = [ 65 | "windows-sys 0.59.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.6" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 73 | dependencies = [ 74 | "anstyle", 75 | "windows-sys 0.59.0", 76 | ] 77 | 78 | [[package]] 79 | name = "anyhow" 80 | version = "1.0.93" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" 83 | 84 | [[package]] 85 | name = "autocfg" 86 | version = "1.4.0" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 89 | 90 | [[package]] 91 | name = "backtrace" 92 | version = "0.3.74" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 95 | dependencies = [ 96 | "addr2line", 97 | "cfg-if", 98 | "libc", 99 | "miniz_oxide", 100 | "object", 101 | "rustc-demangle", 102 | "windows-targets", 103 | ] 104 | 105 | [[package]] 106 | name = "bitflags" 107 | version = "2.6.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 110 | 111 | [[package]] 112 | name = "bytes" 113 | version = "1.7.2" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" 116 | 117 | [[package]] 118 | name = "cfg-if" 119 | version = "1.0.0" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 122 | 123 | [[package]] 124 | name = "cfg_aliases" 125 | version = "0.2.1" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 128 | 129 | [[package]] 130 | name = "clap" 131 | version = "4.5.20" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" 134 | dependencies = [ 135 | "clap_builder", 136 | "clap_derive", 137 | ] 138 | 139 | [[package]] 140 | name = "clap_builder" 141 | version = "4.5.20" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" 144 | dependencies = [ 145 | "anstream", 146 | "anstyle", 147 | "clap_lex", 148 | "strsim", 149 | ] 150 | 151 | [[package]] 152 | name = "clap_derive" 153 | version = "4.5.18" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 156 | dependencies = [ 157 | "heck", 158 | "proc-macro2", 159 | "quote", 160 | "syn", 161 | ] 162 | 163 | [[package]] 164 | name = "clap_lex" 165 | version = "0.7.2" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 168 | 169 | [[package]] 170 | name = "colorchoice" 171 | version = "1.0.3" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 174 | 175 | [[package]] 176 | name = "errno" 177 | version = "0.3.9" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 180 | dependencies = [ 181 | "libc", 182 | "windows-sys 0.52.0", 183 | ] 184 | 185 | [[package]] 186 | name = "fastrand" 187 | version = "2.2.0" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" 190 | 191 | [[package]] 192 | name = "fhsenv" 193 | version = "0.1.0" 194 | dependencies = [ 195 | "anyhow", 196 | "clap", 197 | "futures", 198 | "lazy_static", 199 | "nix", 200 | "regex", 201 | "serde_json", 202 | "tempfile", 203 | "tokio", 204 | ] 205 | 206 | [[package]] 207 | name = "futures" 208 | version = "0.3.31" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 211 | dependencies = [ 212 | "futures-channel", 213 | "futures-core", 214 | "futures-executor", 215 | "futures-io", 216 | "futures-sink", 217 | "futures-task", 218 | "futures-util", 219 | ] 220 | 221 | [[package]] 222 | name = "futures-channel" 223 | version = "0.3.31" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 226 | dependencies = [ 227 | "futures-core", 228 | "futures-sink", 229 | ] 230 | 231 | [[package]] 232 | name = "futures-core" 233 | version = "0.3.31" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 236 | 237 | [[package]] 238 | name = "futures-executor" 239 | version = "0.3.31" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 242 | dependencies = [ 243 | "futures-core", 244 | "futures-task", 245 | "futures-util", 246 | ] 247 | 248 | [[package]] 249 | name = "futures-io" 250 | version = "0.3.31" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 253 | 254 | [[package]] 255 | name = "futures-macro" 256 | version = "0.3.31" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 259 | dependencies = [ 260 | "proc-macro2", 261 | "quote", 262 | "syn", 263 | ] 264 | 265 | [[package]] 266 | name = "futures-sink" 267 | version = "0.3.31" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 270 | 271 | [[package]] 272 | name = "futures-task" 273 | version = "0.3.31" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 276 | 277 | [[package]] 278 | name = "futures-util" 279 | version = "0.3.31" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 282 | dependencies = [ 283 | "futures-channel", 284 | "futures-core", 285 | "futures-io", 286 | "futures-macro", 287 | "futures-sink", 288 | "futures-task", 289 | "memchr", 290 | "pin-project-lite", 291 | "pin-utils", 292 | "slab", 293 | ] 294 | 295 | [[package]] 296 | name = "gimli" 297 | version = "0.31.1" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 300 | 301 | [[package]] 302 | name = "heck" 303 | version = "0.5.0" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 306 | 307 | [[package]] 308 | name = "hermit-abi" 309 | version = "0.3.9" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 312 | 313 | [[package]] 314 | name = "is_terminal_polyfill" 315 | version = "1.70.1" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 318 | 319 | [[package]] 320 | name = "itoa" 321 | version = "1.0.11" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 324 | 325 | [[package]] 326 | name = "lazy_static" 327 | version = "1.5.0" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 330 | 331 | [[package]] 332 | name = "libc" 333 | version = "0.2.162" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" 336 | 337 | [[package]] 338 | name = "linux-raw-sys" 339 | version = "0.4.14" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 342 | 343 | [[package]] 344 | name = "memchr" 345 | version = "2.7.4" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 348 | 349 | [[package]] 350 | name = "miniz_oxide" 351 | version = "0.8.0" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" 354 | dependencies = [ 355 | "adler2", 356 | ] 357 | 358 | [[package]] 359 | name = "mio" 360 | version = "1.0.2" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" 363 | dependencies = [ 364 | "hermit-abi", 365 | "libc", 366 | "wasi", 367 | "windows-sys 0.52.0", 368 | ] 369 | 370 | [[package]] 371 | name = "nix" 372 | version = "0.29.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" 375 | dependencies = [ 376 | "bitflags", 377 | "cfg-if", 378 | "cfg_aliases", 379 | "libc", 380 | ] 381 | 382 | [[package]] 383 | name = "object" 384 | version = "0.36.5" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" 387 | dependencies = [ 388 | "memchr", 389 | ] 390 | 391 | [[package]] 392 | name = "once_cell" 393 | version = "1.20.2" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 396 | 397 | [[package]] 398 | name = "pin-project-lite" 399 | version = "0.2.15" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" 402 | 403 | [[package]] 404 | name = "pin-utils" 405 | version = "0.1.0" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 408 | 409 | [[package]] 410 | name = "proc-macro2" 411 | version = "1.0.89" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 414 | dependencies = [ 415 | "unicode-ident", 416 | ] 417 | 418 | [[package]] 419 | name = "quote" 420 | version = "1.0.37" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 423 | dependencies = [ 424 | "proc-macro2", 425 | ] 426 | 427 | [[package]] 428 | name = "regex" 429 | version = "1.11.1" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 432 | dependencies = [ 433 | "aho-corasick", 434 | "memchr", 435 | "regex-automata", 436 | "regex-syntax", 437 | ] 438 | 439 | [[package]] 440 | name = "regex-automata" 441 | version = "0.4.8" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 444 | dependencies = [ 445 | "aho-corasick", 446 | "memchr", 447 | "regex-syntax", 448 | ] 449 | 450 | [[package]] 451 | name = "regex-syntax" 452 | version = "0.8.5" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 455 | 456 | [[package]] 457 | name = "rustc-demangle" 458 | version = "0.1.24" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 461 | 462 | [[package]] 463 | name = "rustix" 464 | version = "0.38.39" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" 467 | dependencies = [ 468 | "bitflags", 469 | "errno", 470 | "libc", 471 | "linux-raw-sys", 472 | "windows-sys 0.52.0", 473 | ] 474 | 475 | [[package]] 476 | name = "ryu" 477 | version = "1.0.18" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 480 | 481 | [[package]] 482 | name = "serde" 483 | version = "1.0.214" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" 486 | dependencies = [ 487 | "serde_derive", 488 | ] 489 | 490 | [[package]] 491 | name = "serde_derive" 492 | version = "1.0.214" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" 495 | dependencies = [ 496 | "proc-macro2", 497 | "quote", 498 | "syn", 499 | ] 500 | 501 | [[package]] 502 | name = "serde_json" 503 | version = "1.0.132" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" 506 | dependencies = [ 507 | "itoa", 508 | "memchr", 509 | "ryu", 510 | "serde", 511 | ] 512 | 513 | [[package]] 514 | name = "signal-hook-registry" 515 | version = "1.4.2" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 518 | dependencies = [ 519 | "libc", 520 | ] 521 | 522 | [[package]] 523 | name = "slab" 524 | version = "0.4.9" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 527 | dependencies = [ 528 | "autocfg", 529 | ] 530 | 531 | [[package]] 532 | name = "strsim" 533 | version = "0.11.1" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 536 | 537 | [[package]] 538 | name = "syn" 539 | version = "2.0.87" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 542 | dependencies = [ 543 | "proc-macro2", 544 | "quote", 545 | "unicode-ident", 546 | ] 547 | 548 | [[package]] 549 | name = "tempfile" 550 | version = "3.14.0" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" 553 | dependencies = [ 554 | "cfg-if", 555 | "fastrand", 556 | "once_cell", 557 | "rustix", 558 | "windows-sys 0.59.0", 559 | ] 560 | 561 | [[package]] 562 | name = "tokio" 563 | version = "1.41.1" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" 566 | dependencies = [ 567 | "backtrace", 568 | "bytes", 569 | "libc", 570 | "mio", 571 | "pin-project-lite", 572 | "signal-hook-registry", 573 | "tokio-macros", 574 | "windows-sys 0.52.0", 575 | ] 576 | 577 | [[package]] 578 | name = "tokio-macros" 579 | version = "2.4.0" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" 582 | dependencies = [ 583 | "proc-macro2", 584 | "quote", 585 | "syn", 586 | ] 587 | 588 | [[package]] 589 | name = "unicode-ident" 590 | version = "1.0.13" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 593 | 594 | [[package]] 595 | name = "utf8parse" 596 | version = "0.2.2" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 599 | 600 | [[package]] 601 | name = "wasi" 602 | version = "0.11.0+wasi-snapshot-preview1" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 605 | 606 | [[package]] 607 | name = "windows-sys" 608 | version = "0.52.0" 609 | source = "registry+https://github.com/rust-lang/crates.io-index" 610 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 611 | dependencies = [ 612 | "windows-targets", 613 | ] 614 | 615 | [[package]] 616 | name = "windows-sys" 617 | version = "0.59.0" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 620 | dependencies = [ 621 | "windows-targets", 622 | ] 623 | 624 | [[package]] 625 | name = "windows-targets" 626 | version = "0.52.6" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 629 | dependencies = [ 630 | "windows_aarch64_gnullvm", 631 | "windows_aarch64_msvc", 632 | "windows_i686_gnu", 633 | "windows_i686_gnullvm", 634 | "windows_i686_msvc", 635 | "windows_x86_64_gnu", 636 | "windows_x86_64_gnullvm", 637 | "windows_x86_64_msvc", 638 | ] 639 | 640 | [[package]] 641 | name = "windows_aarch64_gnullvm" 642 | version = "0.52.6" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 645 | 646 | [[package]] 647 | name = "windows_aarch64_msvc" 648 | version = "0.52.6" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 651 | 652 | [[package]] 653 | name = "windows_i686_gnu" 654 | version = "0.52.6" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 657 | 658 | [[package]] 659 | name = "windows_i686_gnullvm" 660 | version = "0.52.6" 661 | source = "registry+https://github.com/rust-lang/crates.io-index" 662 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 663 | 664 | [[package]] 665 | name = "windows_i686_msvc" 666 | version = "0.52.6" 667 | source = "registry+https://github.com/rust-lang/crates.io-index" 668 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 669 | 670 | [[package]] 671 | name = "windows_x86_64_gnu" 672 | version = "0.52.6" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 675 | 676 | [[package]] 677 | name = "windows_x86_64_gnullvm" 678 | version = "0.52.6" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 681 | 682 | [[package]] 683 | name = "windows_x86_64_msvc" 684 | version = "0.52.6" 685 | source = "registry+https://github.com/rust-lang/crates.io-index" 686 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 687 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fhsenv" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.93" 10 | clap = { version = "4.5.20", features = ["derive"] } 11 | futures = "0.3.31" 12 | lazy_static = "1.5.0" 13 | nix = { version = "0.29.0", features = ["fs", "mount", "sched", "signal", "user"] } 14 | regex = "1.11.1" 15 | serde_json = "1.0.132" 16 | tempfile = "3.14.0" 17 | tokio = { version = "1.41.1", features = ["fs", "macros", "process", "rt"] } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fhsenv 2 | 3 | fhsenv is a Rust dev-tool to install Nix packages into an FHS-compliant virtual shell. For example, an FHS environment with the `clang`, `clang-tools`, `eigen`, and `libcxx.dev` packages results in the following /usr/include: 4 | - `c++/v1/` 5 | - `eigen3/` 6 | - `gawkapi.h` 7 | 8 | The corresponding shell.nix: 9 | ``` nix 10 | with import {}; 11 | (buildFHSUserEnv { 12 | name = "c++ environment w/ clangd"; 13 | targetPkgs = pkgs: (with pkgs; [ 14 | clang 15 | clang-tools # for clangd 16 | eigen 17 | libcxx.dev 18 | ]); 19 | }).env 20 | ``` 21 | 22 | Examples: 23 | - `fhsenv /path/to/shell.nix`: enters the FHS environment defined by the input file. 24 | - `fhsenv -p hello`: makes /usr/bin/hello available. 25 | - `fhsenv --run 'sudo whoami'`: demonstrates sudo functionality, which is broken in the official implementation. 26 | 27 | ## Installation 28 | Caution: It's not recommended to install SUID programs when not authored by a cybersecurity expert. I however have convinced both myself and [o1-preview](https://chatgpt.com/share/67393c45-24a4-8010-a94c-4813d0f08488) that privilege escalation is effectively mitigated. 29 | 30 | 1) Declare the fhsenv package either in flake.nix or using callPackage with fetchFromGitHub. 31 | 2) Add fhsenv to environment.systemPackages. 32 | 3) Enable setuid in [security.wrappers](https://github.com/NixOS/nixpkgs/blob/dc460ec76cbff0e66e269457d7b728432263166c/nixos/modules/security/wrappers/default.nix#L175-L202): 33 | ``` nix 34 | security.wrappers.fhsenv = { 35 | setuid = true; 36 | owner = "root"; 37 | group = "root"; 38 | source = "${fhsenv}/bin/fhsenv"; 39 | }; 40 | ``` 41 | 42 | ## Implementation 43 | 44 | fhsenv is implemented in Rust and leverages advanced Linux kernel features to create an isolated FHS environment. The [official implementation](https://ryantm.github.io/nixpkgs/builders/special/fhs-environments/) of the FHS environment uses bubblewrap (an application sandboxing utility) to awkwardly achieve this goal. However, the goal is not to enter a sandbox, but simply to rearrange the view of the filesystem to comply with the file hierarchy standard. Each Linux namespace isolates a component of user space. For example, the mount namespace isolates the filesystem, the user namespace isolates user capabilities and permissions, the network namespace isolates the network stack, etc. fhsenv only uses the mount namespace\* whereas the official implementation uses more. Unfortunately, entering a mount namespace without first isolating user capabilities with the user namespace requires the admin capability (CAP_SYS_ADMIN) because a malicious unprivileged user would bind mount onto sensitive security configuration like /etc/shadow, which stores password hashes. fhsenv is installed with setuid and takes effective measures to prevent privilege escalation inside the FHS environment: 45 | - Dropping privileges: only elevate privileges when necessary - creating the namespace and performing mount operations - and drop them afterwards. 46 | - protecting system configuration: normal packages use /run/current-system/sw/etc for configuration, whereas /etc stores system configuration. Entries in the host /etc/ take precedence over those of new packages. This mitigates the mount system call's privilege escalation risk. 47 | 48 | Apart from these, fhsenv has general mitigations common to SUID programs such as being statically linked, using absolute paths for subprocesses (e.g. /run/current-system/sw/bin/nix-instantiate) instead of leaving it to $PATH, and more. 49 | 50 |
51 | * As a learning exercise, I also implemented the user namespace but entering it has many of the drawbacks of the official implementation. fhsenv only creates a user namespace when its setuid bit is not enabled. -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | let rust-overlay = 2 | builtins.fetchTarball "https://github.com/oxalica/rust-overlay/archive/master.tar.gz"; 3 | in { pkgs ? import { overlays = [ (import rust-overlay) ]; }}: 4 | let 5 | arch = builtins.elemAt (builtins.split "-" pkgs.system) 0; 6 | target = "${arch}-unknown-linux-musl"; 7 | toolchain = pkgs.rust-bin.selectLatestNightlyWith 8 | (toolchain: toolchain.default.override { targets = [ target ]; }); 9 | rustPlatform = pkgs.makeRustPlatform { cargo = toolchain; rustc = toolchain; }; 10 | in rustPlatform.buildRustPackage rec { 11 | pname = "fhsenv"; 12 | version = "0.1.0"; 13 | 14 | buildInputs = [ pkgs.nix pkgs.util-linux.bin ]; 15 | 16 | src = ./.; 17 | cargoLock.lockFile = ./Cargo.lock; 18 | 19 | buildPhase = '' 20 | export RUSTFLAGS='-C target-feature=+crt-static' # enable static linking 21 | env nix=${pkgs.nix} util-linux=${pkgs.util-linux.bin} cargo build --release --target ${target} 22 | ''; 23 | installPhase = '' 24 | mkdir -p $out/bin 25 | mv ./target/${target}/release/fhsenv $out/bin 26 | ''; 27 | doCheck = false; 28 | 29 | shellHook = ''PS1="\[\e[1;32m\]\u \W> \[\e[0m\]"''; 30 | } -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1734988233, 24 | "narHash": "sha256-Ucfnxq1rF/GjNP3kTL+uTfgdoE9a3fxDftSfeLIS8mA=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "de1864217bfa9b5845f465e771e0ecb48b30e02d", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs", 41 | "rust-overlay": "rust-overlay" 42 | } 43 | }, 44 | "rust-overlay": { 45 | "inputs": { 46 | "nixpkgs": [ 47 | "nixpkgs" 48 | ] 49 | }, 50 | "locked": { 51 | "lastModified": 1735180071, 52 | "narHash": "sha256-ceUDFBsLf5Cz3GlhQAdaJsEfi5s1MDjDsO9VvPFoKAE=", 53 | "owner": "oxalica", 54 | "repo": "rust-overlay", 55 | "rev": "550e1f10be4a504747a7894c35e887e61235763b", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "oxalica", 60 | "repo": "rust-overlay", 61 | "type": "github" 62 | } 63 | }, 64 | "systems": { 65 | "locked": { 66 | "lastModified": 1681028828, 67 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 68 | "owner": "nix-systems", 69 | "repo": "default", 70 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 71 | "type": "github" 72 | }, 73 | "original": { 74 | "owner": "nix-systems", 75 | "repo": "default", 76 | "type": "github" 77 | } 78 | } 79 | }, 80 | "root": "root", 81 | "version": 7 82 | } 83 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "buildFHSUserEnv reimplemented."; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | 9 | rust-overlay = { 10 | url = "github:oxalica/rust-overlay"; 11 | inputs.nixpkgs.follows = "nixpkgs"; 12 | # inputs.flake-utils.follows = "flake-utils"; 13 | }; 14 | }; 15 | 16 | outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }: 17 | flake-utils.lib.eachDefaultSystem (system: 18 | let 19 | pkgs = import nixpkgs { 20 | inherit system; 21 | overlays = [ (import rust-overlay) ]; 22 | }; 23 | 24 | fhsenv = (import ./default.nix) { inherit pkgs; }; 25 | in { 26 | packages.default = fhsenv; 27 | devShells.default = fhsenv; 28 | } 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::{CString, OsStr}, fs, os::unix::fs::MetadataExt, path::{Path, PathBuf}}; 2 | use anyhow::{bail, Context, Result}; 3 | use nix::{mount::{mount, MsFlags}, sched::{setns, CloneFlags}, sys::signal::{kill, Signal}}; 4 | use nix::unistd::{setegid, seteuid, setresgid, setresuid, Gid, Uid, User}; 5 | 6 | mod prepare_env; 7 | 8 | lazy_static::lazy_static! { 9 | static ref root: &'static Path = Path::new("/"); 10 | // hardcode paths to mitigate malicious $PATH 11 | static ref nix_cli: PathBuf = Path::new(env!("nix")).join("bin/nix"); 12 | static ref nix_instantiate: PathBuf = Path::new(env!("nix")).join("bin/nix-instantiate"); 13 | static ref nix_store: PathBuf = Path::new(env!("nix")).join("bin/nix-store"); 14 | static ref unshare: PathBuf = Path::new(env!("util-linux")).join("bin/unshare"); 15 | } 16 | 17 | fn command(program: &Path) -> Result { 18 | if !program.is_absolute() { 19 | bail!("{program:?} should be hardcoded."); 20 | } 21 | 22 | Ok(tokio::process::Command::new(program)) 23 | } 24 | 25 | async fn subprocess>>(program: &Path, args: I) 26 | -> Result { 27 | let output = command(program)?.args(args).output().await 28 | .context(format!("Error running {program:?}."))?; 29 | 30 | if !output.status.success() { 31 | bail!("Error running {program:?}: {}.", String::from_utf8(output.stderr)?); 32 | } 33 | 34 | Ok(String::from_utf8(output.stdout)?.trim().into()) 35 | } 36 | 37 | // TODO: handle the case where fhs_derivation doesn't actually evaluate to buildFHSUserEnv.env 38 | async fn get_fhs_path(fhs_definition: &str) -> Result { 39 | let derivation_path = subprocess(&nix_instantiate, ["-E", &fhs_definition]).await?; 40 | let output = subprocess(&nix_cli, ["derivation", "show", &derivation_path]).await?; 41 | let derivation = serde_json::from_str::(&output)?; 42 | 43 | let regex = regex::Regex::new(r"^(.*)-shell-env$")?; 44 | let fhsenv_name = ®ex.captures(derivation[&derivation_path]["name"].as_str() 45 | .unwrap_or_default()).context("Couldn't parse derivation for environment name.")?[1]; 46 | 47 | let serde_json::Value::Object(input_drvs) = &derivation[&derivation_path]["inputDrvs"] else { 48 | bail!("Couldn't parse derivation for FHS store path."); 49 | }; 50 | let pattern = format!(r"^/nix/store/([^-]+)-{}-fhsenv-rootfs.drv", regex::escape(fhsenv_name)); 51 | let regex = regex::Regex::new(&pattern)?; 52 | let fhs_drv = input_drvs.keys().filter_map(|input_drv| regex.find(input_drv)).next() 53 | .context("Expected FHS derivation in inputDrvs.")?.as_str(); 54 | 55 | // like subprocess but without piping stderr 56 | let output = command(&nix_store)?.args(["--realise", fhs_drv]) 57 | .stdout(std::process::Stdio::piped()).spawn()?.wait_with_output().await?; 58 | let (output, status) = (std::str::from_utf8(&output.stdout)?.trim(), output.status); 59 | let fhs_path = Path::new(output); 60 | if !status.success() || !fhs_path.exists() { 61 | bail!("Error building {fhs_drv}."); 62 | } 63 | let pattern = format!(r"^/nix/store/([^-]+)-{}-fhsenv-rootfs$", regex::escape(fhsenv_name)); 64 | let regex = regex::Regex::new(&pattern)?; 65 | if regex.find(&output).is_none() { 66 | bail!("Invalid output from nix-store --realise {fhs_drv}: {output}."); 67 | } 68 | 69 | // toctou is mitigated by nix store being a read only filesystem 70 | let entries_expected = ["bin", "etc", "lib", "lib32", "lib64", "libexec", "sbin", "usr"]; 71 | for entry in fhs_path.read_dir()?.collect::, _>>()? { 72 | let compare_file_name = |expected| Some(expected) == entry.file_name().to_str().as_ref(); 73 | if !entries_expected.iter().any(compare_file_name) { 74 | bail!("Unexpected subdirectory in {fhs_path:?}: {entry:?}."); 75 | } 76 | } 77 | 78 | Ok(fhs_path.into()) 79 | } 80 | 81 | #[derive(Clone, Copy)] 82 | enum Mapping { Uid, Gid } 83 | 84 | impl Mapping { 85 | fn mapper(&self) -> PathBuf { 86 | let basename = match self { Mapping::Uid => "newuidmap", Mapping::Gid => "newgidmap" }; 87 | Path::new("/run/wrappers/bin/").join(basename) 88 | } 89 | } 90 | 91 | fn read_subuid(mapping: Mapping, username: &str) -> Result> { 92 | let path = match mapping { Mapping::Uid => "/etc/subuid", Mapping::Gid => "/etc/subgid" }; 93 | let subuid = match fs::read_to_string(path) { 94 | Err(error) if matches!(error.kind(), std::io::ErrorKind::NotFound) => return Ok(vec![]), 95 | result => result.context(format!("Failed to read {path}."))? 96 | }; 97 | let mut ranges = vec![]; 98 | for (i, line) in subuid.split('\n').enumerate() { 99 | let [_username, lower_id, count] = line.split(':').collect::>()[..] else { 100 | continue; 101 | }; 102 | 103 | if _username == username { 104 | ranges.push(( 105 | lower_id.parse().context(format!("{path} line {i}: invalid lower_id."))?, 106 | count.parse().context(format!("{path} line {i}: invalid count."))?, 107 | )); 108 | } 109 | } 110 | 111 | ranges.sort(); 112 | Ok(ranges) 113 | } 114 | 115 | // https://man7.org/linux/man-pages/man1/newuidmap.1.html 116 | async fn set_mapping(mapping: Mapping, pid: u32, uid: u32, username: &str) -> Result { 117 | let mut ranges = read_subuid(mapping, &username)?.into_iter(); 118 | let mut counter = 0; 119 | let mut args = vec![pid]; 120 | let mut overlapping_range = None; 121 | 122 | while let Some((lower_id, count)) = ranges.next() { 123 | if counter + count > uid { 124 | overlapping_range = Some((lower_id, count)); 125 | break; 126 | } else { 127 | args.extend([counter, lower_id, count]); 128 | } 129 | counter += count; 130 | } 131 | 132 | if let Some((lower_id, count)) = overlapping_range { 133 | let fst_count = uid - counter; 134 | if fst_count > 0 { 135 | args.extend([counter, lower_id, fst_count]); 136 | } 137 | args.extend([uid, uid, 1]); 138 | args.extend([uid + 1, lower_id + fst_count, count - fst_count]); 139 | counter += count; 140 | 141 | for (lower_id, count) in ranges { 142 | args.extend([counter, lower_id, count]); 143 | counter += count; 144 | } 145 | } else { 146 | args.extend([uid, uid, 1]); 147 | } 148 | 149 | // let mapper = mapping.mapper()); 150 | subprocess(&mapping.mapper(), args.iter().map(u32::to_string).into_iter()).await 151 | } 152 | 153 | // https://man7.org/linux/man-pages/man7/user_namespaces.7.html 154 | async fn enter_user_namespace(uid: Uid, gid: Gid) -> Result<()> { 155 | let username = User::from_uid(uid) 156 | .unwrap_or(None).context("Failed to get username from uid.")?.name; 157 | 158 | // newuidmap and newgidmap don't work on its own user namespace 159 | // so create it in separate process and then enter it 160 | let mut process = command(&unshare)?.args(&["-U", "sleep", "infinity"]).spawn() 161 | .context("Couldn't create namespace.")?; 162 | let pid = process.id().context("Namespace parent exited prematurely.")?; 163 | 164 | set_mapping(Mapping::Uid, pid, uid.into(), &username).await.context("Failed to map uid.")?; 165 | fs::write(format!("/proc/{pid}/setgroups"), "deny").context("Couldn't disable setgroups.")?; 166 | set_mapping(Mapping::Gid, pid, gid.into(), &username).await.context("Failed to map gid.")?; 167 | 168 | // enter the namespace 169 | let ns_path = format!("/proc/{pid}/ns/user"); 170 | let ns_fd = fs::File::open(&ns_path).context(format!("Failed to open {ns_path}."))?; 171 | setns(ns_fd, CloneFlags::CLONE_NEWUSER).context(format!("Couldn't enter {ns_path}."))?; 172 | 173 | if let Err(error) = kill(nix::unistd::Pid::from_raw(pid as i32), Signal::SIGKILL) { 174 | eprintln!("Failed to kill process {pid}: {error}."); 175 | } else if let Err(error) = process.wait().await { 176 | eprintln!("Failed to wait for process {pid} to exit: {error}."); 177 | } 178 | 179 | Ok(()) 180 | } 181 | 182 | // tokio::fs::try_exists equivalent that doesn't traverse symlinks 183 | async fn exists(path: &Path) -> Result { 184 | match tokio::fs::symlink_metadata(path).await { 185 | Ok(_) => Ok(true), 186 | Err(error) if matches!(error.kind(), tokio::io::ErrorKind::NotFound) => Ok(false), 187 | Err(error) => bail!("Failed to determine {path:?}'s existence: {error}.") 188 | } 189 | } 190 | 191 | // target is inside new_root so isolated from outside 192 | // however entry may be malicious if from fhs_path or /tmp 193 | // TODO: is there a practical limit on number of bind mounts? 194 | async fn bind_entry(entry: &Path, target: &Path) -> Result<()> { 195 | if exists(target).await? { 196 | return Ok(()); // do not overwrite existing entry 197 | } 198 | 199 | let metadata = tokio::fs::symlink_metadata(entry).await 200 | .context(format!("Failed to query {entry:?}'s metadata."))?; 201 | if metadata.is_symlink() { 202 | let source = tokio::fs::read_link(entry).await.context("Failed to read symlink source")?; 203 | return tokio::fs::symlink(source, target).await.context("Failed to copy symlink"); 204 | } else if metadata.is_dir() { 205 | tokio::fs::create_dir(&target).await.context("Failed to create stub directory.")?; 206 | } else { 207 | tokio::fs::write(&target, "").await.context("Failed to create stub file.")?; 208 | } 209 | 210 | // CAUTION: mount does traverse symlinks 211 | mount(Some(entry), target, None::<&str>, MsFlags::MS_BIND | MsFlags::MS_REC, None::<&str>) 212 | .context(format!("Failed to bind {entry:?} to {target:?}.")) 213 | } 214 | 215 | // asynchronously loop over bind_entry 216 | async fn bind_entries(parent: &Path, target: &Path, exclusions: &[&str]) -> Result> { 217 | if !parent.starts_with("/nix/store/") { 218 | let metadata = tokio::fs::symlink_metadata(parent).await 219 | .context(format!("Failed to query {parent:?}'s metadata."))?; 220 | // protect the checks in bind_entry from TOCTOU race conditions 221 | // by ensuring parent is owned by root and doesn't provide write access to others 222 | if metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { 223 | bail!("{parent:?} has loose write access."); 224 | } 225 | } 226 | 227 | futures::future::try_join_all(parent.read_dir()?.map(|result| async move { 228 | let entry = result?; 229 | if !exclusions.into_iter().any(|exclusion| entry.file_name().to_str() == Some(exclusion)) { 230 | bind_entry(&entry.path(), &target.join(entry.file_name())).await?; 231 | } 232 | 233 | Ok(()) 234 | })).await 235 | } 236 | 237 | // mount requires root since it allows the caller to overwrite sensitive system files 238 | // > mount a filesystem of your choice on /etc, with an /etc/shadow containing a root password that you know 239 | // from https://unix.stackexchange.com/questions/65039/ 240 | // this is mitigated by giving entries in /etc precedence over those in fhs_path 241 | // btw normal packages use /run/current-system/sw/etc and /etc only contains system configuration 242 | async fn create_new_root(fhs_path: &Path) -> Result { 243 | mount(None::<&str>, "/", None::<&str>, MsFlags::MS_SLAVE | MsFlags::MS_REC, None::<&str>) 244 | .context("Failed to make root a slave mount.")?; 245 | 246 | let new_root = tempfile::TempDir::new()?.into_path(); 247 | mount(None::<&str>, &new_root, Some("tmpfs"), MsFlags::empty(), None::<&str>)?; // isolates new_root 248 | 249 | bind_entries(fhs_path, &new_root, &["etc"]).await?; 250 | 251 | fs::create_dir(new_root.join("etc")).context("Failed to create etc in new_root")?; 252 | prepare_env::create_ld_so_conf(&new_root)?; 253 | bind_entries(&root.join("etc"), &new_root.join("etc"), &["ld.so.conf"]).await?; 254 | bind_entries(&fhs_path.join("etc"), &new_root.join("etc"), &[]).await?; 255 | 256 | // /tmp isn't mounted to new_root/tmp 257 | // because new_root itself is inside /tmp 258 | // causing pivot_root later to fail 259 | // instead we later mount /tmp after pivot_root 260 | bind_entries(&root, &new_root, &["etc", "tmp"]).await?; 261 | 262 | Ok(new_root) 263 | } 264 | 265 | async fn pivot_root(new_root: &Path) -> Result<()> { 266 | let old_root = tempfile::TempDir::new()?.into_path(); 267 | 268 | // create put_old 269 | let put_old = new_root.join(old_root.strip_prefix("/")?); 270 | fs::create_dir(new_root.join("tmp")).context("Failed to create tmp in new root")?; 271 | fs::set_permissions(new_root.join("tmp"), std::os::unix::fs::PermissionsExt::from_mode(0o777)) 272 | .context("Failed to set permissions on tmp")?; 273 | fs::create_dir_all(&put_old).context("Failed to create stub directory for put_old")?; 274 | 275 | let cwd = std::env::current_dir(); // cwd before pivot_root 276 | nix::unistd::pivot_root(new_root, &put_old)?; 277 | if let Ok(cwd) = cwd { 278 | if let Err(error) = std::env::set_current_dir(&cwd) { // reset cwd 279 | eprintln!("Unable to change back to {cwd:?}: {error}."); 280 | } 281 | } 282 | 283 | // mount old tmp to /tmp and thereby shadow old_root 284 | let flags = MsFlags::MS_BIND | MsFlags::MS_REC; 285 | mount(Some(&old_root.join("tmp")), "/tmp", None::<&str>, flags, None::<&str>) 286 | .context("Failed to mount old tmp to /tmp.") 287 | } 288 | 289 | fn define_fhs(Mode { shell_nix, packages }: Mode) -> Result { 290 | if packages.is_empty() { 291 | let shell_nix = shell_nix.as_ref().map(PathBuf::as_path).unwrap_or(Path::new("shell.nix")); 292 | if !shell_nix.exists() { 293 | bail!("{:?} does not exist.", shell_nix.canonicalize().unwrap_or(shell_nix.into())); 294 | } 295 | 296 | return Ok(fs::read_to_string(shell_nix) 297 | .context(format!("Failed to read from {shell_nix:?}."))?); 298 | } else { 299 | // TODO: check how nix-shell sanitizes/validates packages input 300 | let packages_formatted = 301 | packages.into_iter().map(|pkg| format!("({pkg})")).collect::>().join("\n"); 302 | Ok(format!(" 303 | {{ pkgs ? import {{}} }}: 304 | (pkgs.buildFHSUserEnv {{ 305 | name = \"fhsenv\"; 306 | targetPkgs = pkgs: (with pkgs; [\n{packages_formatted}\n]); 307 | }}).env 308 | ")) 309 | } 310 | } 311 | 312 | // TODO: how does nix-shell do this? 313 | fn enter_shell(entrypoint: Option) -> Result<()> { 314 | let name = CString::new("bash")?; // TODO: use the default shell rather than bash 315 | let entrypoint = entrypoint.unwrap_or_else(|| { 316 | // make the command prompt green 317 | let ps1 = r"\[\e[1;32m\]\u \W> \[\e[0m\]"; 318 | let set_ps1 = format!("export PS1=\"{ps1}\""); 319 | // https://serverfault.com/questions/368054/ 320 | format!("bash --init-file <(echo \"source ~/.bashrc; {}\")", set_ps1.replace("\"", "\\\"")) 321 | }); 322 | let args = [&name, &CString::new("-c")?, &CString::new(entrypoint)?]; 323 | nix::unistd::execvp(&name, &args).context("execvp into bash failed.")?; 324 | 325 | unreachable!(); 326 | } 327 | 328 | #[derive(clap::Parser)] 329 | #[command(version, about, long_about = None)] 330 | struct Cli { 331 | #[command(flatten)] 332 | mode: Mode, 333 | 334 | #[arg(long)] 335 | run: Option 336 | } 337 | 338 | #[derive(clap::Args)] 339 | #[group(required = true, multiple = false)] 340 | struct Mode { 341 | shell_nix: Option, 342 | 343 | #[clap(short, long, num_args = 1..)] 344 | packages: Vec 345 | } 346 | 347 | #[tokio::main(flavor = "current_thread")] 348 | async fn main() -> Result<()> { 349 | let rootless = Uid::effective() != nix::unistd::ROOT; 350 | let (uid, gid) = (Uid::current(), Gid::current()); 351 | // drop privileges temporarily 352 | seteuid(uid)?; 353 | setegid(gid)?; 354 | 355 | let cli: Cli = clap::Parser::parse(); 356 | let fhs_definition = define_fhs(cli.mode)?; 357 | let fhs_path = get_fhs_path(&fhs_definition).await?; 358 | 359 | if rootless { 360 | // this carries all the drawbacks of the bubblewrap implementation 361 | // only really implemented as learning exercise, unreachable when compiled with suid 362 | enter_user_namespace(uid, gid).await.context("Couldn't enter user namespace.")?; 363 | } else { 364 | // elevate to root 365 | seteuid(0.into()).context("Failed to set effective user ID to root")?; 366 | setegid(0.into()).context("Failed to set effective group ID to root")?; 367 | } 368 | // https://unix.stackexchange.com/questions/476847/ 369 | nix::sched::unshare(CloneFlags::CLONE_NEWNS).context("Couldn't create mount namespace.")?; 370 | let new_root = create_new_root(&fhs_path).await.context("Couldn't create new_root")?; 371 | pivot_root(&new_root).await.context(format!("Couldn't pivot root to {new_root:?}."))?; 372 | 373 | // drop privileges using setresuid to make it permanent 374 | setresuid(uid, uid, uid)?; 375 | setresgid(gid, gid, gid)?; 376 | 377 | prepare_env::prepare_env(); 378 | enter_shell(cli.run) 379 | } -------------------------------------------------------------------------------- /src/prepare_env.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | 3 | pub fn create_ld_so_conf(new_root: &std::path::Path) -> Result<()> { 4 | let ld_so_conf_entries = [ 5 | "/lib", 6 | "/lib/x86_64-linux-gnu", 7 | "/lib64", 8 | "/usr/lib", 9 | "/usr/lib/x86_64-linux-gnu", 10 | "/usr/lib64", 11 | "/lib/i386-linux-gnu", 12 | "/lib32", 13 | "/usr/lib/i386-linux-gnu", 14 | "/usr/lib32", 15 | "/run/opengl-driver/lib", 16 | "/run/opengl-driver-32/lib" 17 | ]; 18 | 19 | std::fs::write(new_root.join("etc/ld.so.conf"), ld_so_conf_entries.join("\n")) 20 | .context("Couldn't write to /etc/ld.so.conf.").map_err(Into::into) 21 | } 22 | 23 | fn prepend_entries_in_env(key: &str, additions: &[&str]) { 24 | let mut val = std::env::var(key).unwrap_or_default(); 25 | if !val.is_empty() { 26 | val = ":".to_string() + &val; 27 | } 28 | std::env::set_var(key, additions.join(":") + &val); 29 | } 30 | 31 | pub fn prepare_env() { 32 | prepend_entries_in_env("PATH", &[ 33 | "/usr/local/bin", 34 | "/usr/local/sbin", 35 | "/run/wrappers/bin", 36 | "/usr/bin", 37 | "/usr/sbin", 38 | "/bin", 39 | "/sbin" 40 | ]); 41 | 42 | prepend_entries_in_env("XDG_DATA_DIRS", &[ 43 | "/usr/local/share", 44 | "/usr/share", 45 | "/run/opengl-driver/share", 46 | "/run/opengl-driver-32/share" 47 | ]); 48 | 49 | prepend_entries_in_env("LD_LIBRARY_PATH", &[ 50 | "/usr/lib", 51 | "/usr/lib64", 52 | "/lib", 53 | "/lib64", 54 | "/run/opengl-driver/lib", 55 | "/run/opengl-driver-32/lib" 56 | ]); 57 | 58 | prepend_entries_in_env("ACLOCAL_PATH", &["/usr/share/aclocal"]); 59 | prepend_entries_in_env("PKG_CONFIG_PATH", &["/usr/lib/pkgconfig"]); 60 | std::env::set_var("LOCALE_ARCHIVE", "/usr/lib/locale/locale-archive"); 61 | } 62 | --------------------------------------------------------------------------------